From 28486bff8c3892d9efa4fad6f6a9719f32788ba2 Mon Sep 17 00:00:00 2001 From: fretuks Date: Tue, 7 Jul 2026 11:46:31 +0200 Subject: [PATCH] Implement too many changes to list --- Assets/Data/Cards/CardDatabase.asset | 6 + Assets/Data/Cards/Shop/AsyncDef.asset | 30 + Assets/Data/Cards/Shop/ListAppend.asset | 30 + Assets/Data/Cards/Shop/MathRandom.asset | 30 + Assets/Data/Cards/Shop/PromiseAll.asset | 30 + Assets/Data/Cards/Shop/Spread.asset | 30 + Assets/Data/Cards/Shop/Zip.asset | 30 + Assets/Resources/CombatScene.uss | 953 +++++++++++++ Assets/Scenes/GameScene.unity | 97 +- Assets/Scenes/MainMenuScene.unity | 33 - Assets/Scripts/Combat/CardEffects.cs | 447 ++++++ Assets/Scripts/Combat/CardInstance.cs | 86 ++ Assets/Scripts/Combat/CombatContext.cs | 23 +- Assets/Scripts/Combat/CombatManager.cs | 784 ++++++++++- Assets/Scripts/Combat/CombatTuning.cs | 41 + Assets/Scripts/Combat/CombatantState.cs | 33 +- Assets/Scripts/Combat/DamagePipeline.cs | 111 ++ Assets/Scripts/Combat/DeckController.cs | 72 +- Assets/Scripts/Combat/EnemyInstance.cs | 41 + Assets/Scripts/Combat/EnemyIntent.cs | 57 + Assets/Scripts/Combat/HandController.cs | 21 +- Assets/Scripts/Combat/InterpreterQueue.cs | 39 +- Assets/Scripts/Combat/LazyStack.cs | 36 +- Assets/Scripts/Combat/NativeTrack.cs | 9 +- Assets/Scripts/Combat/RandomRoll.cs | 47 + .../Scripts/Combat/StatusEffectController.cs | 197 ++- Assets/Scripts/Combat/StatusInstance.cs | 66 + Assets/Scripts/Core/GameEvents.cs | 182 +++ Assets/Scripts/Core/Language.cs | 2 +- Assets/Scripts/Core/StatusType.cs | 3 +- Assets/Scripts/Data/CardDefinition.cs | 2 + Assets/Scripts/Data/LanguageCatalog.cs | 2 +- Assets/Scripts/Meta/CardLoadout.cs | 32 +- Assets/Scripts/Meta/GachaService.cs | 109 +- Assets/Scripts/Meta/PlayerCollection.cs | 20 +- Assets/Scripts/Meta/SaveService.cs | 2 +- Assets/Scripts/Run/RepositoryOffer.cs | 157 +++ Assets/Scripts/Run/RewardService.cs | 4 +- Assets/Scripts/Run/RunConfig.cs | 7 + Assets/Scripts/Run/RunContext.cs | 98 ++ Assets/Scripts/Run/RunManager.cs | 378 +++++- Assets/Scripts/UI/AsciiArtFitter.cs | 62 +- Assets/Scripts/UI/BackgroundLogRingBuffer.cs | 72 +- .../Scripts/UI/CollectionScreenController.cs | 242 ++-- Assets/Scripts/UI/CombatSceneController.cs | 1197 +++++++++++++++++ Assets/Scripts/UI/FeaturedUnitPanel.cs | 122 +- Assets/Scripts/UI/GachaScreenController.cs | 218 +-- Assets/Scripts/UI/MainMenuController.cs | 949 ++++++------- Assets/Scripts/UI/ScreenFrameController.cs | 26 +- .../Scripts/UI/StarterSelectionController.cs | 210 +-- 50 files changed, 6344 insertions(+), 1131 deletions(-) create mode 100644 Assets/Data/Cards/Shop/AsyncDef.asset create mode 100644 Assets/Data/Cards/Shop/ListAppend.asset create mode 100644 Assets/Data/Cards/Shop/MathRandom.asset create mode 100644 Assets/Data/Cards/Shop/PromiseAll.asset create mode 100644 Assets/Data/Cards/Shop/Spread.asset create mode 100644 Assets/Data/Cards/Shop/Zip.asset create mode 100644 Assets/Resources/CombatScene.uss create mode 100644 Assets/Scripts/Combat/CardEffects.cs create mode 100644 Assets/Scripts/Combat/CombatTuning.cs create mode 100644 Assets/Scripts/Combat/DamagePipeline.cs create mode 100644 Assets/Scripts/Combat/EnemyInstance.cs create mode 100644 Assets/Scripts/Combat/EnemyIntent.cs create mode 100644 Assets/Scripts/Combat/RandomRoll.cs create mode 100644 Assets/Scripts/Combat/StatusInstance.cs create mode 100644 Assets/Scripts/Run/RepositoryOffer.cs create mode 100644 Assets/Scripts/Run/RunContext.cs create mode 100644 Assets/Scripts/UI/CombatSceneController.cs diff --git a/Assets/Data/Cards/CardDatabase.asset b/Assets/Data/Cards/CardDatabase.asset index 75b07b5..7f50cb0 100644 --- a/Assets/Data/Cards/CardDatabase.asset +++ b/Assets/Data/Cards/CardDatabase.asset @@ -35,3 +35,9 @@ MonoBehaviour: - {fileID: 11400000, guid: a7d40000000000000000000000000004, type: 2} - {fileID: 11400000, guid: a7d40000000000000000000000000005, type: 2} - {fileID: 11400000, guid: a7d40000000000000000000000000006, type: 2} + - {fileID: 11400000, guid: a7d50000000000000000000000000001, type: 2} + - {fileID: 11400000, guid: a7d50000000000000000000000000002, type: 2} + - {fileID: 11400000, guid: a7d50000000000000000000000000003, type: 2} + - {fileID: 11400000, guid: a7d50000000000000000000000000004, type: 2} + - {fileID: 11400000, guid: a7d50000000000000000000000000005, type: 2} + - {fileID: 11400000, guid: a7d50000000000000000000000000006, type: 2} diff --git a/Assets/Data/Cards/Shop/AsyncDef.asset b/Assets/Data/Cards/Shop/AsyncDef.asset new file mode 100644 index 0000000..fd4c3a8 --- /dev/null +++ b/Assets/Data/Cards/Shop/AsyncDef.asset @@ -0,0 +1,30 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3} + m_Name: AsyncDef + m_EditorClassIdentifier: + id: shop_py_async_def + displayName: async def + description: 'queue: draw 2. TODO: gain 1 Cycle next turn.' + flavorText: returns eventually. emotionally, at least. + language: 3 + rarity: 1 + cycleCost: 1 + resolutionTrack: 1 + distroExclusive: 0 + isToken: 0 + isRunOnly: 1 + designNotes: Run-only Repository card. Draw 2 works now; next-turn cycle grant waits for deferred-cycle-grant. + requiresSystem: + - deferred-cycle-grant + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Shop/ListAppend.asset b/Assets/Data/Cards/Shop/ListAppend.asset new file mode 100644 index 0000000..756a515 --- /dev/null +++ b/Assets/Data/Cards/Shop/ListAppend.asset @@ -0,0 +1,30 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3} + m_Name: ListAppend + m_EditorClassIdentifier: + id: shop_py_list_append + displayName: list.append() + description: 'queue: deal 4. TODO: grows when re-queued.' + flavorText: mutable state, but make it cute. + language: 3 + rarity: 1 + cycleCost: 1 + resolutionTrack: 1 + distroExclusive: 0 + isToken: 0 + isRunOnly: 1 + designNotes: Run-only Repository card. Base deal 4 works now; permanent per-combat requeue growth waits for requeue-tracking. + requiresSystem: + - requeue-tracking + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Shop/MathRandom.asset b/Assets/Data/Cards/Shop/MathRandom.asset new file mode 100644 index 0000000..211acc3 --- /dev/null +++ b/Assets/Data/Cards/Shop/MathRandom.asset @@ -0,0 +1,30 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3} + m_Name: MathRandom + m_EditorClassIdentifier: + id: shop_js_math_random + displayName: Math.random() + description: deal 2-20. + flavorText: cryptographically unsuitable, emotionally perfect. + language: 4 + rarity: 1 + cycleCost: 1 + resolutionTrack: 0 + distroExclusive: 0 + isToken: 0 + isRunOnly: 1 + designNotes: Run-only Repository card. JavaScript damage range roll 2-20. + requiresSystem: + - damage-range-roll + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Shop/PromiseAll.asset b/Assets/Data/Cards/Shop/PromiseAll.asset new file mode 100644 index 0000000..11240ec --- /dev/null +++ b/Assets/Data/Cards/Shop/PromiseAll.asset @@ -0,0 +1,30 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3} + m_Name: PromiseAll + m_EditorClassIdentifier: + id: shop_js_promise_all + displayName: Promise.all() + description: deal 4-10 three times. + flavorText: all or nothing, except the stack traces. + language: 4 + rarity: 1 + cycleCost: 2 + resolutionTrack: 0 + distroExclusive: 0 + isToken: 0 + isRunOnly: 1 + designNotes: Run-only Repository card. Three independent JavaScript damage rolls. + requiresSystem: + - damage-range-roll + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Shop/Spread.asset b/Assets/Data/Cards/Shop/Spread.asset new file mode 100644 index 0000000..8d272f2 --- /dev/null +++ b/Assets/Data/Cards/Shop/Spread.asset @@ -0,0 +1,30 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3} + m_Name: Spread + m_EditorClassIdentifier: + id: shop_js_spread + displayName: spread ... + description: 'TODO: copy the lowest-cost card in hand.' + flavorText: cloning by punctuation, naturally. + language: 4 + rarity: 1 + cycleCost: 1 + resolutionTrack: 0 + distroExclusive: 0 + isToken: 0 + isRunOnly: 1 + designNotes: Run-only Repository card. Requires card-copy. + requiresSystem: + - card-copy + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Shop/Zip.asset b/Assets/Data/Cards/Shop/Zip.asset new file mode 100644 index 0000000..6f1075f --- /dev/null +++ b/Assets/Data/Cards/Shop/Zip.asset @@ -0,0 +1,30 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3} + m_Name: Zip + m_EditorClassIdentifier: + id: shop_py_zip + displayName: zip() + description: 'TODO: next 2 queued cards resolve twice.' + flavorText: pairs nicely until lengths disagree. + language: 3 + rarity: 1 + cycleCost: 1 + resolutionTrack: 1 + distroExclusive: 0 + isToken: 0 + isRunOnly: 1 + designNotes: Run-only Repository card. Requires queue-effect-multiply. + requiresSystem: + - queue-effect-multiply + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Resources/CombatScene.uss b/Assets/Resources/CombatScene.uss new file mode 100644 index 0000000..603971d --- /dev/null +++ b/Assets/Resources/CombatScene.uss @@ -0,0 +1,953 @@ +:root { + --kp-bg: #030605; + --kp-bg-deep: #020403; + --kp-panel: #07110d; + --kp-panel-raised: #0b1812; + --kp-panel-track: #050b08; + --kp-panel-targeted: #10160d; + --kp-panel-hover: #0d2016; + --kp-action-hover: #111409; + --kp-text: #63ff91; + --kp-text-dim: #7a9783; + --kp-border: #14592f; + --kp-border-subtle: #0c2d1b; + --kp-border-meter: #0d3a22; + --kp-border-empty: #0a2416; + --kp-border-strong: #27d866; + --kp-warning: #ffb347; + --kp-beneficial: #57d8ff; + --kp-accent: #ff7a1a; + --kp-danger: #ff5f5f; + --kp-overlay: rgba(0, 0, 0, 0.88); + + --kp-lang-c: #8ea4a9; + --kp-lang-cpp: #6da6ff; + --kp-lang-rust: #d78b4a; + --kp-lang-python: #f2ce61; + --kp-lang-javascript: #f7df1e; + --kp-lang-typescript: #5aa7ff; + --kp-lang-haskell: #b48cff; + --kp-lang-assembly: #ff6b6b; + --kp-lang-java: #ff8c42; + --kp-lang-go: #6de6ff; + --kp-lang-ruby: #ff5f7d; + --kp-lang-php: #9aa2ff; +} + +.combat-root { + background-color: var(--kp-bg); + color: var(--kp-text); + font-size: 13px; +} + +.combat-status-bar { + height: 34px; + padding-left: 10px; + padding-right: 10px; + flex-direction: row; + align-items: center; + background-color: var(--kp-bg-deep); + border-bottom-width: 1px; + border-bottom-color: var(--kp-border); +} + +.status-cluster { + flex-direction: row; + align-items: center; + margin-right: 14px; +} + +.status-label { + color: var(--kp-text-dim); + font-size: 11px; + margin-right: 5px; +} + +.status-value { + color: var(--kp-text); + -unity-font-style: bold; +} + +.phase-pill { + color: var(--kp-bg); + background-color: var(--kp-accent); + padding-left: 8px; + padding-right: 8px; + padding-top: 2px; + padding-bottom: 2px; + -unity-font-style: bold; +} + +.status-spacer { + flex-grow: 1; +} + +.seed-value { + color: var(--kp-text-dim); + font-size: 11px; +} + +.main-layout { + flex-grow: 1; + min-height: 0; + flex-direction: row; + padding: 10px; +} + +.bottom-layout { + min-height: 178px; + flex-direction: row; + padding-left: 10px; + padding-right: 10px; + padding-bottom: 10px; +} + +.panel { + min-height: 0; + padding: 10px; + background-color: var(--kp-panel); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.panel-header { + flex-direction: row; + align-items: center; + margin-bottom: 10px; + border-bottom-width: 1px; + border-bottom-color: var(--kp-border-subtle); + padding-bottom: 5px; +} + +.panel-title { + color: var(--kp-text); + -unity-font-style: bold; + font-size: 15px; + flex-grow: 1; +} + +.panel-subtitle { + color: var(--kp-text-dim); + font-size: 11px; +} + +.field-panel { + flex-grow: 1; + margin-left: 10px; + margin-right: 10px; +} + +.hand-panel { + flex-grow: 1; +} + +.turn-panel { + margin-left: 10px; +} + +.meter-block { + margin-bottom: 10px; +} + +.meter-head { + flex-direction: row; + align-items: center; + margin-bottom: 4px; +} + +.metric-label { + color: var(--kp-text-dim); + font-size: 11px; + flex-grow: 1; +} + +.metric-value { + color: var(--kp-text); + -unity-font-style: bold; +} + +.metric-value-muted { + color: var(--kp-text-dim); +} + +.meter-track { + height: 13px; + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border-meter); +} + +.meter-fill { + height: 100%; + background-color: var(--kp-text); +} + +.meter-fill-warning { + background-color: var(--kp-warning); +} + +.meter-fill-danger { + background-color: var(--kp-danger); +} + +.meter-fill-beneficial { + background-color: var(--kp-beneficial); +} + +.cycle-row { + margin-top: 2px; + margin-bottom: 10px; +} + +.cycle-pips { + color: var(--kp-warning); + font-size: 17px; + -unity-font-style: bold; +} + +.ram-note { + color: var(--kp-text-dim); + font-size: 11px; + margin-bottom: 8px; +} + +.status-section-title { + color: var(--kp-text-dim); + font-size: 11px; + margin-top: 8px; + margin-bottom: 5px; +} + +.status-row { + flex-direction: row; + flex-wrap: wrap; +} + +.status-pip { + flex-direction: row; + align-items: center; + margin-right: 5px; + margin-bottom: 5px; + padding-left: 5px; + padding-right: 5px; + padding-top: 2px; + padding-bottom: 2px; + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.status-pip-harmful { + color: var(--kp-warning); + border-color: var(--kp-warning); +} + +.status-pip-beneficial { + color: var(--kp-beneficial); + border-color: var(--kp-beneficial); +} + +.status-pip-empty { + color: var(--kp-text-dim); +} + +.status-tooltip { + color: var(--kp-text-dim); + font-size: 11px; + white-space: normal; + margin-bottom: 4px; +} + +.track-zone { + flex-grow: 1; + min-height: 0; + margin-bottom: 8px; + padding: 8px; + background-color: var(--kp-panel-track); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.track-zone-queue { + border-left-color: var(--kp-lang-python); +} + +.track-zone-stack { + border-left-color: var(--kp-lang-haskell); +} + +.track-zone-token { + border-left-color: var(--kp-lang-go); +} + +.track-header { + flex-direction: row; + align-items: center; + margin-bottom: 6px; +} + +.track-title { + color: var(--kp-text); + -unity-font-style: bold; + flex-grow: 1; +} + +.track-subtitle { + color: var(--kp-text-dim); + font-size: 11px; +} + +.track-contents { + flex-grow: 1; + min-height: 36px; + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; +} + +.empty-state { + color: var(--kp-text-dim); + font-size: 12px; + background-color: var(--kp-bg); + padding-left: 8px; + padding-right: 8px; + padding-top: 5px; + padding-bottom: 5px; + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border-empty); +} + +.card-chip { + margin-right: 6px; + margin-bottom: 6px; + padding-left: 7px; + padding-right: 7px; + padding-top: 5px; + padding-bottom: 5px; + background-color: var(--kp-bg-deep); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.card-chip-name { + color: var(--kp-text); + -unity-font-style: bold; + font-size: 12px; +} + +.card-chip-meta { + color: var(--kp-text-dim); + font-size: 10px; +} + +.enemy-row { + flex-direction: row; + flex-wrap: wrap; + flex-grow: 1; + align-content: flex-start; +} + +.enemy-card { + width: 132px; + min-height: 154px; + margin-right: 8px; + margin-bottom: 8px; + padding: 8px; + background-color: var(--kp-panel-raised); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); + color: var(--kp-text); +} + +.enemy-card:hover { + border-color: var(--kp-border-strong); +} + +.enemy-card-targeted { + border-color: var(--kp-accent); + background-color: var(--kp-panel-targeted); +} + +.enemy-name { + color: var(--kp-text); + -unity-font-style: bold; + font-size: 14px; + margin-bottom: 7px; + white-space: normal; +} + +.enemy-shield { + color: var(--kp-beneficial); + font-size: 11px; + margin-top: 4px; + margin-bottom: 4px; +} + +.intent-panel { + margin-top: 8px; + padding: 6px; + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border-meter); +} + +.intent-label { + color: var(--kp-text-dim); + font-size: 10px; +} + +.intent-value { + -unity-font-style: bold; + font-size: 18px; +} + +.intent-attack { + color: var(--kp-warning); + border-color: var(--kp-warning); +} + +.intent-defend { + color: var(--kp-beneficial); + border-color: var(--kp-beneficial); +} + +.intent-special { + color: var(--kp-accent); + border-color: var(--kp-accent); +} + +.hand-row { + flex-direction: row; + flex-grow: 1; + min-height: 0; +} + +.hand-card { + width: 158px; + min-height: 146px; + margin-right: 8px; + padding: 8px; + background-color: var(--kp-panel-raised); + color: var(--kp-text); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.hand-card:hover { + border-color: var(--kp-border-strong); + background-color: var(--kp-panel-hover); +} + +.hand-card-selected { + border-color: var(--kp-accent); + background-color: var(--kp-action-hover); +} + +.hand-card-unaffordable { + opacity: 0.48; +} + +.hand-card-rare { + border-right-color: var(--kp-beneficial); +} + +.hand-card-epic { + border-right-color: var(--kp-lang-haskell); +} + +.hand-card-legendary { + border-right-color: var(--kp-warning); +} + +.card-top-row { + flex-direction: row; + align-items: center; + margin-bottom: 6px; +} + +.card-cost { + width: 28px; + height: 28px; + margin-right: 7px; + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-warning); + color: var(--kp-warning); + -unity-text-align: middle-center; + -unity-font-style: bold; + font-size: 16px; +} + +.card-name { + flex-grow: 1; + color: var(--kp-text); + -unity-font-style: bold; + font-size: 14px; + white-space: normal; +} + +.tag-row { + flex-direction: row; + flex-wrap: wrap; + margin-bottom: 7px; +} + +.tag { + font-size: 10px; + padding-left: 5px; + padding-right: 5px; + padding-top: 2px; + padding-bottom: 2px; + margin-right: 5px; + margin-bottom: 4px; + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.track-tag { + color: var(--kp-text-dim); +} + +.language-c { + color: var(--kp-lang-c); + border-color: var(--kp-lang-c); +} + +.language-cpp { + color: var(--kp-lang-cpp); + border-color: var(--kp-lang-cpp); +} + +.language-rust { + color: var(--kp-lang-rust); + border-color: var(--kp-lang-rust); +} + +.language-python { + color: var(--kp-lang-python); + border-color: var(--kp-lang-python); +} + +.language-javascript { + color: var(--kp-lang-javascript); + border-color: var(--kp-lang-javascript); +} + +.language-typescript { + color: var(--kp-lang-typescript); + border-color: var(--kp-lang-typescript); +} + +.language-haskell { + color: var(--kp-lang-haskell); + border-color: var(--kp-lang-haskell); +} + +.language-assembly { + color: var(--kp-lang-assembly); + border-color: var(--kp-lang-assembly); +} + +.language-java { + color: var(--kp-lang-java); + border-color: var(--kp-lang-java); +} + +.language-go { + color: var(--kp-lang-go); + border-color: var(--kp-lang-go); +} + +.language-ruby { + color: var(--kp-lang-ruby); + border-color: var(--kp-lang-ruby); +} + +.language-php { + color: var(--kp-lang-php); + border-color: var(--kp-lang-php); +} + +.card-effect { + color: var(--kp-text-dim); + white-space: normal; + font-size: 11px; + flex-grow: 1; +} + +.turn-resource-grid { + margin-bottom: 10px; +} + +.turn-stat-row { + flex-direction: row; + margin-bottom: 4px; +} + +.turn-stat-label { + color: var(--kp-text-dim); + width: 72px; + font-size: 11px; +} + +.turn-stat-value { + color: var(--kp-text); + -unity-font-style: bold; +} + +.primary-action { + height: 44px; + background-color: var(--kp-bg-deep); + color: var(--kp-text); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-accent); + -unity-font-style: bold; +} + +.primary-action:hover { + background-color: var(--kp-action-hover); +} + +.log-line { + color: var(--kp-text-dim); + white-space: normal; + margin-top: 8px; + font-size: 11px; +} + +.overlay { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: var(--kp-overlay); + justify-content: center; + align-items: center; +} + +.overlay-title { + color: var(--kp-warning); + -unity-font-style: bold; + font-size: 28px; + margin-bottom: 10px; +} + +.overlay-line { + color: var(--kp-text); + margin-bottom: 6px; +} + +.repository { + width: 900px; + height: 86%; + padding: 14px; + background-color: var(--kp-panel); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-accent); +} + +.repository-header { + flex-direction: row; + align-items: center; + margin-bottom: 8px; +} + +.repository-header-copy { + flex-grow: 1; +} + +.repository-wallet { + min-width: 132px; + padding: 8px; + background-color: var(--kp-bg-deep); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-accent); +} + +.repository-wallet-value { + color: var(--kp-warning); + -unity-font-style: bold; + font-size: 22px; +} + +.repository-body { + flex-grow: 1; + min-height: 0; + margin-top: 8px; + margin-bottom: 10px; +} + +.repository-offers { + flex-direction: row; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.repository-row { + margin-bottom: 8px; + padding: 8px; + background-color: var(--kp-panel-raised); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.repository-tile { + width: 270px; + min-height: 244px; + margin-right: 10px; + margin-bottom: 10px; + padding: 8px; + background-color: var(--kp-panel-raised); + border-left-width: 2px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.repository-tile-card { + border-left-color: var(--kp-accent); +} + +.repository-tile-upgrade { + border-left-color: var(--kp-beneficial); +} + +.repository-tile-stat { + border-left-color: var(--kp-warning); + min-height: 190px; +} + +.repository-tile-top { + flex-direction: row; + align-items: center; + margin-bottom: 7px; +} + +.repository-kind { + color: var(--kp-text-dim); + font-size: 11px; + flex-grow: 1; +} + +.repository-price { + min-width: 46px; + padding-left: 7px; + padding-right: 7px; + padding-top: 3px; + padding-bottom: 3px; + color: var(--kp-bg); + background-color: var(--kp-warning); + -unity-font-style: bold; + -unity-text-align: middle-center; +} + +.repository-price-warning { + color: var(--kp-warning); + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-warning); +} + +.repository-name { + color: var(--kp-text); + -unity-font-style: bold; + margin-bottom: 4px; +} + +.repository-description { + color: var(--kp-text-dim); + white-space: normal; + margin-bottom: 6px; +} + +.repository-action { + height: 28px; + margin-top: 7px; + background-color: var(--kp-bg-deep); + color: var(--kp-text); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-accent); + -unity-font-style: bold; +} + +.repository-card-content { + flex-grow: 1; +} + +.repository-card-preview { + width: 100%; + min-height: 154px; + margin-right: 0; +} + +.repository-run-only-tag { + color: var(--kp-accent); + border-color: var(--kp-accent); +} + +.repository-upgrade-content { + flex-grow: 1; +} + +.repository-upgrade-card { + width: 100%; + min-height: 126px; + margin-right: 0; + opacity: 0.72; +} + +.repository-delta { + flex-direction: row; + align-items: center; + margin-top: 7px; + padding: 7px; + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-beneficial); +} + +.repository-delta-icon { + color: var(--kp-beneficial); + -unity-font-style: bold; + font-size: 18px; + margin-right: 7px; +} + +.repository-delta-text { + color: var(--kp-text); + white-space: normal; + flex-grow: 1; +} + +.repository-stat-content { + flex-grow: 1; + justify-content: center; +} + +.repository-stat-icon { + color: var(--kp-warning); + -unity-font-style: bold; + font-size: 30px; + margin-bottom: 10px; +} + +.repository-footer { + flex-direction: row; + min-height: 44px; + flex-shrink: 0; +} + +.repository-footer-action { + width: 210px; + margin-right: 8px; +} + +.repository-leave-action { + flex-grow: 1; +} + +.repository-leave-block { + flex-grow: 1; +} + +.repository-banked-hint { + color: var(--kp-text-dim); + font-size: 11px; + margin-top: 4px; + -unity-text-align: middle-center; +} + +.repository-remove { + margin-bottom: 2px; + padding: 8px; + background-color: var(--kp-bg-deep); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border-empty); +} + +.repository-remove-list { + flex-direction: row; + flex-wrap: wrap; +} + +.repo-remove-button { + height: 24px; + margin-right: 6px; + margin-bottom: 6px; + background-color: var(--kp-bg-deep); + color: var(--kp-text-dim); + border-left-width: 1px; + border-right-width: 1px; + border-top-width: 1px; + border-bottom-width: 1px; + border-color: var(--kp-border); +} + +.repo-unaffordable { + opacity: 0.45; +} + +.repo-sold { + opacity: 0.72; + border-color: var(--kp-text-dim); +} diff --git a/Assets/Scenes/GameScene.unity b/Assets/Scenes/GameScene.unity index 68730bc..3d56468 100644 --- a/Assets/Scenes/GameScene.unity +++ b/Assets/Scenes/GameScene.unity @@ -90,7 +90,6 @@ GameObject: - component: {fileID: 100004} - component: {fileID: 100003} - component: {fileID: 100001} - - component: {fileID: 100002} m_Layer: 0 m_Name: Main Camera m_TagString: MainCamera @@ -106,38 +105,6 @@ AudioListener: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 100000} m_Enabled: 1 ---- !u!114 &100002 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 100000} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3} - m_Name: - m_EditorClassIdentifier: - m_RenderShadows: 1 - m_RequiresDepthTextureOption: 2 - m_RequiresOpaqueTextureOption: 2 - m_CameraType: 0 - m_Cameras: [] - m_RendererIndex: -1 - m_VolumeLayerMask: - serializedVersion: 2 - m_Bits: 1 - m_VolumeTrigger: {fileID: 0} - m_RenderPostProcessing: 0 - m_Antialiasing: 0 - m_AntialiasingQuality: 2 - m_StopNaN: 0 - m_Dithering: 0 - m_ClearDepth: 1 - m_AllowXRRendering: 1 - m_RequiresDepthTexture: 0 - m_RequiresColorTexture: 0 - m_Version: 2 --- !u!20 &100003 Camera: m_ObjectHideFlags: 0 @@ -206,6 +173,10 @@ GameObject: serializedVersion: 6 m_Component: - component: {fileID: 200001} + - component: {fileID: 200002} + - component: {fileID: 200003} + - component: {fileID: 200004} + - component: {fileID: 200005} m_Layer: 0 m_Name: GameRoot m_TagString: Untagged @@ -229,3 +200,63 @@ Transform: m_Father: {fileID: 0} m_RootOrder: 1 m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &200002 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 200000} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 19102, guid: 0000000000000000e000000000000000, type: 0} + m_Name: + m_EditorClassIdentifier: + m_PanelSettings: {fileID: 11400000, guid: 7d68bd7978394c8295debbd403a39d80, type: 2} + m_ParentUI: {fileID: 0} + sourceAsset: {fileID: 0} + m_SortingOrder: 0 +--- !u!114 &200003 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 200000} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 9f87432412de1ee46bf7d4c9c530d4c6, type: 3} + m_Name: + m_EditorClassIdentifier: + currentPhase: 0 +--- !u!114 &200004 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 200000} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 234cc6c5ca577b64ea44020f5514cebe, type: 3} + m_Name: + m_EditorClassIdentifier: + combatManager: {fileID: 200003} + currentWaveNumber: 0 + isRunActive: 0 + isPlayerDead: 0 +--- !u!114 &200005 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 200000} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: b1100000000000000000000000000003, type: 3} + m_Name: + m_EditorClassIdentifier: + distroDatabase: {fileID: 11400000, guid: 4d88ca5446fa4d80b2fbd4b12697cb7c, type: 2} + cardDatabase: {fileID: 11400000, guid: 1632132e32bfc31e8a293140f5908aa3, type: 2} + languageDeckDatabase: {fileID: 11400000, guid: a7d20000000000000000000000000001, type: 2} diff --git a/Assets/Scenes/MainMenuScene.unity b/Assets/Scenes/MainMenuScene.unity index 6581eaa..7139b7d 100644 --- a/Assets/Scenes/MainMenuScene.unity +++ b/Assets/Scenes/MainMenuScene.unity @@ -134,7 +134,6 @@ GameObject: - component: {fileID: 519420032} - component: {fileID: 519420031} - component: {fileID: 519420029} - - component: {fileID: 519420030} m_Layer: 0 m_Name: Main Camera m_TagString: MainCamera @@ -150,38 +149,6 @@ AudioListener: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 519420028} m_Enabled: 1 ---- !u!114 &519420030 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 519420028} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3} - m_Name: - m_EditorClassIdentifier: - m_RenderShadows: 1 - m_RequiresDepthTextureOption: 2 - m_RequiresOpaqueTextureOption: 2 - m_CameraType: 0 - m_Cameras: [] - m_RendererIndex: -1 - m_VolumeLayerMask: - serializedVersion: 2 - m_Bits: 1 - m_VolumeTrigger: {fileID: 0} - m_RenderPostProcessing: 0 - m_Antialiasing: 0 - m_AntialiasingQuality: 2 - m_StopNaN: 0 - m_Dithering: 0 - m_ClearDepth: 1 - m_AllowXRRendering: 1 - m_RequiresDepthTexture: 0 - m_RequiresColorTexture: 0 - m_Version: 2 --- !u!20 &519420031 Camera: m_ObjectHideFlags: 0 diff --git a/Assets/Scripts/Combat/CardEffects.cs b/Assets/Scripts/Combat/CardEffects.cs new file mode 100644 index 0000000..f153947 --- /dev/null +++ b/Assets/Scripts/Combat/CardEffects.cs @@ -0,0 +1,447 @@ +using System.Collections.Generic; +using System.Linq; +using KernelPanic.Core; +using KernelPanic.Data; + +namespace KernelPanic.Combat +{ + public sealed class DealDamageEffect : ICardEffect + { + private readonly int _minAmount; + private readonly int _maxAmount; + private readonly Language _language; + private readonly bool _trueDamage; + private readonly bool _canCrit; + private readonly bool _allEnemies; + + public DealDamageEffect(int minAmount, int maxAmount, Language language, bool trueDamage = false, bool canCrit = false, bool allEnemies = false) + { + _minAmount = minAmount; + _maxAmount = maxAmount; + _language = language; + _trueDamage = trueDamage; + _canCrit = canCrit; + _allEnemies = allEnemies; + } + + public void Execute(CombatContext context) + { + IReadOnlyList targets = _allEnemies + ? context.Enemies.Where(enemy => enemy.State != null && !enemy.State.IsDefeated).Select(enemy => enemy.State).ToList() + : context.Targets; + + if (StatusEffectController.Has(context.Source, StatusType.RaceCondition) && targets.Count > 1) + { + targets = ShuffleTargets(targets, context.Source); + } + + for (int i = 0; i < targets.Count; i++) + { + CombatantState target = targets[i]; + if (target == null || target.IsDefeated) + { + continue; + } + + int minAmount = UpgradeMath.ScaleAmount(_minAmount, context.Card); + int maxAmount = UpgradeMath.ScaleAmount(_maxAmount, context.Card); + int amount = minAmount == maxAmount + ? minAmount + : RandomRoll.RollRange(minAmount, maxAmount, new RollContext(context.Source)); + + context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, _trueDamage, _canCrit)); + } + } + + private static IReadOnlyList ShuffleTargets(IReadOnlyList targets, CombatantState source) + { + List shuffled = targets.ToList(); + for (int i = shuffled.Count - 1; i > 0; i--) + { + int swapIndex = RandomRoll.RollRange(0, i, new RollContext(source)); + (shuffled[i], shuffled[swapIndex]) = (shuffled[swapIndex], shuffled[i]); + } + + return shuffled; + } + } + + public sealed class ShieldEffect : ICardEffect + { + private readonly int _amount; + + public ShieldEffect(int amount) + { + _amount = amount; + } + + public void Execute(CombatContext context) + { + int amount = UpgradeMath.ScaleShield(_amount, context.Card); + context.Source.Shield += amount; + context.CombatManager.ReportEffectResult($"gained {amount} shield"); + } + } + + public sealed class ChanceDamageEffect : ICardEffect + { + private readonly int _damageAmount; + private readonly int _successPercent; + private readonly Language _language; + + public ChanceDamageEffect(int damageAmount, int successPercent, Language language) + { + _damageAmount = damageAmount; + _successPercent = successPercent; + _language = language; + } + + public void Execute(CombatContext context) + { + for (int i = 0; i < context.Targets.Count; i++) + { + CombatantState target = context.Targets[i]; + if (target == null || target.IsDefeated) + { + continue; + } + + int roll = RandomRoll.RollRange(1, 100, new RollContext(context.Source)); + int damageAmount = UpgradeMath.ScaleAmount(_damageAmount, context.Card); + int amount = roll <= _successPercent ? damageAmount : 0; + context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, false)); + context.CombatManager.ReportEffectResult(amount == 0 ? "typeof -> NaN (0)" : $"typeof -> {amount}"); + } + } + } + + public sealed class ApplyStatusEffect : ICardEffect + { + private readonly StatusType _statusType; + private readonly int _stacks; + private readonly int _duration; + private readonly bool _targetSelf; + private readonly bool _skipNextTick; + + public ApplyStatusEffect(StatusType statusType, int stacks, int duration, bool targetSelf, bool skipNextTick = false) + { + _statusType = statusType; + _stacks = stacks; + _duration = duration; + _targetSelf = targetSelf; + _skipNextTick = skipNextTick; + } + + public void Execute(CombatContext context) + { + if (_targetSelf) + { + context.StatusEffects.Apply(context.Source, _statusType, UpgradeMath.ScaleAmount(_stacks, context.Card), _duration, context.Source, _skipNextTick); + return; + } + + for (int i = 0; i < context.Targets.Count; i++) + { + context.StatusEffects.Apply(context.Targets[i], _statusType, UpgradeMath.ScaleAmount(_stacks, context.Card), _duration, context.Source, _skipNextTick); + } + } + } + + public sealed class CleanseEffect : ICardEffect + { + private readonly bool _harmfulOnly; + + public CleanseEffect(bool harmfulOnly) + { + _harmfulOnly = harmfulOnly; + } + + public void Execute(CombatContext context) + { + if (_harmfulOnly) + { + context.StatusEffects.CleanseHarmful(context.Source); + } + } + } + + public sealed class DrawEffect : ICardEffect + { + private readonly int _count; + + public DrawEffect(int count) + { + _count = count; + } + + public void Execute(CombatContext context) + { + int room = context.HandController.RamCapacity - context.HandController.Cards.Count; + if (room <= 0) + { + context.CombatManager.ReportEffectResult("hand full: drew 0"); + return; + } + + // RAM is a hard hand cap. Draw effects only pull cards that can fit; blocked cards stay in the draw pile. + int requested = UpgradeMath.ScaleAmount(_count, context.Card); + int drawCount = UnityEngine.Mathf.Min(requested, room); + IReadOnlyList drawn = context.DeckController.Draw(drawCount); + int added = 0; + for (int i = 0; i < drawn.Count; i++) + { + if (context.HandController.Add(drawn[i])) + { + added++; + } + } + + string capped = drawCount < requested ? " (hand full)" : string.Empty; + context.CombatManager.ReportEffectResult($"drew {added}{capped}"); + } + } + + public sealed class UpgradeHandCardsEffect : ICardEffect + { + private readonly int _minCount; + private readonly int _maxCount; + + public UpgradeHandCardsEffect(int minCount, int maxCount) + { + _minCount = minCount; + _maxCount = maxCount; + } + + public void Execute(CombatContext context) + { + int minCount = UpgradeMath.ScaleAmount(_minCount, context.Card); + int maxCount = UpgradeMath.ScaleAmount(_maxCount, context.Card); + int targetCount = minCount == maxCount + ? minCount + : RandomRoll.RollRange(minCount, maxCount, new RollContext(context.Source)); + + List candidates = context.HandController.Cards + .Where(card => card != null && card.CanUpgrade) + .ToList(); + int upgraded = 0; + while (upgraded < targetCount && candidates.Count > 0) + { + int index = RandomRoll.RollRange(0, candidates.Count - 1, new RollContext(context.Source)); + CardInstance card = candidates[index]; + candidates.RemoveAt(index); + if (card.Upgrade()) + { + upgraded++; + } + } + + context.CombatManager.ReportEffectResult(upgraded == 0 ? "no cards upgraded" : $"upgraded {upgraded} card(s)"); + } + } + + public sealed class GenerateCardEffect : ICardEffect + { + private readonly Language _language; + private readonly Rarity _rarity; + + public GenerateCardEffect(Language language, Rarity rarity) + { + _language = language; + _rarity = rarity; + } + + public void Execute(CombatContext context) + { + if (context.HandController.Cards.Count >= context.HandController.RamCapacity) + { + context.CombatManager.ReportEffectResult("hand full: generated 0"); + return; + } + + if (!context.CombatManager.TryCreateGeneratedCard(_language, _rarity, out CardInstance generatedCard)) + { + context.CombatManager.ReportEffectResult($"no {_rarity.ToString().ToLowerInvariant()} {_language} card pool"); + return; + } + + if (context.HandController.Add(generatedCard)) + { + context.CombatManager.ReportEffectResult($"generated {GetCardName(generatedCard)} at 0c"); + } + else + { + context.CombatManager.ReportEffectResult("hand full: generated 0"); + } + } + + private static string GetCardName(CardInstance card) + { + if (card?.Definition == null) + { + return "--"; + } + + return string.IsNullOrWhiteSpace(card.Definition.DisplayName) ? card.Definition.Id : card.Definition.DisplayName; + } + } + + public sealed class NoOpCardEffect : ICardEffect + { + private readonly string _todo; + + public NoOpCardEffect(string todo) + { + _todo = todo; + } + + public void Execute(CombatContext context) + { + context.CombatManager.LogEffectTodo(context.Card, _todo); + } + } + + public static class CardEffectFactory + { + public static IReadOnlyList CreateEffects(CardDefinition definition) + { + List serializedEffects = CreateSerializedEffects(definition); + if (serializedEffects.Count > 0) + { + return serializedEffects; + } + + return definition?.Id switch + { + "lang_py_print" => One(new DealDamageEffect(3, 3, Language.Python)), + "lang_py_import_antigravity" => One(new DrawEffect(2)), + "lang_py_for_loop" => One(new DealDamageEffect(2, 2, Language.Python, allEnemies: true)), + "lang_js_console_log" => One(new DealDamageEffect(3, 9, Language.JavaScript)), + "lang_js_fetch" => One(new DealDamageEffect(5, 13, Language.JavaScript)), + "ubuntu_ask_ubuntu" => One(new DrawEffect(2)), + "ubuntu_unattended_upgrades" => new ICardEffect[] + { + new ShieldEffect(4), + new ApplyStatusEffect(StatusType.UnattendedUpgrades, 4, 2, targetSelf: true, skipNextTick: true) + }, + "mint_fix_broken" => new ICardEffect[] + { + new DealDamageEffect(8, 8, Language.JavaScript), + new CleanseEffect(harmfulOnly: true) + }, + "lang_js_typeof" => One(new ChanceDamageEffect(8, 75, Language.JavaScript)), + "shop_py_list_append" => One(new DealDamageEffect(4, 4, Language.Python)), + "shop_py_async_def" => One(new DrawEffect(2)), + "shop_py_zip" => Todo("TODO: zip() needs queue-effect-multiply support."), + "shop_js_math_random" => One(new DealDamageEffect(2, 20, Language.JavaScript)), + "shop_js_promise_all" => new ICardEffect[] + { + new DealDamageEffect(4, 10, Language.JavaScript), + new DealDamageEffect(4, 10, Language.JavaScript), + new DealDamageEffect(4, 10, Language.JavaScript) + }, + "shop_js_spread" => Todo("TODO: spread ... needs card-copy support."), + "ubuntu_snap_install" => Todo("TODO: snap install needs token/junk insertion system."), + "ubuntu_do_release_upgrade" => One(new UpgradeHandCardsEffect(1, 3)), + "ubuntu_apt_install" => One(new GenerateCardEffect(Language.Python, Rarity.Common)), + "ubuntu_pro_trial" => Todo("TODO: ubuntu pro trial needs unplayable token handling."), + "mint_update_manager" => Todo("TODO: update manager needs delayed repeat system."), + "mint_cinnamon" => Todo("TODO: cinnamon needs shield effect support."), + "mint_nemo" => Todo("TODO: nemo needs conditional repeat based on shield."), + "mint_timeshift" => Todo("TODO: timeshift snapshot needs delayed restore system."), + "fedora_borrow_checker" => Todo("TODO: borrow checker needs shield and first-card tracking."), + "fedora_cargo_build" => Todo("TODO: cargo build --release needs overkill-to-shield handling."), + "fedora_dnf_update" => Todo("TODO: dnf update needs Java played count and cost mutation."), + "fedora_rawhide" => Todo("TODO: rawhide needs next-card cost mutation and crash-risk hook."), + "fedora_selinux" => Todo("TODO: SELinux enforcing needs enemy attack mitigation statuses."), + _ => Todo("TODO: card effect is not implemented yet.") + }; + } + + public static bool RequiresSingleTarget(CardDefinition definition) + { + return definition?.Id is "lang_js_console_log" or "lang_js_fetch" or "lang_js_typeof" or "mint_fix_broken" + or "shop_py_list_append" or "shop_js_math_random" or "shop_js_promise_all"; + } + + public static bool TargetsAllEnemies(CardDefinition definition) + { + return definition?.Id is "lang_py_for_loop"; + } + + private static List CreateSerializedEffects(CardDefinition definition) + { + List effects = new(); + if (definition?.Effects == null) + { + return effects; + } + + for (int i = 0; i < definition.Effects.Count; i++) + { + ICardEffect effect = definition.Effects[i] == null ? null : definition.Effects[i].CreateRuntimeEffect(); + if (effect != null) + { + effects.Add(effect); + } + } + + return effects; + } + + private static IReadOnlyList One(ICardEffect effect) + { + return new[] { effect }; + } + + private static IReadOnlyList Todo(string message) + { + return One(new NoOpCardEffect(message)); + } + + public static string GetRulesText(CardInstance card) + { + if (card?.Definition == null) + { + return "--"; + } + + int level = card.UpgradeLevel; + string marker = level > 0 ? " [upgraded]" : string.Empty; + return card.Definition.Id switch + { + "lang_py_print" => $"queue: deal {UpgradeMath.ScaleAmount(3, card)}.{marker}", + "lang_py_import_antigravity" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}.{marker}", + "lang_py_for_loop" => $"queue: deal {UpgradeMath.ScaleAmount(2, card)} to all enemies.{marker}", + "lang_js_console_log" => $"deal {UpgradeMath.ScaleAmount(3, card)}-{UpgradeMath.ScaleAmount(9, card)}.{marker}", + "lang_js_fetch" => $"deal {UpgradeMath.ScaleAmount(5, card)}-{UpgradeMath.ScaleAmount(13, card)}.{marker}", + "lang_js_typeof" => $"75%: deal {UpgradeMath.ScaleAmount(8, card)}. 25%: deal 0.{marker}", + "shop_py_list_append" => $"queue: deal {UpgradeMath.ScaleAmount(4, card)}. TODO: grows when re-queued.{marker}", + "shop_py_async_def" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}. TODO: gain 1 cycle next turn.{marker}", + "shop_py_zip" => $"TODO: next 2 queued cards resolve twice.{marker}", + "shop_js_math_random" => $"deal {UpgradeMath.ScaleAmount(2, card)}-{UpgradeMath.ScaleAmount(20, card)}.{marker}", + "shop_js_promise_all" => $"deal {UpgradeMath.ScaleAmount(4, card)}-{UpgradeMath.ScaleAmount(10, card)} three times.{marker}", + "shop_js_spread" => $"TODO: copy the lowest-cost card in hand.{marker}", + "ubuntu_ask_ubuntu" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}.{marker}", + "ubuntu_unattended_upgrades" => $"queue: gain {UpgradeMath.ScaleShield(4, card)} shield now and next 2 turns.{marker}", + "ubuntu_do_release_upgrade" => $"upgrade {UpgradeMath.ScaleAmount(1, card)}-{UpgradeMath.ScaleAmount(3, card)} random hand cards.{marker}", + "ubuntu_apt_install" => $"queue: generate a random common Python card at 0c.{marker}", + "mint_fix_broken" => $"deal {UpgradeMath.ScaleAmount(8, card)}. cleanse harmful statuses.{marker}", + _ => string.IsNullOrWhiteSpace(card.Definition.Description) ? "TODO: effect not implemented." : $"{card.Definition.Description}{marker}" + }; + } + } + + public static class UpgradeMath + { + public static int ScaleAmount(int baseAmount, CardInstance card) + { + return baseAmount + UnityEngine.Mathf.Max(0, card?.MagnitudeBonus ?? 0); + } + + public static int ScaleShield(int baseAmount, CardInstance card) + { + return baseAmount + UnityEngine.Mathf.Max(0, card?.MagnitudeBonus ?? 0); + } + + } +} diff --git a/Assets/Scripts/Combat/CardInstance.cs b/Assets/Scripts/Combat/CardInstance.cs index 60c7857..eab7a7f 100644 --- a/Assets/Scripts/Combat/CardInstance.cs +++ b/Assets/Scripts/Combat/CardInstance.cs @@ -1,4 +1,6 @@ using KernelPanic.Data; +using System.Collections.Generic; +using System.Linq; namespace KernelPanic.Combat { @@ -7,14 +9,98 @@ namespace KernelPanic.Combat /// public sealed class CardInstance { + private readonly List _targetSnapshot = new(); + public CardInstance(CardDefinition definition) { Definition = definition; } public CardDefinition Definition { get; } + public IReadOnlyList TargetSnapshot => _targetSnapshot; public int TemporaryCostDelta { get; set; } + public int PermanentCostDelta { get; private set; } + public int MagnitudeBonus { get; private set; } + public int DrawRider { get; private set; } + public int UpgradeLevel { get; private set; } public bool IsBroken { get; set; } public bool IsLocked { get; set; } + public bool CanUpgrade => UpgradeLevel == 0; + public bool CanReceiveRepositoryUpgrade => UpgradeLevel < 3; + public bool IsUpgraded => UpgradeLevel > 0; + + public bool Upgrade() + { + if (!CanUpgrade) + { + return false; + } + + UpgradeLevel = 1; + ApplyCostUpgrade(-1); + ApplyMagnitudeUpgrade(1); + + return true; + } + + public bool ApplyCostUpgrade(int delta) + { + if (Definition == null || Definition.CycleCost + PermanentCostDelta + TemporaryCostDelta <= 0) + { + return false; + } + + PermanentCostDelta += delta; + UpgradeLevel++; + return true; + } + + public bool ApplyMagnitudeUpgrade(int amount) + { + if (amount <= 0) + { + return false; + } + + MagnitudeBonus += amount; + UpgradeLevel++; + return true; + } + + public bool ApplyDrawRider(int amount) + { + if (amount <= 0) + { + return false; + } + + DrawRider += amount; + UpgradeLevel++; + return true; + } + + public void SetTargetSnapshot(IEnumerable targets) + { + _targetSnapshot.Clear(); + if (targets == null) + { + return; + } + + _targetSnapshot.AddRange(targets.Where(target => target != null && !target.IsDefeated)); + } + + public void ClearTargetSnapshot() + { + _targetSnapshot.Clear(); + } + + public void ResetCombatState() + { + TemporaryCostDelta = 0; + IsBroken = false; + IsLocked = false; + ClearTargetSnapshot(); + } } } diff --git a/Assets/Scripts/Combat/CombatContext.cs b/Assets/Scripts/Combat/CombatContext.cs index 5b09b79..327d5cd 100644 --- a/Assets/Scripts/Combat/CombatContext.cs +++ b/Assets/Scripts/Combat/CombatContext.cs @@ -7,15 +7,36 @@ namespace KernelPanic.Combat /// public sealed class CombatContext { - public CombatContext(CombatantState source, IReadOnlyList targets, CombatManager combatManager) + public CombatContext( + CardInstance card, + CombatantState source, + IReadOnlyList targets, + CombatManager combatManager, + DamagePipeline damagePipeline, + StatusEffectController statusEffects, + DeckController deckController, + HandController handController, + IReadOnlyList enemies) { + Card = card; Source = source; Targets = targets; CombatManager = combatManager; + DamagePipeline = damagePipeline; + StatusEffects = statusEffects; + DeckController = deckController; + HandController = handController; + Enemies = enemies; } + public CardInstance Card { get; } public CombatantState Source { get; } public IReadOnlyList Targets { get; } public CombatManager CombatManager { get; } + public DamagePipeline DamagePipeline { get; } + public StatusEffectController StatusEffects { get; } + public DeckController DeckController { get; } + public HandController HandController { get; } + public IReadOnlyList Enemies { get; } } } diff --git a/Assets/Scripts/Combat/CombatManager.cs b/Assets/Scripts/Combat/CombatManager.cs index 0ffd48a..0e9d8f7 100644 --- a/Assets/Scripts/Combat/CombatManager.cs +++ b/Assets/Scripts/Combat/CombatManager.cs @@ -1,5 +1,10 @@ using KernelPanic.Core; +using KernelPanic.Data; +using KernelPanic.Run; using UnityEngine; +using System; +using System.Collections.Generic; +using System.Linq; namespace KernelPanic.Combat { @@ -10,26 +15,277 @@ namespace KernelPanic.Combat { [SerializeField] private TurnPhase currentPhase = TurnPhase.Boot; + private readonly DeckController deckController = new(); + private readonly InterpreterQueue interpreterQueue = new(); + private readonly LazyStack lazyStack = new(); + private readonly NativeTrack nativeTrack = new(); + private readonly StatusEffectController statusEffects = new(); + private readonly DamagePipeline damagePipeline = new(); + private readonly List enemies = new(); + private readonly HashSet loggedEffectTodos = new(); + private readonly List generatedCardPool = new(); + private RunManager runManager; + private RunConfig runConfig; + private HandController handController; + private CombatantState playerState; + private int selectedEnemyIndex = -1; + private CardInstance pendingTargetCard; + private bool awaitingWaveContinue; + private bool runLost; + private bool skipNextAllocateDraw; + public TurnPhase CurrentPhase => currentPhase; + public RunConfig RunConfig => runConfig; + public DeckController DeckController => deckController; + public HandController HandController => handController; + public CombatantState PlayerState => playerState; + public InterpreterQueue InterpreterQueue => interpreterQueue; + public LazyStack LazyStack => lazyStack; + public NativeTrack NativeTrack => nativeTrack; + public DamagePipeline DamagePipeline => damagePipeline; + public IReadOnlyList Enemies => enemies; + public int SelectedEnemyIndex => selectedEnemyIndex; + public CardInstance PendingTargetCard => pendingTargetCard; + public bool AwaitingWaveContinue => awaitingWaveContinue; + public bool RunLost => runLost; + public int CurrentWaveNumber => runManager == null ? 1 : runManager.CurrentWaveNumber; + + public event Action StateChanged; + public event Action CombatLog; + + private void Awake() + { + runManager = GetComponent(); + } public void StartCombat() { + if (runConfig == null) + { + Debug.LogWarning("CombatManager.StartCombat called without a RunConfig."); + return; + } + SetPhase(TurnPhase.Boot); } + public void StartCombat(RunConfig config) + { + runConfig = config; + StartCombat(); + } + public void AdvancePhase() { + if (IsCombatPaused) + { + return; + } + TurnPhase nextPhase = GetNextPhase(currentPhase); SetPhase(nextPhase); } public void EndPlayerTurn() { - SetPhase(TurnPhase.Interpret); + if (currentPhase == TurnPhase.Execute && !IsCombatPaused) + { + pendingTargetCard = null; + selectedEnemyIndex = -1; + SetPhase(TurnPhase.Interpret); + } + } + + public void ContinueToNextWave() + { + if (!awaitingWaveContinue || runLost) + { + return; + } + + awaitingWaveContinue = false; + StartWave(preservePlayerUptime: true); + SetPhase(TurnPhase.Allocate); + } + + public void SelectEnemy(int index) + { + if (IsCombatPaused) + { + return; + } + + if (index < 0 || index >= enemies.Count) + { + selectedEnemyIndex = -1; + StateChanged?.Invoke(); + return; + } + + selectedEnemyIndex = index; + if (pendingTargetCard != null) + { + CardInstance card = pendingTargetCard; + pendingTargetCard = null; + PlayCard(card); + return; + } + + StateChanged?.Invoke(); + } + + public bool PlayCard(CardInstance card) + { + if (currentPhase != TurnPhase.Execute || IsCombatPaused || card == null || handController == null || playerState == null) + { + return false; + } + + int cost = GetCardCost(card); + if (playerState.Cycles < cost) + { + Log($"not enough cycles for {GetCardName(card)}"); + return false; + } + + if (RequiresTarget(card) && selectedEnemyIndex < 0) + { + pendingTargetCard = card; + Log($"select target for {GetCardName(card)}"); + StateChanged?.Invoke(); + return false; + } + + if (!handController.Remove(card)) + { + return false; + } + + playerState.Cycles -= cost; + ResolutionTrack track = card.Definition.ResolutionTrack; + GameEvents.RaiseCardPlayed(new CardPlayedEvent(card, track)); + LogPlayIntent(card); + card.SetTargetSnapshot(CaptureTargets(card)); + + switch (track) + { + case ResolutionTrack.InterpreterQueue: + interpreterQueue.Enqueue(card); + break; + case ResolutionTrack.LazyStack: + lazyStack.Enqueue(card); + break; + default: + ExecuteCardEffects(card, ResolveTargets(card)); + ResolveCardRiders(card); + deckController.Discard(card); + GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.Native)); + selectedEnemyIndex = -1; + break; + } + + pendingTargetCard = null; + StateChanged?.Invoke(); + return true; + } + + public static int GetCardCost(CardInstance card) + { + if (card?.Definition == null) + { + return 0; + } + + return Mathf.Max(0, card.Definition.CycleCost + card.PermanentCostDelta + card.TemporaryCostDelta); + } + + public bool RequiresTarget(CardInstance card) + { + return card?.Definition != null && CardEffectFactory.RequiresSingleTarget(card.Definition) && LivingEnemyStates().Count > 0; + } + + public void LogEffectTodo(CardInstance card, string message) + { + string cardId = card?.Definition == null ? string.Empty : card.Definition.Id; + string key = $"{cardId}:{message}"; + if (loggedEffectTodos.Add(key)) + { + Log(message); + } + } + + public void ReportEffectResult(string message) + { + if (!string.IsNullOrWhiteSpace(message)) + { + Log(message); + } + } + + public void SetGeneratedCardPool(IEnumerable cardPool) + { + generatedCardPool.Clear(); + if (cardPool == null) + { + return; + } + + foreach (CardDefinition card in cardPool) + { + if (card != null && !card.IsToken && !card.IsRunOnly) + { + generatedCardPool.Add(card); + } + } + } + + public bool TryCreateGeneratedCard(Language language, Rarity rarity, out CardInstance generatedCard) + { + generatedCard = null; + + List pool = new(); + HashSet seenIds = new(); + IReadOnlyList source = generatedCardPool.Count > 0 ? generatedCardPool : runConfig?.StartingDeck; + if (source == null) + { + return false; + } + + for (int i = 0; i < source.Count; i++) + { + CardDefinition definition = source[i]; + if (definition == null || definition.IsToken || definition.IsRunOnly || definition.Language != language || definition.Rarity != rarity) + { + continue; + } + + if (seenIds.Add(definition.Id)) + { + pool.Add(definition); + } + } + + if (pool.Count == 0) + { + return false; + } + + int index = RandomRoll.RollRange(0, pool.Count - 1, new RollContext(playerState)); + CardDefinition selected = pool[index]; + generatedCard = new CardInstance(selected) + { + TemporaryCostDelta = -selected.CycleCost + }; + return true; } private void SetPhase(TurnPhase nextPhase) { + if (IsCombatPaused && nextPhase != TurnPhase.Boot) + { + return; + } + TurnPhase previousPhase = currentPhase; currentPhase = nextPhase; GameEvents.RaisePhaseChanged(new PhaseChangedEvent(previousPhase, nextPhase)); @@ -51,6 +307,532 @@ namespace KernelPanic.Combat private void EnterPhase(TurnPhase phase) { + switch (phase) + { + case TurnPhase.Boot: + BootCombat(); + if (!IsCombatPaused) + { + AdvancePhase(); + } + break; + case TurnPhase.Allocate: + AllocateTurn(); + if (!IsCombatPaused) + { + AdvancePhase(); + } + break; + case TurnPhase.Execute: + StateChanged?.Invoke(); + break; + case TurnPhase.Interpret: + InterpretQueue(); + if (!IsCombatPaused) + { + AdvancePhase(); + } + break; + case TurnPhase.EnemyProcess: + ProcessEnemies(); + if (!IsCombatPaused) + { + AdvancePhase(); + } + break; + case TurnPhase.GarbageCollection: + GarbageCollect(); + if (!IsCombatPaused) + { + AdvancePhase(); + } + break; + } } + + private void BootCombat() + { + runLost = false; + awaitingWaveContinue = false; + loggedEffectTodos.Clear(); + RandomRoll.Seed(runConfig.RunSeed); + playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles()); + StartWave(preservePlayerUptime: false); + Log($"booted {runConfig.Distro.DisplayName} with {runManager.RunDeck.Count} cards"); + StateChanged?.Invoke(); + } + + private void StartWave(bool preservePlayerUptime) + { + int carriedUptime = playerState == null ? 0 : playerState.CurrentUptime; + if (!preservePlayerUptime || playerState == null) + { + playerState = new CombatantState(runManager.EffectiveMaxUptime(), runManager.EffectiveRam(), runManager.EffectiveMaxCycles()); + } + else + { + playerState.MaxUptime = runManager.EffectiveMaxUptime(); + playerState.CurrentUptime = Mathf.Clamp(carriedUptime, 1, playerState.MaxUptime); + playerState.Ram = runManager.EffectiveRam(); + playerState.MaxCycles = runManager.EffectiveMaxCycles(); + playerState.Cycles = 0; + playerState.Shield = 0; + playerState.IsDefeated = false; + playerState.MutableStatuses.Clear(); + } + + handController = new HandController(playerState.Ram); + selectedEnemyIndex = -1; + pendingTargetCard = null; + + List startingCards = new(); + for (int i = 0; i < runManager.RunDeck.Count; i++) + { + CardInstance card = runManager.RunDeck[i]; + if (card != null) + { + card.ResetCombatState(); + startingCards.Add(card); + } + } + + deckController.Initialize(startingCards); + interpreterQueue.Clear(); + enemies.Clear(); + SpawnStructuralEnemies(); + PickEnemyIntents(); + DrawOpeningHand(); + skipNextAllocateDraw = true; + } + + private void AllocateTurn() + { + if (CheckLoss()) + { + return; + } + + playerState.Cycles = playerState.MaxCycles; + statusEffects.Tick(playerState, StatusTickTiming.StartOfTurn, playerState, damagePipeline); + if (CheckLoss()) + { + return; + } + + if (skipNextAllocateDraw) + { + skipNextAllocateDraw = false; + } + else + { + DrawForTurn(); + } + + StateChanged?.Invoke(); + } + + private void InterpretQueue() + { + while (interpreterQueue.TryDequeue(out CardInstance card)) + { + LogPlayIntent(card); + ExecuteCardEffects(card, ResolveQueuedTargets(card)); + ResolveCardRiders(card); + deckController.Discard(card); + GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue)); + if (CheckWinOrLoss()) + { + return; + } + } + + CheckWinOrLoss(); + StateChanged?.Invoke(); + } + + private void ProcessEnemies() + { + for (int i = 0; i < enemies.Count; i++) + { + EnemyInstance enemy = enemies[i]; + if (enemy.State.IsDefeated) + { + continue; + } + + statusEffects.Tick(enemy.State, StatusTickTiming.StartOfTurn, playerState, damagePipeline); + RemoveDefeatedEnemies(); + if (CheckWinOrLoss()) + { + return; + } + + if (enemy.State.IsDefeated) + { + continue; + } + + Log($"{enemy.Name} executes {enemy.CurrentIntent.DisplayText}"); + GameEvents.RaiseEnemyWouldAct(new EnemyWouldActEvent(enemy)); + ExecuteEnemyIntent(enemy); + GameEvents.RaiseEnemyActed(new EnemyActedEvent(enemy, enemy.CurrentIntent)); + if (CheckLoss()) + { + return; + } + } + + PickEnemyIntents(); + StateChanged?.Invoke(); + } + + private void GarbageCollect() + { + selectedEnemyIndex = -1; + pendingTargetCard = null; + statusEffects.Tick(playerState, StatusTickTiming.EndOfTurn, playerState, damagePipeline); + if (CheckLoss()) + { + return; + } + + RemoveDefeatedEnemies(); + CheckWinOrLoss(); + StateChanged?.Invoke(); + } + + private void DrawOpeningHand() + { + int openingDraw = Mathf.Min(CombatTuning.OpeningHandSize, playerState.Ram); + DrawCardsToHand(openingDraw, "opening hand"); + } + + private void DrawForTurn() + { + int requested = CombatTuning.DrawPerTurn; + if (CombatTuning.MinimumHandFloor > 0 && handController.Cards.Count < CombatTuning.MinimumHandFloor && deckController.AvailableToDrawCount > 0) + { + requested = Mathf.Max(requested, CombatTuning.MinimumHandFloor - handController.Cards.Count); + } + + DrawCardsToHand(requested, "turn draw"); + } + + private void DrawCardsToHand(int requestedCount, string label) + { + if (handController == null || playerState == null || requestedCount <= 0) + { + return; + } + + int room = playerState.Ram - handController.Cards.Count; + if (room <= 0) + { + Log($"{label}: hand full"); + return; + } + + int drawCount = Mathf.Min(requestedCount, room); + IReadOnlyList drawn = deckController.Draw(drawCount); + int added = 0; + for (int i = 0; i < drawn.Count; i++) + { + if (handController.Add(drawn[i])) + { + added++; + } + } + + if (added == 0 && deckController.AvailableToDrawCount == 0) + { + Log($"{label}: no cards to draw"); + } + else if (added < requestedCount) + { + Log($"{label}: drew {added} (hand full)"); + } + else + { + Log($"{label}: drew {added}"); + } + } + + private CombatContext BuildContext(CardInstance card, IReadOnlyList targets) + { + return new CombatContext(card, playerState, targets, this, damagePipeline, statusEffects, deckController, handController, enemies); + } + + private void ExecuteCardEffects(CardInstance card, IReadOnlyList targets) + { + IReadOnlyList effects = CardEffectFactory.CreateEffects(card?.Definition); + CombatContext context = BuildContext(card, targets); + for (int i = 0; i < effects.Count; i++) + { + effects[i].Execute(context); + RemoveDefeatedEnemies(); + if (CheckWinOrLoss()) + { + return; + } + } + } + + private void ResolveCardRiders(CardInstance card) + { + if (card == null || card.DrawRider <= 0 || handController == null || playerState == null) + { + return; + } + + DrawCardsToHand(card.DrawRider, "upgrade rider"); + } + + private IReadOnlyList CaptureTargets(CardInstance card) + { + if (card?.Definition == null) + { + return Array.Empty(); + } + + if (CardEffectFactory.TargetsAllEnemies(card.Definition)) + { + return LivingEnemyStates(); + } + + if (selectedEnemyIndex >= 0 && selectedEnemyIndex < enemies.Count && !enemies[selectedEnemyIndex].State.IsDefeated) + { + return new[] { enemies[selectedEnemyIndex].State }; + } + + if (RequiresEnemyTarget(card)) + { + CombatantState firstLiving = FirstLivingEnemyState(); + return firstLiving == null ? Array.Empty() : new[] { firstLiving }; + } + + return Array.Empty(); + } + + private IReadOnlyList ResolveTargets(CardInstance card) + { + List targets = card.TargetSnapshot.Where(target => target != null && !target.IsDefeated).ToList(); + if (targets.Count == 0 && RequiresEnemyTarget(card)) + { + CombatantState firstLiving = FirstLivingEnemyState(); + if (firstLiving != null) + { + targets.Add(firstLiving); + } + } + + return targets; + } + + private IReadOnlyList ResolveQueuedTargets(CardInstance card) + { + List targets = ResolveTargets(card).ToList(); + if (targets.Count == 0 && CardEffectFactory.TargetsAllEnemies(card?.Definition)) + { + targets.AddRange(LivingEnemyStates()); + } + + // Queued cards snapshot targets when played. If those targets die before Interpret, retarget to living enemies. + return targets; + } + + private bool RequiresEnemyTarget(CardInstance card) + { + if (card?.Definition == null) + { + return false; + } + + return CardEffectFactory.RequiresSingleTarget(card.Definition) + || CardEffectFactory.TargetsAllEnemies(card.Definition) + || card.Definition.Id == "lang_py_print"; + } + + private List LivingEnemyStates() + { + return enemies + .Where(enemy => enemy.State != null && !enemy.State.IsDefeated) + .Select(enemy => enemy.State) + .ToList(); + } + + private CombatantState FirstLivingEnemyState() + { + for (int i = 0; i < enemies.Count; i++) + { + if (enemies[i].State != null && !enemies[i].State.IsDefeated) + { + return enemies[i].State; + } + } + + return null; + } + + private void RemoveDefeatedEnemies() + { + for (int i = enemies.Count - 1; i >= 0; i--) + { + if (enemies[i].State.IsDefeated) + { + Log($"{enemies[i].Name} exited"); + runManager?.AddBits(CombatTuning.BitsPerKill); + enemies.RemoveAt(i); + } + } + + if (selectedEnemyIndex >= enemies.Count) + { + selectedEnemyIndex = -1; + } + } + + private void SpawnStructuralEnemies() + { + int wave = CurrentWaveNumber; + int count = Mathf.Clamp(CombatTuning.BaseEnemiesPerWave + ((wave - 1) * CombatTuning.AdditionalEnemiesPerWave), CombatTuning.BaseEnemiesPerWave, CombatTuning.MaxEnemiesPerWave); + int waveUptimeBonus = Mathf.Max(0, wave - 1) * CombatTuning.EnemyUptimeGrowthPerWave; + for (int i = 0; i < count; i++) + { + // TODO: Replace this low-HP additive wave stub with the real encounter/difficulty system. + int baseUptime = RandomRoll.RollRange(CombatTuning.BaseEnemyUptimeMin, CombatTuning.BaseEnemyUptimeMax, RollContext.None); + int uptime = baseUptime + waveUptimeBonus; + enemies.Add(new EnemyInstance("zombie_process", uptime, CreateZombieProcessIntentPool(wave, i))); + } + } + + private static IReadOnlyList CreateZombieProcessIntentPool(int wave, int slotIndex) + { + // TODO: Replace this code-shaped pool with EnemyDefinition-provided intent data. + int attack = GetZombieProcessAttack(wave, slotIndex); + return new[] + { + new EnemyIntent(EnemyIntentKind.Attack, attack, attack, Language.C, "attack", ">"), + new EnemyIntent(EnemyIntentKind.Defend, 3 + wave, 3 + wave, Language.C, "defend", "#"), + new EnemyIntent(EnemyIntentKind.StatusAttack, CombatTuning.EnemyStatusAttackDamage, CombatTuning.EnemyStatusAttackDamage, Language.C, "leak", "!", false, StatusType.MemoryLeak, 1, CombatTuning.EnemyStatusDuration) + }; + } + + private static int GetZombieProcessAttack(int wave, int slotIndex) + { + int safeWave = Mathf.Max(1, wave); + int waveGrowth = (safeWave - 1) / Mathf.Max(1, CombatTuning.EnemyAttackGrowthEveryWaves); + int slotVariance = CombatTuning.EnemySlotAttackVariance <= 0 ? 0 : slotIndex % (CombatTuning.EnemySlotAttackVariance + 1); + return CombatTuning.BaseEnemyAttack + waveGrowth + slotVariance; + } + + private void PickEnemyIntents() + { + for (int i = 0; i < enemies.Count; i++) + { + if (!enemies[i].State.IsDefeated) + { + enemies[i].PickNextIntent(); + } + } + } + + private void ExecuteEnemyIntent(EnemyInstance enemy) + { + EnemyIntent intent = enemy.CurrentIntent; + int value = intent.MinValue == intent.MaxValue + ? intent.MinValue + : RandomRoll.RollRange(intent.MinValue, intent.MaxValue, new RollContext(enemy.State)); + + switch (intent.Kind) + { + case EnemyIntentKind.Attack: + DamageResult attackResult = damagePipeline.DealDamage(new DamageRequest(enemy.State, playerState, value, intent.DamageType, intent.TrueDamage, false)); + GameEvents.RaisePlayerDamaged(new PlayerDamagedEvent(enemy, attackResult.FinalAmount)); + break; + case EnemyIntentKind.Defend: + enemy.State.Shield += value; + break; + case EnemyIntentKind.StatusAttack: + DamageResult statusResult = damagePipeline.DealDamage(new DamageRequest(enemy.State, playerState, value, intent.DamageType, intent.TrueDamage, false)); + GameEvents.RaisePlayerDamaged(new PlayerDamagedEvent(enemy, statusResult.FinalAmount)); + if (intent.StatusStacks > 0) + { + statusEffects.Apply(playerState, intent.StatusType, intent.StatusStacks, intent.StatusDuration, enemy.State, skipNextTick: true); + } + break; + case EnemyIntentKind.Buff: + Log("TODO: buff intent needs enemy buff/status support."); + break; + case EnemyIntentKind.Special: + Log("TODO: special intent needs enemy-specific behavior hooks."); + break; + } + } + + private bool CheckWinOrLoss() + { + return CheckLoss() || CheckWin(); + } + + private bool CheckLoss() + { + if (runLost || playerState == null || !playerState.IsDefeated) + { + return runLost; + } + + runLost = true; + awaitingWaveContinue = false; + selectedEnemyIndex = -1; + pendingTargetCard = null; + GameEvents.RaiseEncounterLost(new EncounterLostEvent(CurrentWaveNumber)); + GameEvents.RaiseRunEnded(new RunEndedEvent(playerDied: true, entropyEarned: 0)); + Log("kernel panic: player uptime reached 0"); + StateChanged?.Invoke(); + return true; + } + + private bool CheckWin() + { + if (awaitingWaveContinue || runLost || enemies.Count > 0) + { + return awaitingWaveContinue; + } + + awaitingWaveContinue = true; + selectedEnemyIndex = -1; + pendingTargetCard = null; + int clearedWave = CurrentWaveNumber; + GameEvents.RaiseEncounterWon(new EncounterWonEvent(clearedWave)); + GameEvents.RaiseWaveCleared(new WaveClearedEvent(clearedWave)); + Log("wave cleared -> repository available"); + StateChanged?.Invoke(); + return true; + } + + private void LogPlayIntent(CardInstance card) + { + string notes = card?.Definition == null || string.IsNullOrWhiteSpace(card.Definition.DesignNotes) + ? "no-op scaffold" + : card.Definition.DesignNotes; + Log($"{GetCardName(card)} would resolve: {notes}"); + } + + private void Log(string message) + { + CombatLog?.Invoke(message); + } + + private static string GetCardName(CardInstance card) + { + if (card?.Definition == null) + { + return "--"; + } + + return string.IsNullOrWhiteSpace(card.Definition.DisplayName) ? card.Definition.Id : card.Definition.DisplayName; + } + + private bool IsCombatPaused => awaitingWaveContinue || runLost; } } diff --git a/Assets/Scripts/Combat/CombatTuning.cs b/Assets/Scripts/Combat/CombatTuning.cs new file mode 100644 index 0000000..5eb3001 --- /dev/null +++ b/Assets/Scripts/Combat/CombatTuning.cs @@ -0,0 +1,41 @@ +namespace KernelPanic.Combat +{ + /// + /// Central combat economy/scaffold tuning. Kept in code while the combat scaffold is still volatile; + /// promote to a ScriptableObject once designers need per-run or per-encounter tuning without recompiles. + /// + public static class CombatTuning + { + public const int OpeningHandSize = 5; + public const int DrawPerTurn = 1; + public const int MinimumHandFloor = 0; + + public const int BaseEnemyUptimeMin = 5; + public const int BaseEnemyUptimeMax = 10; + public const int EnemyUptimeGrowthPerWave = 2; + + public const int BaseEnemyAttack = 3; + public const int EnemyAttackGrowthEveryWaves = 2; + public const int EnemySlotAttackVariance = 1; + public const int EnemyStatusAttackDamage = 1; + public const int EnemyStatusDuration = 3; + + public const int BaseEnemiesPerWave = 2; + public const int AdditionalEnemiesPerWave = 1; + public const int MaxEnemiesPerWave = 5; + + public const int BitsPerKill = 1; + public const int ShopSize = 5; + public const int RerollBaseCost = 1; + public const int RerollCostStep = 1; + public const int RemoveCardCost = 1; + public const int NewCardOfferCost = 2; + public const int UpgradeOfferCost = 2; + public const int StatUpgradeCost = 3; + public const int StatUpgradeMaxCycles = 1; + public const int StatUpgradeMaxUptime = 4; + public const int StatUpgradeHeal = 5; + public const int StatUpgradeRam = 1; + public const int UpgradeMagnitudeBonus = 2; + } +} diff --git a/Assets/Scripts/Combat/CombatantState.cs b/Assets/Scripts/Combat/CombatantState.cs index 0da3b16..9657e9f 100644 --- a/Assets/Scripts/Combat/CombatantState.cs +++ b/Assets/Scripts/Combat/CombatantState.cs @@ -8,7 +8,7 @@ namespace KernelPanic.Combat /// public sealed class CombatantState { - private readonly Dictionary activeStatuses = new(); + private readonly List _statuses = new(); public CombatantState(int maxUptime, int ram, int cycles) { @@ -16,6 +16,7 @@ namespace KernelPanic.Combat CurrentUptime = maxUptime; Ram = ram; Cycles = cycles; + MaxCycles = cycles; } public int CurrentUptime { get; set; } @@ -23,6 +24,34 @@ namespace KernelPanic.Combat public int Shield { get; set; } public int Ram { get; set; } public int Cycles { get; set; } - public IReadOnlyDictionary ActiveStatuses => activeStatuses; + public int MaxCycles { get; set; } + public bool IsDefeated { get; set; } + public bool forceMaxRolls { get; set; } + public IReadOnlyList Statuses => _statuses; + public IReadOnlyDictionary ActiveStatuses => BuildStatusSnapshot(); + + internal List MutableStatuses => _statuses; + + public bool HasStatus(StatusType type) + { + return _statuses.Exists(status => status.Type == type); + } + + public int StacksOf(StatusType type) + { + StatusInstance status = _statuses.Find(instance => instance.Type == type); + return status == null ? 0 : status.Stacks; + } + + private IReadOnlyDictionary BuildStatusSnapshot() + { + Dictionary snapshot = new(); + for (int i = 0; i < _statuses.Count; i++) + { + snapshot[_statuses[i].Type] = _statuses[i].Stacks; + } + + return snapshot; + } } } diff --git a/Assets/Scripts/Combat/DamagePipeline.cs b/Assets/Scripts/Combat/DamagePipeline.cs new file mode 100644 index 0000000..af7eb05 --- /dev/null +++ b/Assets/Scripts/Combat/DamagePipeline.cs @@ -0,0 +1,111 @@ +using KernelPanic.Core; +using UnityEngine; + +namespace KernelPanic.Combat +{ + public readonly struct DamageRequest + { + public DamageRequest( + CombatantState source, + CombatantState target, + int amount, + Language language, + bool trueDamage, + bool canCrit) + { + Source = source; + Target = target; + Amount = amount; + Language = language; + TrueDamage = trueDamage; + CanCrit = canCrit; + } + + public CombatantState Source { get; } + public CombatantState Target { get; } + public int Amount { get; } + public Language Language { get; } + public bool TrueDamage { get; } + public bool CanCrit { get; } + } + + public readonly struct DamageResult + { + public DamageResult(int finalAmount, bool targetDefeated) + { + FinalAmount = finalAmount; + TargetDefeated = targetDefeated; + } + + public int FinalAmount { get; } + public bool TargetDefeated { get; } + } + + /// + /// Single damage chokepoint for shield, uptime, damage events, and defeat decisions. + /// + public sealed class DamagePipeline + { + public DamageResult DealDamage(DamageRequest request) + { + if (request.Target == null || request.Target.IsDefeated) + { + return new DamageResult(0, false); + } + + int amount = Mathf.Max(0, request.Amount); + amount = ApplyMultipliers(amount, request); + amount = ApplyCrit(amount, request); + amount = ApplyResistancesAndWeaknesses(amount, request); + + int finalAmount = ApplyShieldAndUptime(amount, request); + GameEvents.RaiseDamageDealt(new DamageDealtEvent(request.Source, request.Target, finalAmount, request.Language)); + + bool defeated = false; + if (request.Target.CurrentUptime <= 0 && !request.Target.IsDefeated) + { + request.Target.IsDefeated = true; + defeated = true; + GameEvents.RaiseCombatantDefeated(new CombatantDefeatedEvent(request.Target)); + } + + return new DamageResult(finalAmount, defeated); + } + + private static int ApplyMultipliers(int amount, DamageRequest request) + { + // TODO: Apply buffs/debuffs and Mint's no-multiplier rule here. + return amount; + } + + private static int ApplyCrit(int amount, DamageRequest request) + { + // TODO: Add crit calculation and crit modifiers. + return amount; + } + + private static int ApplyResistancesAndWeaknesses(int amount, DamageRequest request) + { + // TODO: Apply enemy language resistances and weaknesses here. + return amount; + } + + private static int ApplyShieldAndUptime(int amount, DamageRequest request) + { + CombatantState target = request.Target; + int remaining = amount; + int shieldDamage = 0; + + if (!request.TrueDamage && target.Shield > 0) + { + shieldDamage = Mathf.Min(target.Shield, remaining); + target.Shield -= shieldDamage; + remaining -= shieldDamage; + } + + int uptimeDamage = Mathf.Min(target.CurrentUptime, remaining); + target.CurrentUptime = Mathf.Max(0, target.CurrentUptime - uptimeDamage); + return shieldDamage + uptimeDamage; + } + } +} diff --git a/Assets/Scripts/Combat/DeckController.cs b/Assets/Scripts/Combat/DeckController.cs index b64e029..6138a43 100644 --- a/Assets/Scripts/Combat/DeckController.cs +++ b/Assets/Scripts/Combat/DeckController.cs @@ -1,5 +1,5 @@ -using System; using System.Collections.Generic; +using System.Linq; namespace KernelPanic.Combat { @@ -8,42 +8,88 @@ namespace KernelPanic.Combat /// public sealed class DeckController { - private readonly List drawPile = new(); - private readonly List discardPile = new(); - private readonly List exhaustPile = new(); + private readonly List _drawPile = new(); + private readonly List _discardPile = new(); + private readonly List _exhaustPile = new(); - public IReadOnlyList DrawPile => drawPile; - public IReadOnlyList DiscardPile => discardPile; - public IReadOnlyList ExhaustPile => exhaustPile; + public IReadOnlyList DrawPile => _drawPile; + public IReadOnlyList DiscardPile => _discardPile; + public IReadOnlyList ExhaustPile => _exhaustPile; + public int AvailableToDrawCount => _drawPile.Count + _discardPile.Count; public void Initialize(IEnumerable cards) { - throw new NotImplementedException(); + _drawPile.Clear(); + _discardPile.Clear(); + _exhaustPile.Clear(); + + if (cards != null) + { + _drawPile.AddRange(cards.Where(card => card != null)); + } + + ShuffleDrawPile(); } public IReadOnlyList Draw(int count) { - throw new NotImplementedException(); + List drawn = new(); + for (int i = 0; i < count; i++) + { + if (_drawPile.Count == 0) + { + ReshuffleDiscardIntoDraw(); + } + + if (_drawPile.Count == 0) + { + break; + } + + int topIndex = _drawPile.Count - 1; + CardInstance card = _drawPile[topIndex]; + _drawPile.RemoveAt(topIndex); + drawn.Add(card); + } + + return drawn; } public void Discard(CardInstance card) { - throw new NotImplementedException(); + if (card != null) + { + _discardPile.Add(card); + } } public void Exhaust(CardInstance card) { - throw new NotImplementedException(); + if (card != null) + { + _exhaustPile.Add(card); + } } public void ShuffleDrawPile() { - throw new NotImplementedException(); + for (int i = _drawPile.Count - 1; i > 0; i--) + { + int swapIndex = RandomRoll.RollRange(0, i, RollContext.None); + (_drawPile[i], _drawPile[swapIndex]) = (_drawPile[swapIndex], _drawPile[i]); + } } public void ReshuffleDiscardIntoDraw() { - throw new NotImplementedException(); + if (_discardPile.Count == 0) + { + return; + } + + _drawPile.AddRange(_discardPile); + _discardPile.Clear(); + ShuffleDrawPile(); } } } diff --git a/Assets/Scripts/Combat/EnemyInstance.cs b/Assets/Scripts/Combat/EnemyInstance.cs new file mode 100644 index 0000000..e16d047 --- /dev/null +++ b/Assets/Scripts/Combat/EnemyInstance.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace KernelPanic.Combat +{ + /// + /// Runtime-only enemy placeholder used until encounter and EnemyDefinition assets are wired. + /// + public sealed class EnemyInstance + { + private readonly List _intentPool = new(); + + public EnemyInstance(string name, int maxUptime, IEnumerable intentPool) + { + Name = string.IsNullOrWhiteSpace(name) ? "unknown_process" : name; + State = new CombatantState(maxUptime, 0, 0); + if (intentPool != null) + { + _intentPool.AddRange(intentPool); + } + } + + public string Name { get; } + public int CurrentUptime => State.CurrentUptime; + public int MaxUptime => State.MaxUptime; + public CombatantState State { get; } + public IReadOnlyList IntentPool => _intentPool; + public EnemyIntent CurrentIntent { get; private set; } + + public void PickNextIntent() + { + if (_intentPool.Count == 0) + { + CurrentIntent = default; + return; + } + + int index = RandomRoll.RollRange(0, _intentPool.Count - 1, new RollContext(State)); + CurrentIntent = _intentPool[index]; + } + } +} diff --git a/Assets/Scripts/Combat/EnemyIntent.cs b/Assets/Scripts/Combat/EnemyIntent.cs new file mode 100644 index 0000000..4ae2a30 --- /dev/null +++ b/Assets/Scripts/Combat/EnemyIntent.cs @@ -0,0 +1,57 @@ +using KernelPanic.Core; + +namespace KernelPanic.Combat +{ + public enum EnemyIntentKind + { + Attack, + Defend, + Buff, + StatusAttack, + Special + } + + /// + /// Runtime enemy intent shape. Future EnemyDefinition assets should provide pools of this data. + /// + public readonly struct EnemyIntent + { + public EnemyIntent( + EnemyIntentKind kind, + int minValue, + int maxValue, + Language damageType, + string displayLabel, + string iconKey, + bool trueDamage = false, + StatusType statusType = StatusType.MemoryLeak, + int statusStacks = 0, + int statusDuration = -1) + { + Kind = kind; + MinValue = minValue; + MaxValue = maxValue; + DamageType = damageType; + DisplayLabel = string.IsNullOrWhiteSpace(displayLabel) ? kind.ToString() : displayLabel; + IconKey = string.IsNullOrWhiteSpace(iconKey) ? "?" : iconKey; + TrueDamage = trueDamage; + StatusType = statusType; + StatusStacks = statusStacks; + StatusDuration = statusDuration; + } + + public EnemyIntentKind Kind { get; } + public int MinValue { get; } + public int MaxValue { get; } + public Language DamageType { get; } + public string DisplayLabel { get; } + public string IconKey { get; } + public bool TrueDamage { get; } + public StatusType StatusType { get; } + public int StatusStacks { get; } + public int StatusDuration { get; } + + public string ValueText => MinValue == MaxValue ? MinValue.ToString() : $"{MinValue}-{MaxValue}"; + public string DisplayText => $"{IconKey} {DisplayLabel} {ValueText}"; + } +} diff --git a/Assets/Scripts/Combat/HandController.cs b/Assets/Scripts/Combat/HandController.cs index e299a7d..9658eeb 100644 --- a/Assets/Scripts/Combat/HandController.cs +++ b/Assets/Scripts/Combat/HandController.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; namespace KernelPanic.Combat @@ -8,7 +7,7 @@ namespace KernelPanic.Combat /// public sealed class HandController { - private readonly List cards = new(); + private readonly List _cards = new(); public HandController(int ramCapacity) { @@ -16,26 +15,32 @@ namespace KernelPanic.Combat } public int RamCapacity { get; private set; } - public IReadOnlyList Cards => cards; + public IReadOnlyList Cards => _cards; public bool CanAdd(CardInstance card) { - throw new NotImplementedException(); + return card != null && _cards.Count < RamCapacity; } - public void Add(CardInstance card) + public bool Add(CardInstance card) { - throw new NotImplementedException(); + if (!CanAdd(card)) + { + return false; + } + + _cards.Add(card); + return true; } public bool Remove(CardInstance card) { - throw new NotImplementedException(); + return card != null && _cards.Remove(card); } public void Clear() { - throw new NotImplementedException(); + _cards.Clear(); } } } diff --git a/Assets/Scripts/Combat/InterpreterQueue.cs b/Assets/Scripts/Combat/InterpreterQueue.cs index 3cf2d60..0375161 100644 --- a/Assets/Scripts/Combat/InterpreterQueue.cs +++ b/Assets/Scripts/Combat/InterpreterQueue.cs @@ -1,5 +1,5 @@ -using System; using System.Collections.Generic; +using KernelPanic.Core; namespace KernelPanic.Combat { @@ -8,23 +8,50 @@ namespace KernelPanic.Combat /// public sealed class InterpreterQueue : IResolutionTrack { - private readonly Queue queuedCards = new(); + private readonly Queue _queuedCards = new(); + private readonly List _queuedView = new(); - public int Count => queuedCards.Count; + public int Count => _queuedCards.Count; + public IReadOnlyList Cards => _queuedView; public void Enqueue(CardInstance card) { - throw new NotImplementedException(); + if (card == null) + { + return; + } + + _queuedCards.Enqueue(card); + _queuedView.Add(card); } public void Resolve(CombatContext context) { - throw new NotImplementedException(); + while (_queuedCards.Count > 0) + { + CardInstance card = _queuedCards.Dequeue(); + _queuedView.Remove(card); + GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.InterpreterQueue)); + } + } + + public bool TryDequeue(out CardInstance card) + { + if (_queuedCards.Count == 0) + { + card = null; + return false; + } + + card = _queuedCards.Dequeue(); + _queuedView.Remove(card); + return true; } public void Clear() { - throw new NotImplementedException(); + _queuedCards.Clear(); + _queuedView.Clear(); } } } diff --git a/Assets/Scripts/Combat/LazyStack.cs b/Assets/Scripts/Combat/LazyStack.cs index a99a5e6..3376f8e 100644 --- a/Assets/Scripts/Combat/LazyStack.cs +++ b/Assets/Scripts/Combat/LazyStack.cs @@ -1,5 +1,5 @@ -using System; using System.Collections.Generic; +using KernelPanic.Core; namespace KernelPanic.Combat { @@ -8,23 +8,47 @@ namespace KernelPanic.Combat /// public sealed class LazyStack : IResolutionTrack { - private readonly Stack stackedCards = new(); + private readonly Stack _stackedCards = new(); + private readonly List _stackedView = new(); - public int Count => stackedCards.Count; + public int Count => _stackedCards.Count; + public IReadOnlyList Cards => _stackedView; public void Enqueue(CardInstance card) { - throw new NotImplementedException(); + if (card == null) + { + return; + } + + _stackedCards.Push(card); + _stackedView.Insert(0, card); } public void Resolve(CombatContext context) { - throw new NotImplementedException(); + ForceEvaluate(context); } public void ForceEvaluate(CombatContext context) { - throw new NotImplementedException(); + while (TryPop(out CardInstance card)) + { + GameEvents.RaiseCardResolved(new CardResolvedEvent(card, ResolutionTrack.LazyStack)); + } + } + + public bool TryPop(out CardInstance card) + { + if (_stackedCards.Count == 0) + { + card = null; + return false; + } + + card = _stackedCards.Pop(); + _stackedView.Remove(card); + return true; } } } diff --git a/Assets/Scripts/Combat/NativeTrack.cs b/Assets/Scripts/Combat/NativeTrack.cs index a3ed407..881f325 100644 --- a/Assets/Scripts/Combat/NativeTrack.cs +++ b/Assets/Scripts/Combat/NativeTrack.cs @@ -1,4 +1,4 @@ -using System; +using KernelPanic.Core; namespace KernelPanic.Combat { @@ -9,12 +9,15 @@ namespace KernelPanic.Combat { public void Enqueue(CardInstance card) { - throw new NotImplementedException(); + if (card != null) + { + GameEvents.RaiseCardPlayed(new CardPlayedEvent(card, ResolutionTrack.Native)); + } } public void Resolve(CombatContext context) { - throw new NotImplementedException(); + // Native cards resolve immediately in CombatManager.PlayCard; this track has no queue. } } } diff --git a/Assets/Scripts/Combat/RandomRoll.cs b/Assets/Scripts/Combat/RandomRoll.cs new file mode 100644 index 0000000..3d1da91 --- /dev/null +++ b/Assets/Scripts/Combat/RandomRoll.cs @@ -0,0 +1,47 @@ +using System; + +namespace KernelPanic.Combat +{ + /// + /// Carries source data for combat rolls so later passives can bias the result. + /// + public readonly struct RollContext + { + public RollContext(CombatantState source) + { + Source = source; + } + + public static RollContext None => new(null); + public CombatantState Source { get; } + } + + /// + /// Single combat randomness chokepoint. Seeded from RunConfig.RunSeed during boot for reproducible runs. + /// + public static class RandomRoll + { + private static Random _random = new(0); + + public static void Seed(int seed) + { + _random = new Random(seed); + } + + public static int RollRange(int min, int max, RollContext context) + { + if (min > max) + { + (min, max) = (max, min); + } + + // Mint's passive will flip this bool; no passive sets it in this scaffold. + if (context.Source != null && context.Source.forceMaxRolls) + { + return max; + } + + return _random.Next(min, max + 1); + } + } +} diff --git a/Assets/Scripts/Combat/StatusEffectController.cs b/Assets/Scripts/Combat/StatusEffectController.cs index b0290ee..1887358 100644 --- a/Assets/Scripts/Combat/StatusEffectController.cs +++ b/Assets/Scripts/Combat/StatusEffectController.cs @@ -1,26 +1,211 @@ using System; +using System.Collections.Generic; using KernelPanic.Core; +using UnityEngine; namespace KernelPanic.Combat { /// - /// Applies, ticks, and expires status effects on a combatant. + /// Applies, ticks, cleanses, and expires status effects on a combatant. /// public sealed class StatusEffectController { - public void Apply(CombatantState target, StatusType statusType, int stacks) + private static readonly Dictionary Descriptors = new() { - throw new NotImplementedException(); + [StatusType.MemoryLeak] = new(StatusType.MemoryLeak, "Memory Leak", "leak", false, StatusStackingRule.Intensity, StatusTickTiming.StartOfTurn, "Takes damage equal to stacks at start of turn."), + [StatusType.Segfault] = new(StatusType.Segfault, "Segfault", "segv", false, StatusStackingRule.Unique, StatusTickTiming.StartOfTurn, "Loses one cycle this turn. TODO: lock a random card slot."), + [StatusType.RaceCondition] = new(StatusType.RaceCondition, "Race Condition", "race", false, StatusStackingRule.Refresh, StatusTickTiming.None, "Randomizes multi-target resolution order where supported."), + [StatusType.Deprecated] = new(StatusType.Deprecated, "Deprecated", "dep", false, StatusStackingRule.Duration, StatusTickTiming.StartOfTurn, "A temporary positive value decays. Currently reduces shield by 1."), + [StatusType.DependencyError] = new(StatusType.DependencyError, "Dependency Error", "deps", false, StatusStackingRule.Unique, StatusTickTiming.None, "Registered for off-support card cost rules. TODO: connect draw-time cost path."), + [StatusType.Deadlock] = new(StatusType.Deadlock, "Deadlock", "lock", false, StatusStackingRule.Duration, StatusTickTiming.None, "Registered goroutine freeze. TODO: connect goroutine token ticking."), + [StatusType.UnattendedUpgrades] = new(StatusType.UnattendedUpgrades, "Unattended Upgrades", "apt", true, StatusStackingRule.Duration, StatusTickTiming.EndOfTurn, "Grants shield at end of turn, then expires."), + }; + + public static StatusDescriptor GetDescriptor(StatusType statusType) + { + return Descriptors[statusType]; } - public void Tick(CombatantState target) + public static bool Has(CombatantState target, StatusType statusType) { - throw new NotImplementedException(); + return target != null && target.HasStatus(statusType); + } + + public static int StacksOf(CombatantState target, StatusType statusType) + { + return target == null ? 0 : target.StacksOf(statusType); + } + + public void Apply(CombatantState target, StatusType statusType, int stacks, int duration, CombatantState source = null, bool skipNextTick = false) + { + if (target == null || target.IsDefeated) + { + return; + } + + StatusDescriptor descriptor = GetDescriptor(statusType); + StatusInstance existing = target.MutableStatuses.Find(status => status.Type == statusType); + int safeStacks = Mathf.Max(1, stacks); + int safeDuration = duration == -1 ? -1 : Mathf.Max(1, duration); + + if (existing == null) + { + target.MutableStatuses.Add(new StatusInstance(statusType, safeStacks, safeDuration, source, skipNextTick)); + } + else + { + existing.Source = source ?? existing.Source; + Merge(existing, descriptor.StackingRule, safeStacks, safeDuration, skipNextTick); + } + + GameEvents.RaiseStatusApplied(new StatusAppliedEvent(source, target, statusType, safeStacks, safeDuration)); + } + + public void Tick(CombatantState target, StatusTickTiming timing, CombatantState source, DamagePipeline damagePipeline) + { + if (target == null || target.IsDefeated) + { + return; + } + + List snapshot = new(target.MutableStatuses); + for (int i = 0; i < snapshot.Count; i++) + { + StatusInstance status = snapshot[i]; + if (!target.MutableStatuses.Contains(status)) + { + continue; + } + + StatusDescriptor descriptor = GetDescriptor(status.Type); + if (descriptor.TickTiming != timing) + { + continue; + } + + if (status.SkipNextTick) + { + status.SkipNextTick = false; + continue; + } + + ResolveTick(status, target, status.Source ?? source, damagePipeline); + DecayDuration(target, status); + } + } + + public int Cleanse(CombatantState target, Predicate predicate) + { + if (target == null || predicate == null) + { + return 0; + } + + int removed = 0; + for (int i = target.MutableStatuses.Count - 1; i >= 0; i--) + { + StatusInstance status = target.MutableStatuses[i]; + if (predicate(status)) + { + target.MutableStatuses.RemoveAt(i); + removed++; + GameEvents.RaiseStatusCleansed(new StatusCleansedEvent(target, status.Type)); + } + } + + return removed; + } + + public int CleanseHarmful(CombatantState target) + { + return Cleanse(target, status => !GetDescriptor(status.Type).IsBeneficial); } public void Expire(CombatantState target, StatusType statusType) { - throw new NotImplementedException(); + if (target == null) + { + return; + } + + for (int i = target.MutableStatuses.Count - 1; i >= 0; i--) + { + if (target.MutableStatuses[i].Type == statusType) + { + target.MutableStatuses.RemoveAt(i); + GameEvents.RaiseStatusExpired(new StatusExpiredEvent(target, statusType)); + } + } + } + + private static void Merge(StatusInstance existing, StatusStackingRule stackingRule, int stacks, int duration, bool skipNextTick) + { + switch (stackingRule) + { + case StatusStackingRule.Intensity: + existing.Stacks += stacks; + existing.Duration = MergeDuration(existing.Duration, duration); + break; + case StatusStackingRule.Duration: + existing.Stacks = Mathf.Max(existing.Stacks, stacks); + existing.Duration = existing.Duration == -1 || duration == -1 ? -1 : existing.Duration + duration; + break; + case StatusStackingRule.Refresh: + existing.Stacks = Mathf.Max(existing.Stacks, stacks); + existing.Duration = duration; + break; + case StatusStackingRule.Unique: + break; + } + + existing.SkipNextTick |= skipNextTick; + } + + private static int MergeDuration(int current, int incoming) + { + if (current == -1 || incoming == -1) + { + return -1; + } + + return Mathf.Max(current, incoming); + } + + private static void ResolveTick(StatusInstance status, CombatantState target, CombatantState source, DamagePipeline damagePipeline) + { + switch (status.Type) + { + case StatusType.MemoryLeak: + damagePipeline.DealDamage(new DamageRequest(source, target, status.Stacks, Language.C, false, false)); + break; + case StatusType.Segfault: + target.Cycles = Mathf.Max(0, target.Cycles - 1); + break; + case StatusType.Deprecated: + target.Shield = Mathf.Max(0, target.Shield - 1); + break; + case StatusType.UnattendedUpgrades: + target.Shield += status.Stacks; + break; + case StatusType.DependencyError: + case StatusType.Deadlock: + case StatusType.RaceCondition: + break; + } + } + + private void DecayDuration(CombatantState target, StatusInstance status) + { + if (status.Duration == -1) + { + return; + } + + status.Duration--; + if (status.Duration <= 0) + { + Expire(target, status.Type); + } } } } diff --git a/Assets/Scripts/Combat/StatusInstance.cs b/Assets/Scripts/Combat/StatusInstance.cs new file mode 100644 index 0000000..d7d4eea --- /dev/null +++ b/Assets/Scripts/Combat/StatusInstance.cs @@ -0,0 +1,66 @@ +using KernelPanic.Core; + +namespace KernelPanic.Combat +{ + public enum StatusStackingRule + { + Intensity, + Duration, + Refresh, + Unique + } + + public enum StatusTickTiming + { + None, + StartOfTurn, + EndOfTurn + } + + public sealed class StatusInstance + { + public StatusInstance(StatusType type, int stacks, int duration, CombatantState source = null, bool skipNextTick = false) + { + Type = type; + Stacks = stacks; + Duration = duration; + Source = source; + SkipNextTick = skipNextTick; + } + + public StatusType Type { get; } + public int Stacks { get; set; } + public int Duration { get; set; } + public CombatantState Source { get; set; } + public bool SkipNextTick { get; set; } + } + + public readonly struct StatusDescriptor + { + public StatusDescriptor( + StatusType type, + string displayName, + string iconKey, + bool isBeneficial, + StatusStackingRule stackingRule, + StatusTickTiming tickTiming, + string tooltip) + { + Type = type; + DisplayName = displayName; + IconKey = iconKey; + IsBeneficial = isBeneficial; + StackingRule = stackingRule; + TickTiming = tickTiming; + Tooltip = tooltip; + } + + public StatusType Type { get; } + public string DisplayName { get; } + public string IconKey { get; } + public bool IsBeneficial { get; } + public StatusStackingRule StackingRule { get; } + public StatusTickTiming TickTiming { get; } + public string Tooltip { get; } + } +} diff --git a/Assets/Scripts/Core/GameEvents.cs b/Assets/Scripts/Core/GameEvents.cs index 66050b5..3b02006 100644 --- a/Assets/Scripts/Core/GameEvents.cs +++ b/Assets/Scripts/Core/GameEvents.cs @@ -12,7 +12,17 @@ namespace KernelPanic.Core public static event Action CardResolved; public static event Action PhaseChanged; public static event Action DamageDealt; + public static event Action CombatantDefeated; + public static event Action EnemyWouldAct; + public static event Action EnemyActed; + public static event Action PlayerDamaged; + public static event Action EncounterWon; + public static event Action EncounterLost; public static event Action WaveCleared; + public static event Action WaveAdvanced; + public static event Action StatusApplied; + public static event Action StatusExpired; + public static event Action StatusCleansed; public static event Action RunEnded; public static void RaiseCardPlayed(CardPlayedEvent payload) @@ -35,11 +45,61 @@ namespace KernelPanic.Core DamageDealt?.Invoke(payload); } + public static void RaiseCombatantDefeated(CombatantDefeatedEvent payload) + { + CombatantDefeated?.Invoke(payload); + } + + public static void RaiseEnemyWouldAct(EnemyWouldActEvent payload) + { + EnemyWouldAct?.Invoke(payload); + } + + public static void RaiseEnemyActed(EnemyActedEvent payload) + { + EnemyActed?.Invoke(payload); + } + + public static void RaisePlayerDamaged(PlayerDamagedEvent payload) + { + PlayerDamaged?.Invoke(payload); + } + + public static void RaiseEncounterWon(EncounterWonEvent payload) + { + EncounterWon?.Invoke(payload); + } + + public static void RaiseEncounterLost(EncounterLostEvent payload) + { + EncounterLost?.Invoke(payload); + } + public static void RaiseWaveCleared(WaveClearedEvent payload) { WaveCleared?.Invoke(payload); } + public static void RaiseWaveAdvanced(WaveAdvancedEvent payload) + { + WaveAdvanced?.Invoke(payload); + } + + public static void RaiseStatusApplied(StatusAppliedEvent payload) + { + StatusApplied?.Invoke(payload); + } + + public static void RaiseStatusExpired(StatusExpiredEvent payload) + { + StatusExpired?.Invoke(payload); + } + + public static void RaiseStatusCleansed(StatusCleansedEvent payload) + { + StatusCleansed?.Invoke(payload); + } + public static void RaiseRunEnded(RunEndedEvent payload) { RunEnded?.Invoke(payload); @@ -110,6 +170,76 @@ namespace KernelPanic.Core public Language Language { get; } } + /// + /// Describes a combatant reaching zero uptime through the damage pipeline. + /// + public readonly struct CombatantDefeatedEvent + { + public CombatantDefeatedEvent(CombatantState combatant) + { + Combatant = combatant; + } + + public CombatantState Combatant { get; } + } + + /// + /// Describes an enemy placeholder reaching its structural action point. + /// + public readonly struct EnemyWouldActEvent + { + public EnemyWouldActEvent(EnemyInstance enemy) + { + Enemy = enemy; + } + + public EnemyInstance Enemy { get; } + } + + public readonly struct EnemyActedEvent + { + public EnemyActedEvent(EnemyInstance enemy, EnemyIntent intent) + { + Enemy = enemy; + Intent = intent; + } + + public EnemyInstance Enemy { get; } + public EnemyIntent Intent { get; } + } + + public readonly struct PlayerDamagedEvent + { + public PlayerDamagedEvent(EnemyInstance enemy, int amount) + { + Enemy = enemy; + Amount = amount; + } + + public EnemyInstance Enemy { get; } + public int Amount { get; } + } + + public readonly struct EncounterWonEvent + { + public EncounterWonEvent(int waveNumber) + { + WaveNumber = waveNumber; + } + + public int WaveNumber { get; } + } + + public readonly struct EncounterLostEvent + { + public EncounterLostEvent(int waveNumber) + { + WaveNumber = waveNumber; + } + + public int WaveNumber { get; } + } + /// /// Describes a completed enemy wave. /// @@ -123,6 +253,58 @@ namespace KernelPanic.Core public int WaveNumber { get; } } + public readonly struct WaveAdvancedEvent + { + public WaveAdvancedEvent(int waveNumber) + { + WaveNumber = waveNumber; + } + + public int WaveNumber { get; } + } + + public readonly struct StatusAppliedEvent + { + public StatusAppliedEvent(CombatantState source, CombatantState target, StatusType statusType, int stacks, int duration) + { + Source = source; + Target = target; + StatusType = statusType; + Stacks = stacks; + Duration = duration; + } + + public CombatantState Source { get; } + public CombatantState Target { get; } + public StatusType StatusType { get; } + public int Stacks { get; } + public int Duration { get; } + } + + public readonly struct StatusExpiredEvent + { + public StatusExpiredEvent(CombatantState target, StatusType statusType) + { + Target = target; + StatusType = statusType; + } + + public CombatantState Target { get; } + public StatusType StatusType { get; } + } + + public readonly struct StatusCleansedEvent + { + public StatusCleansedEvent(CombatantState target, StatusType statusType) + { + Target = target; + StatusType = statusType; + } + + public CombatantState Target { get; } + public StatusType StatusType { get; } + } + /// /// Describes the end of a run and its earned meta currency. /// diff --git a/Assets/Scripts/Core/Language.cs b/Assets/Scripts/Core/Language.cs index 55cdd08..b1a43cd 100644 --- a/Assets/Scripts/Core/Language.cs +++ b/Assets/Scripts/Core/Language.cs @@ -16,6 +16,6 @@ namespace KernelPanic.Core Java, Go, Ruby, - PHP + Php } } diff --git a/Assets/Scripts/Core/StatusType.cs b/Assets/Scripts/Core/StatusType.cs index f8a4dfb..cc8cdab 100644 --- a/Assets/Scripts/Core/StatusType.cs +++ b/Assets/Scripts/Core/StatusType.cs @@ -10,6 +10,7 @@ namespace KernelPanic.Core RaceCondition, Deprecated, DependencyError, - Deadlock + Deadlock, + UnattendedUpgrades } } diff --git a/Assets/Scripts/Data/CardDefinition.cs b/Assets/Scripts/Data/CardDefinition.cs index 20693cc..2e946a2 100644 --- a/Assets/Scripts/Data/CardDefinition.cs +++ b/Assets/Scripts/Data/CardDefinition.cs @@ -19,6 +19,7 @@ namespace KernelPanic.Data [SerializeField] private ResolutionTrack resolutionTrack; [SerializeField] private bool distroExclusive; [SerializeField] private bool isToken; + [SerializeField] private bool isRunOnly; [SerializeField, TextArea] private string designNotes; [SerializeField] private List requiresSystem = new(); [SerializeField] private CardDefinition upgradeTarget; @@ -34,6 +35,7 @@ namespace KernelPanic.Data public ResolutionTrack ResolutionTrack => resolutionTrack; public bool DistroExclusive => distroExclusive; public bool IsToken => isToken; + public bool IsRunOnly => isRunOnly; public string DesignNotes => designNotes; public IReadOnlyList RequiresSystem => requiresSystem; public CardDefinition UpgradeTarget => upgradeTarget; diff --git a/Assets/Scripts/Data/LanguageCatalog.cs b/Assets/Scripts/Data/LanguageCatalog.cs index 5bc2155..5921ff2 100644 --- a/Assets/Scripts/Data/LanguageCatalog.cs +++ b/Assets/Scripts/Data/LanguageCatalog.cs @@ -22,7 +22,7 @@ namespace KernelPanic.Data new(Language.Java, "Java", "warming vm", ResolutionTrack.Native, "TODO: Java starts expensive and gets cheaper as the combat warms up. Signature mechanic copy pending.", null, Distro("debian", "Debian"), Distro("fedora", "Fedora")), new(Language.Go, "Go", "small services", ResolutionTrack.Native, "TODO: Go uses direct native actions with simple scaling and concurrency hooks. Signature mechanic copy pending.", null, Distro("cachyos", "CachyOS"), Distro("nixos", "NixOS")), new(Language.Ruby, "Ruby", "sharp scripts", ResolutionTrack.InterpreterQueue, "TODO: Ruby queues expressive effects with combo-friendly timing. Signature mechanic copy pending.", null, Distro("kali", "Kali"), Distro("endeavouros", "EndeavourOS")), - new(Language.PHP, "PHP", "legacy glue", ResolutionTrack.InterpreterQueue, "TODO: PHP queues practical effects that improve when the board is messy. Signature mechanic copy pending.", null, Distro("debian", "Debian"), Distro("cachyos", "CachyOS")), + new(Language.Php, "PHP", "legacy glue", ResolutionTrack.InterpreterQueue, "TODO: PHP queues practical effects that improve when the board is messy. Signature mechanic copy pending.", null, Distro("debian", "Debian"), Distro("cachyos", "CachyOS")), }; public static IReadOnlyList All => Entries; diff --git a/Assets/Scripts/Meta/CardLoadout.cs b/Assets/Scripts/Meta/CardLoadout.cs index 572e918..b341f64 100644 --- a/Assets/Scripts/Meta/CardLoadout.cs +++ b/Assets/Scripts/Meta/CardLoadout.cs @@ -21,24 +21,24 @@ namespace KernelPanic.Meta { public const int MaxEquippedCards = 4; - private readonly IReadOnlyList ownedUnits; - private readonly Dictionary> equippedByDistro = new(StringComparer.OrdinalIgnoreCase); + private readonly IReadOnlyList _ownedUnits; + private readonly Dictionary> _equippedByDistro = new(StringComparer.OrdinalIgnoreCase); public CardLoadout(IReadOnlyList ownedUnits) { - this.ownedUnits = ownedUnits ?? Array.Empty(); + this._ownedUnits = ownedUnits ?? Array.Empty(); } public IReadOnlyList GetEquippedCardIds(string distroId) { - return equippedByDistro.TryGetValue(distroId, out List equipped) + return _equippedByDistro.TryGetValue(distroId, out List equipped) ? equipped : Array.Empty(); } public bool HasLoadout(string distroId) { - return !string.IsNullOrWhiteSpace(distroId) && equippedByDistro.ContainsKey(distroId); + return !string.IsNullOrWhiteSpace(distroId) && _equippedByDistro.ContainsKey(distroId); } public void EnsureLoadout(DistroDefinition distro) @@ -48,12 +48,12 @@ namespace KernelPanic.Meta return; } - equippedByDistro[distro.Id] = new List(); + _equippedByDistro[distro.Id] = new List(); } public void ClearAll() { - equippedByDistro.Clear(); + _equippedByDistro.Clear(); } public void ClearLoadout(DistroDefinition distro) @@ -63,7 +63,7 @@ namespace KernelPanic.Meta return; } - equippedByDistro[distro.Id] = new List(); + _equippedByDistro[distro.Id] = new List(); } public bool TryLoad(string distroId, IEnumerable cardIds, out bool skippedInvalid) @@ -80,7 +80,7 @@ namespace KernelPanic.Meta foreach (string cardId in cardIds ?? Array.Empty()) { CardDefinition card = FindExclusiveCard(distro, cardId); - if (card == null || card.IsToken || equipped.Contains(card.Id)) + if (card == null || card.IsToken || card.IsRunOnly || equipped.Contains(card.Id)) { skippedInvalid = true; continue; @@ -96,7 +96,7 @@ namespace KernelPanic.Meta } } - equippedByDistro[distro.Id] = equipped; + _equippedByDistro[distro.Id] = equipped; return true; } @@ -116,14 +116,14 @@ namespace KernelPanic.Meta return false; } - if (card.IsToken) + if (card.IsToken || card.IsRunOnly) { reason = CardLoadoutFailureReason.Token; return false; } EnsureLoadout(distro); - List equipped = equippedByDistro[distro.Id]; + List equipped = _equippedByDistro[distro.Id]; if (equipped.Contains(card.Id)) { reason = CardLoadoutFailureReason.Duplicate; @@ -151,7 +151,7 @@ namespace KernelPanic.Meta } EnsureLoadout(distro); - List equipped = equippedByDistro[distro.Id]; + List equipped = _equippedByDistro[distro.Id]; if (!equipped.Remove(cardId)) { reason = CardLoadoutFailureReason.NotEquipped; @@ -169,9 +169,9 @@ namespace KernelPanic.Meta return null; } - for (int i = 0; i < ownedUnits.Count; i++) + for (int i = 0; i < _ownedUnits.Count; i++) { - DistroDefinition distro = ownedUnits[i]; + DistroDefinition distro = _ownedUnits[i]; if (distro != null && string.Equals(distro.Id, distroId, StringComparison.OrdinalIgnoreCase)) { return distro; @@ -191,7 +191,7 @@ namespace KernelPanic.Meta for (int i = 0; i < distro.ExclusiveCards.Count; i++) { CardDefinition card = distro.ExclusiveCards[i]; - if (card != null && string.Equals(card.Id, cardId, StringComparison.OrdinalIgnoreCase)) + if (card != null && !card.IsRunOnly && string.Equals(card.Id, cardId, StringComparison.OrdinalIgnoreCase)) { return card; } diff --git a/Assets/Scripts/Meta/GachaService.cs b/Assets/Scripts/Meta/GachaService.cs index 438b173..fef40ec 100644 --- a/Assets/Scripts/Meta/GachaService.cs +++ b/Assets/Scripts/Meta/GachaService.cs @@ -19,6 +19,7 @@ namespace KernelPanic.Meta public const int FiveStarSoftPityStart = 66; public const int FiveStarHardPity = 80; public const int EntropyPerPullToken = 100; + public const int TestRootCreditsBalance = 10000; private const double FourStarBaseChance = 0.12d; private const double FiveStarBaseChance = 0.008d; @@ -26,10 +27,10 @@ namespace KernelPanic.Meta private const double DistroChanceOnFeaturedTier = 0.5d; private const double LimitedStandardFiveStarChance = 1d / 3d; - private readonly List bannerPool = new(); - private readonly Dictionary currencyBalances = new(); - private readonly Random random; - private GachaBannerState beginnerState = new(BeginnerBannerId); + private readonly List _bannerPool = new(); + private readonly Dictionary _currencyBalances = new(); + private readonly Random _random; + private GachaBannerState _beginnerState = new(BeginnerBannerId); public GachaService() : this(new Random()) @@ -38,17 +39,17 @@ namespace KernelPanic.Meta public GachaService(Random random) { - this.random = random ?? new Random(); - currencyBalances[GachaCurrencyType.StandardPull] = 0; - currencyBalances[GachaCurrencyType.LimitedPull] = 0; + this._random = random ?? new Random(); + _currencyBalances[GachaCurrencyType.StandardPull] = 0; + _currencyBalances[GachaCurrencyType.LimitedPull] = 0; } public int PullTokens => GetCurrencyBalance(GachaCurrencyType.StandardPull); public int LimitedPullTokens => GetCurrencyBalance(GachaCurrencyType.LimitedPull); public int RootCredits { get; private set; } - public IReadOnlyList BannerPool => bannerPool; - public GachaBannerState BeginnerState => beginnerState; - public bool IsBeginnerBannerAvailable => !beginnerState.exhausted && beginnerState.totalPulls < BeginnerMaxPulls && bannerPool.Count > 0; + public IReadOnlyList BannerPool => _bannerPool; + public GachaBannerState BeginnerState => _beginnerState; + public bool IsBeginnerBannerAvailable => !_beginnerState.exhausted && _beginnerState.totalPulls < BeginnerMaxPulls && _bannerPool.Count > 0; public event Action BannerPoolChanged; public event Action Changed; @@ -61,18 +62,18 @@ namespace KernelPanic.Meta } data.EnsureLists(); - RootCredits = Math.Max(0, data.rootCredits); + RootCredits = Math.Max(TestRootCreditsBalance, data.rootCredits); SetCurrencyBalanceSilently(GachaCurrencyType.StandardPull, data.standardPullCurrency); SetCurrencyBalanceSilently(GachaCurrencyType.LimitedPull, data.limitedPullCurrency); - beginnerState = data.beginnerBannerState ?? new GachaBannerState(BeginnerBannerId); - beginnerState.bannerId = BeginnerBannerId; - beginnerState.EnsureLists(); - if (beginnerState.totalPulls >= BeginnerMaxPulls) + _beginnerState = data.beginnerBannerState ?? new GachaBannerState(BeginnerBannerId); + _beginnerState.bannerId = BeginnerBannerId; + _beginnerState.EnsureLists(); + if (_beginnerState.totalPulls >= BeginnerMaxPulls) { - beginnerState.exhausted = true; + _beginnerState.exhausted = true; } - beginnerState.fiveStarPityCounter = Math.Max(0, beginnerState.fiveStarPityCounter); + _beginnerState.fiveStarPityCounter = Math.Max(0, _beginnerState.fiveStarPityCounter); Changed?.Invoke(); } @@ -88,12 +89,12 @@ namespace KernelPanic.Meta data.rootCredits = RootCredits; data.standardPullCurrency = GetCurrencyBalance(GachaCurrencyType.StandardPull); data.limitedPullCurrency = GetCurrencyBalance(GachaCurrencyType.LimitedPull); - data.beginnerBannerState = CloneState(beginnerState); + data.beginnerBannerState = CloneState(_beginnerState); } public int GetCurrencyBalance(GachaCurrencyType currencyType) { - return currencyBalances.TryGetValue(currencyType, out int balance) ? balance : 0; + return _currencyBalances.TryGetValue(currencyType, out int balance) ? balance : 0; } public void SetCurrencyBalance(GachaCurrencyType currencyType, int amount) @@ -152,19 +153,19 @@ namespace KernelPanic.Meta public void AddToBannerPool(DistroDefinition distro) { - if (distro == null || bannerPool.Contains(distro)) + if (distro == null || _bannerPool.Contains(distro)) { return; } - bannerPool.Add(distro); + _bannerPool.Add(distro); BannerPoolChanged?.Invoke(); Changed?.Invoke(); } public void SetBeginnerGuaranteedDistros(IReadOnlyList distros) { - beginnerState.guaranteedDistroIds.Clear(); + _beginnerState.guaranteedDistroIds.Clear(); if (distros != null) { for (int i = 0; i < distros.Count; i++) @@ -172,7 +173,7 @@ namespace KernelPanic.Meta DistroDefinition distro = distros[i]; if (distro != null && !string.IsNullOrWhiteSpace(distro.Id)) { - beginnerState.guaranteedDistroIds.Add(distro.Id); + _beginnerState.guaranteedDistroIds.Add(distro.Id); } } } @@ -199,7 +200,7 @@ namespace KernelPanic.Meta return false; } - int remainingPulls = BeginnerMaxPulls - beginnerState.totalPulls; + int remainingPulls = BeginnerMaxPulls - _beginnerState.totalPulls; if (pullCount > remainingPulls) { failureReason = $"only {remainingPulls} beginner pull(s) remain"; @@ -255,13 +256,13 @@ namespace KernelPanic.Meta List rewards = new(); for (int i = 0; i < pullCount; i++) { - beginnerState.totalPulls++; - rewards.Add(RollBeginnerReward(beginnerState.totalPulls)); + _beginnerState.totalPulls++; + rewards.Add(RollBeginnerReward(_beginnerState.totalPulls)); } - if (beginnerState.totalPulls >= BeginnerMaxPulls) + if (_beginnerState.totalPulls >= BeginnerMaxPulls) { - beginnerState.exhausted = true; + _beginnerState.exhausted = true; } Changed?.Invoke(); @@ -272,8 +273,8 @@ namespace KernelPanic.Meta { return currencyType switch { - GachaCurrencyType.LimitedPull => "feature-pull-token", - _ => "stable-pull-token" + GachaCurrencyType.LimitedPull => "Compute Credits", + _ => "Commits" }; } @@ -308,39 +309,39 @@ namespace KernelPanic.Meta { if (pullNumber == 20) { - AdvanceFiveStarPityAfterNonFiveStar(beginnerState); + AdvanceFiveStarPityAfterNonFiveStar(_beginnerState); return RewardGuaranteedBeginnerDistro(0, "20-pull starter guarantee"); } if (pullNumber == 40) { - AdvanceFiveStarPityAfterNonFiveStar(beginnerState); + AdvanceFiveStarPityAfterNonFiveStar(_beginnerState); return RewardGuaranteedBeginnerDistro(1, "40-pull starter guarantee"); } if (pullNumber == 50) { - beginnerState.pityCounter = 0; + _beginnerState.pityCounter = 0; return GachaReward.FutureStandardFiveStar("future standard 5-star distro", "50-pull beginner capstone"); } - if (TryRollFiveStar(beginnerState, out bool fiveStarPityTriggered)) + if (TryRollFiveStar(_beginnerState, out bool fiveStarPityTriggered)) { return RewardRandomFiveStar(fiveStarPityTriggered); } - bool pityTriggered = beginnerState.pityCounter >= FourStarHardPity - 1; - bool hitFourStarOrCharacter = pityTriggered || random.NextDouble() < FourStarBaseChance; + bool pityTriggered = _beginnerState.pityCounter >= FourStarHardPity - 1; + bool hitFourStarOrCharacter = pityTriggered || _random.NextDouble() < FourStarBaseChance; if (!hitFourStarOrCharacter) { - beginnerState.pityCounter++; + _beginnerState.pityCounter++; return GachaReward.Equipment(3, "3-star equipment", false, false); } - beginnerState.pityCounter = 0; - if (bannerPool.Count > 0 && random.NextDouble() < DistroChanceOnFeaturedTier) + _beginnerState.pityCounter = 0; + if (_bannerPool.Count > 0 && _random.NextDouble() < DistroChanceOnFeaturedTier) { - DistroDefinition distro = bannerPool[random.Next(bannerPool.Count)]; + DistroDefinition distro = _bannerPool[_random.Next(_bannerPool.Count)]; return GachaReward.DistroReward(distro, 4, pityTriggered, false); } @@ -349,12 +350,12 @@ namespace KernelPanic.Meta private bool TryRollFiveStar(GachaBannerState state, out bool pityTriggered) { - state ??= beginnerState; + state ??= _beginnerState; int pityPullNumber = Math.Max(0, state.fiveStarPityCounter) + 1; pityTriggered = pityPullNumber >= FiveStarSoftPityStart; double chance = GetFiveStarChance(state); - bool hit = chance >= 1d || random.NextDouble() < chance; + bool hit = chance >= 1d || _random.NextDouble() < chance; if (!hit) { state.fiveStarPityCounter++; @@ -376,18 +377,18 @@ namespace KernelPanic.Meta private GachaReward RewardRandomFiveStar(bool pityTriggered) { - if (bannerPool.Count == 0 || random.NextDouble() >= DistroChanceOnFeaturedTier) + if (_bannerPool.Count == 0 || _random.NextDouble() >= DistroChanceOnFeaturedTier) { return GachaReward.Equipment(5, "5-star equipment", pityTriggered, false); } - DistroDefinition distro = bannerPool.Count == 0 ? null : bannerPool[random.Next(bannerPool.Count)]; + DistroDefinition distro = _bannerPool.Count == 0 ? null : _bannerPool[_random.Next(_bannerPool.Count)]; return GachaReward.DistroReward(distro, 5, pityTriggered, false); } public bool ResolveFeaturedTierIsDistro() { - return random.NextDouble() < DistroChanceOnFeaturedTier; + return _random.NextDouble() < DistroChanceOnFeaturedTier; } public bool ResolveLimitedFiveStarUsesStandardPool(GachaBannerState limitedState) @@ -403,21 +404,21 @@ namespace KernelPanic.Meta return false; } - bool useStandardPool = random.NextDouble() < LimitedStandardFiveStarChance; + bool useStandardPool = _random.NextDouble() < LimitedStandardFiveStarChance; limitedState.featuredFiveStarGuaranteed = useStandardPool; return useStandardPool; } private GachaReward RewardGuaranteedBeginnerDistro(int guaranteeIndex, string reason) { - beginnerState.pityCounter = 0; + _beginnerState.pityCounter = 0; DistroDefinition distro = null; - if (guaranteeIndex >= 0 && guaranteeIndex < beginnerState.guaranteedDistroIds.Count) + if (guaranteeIndex >= 0 && guaranteeIndex < _beginnerState.guaranteedDistroIds.Count) { - distro = FindBannerPoolDistro(beginnerState.guaranteedDistroIds[guaranteeIndex]); + distro = FindBannerPoolDistro(_beginnerState.guaranteedDistroIds[guaranteeIndex]); } - distro ??= bannerPool.Count == 0 ? null : bannerPool[Math.Min(guaranteeIndex, bannerPool.Count - 1)]; + distro ??= _bannerPool.Count == 0 ? null : _bannerPool[Math.Min(guaranteeIndex, _bannerPool.Count - 1)]; return distro == null ? GachaReward.Equipment(4, "4-star equipment", true, true) : GachaReward.DistroReward(distro, 4, true, true, reason); @@ -430,9 +431,9 @@ namespace KernelPanic.Meta return null; } - for (int i = 0; i < bannerPool.Count; i++) + for (int i = 0; i < _bannerPool.Count; i++) { - DistroDefinition distro = bannerPool[i]; + DistroDefinition distro = _bannerPool[i]; if (distro != null && string.Equals(distro.Id, id, StringComparison.OrdinalIgnoreCase)) { return distro; @@ -444,7 +445,7 @@ namespace KernelPanic.Meta private void SetCurrencyBalanceSilently(GachaCurrencyType currencyType, int amount) { - currencyBalances[currencyType] = Math.Max(0, amount); + _currencyBalances[currencyType] = Math.Max(0, amount); } private bool CanPullBeginnerWithEntropy(int pullCount, EntropyWallet wallet, int entropyTokenCount, out string failureReason) @@ -461,7 +462,7 @@ namespace KernelPanic.Meta return false; } - int remainingPulls = BeginnerMaxPulls - beginnerState.totalPulls; + int remainingPulls = BeginnerMaxPulls - _beginnerState.totalPulls; if (pullCount > remainingPulls) { failureReason = $"only {remainingPulls} beginner pull(s) remain"; diff --git a/Assets/Scripts/Meta/PlayerCollection.cs b/Assets/Scripts/Meta/PlayerCollection.cs index 69d9959..c496edd 100644 --- a/Assets/Scripts/Meta/PlayerCollection.cs +++ b/Assets/Scripts/Meta/PlayerCollection.cs @@ -9,12 +9,12 @@ namespace KernelPanic.Meta /// public sealed class PlayerCollection { - private readonly List ownedUnits = new(); - private int featuredIndex; + private readonly List _ownedUnits = new(); + private int _featuredIndex; // TODO: Populate owned units from SaveService during bootstrap. - public IReadOnlyList OwnedUnits => ownedUnits; - public DistroDefinition FeaturedUnit => ownedUnits.Count == 0 ? null : ownedUnits[Math.Clamp(featuredIndex, 0, ownedUnits.Count - 1)]; + public IReadOnlyList OwnedUnits => _ownedUnits; + public DistroDefinition FeaturedUnit => _ownedUnits.Count == 0 ? null : _ownedUnits[Math.Clamp(_featuredIndex, 0, _ownedUnits.Count - 1)]; public event Action Changed; @@ -26,8 +26,8 @@ namespace KernelPanic.Meta } // Persistence is wired by the owner so this domain class stays IO-free. - ownedUnits.Add(unit); - featuredIndex = Math.Clamp(featuredIndex, 0, ownedUnits.Count - 1); + _ownedUnits.Add(unit); + _featuredIndex = Math.Clamp(_featuredIndex, 0, _ownedUnits.Count - 1); Changed?.Invoke(); } @@ -39,9 +39,9 @@ namespace KernelPanic.Meta } int count = 0; - for (int i = 0; i < ownedUnits.Count; i++) + for (int i = 0; i < _ownedUnits.Count; i++) { - DistroDefinition unit = ownedUnits[i]; + DistroDefinition unit = _ownedUnits[i]; if (unit != null && string.Equals(unit.Id, unitId, StringComparison.OrdinalIgnoreCase)) { count++; @@ -53,12 +53,12 @@ namespace KernelPanic.Meta public void SelectNextFeatured() { - if (ownedUnits.Count < 2) + if (_ownedUnits.Count < 2) { return; } - featuredIndex = (featuredIndex + 1) % ownedUnits.Count; + _featuredIndex = (_featuredIndex + 1) % _ownedUnits.Count; } } } diff --git a/Assets/Scripts/Meta/SaveService.cs b/Assets/Scripts/Meta/SaveService.cs index 62d5f8c..556deaf 100644 --- a/Assets/Scripts/Meta/SaveService.cs +++ b/Assets/Scripts/Meta/SaveService.cs @@ -59,7 +59,7 @@ namespace KernelPanic.Meta { public bool starterChosen; public int entropyBalance; - public int rootCredits; + public int rootCredits = GachaService.TestRootCreditsBalance; public int standardPullCurrency; public int limitedPullCurrency; public List ownedUnitIds = new(); diff --git a/Assets/Scripts/Run/RepositoryOffer.cs b/Assets/Scripts/Run/RepositoryOffer.cs new file mode 100644 index 0000000..2973222 --- /dev/null +++ b/Assets/Scripts/Run/RepositoryOffer.cs @@ -0,0 +1,157 @@ +using KernelPanic.Combat; +using KernelPanic.Data; + +namespace KernelPanic.Run +{ + public enum RepositoryOfferKind + { + NewCard, + CardUpgrade, + StatUpgrade + } + + public enum CardUpgradeKind + { + CostDown, + MagnitudeUp, + DrawRider + } + + public enum RunStatUpgradeKind + { + MaxCycles, + MaxUptime, + Heal, + Ram + } + + public sealed class RepositoryOffer + { + private RepositoryOffer( + RepositoryOfferKind kind, + string commandName, + string displayName, + string description, + int price) + { + Kind = kind; + CommandName = commandName; + DisplayName = displayName; + Description = description; + Price = price; + } + + public RepositoryOfferKind Kind { get; } + public string CommandName { get; } + public string DisplayName { get; } + public string Description { get; } + public int Price { get; } + public CardDefinition CardDefinition { get; private set; } + public CardInstance TargetCard { get; private set; } + public CardUpgradeKind CardUpgradeKind { get; private set; } + public RunStatUpgradeKind StatUpgradeKind { get; private set; } + public bool Sold { get; private set; } + + public static RepositoryOffer NewCard(CardDefinition card, int price) + { + string name = DisplayNameFor(card); + RepositoryOffer offer = new(RepositoryOfferKind.NewCard, SafeCommandName(card?.Id, name), name, RulesTextFor(card), price) + { + CardDefinition = card + }; + return offer; + } + + public static RepositoryOffer CardUpgrade(CardInstance target, CardUpgradeKind upgradeKind, int price) + { + string cardName = DisplayNameFor(target?.Definition); + string description = UpgradeDescription(target, upgradeKind); + RepositoryOffer offer = new(RepositoryOfferKind.CardUpgrade, SafeCommandName(target?.Definition?.Id, cardName), $"upgrade {cardName}", description, price) + { + TargetCard = target, + CardUpgradeKind = upgradeKind + }; + return offer; + } + + public static RepositoryOffer StatUpgrade(RunStatUpgradeKind upgradeKind, int price) + { + return new RepositoryOffer(RepositoryOfferKind.StatUpgrade, upgradeKind.ToString().ToLowerInvariant(), StatName(upgradeKind), StatDescription(upgradeKind), price) + { + StatUpgradeKind = upgradeKind + }; + } + + public void MarkSold() + { + Sold = true; + } + + private static string RulesTextFor(CardDefinition card) + { + if (card == null) + { + return "--"; + } + + CardInstance preview = new(card); + return CardEffectFactory.GetRulesText(preview); + } + + private static string UpgradeDescription(CardInstance target, CardUpgradeKind upgradeKind) + { + if (target?.Definition == null) + { + return "--"; + } + + return upgradeKind switch + { + CardUpgradeKind.CostDown => $"cost {CombatManager.GetCardCost(target)} -> {UnityEngine.Mathf.Max(0, CombatManager.GetCardCost(target) - 1)}", + CardUpgradeKind.MagnitudeUp => $"effect magnitude +{CombatTuning.UpgradeMagnitudeBonus}", + CardUpgradeKind.DrawRider => "add rider: draw 1 after resolving", + _ => "--" + }; + } + + private static string StatName(RunStatUpgradeKind upgradeKind) + { + return upgradeKind switch + { + RunStatUpgradeKind.MaxCycles => "+1 max cycles", + RunStatUpgradeKind.MaxUptime => "+max uptime", + RunStatUpgradeKind.Heal => "heal uptime", + RunStatUpgradeKind.Ram => "+1 RAM", + _ => upgradeKind.ToString() + }; + } + + private static string StatDescription(RunStatUpgradeKind upgradeKind) + { + return upgradeKind switch + { + RunStatUpgradeKind.MaxCycles => $"+{CombatTuning.StatUpgradeMaxCycles} max cycles this run", + RunStatUpgradeKind.MaxUptime => $"+{CombatTuning.StatUpgradeMaxUptime} max uptime this run", + RunStatUpgradeKind.Heal => $"heal {CombatTuning.StatUpgradeHeal} uptime now", + RunStatUpgradeKind.Ram => $"+{CombatTuning.StatUpgradeRam} RAM this run", + _ => "--" + }; + } + + private static string DisplayNameFor(CardDefinition card) + { + if (card == null) + { + return "--"; + } + + return string.IsNullOrWhiteSpace(card.DisplayName) ? card.Id : card.DisplayName; + } + + private static string SafeCommandName(string id, string fallback) + { + string value = string.IsNullOrWhiteSpace(id) ? fallback : id; + return string.IsNullOrWhiteSpace(value) ? "package" : value.Replace(' ', '-'); + } + } +} diff --git a/Assets/Scripts/Run/RewardService.cs b/Assets/Scripts/Run/RewardService.cs index b96deb4..e9147ce 100644 --- a/Assets/Scripts/Run/RewardService.cs +++ b/Assets/Scripts/Run/RewardService.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using KernelPanic.Data; @@ -11,7 +10,8 @@ namespace KernelPanic.Run { public IReadOnlyList GeneratePostWaveChoices(RunConfig runConfig, int waveNumber) { - throw new NotImplementedException(); + // TODO: Generate real post-wave rewards once card reward pools and upgrade rules exist. + return new[] { new RewardChoice(RewardChoiceType.Skip, null) }; } } diff --git a/Assets/Scripts/Run/RunConfig.cs b/Assets/Scripts/Run/RunConfig.cs index 2edc5e8..78a6a4f 100644 --- a/Assets/Scripts/Run/RunConfig.cs +++ b/Assets/Scripts/Run/RunConfig.cs @@ -10,16 +10,23 @@ namespace KernelPanic.Run public sealed class RunConfig { public RunConfig(DistroDefinition distro, Language primaryLanguage, Language secondaryLanguage, IReadOnlyList startingDeck) + : this(distro, primaryLanguage, secondaryLanguage, startingDeck, 0) + { + } + + public RunConfig(DistroDefinition distro, Language primaryLanguage, Language secondaryLanguage, IReadOnlyList startingDeck, int runSeed) { Distro = distro; PrimaryLanguage = primaryLanguage; SecondaryLanguage = secondaryLanguage; StartingDeck = startingDeck; + RunSeed = runSeed; } public DistroDefinition Distro { get; } public Language PrimaryLanguage { get; } public Language SecondaryLanguage { get; } public IReadOnlyList StartingDeck { get; } + public int RunSeed { get; } } } diff --git a/Assets/Scripts/Run/RunContext.cs b/Assets/Scripts/Run/RunContext.cs new file mode 100644 index 0000000..e8b2485 --- /dev/null +++ b/Assets/Scripts/Run/RunContext.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using KernelPanic.Core; +using KernelPanic.Data; + +namespace KernelPanic.Run +{ + /// + /// Scene-transition handoff for a just-configured run. This is intentionally a plain static + /// context instead of a ScriptableObject asset because the payload is transient runtime state, + /// not reusable authored content. + /// + public static class RunContext + { + private static PendingRun pendingRun; + + public static bool HasPendingRun => pendingRun != null; + + public static void Set(DistroDefinition distro, IReadOnlyList equippedCards, Language primaryLanguage, Language secondaryLanguage) + { + pendingRun = new PendingRun(distro, equippedCards, primaryLanguage, secondaryLanguage, Environment.TickCount); + } + + public static bool TryCreateRunConfig(LanguageDeckDatabase languageDeckDatabase, out RunConfig config) + { + if (pendingRun == null || pendingRun.Distro == null) + { + config = null; + return false; + } + + config = pendingRun.CreateRunConfig(languageDeckDatabase); + pendingRun = null; + return true; + } + + private sealed class PendingRun + { + private readonly DistroDefinition distro; + private readonly List equippedCards = new(); + private readonly Language primaryLanguage; + private readonly Language secondaryLanguage; + private readonly int runSeed; + + public PendingRun(DistroDefinition distro, IReadOnlyList cards, Language primaryLanguage, Language secondaryLanguage, int runSeed) + { + this.distro = distro; + this.primaryLanguage = primaryLanguage; + this.secondaryLanguage = secondaryLanguage; + this.runSeed = runSeed; + + if (cards == null) + { + return; + } + + for (int i = 0; i < cards.Count; i++) + { + if (cards[i] != null && !cards[i].IsRunOnly) + { + equippedCards.Add(cards[i]); + } + } + } + + public DistroDefinition Distro => distro; + + public RunConfig CreateRunConfig(LanguageDeckDatabase languageDeckDatabase) + { + List startingDeck = new(equippedCards); + AddLanguageDeck(languageDeckDatabase, primaryLanguage, startingDeck); + AddLanguageDeck(languageDeckDatabase, secondaryLanguage, startingDeck); + return new RunConfig(distro, primaryLanguage, secondaryLanguage, startingDeck, runSeed); + } + + private static void AddLanguageDeck(LanguageDeckDatabase languageDeckDatabase, Language language, List target) + { + LanguageDeckDefinition deck = languageDeckDatabase == null ? null : languageDeckDatabase.FindByLanguage(language); + if (deck == null) + { + return; + } + + for (int entryIndex = 0; entryIndex < deck.Entries.Count; entryIndex++) + { + LanguageDeckDefinition.LanguageDeckEntry entry = deck.Entries[entryIndex]; + for (int copy = 0; copy < entry.Count; copy++) + { + if (entry.Card != null && !entry.Card.IsRunOnly) + { + target.Add(entry.Card); + } + } + } + } + } + } +} diff --git a/Assets/Scripts/Run/RunManager.cs b/Assets/Scripts/Run/RunManager.cs index 8de8559..74f5906 100644 --- a/Assets/Scripts/Run/RunManager.cs +++ b/Assets/Scripts/Run/RunManager.cs @@ -1,5 +1,9 @@ using KernelPanic.Combat; using KernelPanic.Core; +using KernelPanic.Data; +using System; +using System.Collections.Generic; +using System.Linq; using UnityEngine; namespace KernelPanic.Run @@ -14,9 +18,30 @@ namespace KernelPanic.Run [SerializeField] private bool isRunActive; [SerializeField] private bool isPlayerDead; + private readonly List runDeck = new(); + private readonly List repositoryOffers = new(); + private readonly HashSet soldOfferKeys = new(StringComparer.OrdinalIgnoreCase); + private int bits; + private int rerollsThisVisit; + private int maxUptimeBonus; + private int maxCyclesBonus; + private int ramBonus; + private bool repositoryVisitActive; + public int CurrentWaveNumber => currentWaveNumber; public bool IsRunActive => isRunActive; public bool IsPlayerDead => isPlayerDead; + public RunConfig CurrentConfig { get; private set; } + public IReadOnlyList RunDeck => runDeck; + public IReadOnlyList RepositoryOffers => repositoryOffers; + public bool RepositoryVisitActive => repositoryVisitActive; + public int Bits => bits; + public int RerollCost => CombatTuning.RerollBaseCost + (rerollsThisVisit * CombatTuning.RerollCostStep); + public int MaxUptimeBonus => maxUptimeBonus; + public int MaxCyclesBonus => maxCyclesBonus; + public int RamBonus => ramBonus; + + public event Action RepositoryChanged; private void OnEnable() { @@ -32,25 +57,376 @@ namespace KernelPanic.Run public void StartRun(RunConfig config) { + CurrentConfig = config; isRunActive = true; isPlayerDead = false; currentWaveNumber = 1; + bits = 0; + rerollsThisVisit = 0; + maxUptimeBonus = 0; + maxCyclesBonus = 0; + ramBonus = 0; + InitializeRunDeck(config); StartCombat(); } public void StartCombat() { - combatManager?.StartCombat(); + combatManager?.StartCombat(CurrentConfig); + } + + public void AddBits(int amount) + { + if (!isRunActive || amount <= 0) + { + return; + } + + bits += amount; + RepositoryChanged?.Invoke(); + } + + public void GenerateRepositoryOffers(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase) + { + repositoryOffers.Clear(); + rerollsThisVisit = 0; + soldOfferKeys.Clear(); + FillRepositoryOffers(cardDatabase, languageDeckDatabase); + repositoryVisitActive = true; + RepositoryChanged?.Invoke(); + } + + public bool RerollRepositoryOffers(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase) + { + int cost = RerollCost; + if (bits < cost) + { + return false; + } + + bits -= cost; + rerollsThisVisit++; + repositoryOffers.Clear(); + FillRepositoryOffers(cardDatabase, languageDeckDatabase); + repositoryVisitActive = true; + RepositoryChanged?.Invoke(); + return true; + } + + public bool BuyOffer(RepositoryOffer offer, CombatantState playerState) + { + if (offer == null || offer.Sold || bits < offer.Price) + { + return false; + } + + bool applied = offer.Kind switch + { + RepositoryOfferKind.NewCard => AddCardToRunDeck(offer.CardDefinition), + RepositoryOfferKind.CardUpgrade => ApplyCardUpgrade(offer.TargetCard, offer.CardUpgradeKind), + RepositoryOfferKind.StatUpgrade => ApplyStatUpgrade(offer.StatUpgradeKind, playerState), + _ => false + }; + + if (!applied) + { + return false; + } + + bits -= offer.Price; + offer.MarkSold(); + soldOfferKeys.Add(OfferKey(offer)); + RepositoryChanged?.Invoke(); + return true; + } + + public bool RemoveCard(CardInstance card) + { + if (card == null || bits < CombatTuning.RemoveCardCost || runDeck.Count <= 1) + { + return false; + } + + if (!runDeck.Remove(card)) + { + return false; + } + + bits -= CombatTuning.RemoveCardCost; + RepositoryChanged?.Invoke(); + return true; + } + + public int EffectiveMaxUptime() + { + return Mathf.Max(1, (CurrentConfig?.Distro?.BaseUptime ?? 1) + maxUptimeBonus); + } + + public int EffectiveRam() + { + return Mathf.Max(1, (CurrentConfig?.Distro?.BaseRam ?? 1) + ramBonus); + } + + public int EffectiveMaxCycles() + { + return Mathf.Max(1, (CurrentConfig?.Distro?.BaseCyclesPerTurn ?? 1) + maxCyclesBonus); } private void HandleWaveCleared(WaveClearedEvent payload) { + currentWaveNumber = Mathf.Max(currentWaveNumber + 1, payload.WaveNumber + 1); + repositoryOffers.Clear(); + rerollsThisVisit = 0; + soldOfferKeys.Clear(); + repositoryVisitActive = false; + GameEvents.RaiseWaveAdvanced(new WaveAdvancedEvent(currentWaveNumber)); } private void HandleRunEnded(RunEndedEvent payload) { isRunActive = false; isPlayerDead = payload.PlayerDied; + bits = 0; + repositoryOffers.Clear(); + repositoryVisitActive = false; + runDeck.Clear(); + maxUptimeBonus = 0; + maxCyclesBonus = 0; + ramBonus = 0; + RepositoryChanged?.Invoke(); + } + + private void InitializeRunDeck(RunConfig config) + { + runDeck.Clear(); + if (config?.StartingDeck == null) + { + return; + } + + for (int i = 0; i < config.StartingDeck.Count; i++) + { + CardDefinition definition = config.StartingDeck[i]; + if (definition != null && !definition.IsRunOnly) + { + runDeck.Add(new CardInstance(definition)); + } + } + } + + private void FillRepositoryOffers(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase) + { + int guard = 0; + while (repositoryOffers.Count < CombatTuning.ShopSize && guard < 80) + { + guard++; + RepositoryOffer offer = CreateRandomOffer(cardDatabase, languageDeckDatabase); + if (offer == null || soldOfferKeys.Contains(OfferKey(offer)) || repositoryOffers.Any(existing => IsDuplicateOffer(existing, offer))) + { + continue; + } + + repositoryOffers.Add(offer); + } + } + + private RepositoryOffer CreateRandomOffer(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase) + { + int roll = UnityEngine.Random.Range(0, 100); + if (roll < 45) + { + return CreateCardOffer(cardDatabase, languageDeckDatabase); + } + + if (roll < 75) + { + return CreateUpgradeOffer(); + } + + return CreateStatOffer(); + } + + private RepositoryOffer CreateCardOffer(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase) + { + List pool = BuildCardOfferPool(cardDatabase, languageDeckDatabase); + if (pool.Count == 0) + { + return null; + } + + CardDefinition selected = pool[UnityEngine.Random.Range(0, pool.Count)]; + return RepositoryOffer.NewCard(selected, selected.IsRunOnly ? CombatTuning.NewCardOfferCost + 1 : CombatTuning.NewCardOfferCost); + } + + private RepositoryOffer CreateUpgradeOffer() + { + List candidates = runDeck.Where(card => card != null && card.CanReceiveRepositoryUpgrade).ToList(); + if (candidates.Count == 0) + { + return CreateStatOffer(); + } + + CardInstance target = candidates[UnityEngine.Random.Range(0, candidates.Count)]; + CardUpgradeKind kind = (CardUpgradeKind)UnityEngine.Random.Range(0, 3); + if (kind == CardUpgradeKind.CostDown && CombatManager.GetCardCost(target) <= 0) + { + kind = CardUpgradeKind.MagnitudeUp; + } + + return RepositoryOffer.CardUpgrade(target, kind, CombatTuning.UpgradeOfferCost); + } + + private static RepositoryOffer CreateStatOffer() + { + RunStatUpgradeKind kind = (RunStatUpgradeKind)UnityEngine.Random.Range(0, 4); + return RepositoryOffer.StatUpgrade(kind, CombatTuning.StatUpgradeCost); + } + + private List BuildCardOfferPool(CardDatabase cardDatabase, LanguageDeckDatabase languageDeckDatabase) + { + List pool = new(); + AddLanguageCards(languageDeckDatabase, CurrentConfig?.PrimaryLanguage ?? Language.Python, pool, 3); + AddLanguageCards(languageDeckDatabase, CurrentConfig?.SecondaryLanguage ?? Language.JavaScript, pool, 3); + + if (cardDatabase != null) + { + for (int i = 0; i < cardDatabase.AllCards.Count; i++) + { + CardDefinition card = cardDatabase.AllCards[i]; + if (card == null || card.IsToken) + { + continue; + } + + if (card.IsRunOnly || card.Language == CurrentConfig.PrimaryLanguage || card.Language == CurrentConfig.SecondaryLanguage) + { + pool.Add(card); + } + } + } + + return pool; + } + + private static void AddLanguageCards(LanguageDeckDatabase languageDeckDatabase, Language language, List pool, int weight) + { + LanguageDeckDefinition deck = languageDeckDatabase == null ? null : languageDeckDatabase.FindByLanguage(language); + if (deck == null) + { + return; + } + + for (int i = 0; i < deck.Entries.Count; i++) + { + CardDefinition card = deck.Entries[i].Card; + if (card == null || card.IsToken || card.IsRunOnly) + { + continue; + } + + for (int copy = 0; copy < Mathf.Max(1, weight); copy++) + { + pool.Add(card); + } + } + } + + private static bool IsDuplicateOffer(RepositoryOffer left, RepositoryOffer right) + { + if (left.Kind != right.Kind) + { + return false; + } + + return left.Kind switch + { + RepositoryOfferKind.NewCard => left.CardDefinition == right.CardDefinition, + RepositoryOfferKind.CardUpgrade => left.TargetCard == right.TargetCard && left.CardUpgradeKind == right.CardUpgradeKind, + RepositoryOfferKind.StatUpgrade => left.StatUpgradeKind == right.StatUpgradeKind, + _ => false + }; + } + + private static string OfferKey(RepositoryOffer offer) + { + if (offer == null) + { + return string.Empty; + } + + return offer.Kind switch + { + RepositoryOfferKind.NewCard => $"card:{offer.CardDefinition?.Id}", + RepositoryOfferKind.CardUpgrade => $"upgrade:{offer.TargetCard?.Definition?.Id}:{offer.TargetCard?.GetHashCode()}:{offer.CardUpgradeKind}", + RepositoryOfferKind.StatUpgrade => $"stat:{offer.StatUpgradeKind}", + _ => offer.DisplayName + }; + } + + private bool AddCardToRunDeck(CardDefinition card) + { + if (card == null) + { + return false; + } + + runDeck.Add(new CardInstance(card)); + return true; + } + + private static bool ApplyCardUpgrade(CardInstance card, CardUpgradeKind kind) + { + if (card == null) + { + return false; + } + + return kind switch + { + CardUpgradeKind.CostDown => card.ApplyCostUpgrade(-1), + CardUpgradeKind.MagnitudeUp => card.ApplyMagnitudeUpgrade(CombatTuning.UpgradeMagnitudeBonus), + CardUpgradeKind.DrawRider => card.ApplyDrawRider(1), + _ => false + }; + } + + private bool ApplyStatUpgrade(RunStatUpgradeKind kind, CombatantState playerState) + { + switch (kind) + { + case RunStatUpgradeKind.MaxCycles: + maxCyclesBonus += CombatTuning.StatUpgradeMaxCycles; + if (playerState != null) + { + playerState.MaxCycles += CombatTuning.StatUpgradeMaxCycles; + playerState.Cycles += CombatTuning.StatUpgradeMaxCycles; + } + return true; + case RunStatUpgradeKind.MaxUptime: + maxUptimeBonus += CombatTuning.StatUpgradeMaxUptime; + if (playerState != null) + { + playerState.MaxUptime += CombatTuning.StatUpgradeMaxUptime; + } + return true; + case RunStatUpgradeKind.Heal: + if (playerState == null) + { + return false; + } + + playerState.CurrentUptime = Mathf.Min(playerState.MaxUptime, playerState.CurrentUptime + CombatTuning.StatUpgradeHeal); + return true; + case RunStatUpgradeKind.Ram: + ramBonus += CombatTuning.StatUpgradeRam; + if (playerState != null) + { + playerState.Ram += CombatTuning.StatUpgradeRam; + } + return true; + default: + return false; + } } } } diff --git a/Assets/Scripts/UI/AsciiArtFitter.cs b/Assets/Scripts/UI/AsciiArtFitter.cs index 2953fbc..b1e1e3a 100644 --- a/Assets/Scripts/UI/AsciiArtFitter.cs +++ b/Assets/Scripts/UI/AsciiArtFitter.cs @@ -18,40 +18,40 @@ namespace KernelPanic.UI private const float MonospaceTolerance = 0.02f; private static readonly Dictionary AdvanceRatioByFont = new(); private static readonly HashSet WarnedFonts = new(); - private static bool hasDefaultAdvanceRatio; - private static float defaultAdvanceRatio; + private static bool _hasDefaultAdvanceRatio; + private static float _defaultAdvanceRatio; - private readonly Label label; - private readonly FontAsset font; - private string art; + private readonly Label _label; + private readonly FontAsset _font; + private string _art; public AsciiArtFitter(Label label, FontAsset font) { - this.label = label; - this.font = font; + this._label = label; + this._font = font; label.RegisterCallback(_ => Fit()); } public void SetArt(string value) { - art = value; + _art = value; Fit(); } private void Fit() { - if (string.IsNullOrWhiteSpace(art)) + if (string.IsNullOrWhiteSpace(_art)) { return; } - Rect region = label.layout; + Rect region = _label.layout; if (!IsUsable(region.width) || !IsUsable(region.height)) { return; } - GetArtDimensions(art, out int rows, out int columns); + GetArtDimensions(_art, out int rows, out int columns); if (rows <= 0 || columns <= 0) { return; @@ -64,49 +64,49 @@ namespace KernelPanic.UI float blockWidth = columns * fontSize * advanceRatio; float blockHeight = rows * fontSize * LineHeightRatio; - label.style.fontSize = fontSize; - label.style.paddingLeft = Mathf.Max(0f, (region.width - blockWidth) * 0.5f); - label.style.paddingTop = Mathf.Max(0f, (region.height - blockHeight) * 0.5f); - label.style.paddingRight = 0f; - label.style.paddingBottom = 0f; + _label.style.fontSize = fontSize; + _label.style.paddingLeft = Mathf.Max(0f, (region.width - blockWidth) * 0.5f); + _label.style.paddingTop = Mathf.Max(0f, (region.height - blockHeight) * 0.5f); + _label.style.paddingRight = 0f; + _label.style.paddingBottom = 0f; } private float GetAdvanceRatio() { - if (font == null && hasDefaultAdvanceRatio) + if (_font == null && _hasDefaultAdvanceRatio) { - return defaultAdvanceRatio; + return _defaultAdvanceRatio; } - if (font != null && AdvanceRatioByFont.TryGetValue(font, out float cached)) + if (_font != null && AdvanceRatioByFont.TryGetValue(_font, out float cached)) { return cached; } - float previousFontSize = label.resolvedStyle.fontSize; - label.style.fontSize = ReferenceFontSize; + float previousFontSize = _label.resolvedStyle.fontSize; + _label.style.fontSize = ReferenceFontSize; - float mWidth = label.MeasureTextSize("MMMMMMMMMM", 0f, VisualElement.MeasureMode.Undefined, 0f, VisualElement.MeasureMode.Undefined).x; - float iWidth = label.MeasureTextSize("iiiiiiiiii", 0f, VisualElement.MeasureMode.Undefined, 0f, VisualElement.MeasureMode.Undefined).x; - label.style.fontSize = previousFontSize; + float mWidth = _label.MeasureTextSize("MMMMMMMMMM", 0f, VisualElement.MeasureMode.Undefined, 0f, VisualElement.MeasureMode.Undefined).x; + float iWidth = _label.MeasureTextSize("iiiiiiiiii", 0f, VisualElement.MeasureMode.Undefined, 0f, VisualElement.MeasureMode.Undefined).x; + _label.style.fontSize = previousFontSize; float advanceRatio = mWidth > 0f ? mWidth / (10f * ReferenceFontSize) : 0.6f; - if (font == null) + if (_font == null) { - defaultAdvanceRatio = advanceRatio; - hasDefaultAdvanceRatio = true; + _defaultAdvanceRatio = advanceRatio; + _hasDefaultAdvanceRatio = true; } else { - AdvanceRatioByFont[font] = advanceRatio; + AdvanceRatioByFont[_font] = advanceRatio; } - if (font != null && mWidth > 0f && iWidth > 0f) + if (_font != null && mWidth > 0f && iWidth > 0f) { float difference = Mathf.Abs(mWidth - iWidth) / Mathf.Max(mWidth, iWidth); - if (difference > MonospaceTolerance && WarnedFonts.Add(font)) + if (difference > MonospaceTolerance && WarnedFonts.Add(_font)) { - Debug.LogWarning($"ASCII art font '{font.name}' is not monospace; art may misalign."); + Debug.LogWarning($"ASCII art font '{_font.name}' is not monospace; art may misalign."); } } diff --git a/Assets/Scripts/UI/BackgroundLogRingBuffer.cs b/Assets/Scripts/UI/BackgroundLogRingBuffer.cs index 4ea252d..9dfc5c4 100644 --- a/Assets/Scripts/UI/BackgroundLogRingBuffer.cs +++ b/Assets/Scripts/UI/BackgroundLogRingBuffer.cs @@ -14,31 +14,31 @@ namespace KernelPanic.UI private const int ExtraLines = 2; private const int TickMilliseconds = 450; - private readonly VisualElement layer; - private readonly IReadOnlyList sourceLines; - private readonly List