diff --git a/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml b/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml index 315ec41..4126be3 100644 --- a/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml +++ b/.idea/.idea.linuxdistrosbuttheyarebreedable/.idea/workspace.xml @@ -11,30 +11,40 @@ - + + + + + + + + + + + + + + + + - + - - - - - + + - - + + + + + + + @@ -111,23 +128,23 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + @@ -138,34 +155,34 @@ - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + { @@ -258,7 +275,8 @@ - + + - @@ -500,7 +526,6 @@ diff --git a/Assets/Data/Cards/Arch/AurHelper.asset b/Assets/Data/Cards/Arch/AurHelper.asset new file mode 100644 index 0000000..87576a4 --- /dev/null +++ b/Assets/Data/Cards/Arch/AurHelper.asset @@ -0,0 +1,28 @@ +%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_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3} + m_Name: AurHelper + m_EditorClassIdentifier: + id: arch_aur_helper + displayName: AUR helper + description: add a random common C or Rust card to hand. it costs 0 this turn. + flavorText: definitely read the PKGBUILD. definitely. + language: 2 + rarity: 1 + cycleCost: 1 + resolutionTrack: 0 + distroExclusive: 1 + isToken: 0 + isRunOnly: 0 + designNotes: Arch exclusive. Uses existing generated-card pool; reports TODO if C/Rust commons are unavailable. + requiresSystem: [] + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Arch/AurHelper.asset.meta b/Assets/Data/Cards/Arch/AurHelper.asset.meta new file mode 100644 index 0000000..58c5d30 --- /dev/null +++ b/Assets/Data/Cards/Arch/AurHelper.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aa110000000000000000000000000004 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/Cards/Arch/ConsultWiki.asset b/Assets/Data/Cards/Arch/ConsultWiki.asset new file mode 100644 index 0000000..b1a30b6 --- /dev/null +++ b/Assets/Data/Cards/Arch/ConsultWiki.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: ConsultWiki + m_EditorClassIdentifier: + id: arch_consult_wiki + displayName: Consult the Wiki + description: fix a broken card and draw 1. if none are broken, deal 6. + flavorText: the answer was in a footnote from 2013. + language: 2 + rarity: 0 + cycleCost: 0 + resolutionTrack: 0 + distroExclusive: 1 + isToken: 0 + isRunOnly: 0 + designNotes: Arch exclusive. Uses existing IsBroken flag if present; card-break creation is TODO, draw/damage fallback works now. + requiresSystem: + - card-break + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Arch/ConsultWiki.asset.meta b/Assets/Data/Cards/Arch/ConsultWiki.asset.meta new file mode 100644 index 0000000..4fd8ab4 --- /dev/null +++ b/Assets/Data/Cards/Arch/ConsultWiki.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aa110000000000000000000000000002 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/Cards/Arch/Makepkg.asset b/Assets/Data/Cards/Arch/Makepkg.asset new file mode 100644 index 0000000..46d869e --- /dev/null +++ b/Assets/Data/Cards/Arch/Makepkg.asset @@ -0,0 +1,28 @@ +%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_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3} + m_Name: Makepkg + m_EditorClassIdentifier: + id: arch_makepkg + displayName: makepkg + description: deal 7 + btw stacks x3; x4 at Arch v3+. overkill spills. 20% segfault: uptime -3 and corrupt a random card in hand. + flavorText: it built on my machine, which is the only machine. + language: 0 + rarity: 1 + cycleCost: 2 + resolutionTrack: 0 + distroExclusive: 1 + isToken: 0 + isRunOnly: 0 + designNotes: Arch exclusive. Reads CombatantState.ArchBtwStacks, then damage still routes through DamagePipeline and receives normal btw flat bonus too. + requiresSystem: [] + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Arch/Makepkg.asset.meta b/Assets/Data/Cards/Arch/Makepkg.asset.meta new file mode 100644 index 0000000..2123770 --- /dev/null +++ b/Assets/Data/Cards/Arch/Makepkg.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aa110000000000000000000000000003 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/Cards/Arch/MinimalInstall.asset b/Assets/Data/Cards/Arch/MinimalInstall.asset new file mode 100644 index 0000000..f3cbd0c --- /dev/null +++ b/Assets/Data/Cards/Arch/MinimalInstall.asset @@ -0,0 +1,28 @@ +%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_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5178747fac4dbfc448ff15d8a687e03c, type: 3} + m_Name: MinimalInstall + m_EditorClassIdentifier: + id: arch_minimal_install + displayName: minimal install + description: exhaust up to 2 cards from hand. per card exhausted, gain 3 Shield and 1 Cycle. + flavorText: no desktop, no browser, no regrets. + language: 2 + rarity: 1 + cycleCost: 1 + resolutionTrack: 0 + distroExclusive: 1 + isToken: 0 + isRunOnly: 0 + designNotes: Arch exclusive. Exhausts first available hand cards using existing exhaust pile, shield, and cycle systems. + requiresSystem: [] + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Arch/MinimalInstall.asset.meta b/Assets/Data/Cards/Arch/MinimalInstall.asset.meta new file mode 100644 index 0000000..8658748 --- /dev/null +++ b/Assets/Data/Cards/Arch/MinimalInstall.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aa110000000000000000000000000005 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/Cards/Arch/PacmanSyu.asset b/Assets/Data/Cards/Arch/PacmanSyu.asset new file mode 100644 index 0000000..636e722 --- /dev/null +++ b/Assets/Data/Cards/Arch/PacmanSyu.asset @@ -0,0 +1,29 @@ +%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: PacmanSyu + m_EditorClassIdentifier: + id: arch_pacman_syu + displayName: pacman -Syu + description: deal 14. overkill spills. 20% segfault: uptime -3 and corrupt a random card in hand. + flavorText: full system upgrade. what could possibly ABI wrong? + language: 0 + rarity: 1 + cycleCost: 1 + resolutionTrack: 0 + distroExclusive: 1 + isToken: 0 + isRunOnly: 0 + designNotes: Arch exclusive. Damage and segfault card corruption are live through DamagePipeline and the broken-card hand state. + requiresSystem: [] + upgradeTarget: {fileID: 0} + effects: [] diff --git a/Assets/Data/Cards/Arch/PacmanSyu.asset.meta b/Assets/Data/Cards/Arch/PacmanSyu.asset.meta new file mode 100644 index 0000000..09072c1 --- /dev/null +++ b/Assets/Data/Cards/Arch/PacmanSyu.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: aa110000000000000000000000000001 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Data/Cards/Fedora/DnfUpdate.asset b/Assets/Data/Cards/Fedora/DnfUpdate.asset index 2486c05..40c058a 100644 --- a/Assets/Data/Cards/Fedora/DnfUpdate.asset +++ b/Assets/Data/Cards/Fedora/DnfUpdate.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: fedora_dnf_update displayName: dnf update - description: deal 9. costs 1 less per java card played this combat (min 0). + description: deal 9. overkill spills. costs 1 less per java card played this combat (min 0). flavorText: today everything gets newer. language: 8 rarity: 0 diff --git a/Assets/Data/Cards/Languages/C/Malloc.asset b/Assets/Data/Cards/Languages/C/Malloc.asset index 383fbf5..e3d67c7 100644 --- a/Assets/Data/Cards/Languages/C/Malloc.asset +++ b/Assets/Data/Cards/Languages/C/Malloc.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: lang_c_malloc displayName: malloc() - description: deal 8. 20% segfault: uptime -3. + description: deal 12. overkill spills. 20% segfault: uptime -3 and corrupt a random card in hand. flavorText: memory acquired. ownership emotionally unresolved. language: 0 rarity: 0 diff --git a/Assets/Data/Cards/Languages/C/Pointer.asset b/Assets/Data/Cards/Languages/C/Pointer.asset index 3d68b5c..45d1627 100644 --- a/Assets/Data/Cards/Languages/C/Pointer.asset +++ b/Assets/Data/Cards/Languages/C/Pointer.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: lang_c_pointer displayName: pointer - description: deal 6. if the previous card was C, deal 10 instead. 20% segfault: uptime -3. + description: deal 9. if the previous card was C, deal 15 instead. overkill spills. 20% segfault: uptime -3 and corrupt a random card in hand. flavorText: points at the bug with suspicious confidence. language: 0 rarity: 0 diff --git a/Assets/Data/Cards/Languages/C/Printf.asset b/Assets/Data/Cards/Languages/C/Printf.asset index 1503090..7418020 100644 --- a/Assets/Data/Cards/Languages/C/Printf.asset +++ b/Assets/Data/Cards/Languages/C/Printf.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: lang_c_printf displayName: printf() - description: deal 4. 20% segfault: uptime -3. + description: deal 7. overkill spills. 20% segfault: uptime -3 and corrupt a random card in hand. flavorText: debugging by yelling into the void. language: 0 rarity: 0 diff --git a/Assets/Data/Cards/Languages/Java/JavaNewObject.asset b/Assets/Data/Cards/Languages/Java/JavaNewObject.asset index ec9bb53..b31d11e 100644 --- a/Assets/Data/Cards/Languages/Java/JavaNewObject.asset +++ b/Assets/Data/Cards/Languages/Java/JavaNewObject.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: lang_java_new_object displayName: new Object() - description: uses 2 RAM. deal 3. JIT applies. + description: uses 2 RAM. deal 3. overkill spills. JIT applies. flavorText: allocation as a love language. language: 8 rarity: 0 diff --git a/Assets/Data/Cards/Languages/Java/JavaPublicStaticMain.asset b/Assets/Data/Cards/Languages/Java/JavaPublicStaticMain.asset index 8b1b583..e72d007 100644 --- a/Assets/Data/Cards/Languages/Java/JavaPublicStaticMain.asset +++ b/Assets/Data/Cards/Languages/Java/JavaPublicStaticMain.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: lang_java_public_static_main displayName: public static void main - description: uses 2 RAM. deal 7. JIT: Java cards cost 1 less this combat each time you play a Java card; the discount decays by 1 after each wave. + description: uses 2 RAM. deal 7. overkill spills. JIT: Java cards cost 1 less this combat each time you play a Java card; the discount decays by 1 after each wave. flavorText: verbose, but warmed up. language: 8 rarity: 0 diff --git a/Assets/Data/Cards/Languages/Java/JavaSystemOutPrintln.asset b/Assets/Data/Cards/Languages/Java/JavaSystemOutPrintln.asset index 757ab81..9a6c93a 100644 --- a/Assets/Data/Cards/Languages/Java/JavaSystemOutPrintln.asset +++ b/Assets/Data/Cards/Languages/Java/JavaSystemOutPrintln.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: lang_java_system_out_println displayName: System.out.println - description: uses 2 RAM. deal 5. JIT applies. + description: uses 2 RAM. deal 5. overkill spills. JIT applies. flavorText: standard output, standard damage. language: 8 rarity: 0 diff --git a/Assets/Data/Cards/Shop/Free.asset b/Assets/Data/Cards/Shop/Free.asset index 0e92e60..2c066a6 100644 --- a/Assets/Data/Cards/Shop/Free.asset +++ b/Assets/Data/Cards/Shop/Free.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: shop_c_free displayName: free() - description: deal 12. 20% segfault: uptime -3 and draw 2. + description: deal 18. overkill spills. 20% segfault: uptime -3, corrupt a random card in hand, and draw 2. flavorText: double-free your schedule, not your heap. language: 0 rarity: 0 diff --git a/Assets/Data/Cards/Shop/KernelExploit.asset b/Assets/Data/Cards/Shop/KernelExploit.asset index 04ef461..417346e 100644 --- a/Assets/Data/Cards/Shop/KernelExploit.asset +++ b/Assets/Data/Cards/Shop/KernelExploit.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: shop_c_kernel_exploit displayName: kernel_exploit - description: deal 25 ignoring Shield. 50% segfault: uptime -8. + description: deal 36 ignoring Shield. overkill spills. 50% segfault: uptime -8 and corrupt a random card in hand. flavorText: privilege escalated. heartbeat also escalated. language: 0 rarity: 1 diff --git a/Assets/Data/Cards/Shop/Sizeof.asset b/Assets/Data/Cards/Shop/Sizeof.asset index e24862f..b1d2461 100644 --- a/Assets/Data/Cards/Shop/Sizeof.asset +++ b/Assets/Data/Cards/Shop/Sizeof.asset @@ -14,7 +14,7 @@ MonoBehaviour: m_EditorClassIdentifier: id: shop_c_sizeof displayName: sizeof() - description: deal 3 x C cards in hand. 20% segfault: uptime -3. + description: deal 5 x C cards in hand. overkill spills. 20% segfault: uptime -3 and corrupt a random card in hand. flavorText: compile-time confidence, runtime consequences. language: 0 rarity: 1 diff --git a/Assets/Resources/CombatScene.uss b/Assets/Resources/CombatScene.uss index 1f25108..41223df 100644 --- a/Assets/Resources/CombatScene.uss +++ b/Assets/Resources/CombatScene.uss @@ -400,10 +400,23 @@ font-size: 10px; } +.enemy-scroll { + flex-grow: 1; + min-height: 0; + overflow: hidden; +} + +.enemy-scroll .unity-scroll-view__content-container { + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; +} + .enemy-row { flex-direction: row; flex-wrap: wrap; flex-grow: 1; + min-height: 0; align-content: flex-start; } @@ -422,6 +435,64 @@ color: var(--kp-text); } +.enemy-row-compact { + align-content: flex-start; +} + +.enemy-card-compact { + width: 98px; + min-height: 126px; + margin-right: 6px; + margin-bottom: 6px; + padding: 6px; +} + +.enemy-card-compact .enemy-name { + font-size: 12px; + margin-bottom: 5px; +} + +.enemy-card-compact .meter-block { + margin-bottom: 6px; +} + +.enemy-card-compact .meter-head { + margin-bottom: 3px; +} + +.enemy-card-compact .metric-label, +.enemy-card-compact .status-label, +.enemy-card-compact .enemy-shield { + font-size: 10px; +} + +.enemy-card-compact .metric-value, +.enemy-card-compact .metric-value-muted { + font-size: 11px; +} + +.enemy-card-compact .meter-track { + height: 10px; +} + +.enemy-card-compact .status-pip, +.enemy-card-compact .tag { + font-size: 9px; + padding-left: 4px; + padding-right: 4px; + margin-right: 3px; + margin-bottom: 3px; +} + +.enemy-card-compact .intent-panel { + margin-top: 5px; + padding: 5px; +} + +.enemy-card-compact .intent-value { + font-size: 15px; +} + .enemy-card:hover { border-color: var(--kp-border-strong); } diff --git a/Assets/Scripts/Combat/CardEffects.cs b/Assets/Scripts/Combat/CardEffects.cs index c6f982e..974259e 100644 --- a/Assets/Scripts/Combat/CardEffects.cs +++ b/Assets/Scripts/Combat/CardEffects.cs @@ -7,12 +7,15 @@ namespace KernelPanic.Combat { public sealed class DealDamageEffect : ICardEffect { + private const int RustOverflowRollMax = 1000000; + private const string RustUnsigned64LimitText = "18446744073709551615"; + private readonly int _minAmount; private readonly int _maxAmount; private readonly Language _language; private readonly bool _trueDamage; private readonly bool _canCrit; - private readonly bool _allEnemies; + private readonly bool _allEnemies; public DealDamageEffect(int minAmount, int maxAmount, Language language, bool trueDamage = false, bool canCrit = true, bool allEnemies = false) { @@ -49,22 +52,80 @@ namespace KernelPanic.Combat int amount = minAmount == maxAmount ? minAmount : RandomRoll.RollRange(minAmount, maxAmount, new RollContext(context.Source)); + bool rustUnsignedOverflow = IsRustDamage(context) + && RandomRoll.RollRange(1, RustOverflowRollMax, new RollContext(context.Source)) == 1; + if (rustUnsignedOverflow) + { + amount = int.MaxValue; + context.CombatManager.ReportEffectResult($"rust unsigned overflow: {RustUnsigned64LimitText}"); + } - bool rustOverkillToShield = _language == Language.Rust || context.Card?.Definition?.Language == Language.Rust; - int survivable = rustOverkillToShield - ? _trueDamage ? target.CurrentUptime : target.CurrentUptime + target.Shield + bool rustOverkillToShield = IsRustDamage(context); + bool overkillSpills = IsSpillDamage(context); + int survivable = rustOverkillToShield || overkillSpills + ? GetSurvivableDamage(target, _trueDamage) : 0; - DamageResult result = context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, _trueDamage, _canCrit)); + DamageResult result = context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, _trueDamage, _canCrit && !rustUnsignedOverflow)); dealtDamage |= result.IncomingAmount > 0; if (rustOverkillToShield) { GrantRustOverkillShield(context, result, survivable); } + + if (overkillSpills) + { + dealtDamage |= SpillOverkillDamage(context, target, result, survivable); + } } SegfaultRecoil.TryApply(context, _language, dealtDamage); } + private bool IsRustDamage(CombatContext context) + { + return _language == Language.Rust || context.Card?.Definition?.Language == Language.Rust; + } + + private bool IsSpillDamage(CombatContext context) + { + Language cardLanguage = context.Card?.Definition == null ? _language : context.Card.Definition.Language; + return _language == Language.C + || _language == Language.Java + || cardLanguage == Language.C + || cardLanguage == Language.Java; + } + + private static int GetSurvivableDamage(CombatantState target, bool trueDamage) + { + return target == null ? 0 : trueDamage ? target.CurrentUptime : target.CurrentUptime + target.Shield; + } + + private bool SpillOverkillDamage(CombatContext context, CombatantState firstTarget, DamageResult firstResult, int firstSurvivable) + { + int remaining = UnityEngine.Mathf.Max(0, firstResult.IncomingAmount - firstSurvivable); + if (remaining <= 0) + { + return false; + } + + bool dealtDamage = false; + for (int i = 0; i < context.Enemies.Count && remaining > 0; i++) + { + CombatantState spillTarget = context.Enemies[i].State; + if (spillTarget == null || spillTarget == firstTarget || spillTarget.IsDefeated) + { + continue; + } + + int survivable = GetSurvivableDamage(spillTarget, _trueDamage); + DamageResult spillResult = context.DamagePipeline.DealDamage(new DamageRequest(context.Source, spillTarget, remaining, _language, _trueDamage, false, applySourceModifiers: false)); + dealtDamage |= spillResult.IncomingAmount > 0; + remaining = UnityEngine.Mathf.Max(0, spillResult.IncomingAmount - survivable); + } + + return dealtDamage; + } + private static void GrantRustOverkillShield(CombatContext context, DamageResult result, int survivable) { int overkill = UnityEngine.Mathf.Max(0, result.IncomingAmount - survivable); @@ -165,7 +226,9 @@ namespace KernelPanic.Combat continue; } - int roll = RandomRoll.RollRange(1, 100, new RollContext(context.Source)); + int roll = context.Source != null && context.Source.ForceMaxRolls + ? 1 + : RandomRoll.RollRange(1, 100, RollContext.None); int damageAmount = UpgradeMath.ScaleAmount(UpgradeMath.ApplySourceFlatBonus(_damageAmount, context), context.Card); int amount = roll <= _successPercent ? damageAmount : 0; DamageResult result = context.DamagePipeline.DealDamage(new DamageRequest(context.Source, target, amount, _language, false, true)); @@ -308,7 +371,7 @@ namespace KernelPanic.Combat public void Execute(CombatContext context) { int cCards = context.HandController.Cards.Count(card => card?.Definition != null && card.Definition.Language == Language.C); - int amount = 3 * cCards; + int amount = 5 * cCards; new DealDamageEffect(amount, amount, Language.C).Execute(context); } } @@ -873,6 +936,7 @@ namespace KernelPanic.Combat DamageResult result = context.DamagePipeline.DealDamage(new DamageRequest(null, context.Source, uptimeDamage, Language.C, true, false)); context.CombatManager.ReportEffectResult($"segfault: uptime -{result.FinalAmount}"); + context.CombatManager.TryCorruptRandomHandCard("segfault"); if (drawOnSegfault) { context.CombatManager.DrawCards(2, "free() recoil"); @@ -922,9 +986,9 @@ namespace KernelPanic.Combat new DrawEffect(1) }, "lang_rs_borrow" => One(new ShieldEffect(5)), - "lang_c_malloc" => One(new DealDamageEffect(8, 8, Language.C)), - "lang_c_printf" => One(new DealDamageEffect(4, 4, Language.C)), - "lang_c_pointer" => One(new PreviousLanguageDamageEffect(6, 10, Language.C)), + "lang_c_malloc" => One(new DealDamageEffect(12, 12, Language.C)), + "lang_c_printf" => One(new DealDamageEffect(7, 7, Language.C)), + "lang_c_pointer" => One(new PreviousLanguageDamageEffect(9, 15, Language.C)), "lang_java_public_static_main" => One(new DealDamageEffect(7, 7, Language.Java)), "lang_java_system_out_println" => One(new DealDamageEffect(5, 5, Language.Java)), "lang_java_new_object" => One(new DealDamageEffect(3, 3, Language.Java)), @@ -976,14 +1040,14 @@ namespace KernelPanic.Combat "fedora_dnf_update" => One(new DealDamageEffect(9, 9, Language.Java)), "fedora_rawhide" => One(new RawhideEffect()), "fedora_selinux" => One(new IncomingAttackHalveEffect(2)), - "arch_pacman_syu" => One(new DealDamageEffect(8, 8, Language.C)), + "arch_pacman_syu" => One(new DealDamageEffect(14, 14, Language.C)), "arch_consult_wiki" => One(new ConsultWikiEffect()), - "arch_makepkg" => One(new BtwScaledDamageEffect(4, 2, Language.C)), + "arch_makepkg" => One(new BtwScaledDamageEffect(7, 3, Language.C)), "arch_aur_helper" => One(new RandomCommonLanguageCardEffect()), "arch_minimal_install" => One(new MinimalInstallEffect()), - "shop_c_free" => One(new DealDamageEffect(12, 12, Language.C)), + "shop_c_free" => One(new DealDamageEffect(18, 18, Language.C)), "shop_c_sizeof" => One(new SizeofDamageEffect()), - "shop_c_kernel_exploit" => One(new DealDamageEffect(25, 25, Language.C, true)), + "shop_c_kernel_exploit" => One(new DealDamageEffect(36, 36, Language.C, true)), _ => Todo("card effect is not authored") }; } @@ -1053,12 +1117,12 @@ namespace KernelPanic.Combat "lang_rs_fn_main" => $"deal {UpgradeMath.ScaleAmount(6, card)}. overkill becomes shield.{marker}", "lang_rs_unwrap" => $"deal {UpgradeMath.ScaleAmount(9, card)}. overkill becomes shield. draw {UpgradeMath.ScaleAmount(1, card)}.{marker}", "lang_rs_borrow" => $"gain {UpgradeMath.ScaleShield(5, card)} shield.{marker}", - "lang_c_malloc" => $"deal {UpgradeMath.ScaleAmount(8, card)}. 20%: segfault, uptime -3.{marker}", - "lang_c_printf" => $"deal {UpgradeMath.ScaleAmount(4, card)}. 20%: segfault, uptime -3.{marker}", - "lang_c_pointer" => $"deal {UpgradeMath.ScaleAmount(6, card)}; {UpgradeMath.ScaleAmount(10, card)} if the previous card was C. 20%: segfault, uptime -3.{marker}", - "lang_java_public_static_main" => $"uses 2 RAM. deal {UpgradeMath.ScaleAmount(7, card)}. JIT: Java cards cost 1 less this combat each time you play a Java card; discount decays by 1 after each wave.{marker}", - "lang_java_system_out_println" => $"uses 2 RAM. deal {UpgradeMath.ScaleAmount(5, card)}. JIT applies; discount decays by 1 after each wave.{marker}", - "lang_java_new_object" => $"uses 2 RAM. deal {UpgradeMath.ScaleAmount(3, card)}. JIT applies; discount decays by 1 after each wave.{marker}", + "lang_c_malloc" => $"deal {UpgradeMath.ScaleAmount(12, card)}. overkill spills. 20%: segfault, uptime -3 and corrupt a hand card.{marker}", + "lang_c_printf" => $"deal {UpgradeMath.ScaleAmount(7, card)}. overkill spills. 20%: segfault, uptime -3 and corrupt a hand card.{marker}", + "lang_c_pointer" => $"deal {UpgradeMath.ScaleAmount(9, card)}; {UpgradeMath.ScaleAmount(15, card)} if the previous card was C. overkill spills. 20%: segfault, uptime -3 and corrupt a hand card.{marker}", + "lang_java_public_static_main" => $"uses 2 RAM. deal {UpgradeMath.ScaleAmount(7, card)}. overkill spills. JIT: Java cards cost 1 less this combat each time you play a Java card; discount decays by 1 after each wave.{marker}", + "lang_java_system_out_println" => $"uses 2 RAM. deal {UpgradeMath.ScaleAmount(5, card)}. overkill spills. JIT applies; discount decays by 1 after each wave.{marker}", + "lang_java_new_object" => $"uses 2 RAM. deal {UpgradeMath.ScaleAmount(3, card)}. overkill spills. JIT applies; discount decays by 1 after each wave.{marker}", "shop_py_list_append" => $"queue: deal {UpgradeMath.ScaleAmount(4 + UnityEngine.Mathf.Max(0, card.QueuePlayCount - 1), card)}; +1 each time re-queued.{marker}", "shop_py_async_def" => $"queue: draw {UpgradeMath.ScaleAmount(2, card)}. gain {UpgradeMath.ScaleAmount(1, card)} cycle next turn.{marker}", "shop_py_zip" => $"next {UpgradeMath.ScaleAmount(2, card)} queued cards resolve twice.{marker}", @@ -1077,17 +1141,17 @@ namespace KernelPanic.Combat "mint_timeshift" => $"queue: record uptime now. at end of your next turn, restore to it if lower.{marker}", "fedora_dnf_autoremove" => $"deal {UpgradeMath.ScaleAmount(7, card)}. exhaust a junk/NOP/broken card from hand; if you do, gain {UpgradeMath.ScaleAmount(1, card)} cycle. if not, do nothing.{marker}", "fedora_first_to_package" => $"deal {UpgradeMath.ScaleAmount(7, card)}. Java cards cost 1 less this turn.{marker}", - "fedora_dnf_update" => $"deal {UpgradeMath.ScaleAmount(9, card)}. costs 1 less per Java card played this combat.{marker}", + "fedora_dnf_update" => $"deal {UpgradeMath.ScaleAmount(9, card)}. overkill spills. costs 1 less per Java card played this combat.{marker}", "fedora_rawhide" => $"draw {UpgradeMath.ScaleAmount(2, card)}. next card this turn gains bleeding edge.{marker}", "fedora_selinux" => $"next {UpgradeMath.ScaleAmount(2, card)} enemy attacks deal half damage.{marker}", - "arch_pacman_syu" => $"deal {UpgradeMath.ScaleAmount(8, card)}. 20%: segfault, uptime -3. TODO: 20% break a random hand card.{marker}", + "arch_pacman_syu" => $"deal {UpgradeMath.ScaleAmount(14, card)}. overkill spills. 20%: segfault, uptime -3 and corrupt a hand card.{marker}", "arch_consult_wiki" => $"fix a broken card and draw {UpgradeMath.ScaleAmount(1, card)}; if none, deal {UpgradeMath.ScaleAmount(6, card)}.{marker}", - "arch_makepkg" => $"deal {UpgradeMath.ScaleAmount(4, card)} + btw x2; x3 at Arch v3+. 20%: segfault, uptime -3.{marker}", + "arch_makepkg" => $"deal {UpgradeMath.ScaleAmount(7, card)} + btw x3; x4 at Arch v3+. overkill spills. 20%: segfault, uptime -3 and corrupt a hand card.{marker}", "arch_aur_helper" => $"add a random C/Rust common card to hand; it costs 0 this turn.{marker}", "arch_minimal_install" => $"exhaust up to 2 hand cards. per card: gain 3 Shield and 1 Cycle.{marker}", - "shop_c_free" => $"deal {UpgradeMath.ScaleAmount(12, card)}. 20%: segfault, uptime -3 and draw 2.{marker}", - "shop_c_sizeof" => $"deal 3 x C cards in hand. 20%: segfault, uptime -3.{marker}", - "shop_c_kernel_exploit" => $"deal {UpgradeMath.ScaleAmount(25, card)} ignoring Shield. 50%: segfault, uptime -8.{marker}", + "shop_c_free" => $"deal {UpgradeMath.ScaleAmount(18, card)}. overkill spills. 20%: segfault, uptime -3, corrupt a hand card, and draw 2.{marker}", + "shop_c_sizeof" => $"deal 5 x C cards in hand. overkill spills. 20%: segfault, uptime -3 and corrupt a hand card.{marker}", + "shop_c_kernel_exploit" => $"deal {UpgradeMath.ScaleAmount(36, card)} ignoring Shield. overkill spills. 50%: segfault, uptime -8 and corrupt a hand card.{marker}", _ => string.IsNullOrWhiteSpace(card.Definition.Description) ? "effect not authored." : $"{card.Definition.Description}{marker}" }; } diff --git a/Assets/Scripts/Combat/CombatManager.cs b/Assets/Scripts/Combat/CombatManager.cs index 25bf43c..ede66d5 100644 --- a/Assets/Scripts/Combat/CombatManager.cs +++ b/Assets/Scripts/Combat/CombatManager.cs @@ -53,6 +53,7 @@ namespace KernelPanic.Combat private int _rawhideBonusCharges; private int _queuedRepeatCharges; private int _nextTurnCycleBonus; + private int _nextRacePairId = 1; private Coroutine _phaseCoroutine; public TurnPhase CurrentPhase => currentPhase; @@ -77,6 +78,7 @@ namespace KernelPanic.Combat private void Awake() { _runManager = GetComponent(); + _damagePipeline.ResistanceResolver = ResolveDamageResistance; } private void OnEnable() @@ -184,6 +186,18 @@ namespace KernelPanic.Combat return false; } + if (card.IsBroken) + { + Log($"{GetCardName(card)} is corrupted"); + return false; + } + + if (card.IsLocked) + { + Log($"{GetCardName(card)} is locked"); + return false; + } + bool fedoraBonus = CanApplyFedoraBonus(); int cost = GetEffectiveCardCost(card); if (fedoraBonus) @@ -343,6 +357,21 @@ namespace KernelPanic.Combat } } + public bool TryCorruptRandomHandCard(string sourceLabel) + { + CardInstance card = RandomHandCard(cardInstance => cardInstance != null && !cardInstance.IsBroken); + if (card == null) + { + Log($"{sourceLabel}: no card available to corrupt"); + return false; + } + + card.IsBroken = true; + Log($"{sourceLabel}: corrupted {GetCardName(card)}"); + StateChanged?.Invoke(); + return true; + } + public void ReportEffectResult(string message) { if (!string.IsNullOrWhiteSpace(message)) @@ -651,6 +680,7 @@ namespace KernelPanic.Combat _pendingTargetCard = null; _cardsPlayedThisWave = 0; _turnNumberThisWave = 0; + _nextRacePairId = 1; _packageShieldBonusTriggeredThisTurn = false; _packageTimeshiftTriggeredThisWave = false; ResetArchWaveState(); @@ -753,10 +783,10 @@ namespace KernelPanic.Combat private IEnumerator ProcessEnemiesSequenced() { - int enemyCountAtPhaseStart = _enemies.Count; - for (int i = 0; i < enemyCountAtPhaseStart && i < _enemies.Count; i++) + List actors = BuildEnemyActionOrder(); + for (int i = 0; i < actors.Count; i++) { - EnemyInstance enemy = _enemies[i]; + EnemyInstance enemy = actors[i]; if (enemy.State.IsDefeated) { continue; @@ -779,11 +809,17 @@ namespace KernelPanic.Combat StateChanged?.Invoke(); yield return Wait(CombatTuning.EnemyTelegraphDelaySeconds); - ExecuteEnemyIntent(enemy, i); - enemy.MarkTurnSurvived(); + ExecuteEnemyIntent(enemy, _enemies.IndexOf(enemy)); + ResolvePassiveEnemyAfterAction(enemy); + if (!enemy.State.IsDefeated) + { + enemy.MarkTurnSurvived(); + } + GameEvents.RaiseEnemyActed(new EnemyActedEvent(enemy, enemy.CurrentIntent)); + RemoveDefeatedEnemies(); StateChanged?.Invoke(); - if (CheckLoss()) + if (CheckWinOrLoss()) { yield break; } @@ -1519,7 +1555,7 @@ namespace KernelPanic.Combat state.DamageMultiplierPercent = 100; state.CurrentCardDamageMultiplierPercent = 100; state.ArchBtwDamagePerStack = IsDistro("arch") && _runConfig.DistroVersion >= 2 ? 2 : 1; - state.ArchMakepkgBtwMultiplier = IsDistro("arch") && _runConfig.DistroVersion >= 3 ? 3 : 2; + state.ArchMakepkgBtwMultiplier = IsDistro("arch") && _runConfig.DistroVersion >= 3 ? 4 : 3; state.ArchRollingReleaseShieldOnSave = IsDistro("arch") && _runConfig.DistroVersion >= 4 ? 15 : 0; state.ArchRollingReleaseCyclesOnSave = IsDistro("arch") && _runConfig.DistroVersion >= 4 ? 2 : 0; } @@ -1693,13 +1729,15 @@ namespace KernelPanic.Combat { if (_enemies[i].State.IsDefeated) { - if (_enemies[i].PendingRevive) + EnemyInstance enemy = _enemies[i]; + if (enemy.PendingRevive) { continue; } - Log($"{_enemies[i].Name} exited"); + Log($"{enemy.Name} exited"); _runManager?.AddBits(CombatTuning.BitsPerKill); + HandleEnemyDeath(enemy); _enemies.RemoveAt(i); } } @@ -1710,23 +1748,135 @@ namespace KernelPanic.Combat } } + private void HandleEnemyDeath(EnemyInstance enemy) + { + if (enemy == null) + { + return; + } + + if (enemy.HasBehavior(EnemyBehaviorFlags.SegfaultOnDeath)) + { + ApplySegfaultDeathPayload(enemy); + } + + if (enemy.HasBehavior(EnemyBehaviorFlags.RacePair)) + { + BreakRacePair(enemy); + } + + if (enemy.HasBehavior(EnemyBehaviorFlags.LeavesOrphan)) + { + SpawnOrphanFromDeath(enemy); + } + } + + private void ApplySegfaultDeathPayload(EnemyInstance enemy) + { + _statusEffects.Apply(_playerState, StatusType.Segfault, 1, -1, enemy.State, skipNextTick: true); + TryCorruptRandomHandCard("segfault"); + } + + private CardInstance RandomHandCard(Predicate predicate) + { + if (_handController == null || _handController.Cards.Count == 0) + { + return null; + } + + List candidates = new(); + for (int i = 0; i < _handController.Cards.Count; i++) + { + CardInstance candidate = _handController.Cards[i]; + if (predicate == null || predicate(candidate)) + { + candidates.Add(candidate); + } + } + + if (candidates.Count == 0) + { + return null; + } + + int index = RandomRoll.RollRange(0, candidates.Count - 1, new RollContext(_playerState)); + return candidates[index]; + } + + private void BreakRacePair(EnemyInstance defeated) + { + EnemyInstance survivor = RacePartner(defeated); + if (survivor == null) + { + return; + } + + survivor.EnrageRaceSurvivor(); + Log($"{survivor.Name} race link broke; survivor enraged"); + } + + private void SpawnOrphanFromDeath(EnemyInstance source) + { + EnemyInstance orphan = SpawnEnemy("orphan_process", 0); + orphan.MarkSpawnedFromDeath(); + Log($"{source.Name} left an orphan process"); + } + private void SpawnStructuralEnemies() { int wave = CurrentWaveNumber; int countGrowth = ((wave - 1) / 2) * CombatTuning.AdditionalEnemiesPerWave; int count = Mathf.Clamp(CombatTuning.BaseEnemiesPerWave + countGrowth, CombatTuning.BaseEnemiesPerWave, CombatTuning.MaxEnemiesPerWave); int waveUptimeBonus = Mathf.Max(0, wave - 1) * CombatTuning.EnemyUptimeGrowthPerWave; + bool rootkitSpawned = false; for (int i = 0; i < count; i++) { - SpawnEnemy(CreatePlaceholderArchetypeId(wave, i), waveUptimeBonus); + string archetypeId = CreatePlaceholderArchetypeId(wave, i); + if (archetypeId == "rootkit") + { + if (rootkitSpawned) + { + archetypeId = CreateRootkitFallbackArchetypeId(wave, i); + } + else + { + rootkitSpawned = true; + } + } + + if (archetypeId == "race_condition" && i + 1 < count) + { + SpawnRacePair(waveUptimeBonus); + i++; + continue; + } + + if (archetypeId == "race_condition") + { + archetypeId = "segfault"; + } + + SpawnEnemy(archetypeId, waveUptimeBonus); } } - private void SpawnEnemy(string archetypeId, int uptimeBonus) + private void SpawnRacePair(int uptimeBonus) + { + int pairId = _nextRacePairId++; + EnemyInstance first = SpawnEnemy("race_condition", uptimeBonus); + EnemyInstance second = SpawnEnemy("race_condition", uptimeBonus); + first.SetPairId(pairId); + second.SetPairId(pairId); + Log("race condition pair spawned"); + } + + private EnemyInstance SpawnEnemy(string archetypeId, int uptimeBonus) { EnemyArchetypeDescriptor archetype = EnemyArchetypeCatalog.Get(archetypeId); int baseUptime = RandomRoll.RollRange(archetype.BaseUptimeMin, archetype.BaseUptimeMax, RollContext.None); - _enemies.Add(new EnemyInstance(archetype, baseUptime + Mathf.Max(0, uptimeBonus))); + EnemyInstance enemy = new(archetype, baseUptime + Mathf.Max(0, uptimeBonus)); + _enemies.Add(enemy); + return enemy; } private static string CreatePlaceholderArchetypeId(int wave, int slotIndex) @@ -1736,17 +1886,28 @@ namespace KernelPanic.Combat { <= 1 => new[] { "zombie_process", "memory_leak" }, 2 => new[] { "zombie_process", "memory_leak", "fork_bomb", "firewalld" }, - 3 => new[] { "zombie_process", "memory_leak", "fork_bomb", "firewalld", "daemon" }, - _ => new[] { "zombie_process", "memory_leak", "fork_bomb", "firewalld", "daemon", "cron_job" } + 3 => new[] { "zombie_process", "memory_leak", "fork_bomb", "firewalld", "segfault" }, + 4 => new[] { "zombie_process", "memory_leak", "fork_bomb", "firewalld", "cron_job", "segfault", "race_condition" }, + _ => new[] { "zombie_process", "memory_leak", "fork_bomb", "firewalld", "daemon", "cron_job", "segfault", "race_condition", "rootkit" } }; if (slotIndex == 0) { + if (wave >= 5 && wave % 2 == 1) + { + return "rootkit"; + } + return wave % 2 == 0 ? "firewalld" : "zombie_process"; } if (slotIndex == 1 && wave >= 2) { + if (wave >= 4 && wave % 4 == 0) + { + return "race_condition"; + } + return wave % 3 == 0 ? "fork_bomb" : "memory_leak"; } @@ -1754,6 +1915,13 @@ namespace KernelPanic.Combat return pool[index]; } + private static string CreateRootkitFallbackArchetypeId(int wave, int slotIndex) + { + return slotIndex % 2 == 0 + ? "firewalld" + : wave % 3 == 0 ? "fork_bomb" : "memory_leak"; + } + private void PickEnemyIntents() { for (int i = 0; i < _enemies.Count; i++) @@ -1765,6 +1933,37 @@ namespace KernelPanic.Combat } } + private List BuildEnemyActionOrder() + { + List actors = _enemies + .Where(enemy => enemy.State != null && !enemy.State.IsDefeated) + .ToList(); + + for (int i = 0; i < actors.Count - 1; i++) + { + EnemyInstance enemy = actors[i]; + if (!IsRacePairLinked(enemy)) + { + continue; + } + + EnemyInstance partner = RacePartner(enemy); + int partnerIndex = partner == null ? -1 : actors.IndexOf(partner); + if (partnerIndex <= i) + { + continue; + } + + if (RandomRoll.RollRange(0, 1, new RollContext(enemy.State)) == 1) + { + actors[i] = partner; + actors[partnerIndex] = enemy; + } + } + + return actors; + } + private void ExecuteEnemyIntent(EnemyInstance enemy, int enemyIndex) { EnemyIntent intent = enemy.CurrentIntent; @@ -1775,6 +1974,7 @@ namespace KernelPanic.Combat switch (intent.Kind) { case EnemyIntentKind.Attack: + value = ApplyEnemyAttackBonuses(enemy, value); value = ApplyIncomingAttackMitigation(value); DamageResult attackResult = _damagePipeline.DealDamage(new DamageRequest(enemy.State, _playerState, value, intent.DamageType, intent.TrueDamage, false)); GameEvents.RaisePlayerDamaged(new PlayerDamagedEvent(enemy, attackResult.FinalAmount)); @@ -1790,6 +1990,7 @@ namespace KernelPanic.Combat } break; case EnemyIntentKind.StatusAttack: + value = ApplyEnemyAttackBonuses(enemy, value); 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) @@ -1810,15 +2011,50 @@ namespace KernelPanic.Combat private void ExecuteSpecialEnemyIntent(EnemyInstance enemy) { - if (enemy.HasBehavior(EnemyBehaviorFlags.Split)) + if (enemy.HasBehavior(EnemyBehaviorFlags.SegfaultOnDeath)) { - TrySplitForkBomb(enemy); + enemy.AdvanceSegfaultCountdown(); + if (enemy.CountdownRemaining <= 0) + { + _damagePipeline.DealDamage(new DamageRequest(enemy.State, enemy.State, enemy.CurrentUptime, Language.C, true, false)); + Log($"{enemy.Name} dereferenced null"); + } + return; } Log("TODO: special intent needs enemy-specific behavior hooks."); } + private void ResolvePassiveEnemyAfterAction(EnemyInstance enemy) + { + if (enemy == null || enemy.State.IsDefeated) + { + return; + } + + if (enemy.HasBehavior(EnemyBehaviorFlags.Split)) + { + TrySplitForkBomb(enemy); + } + } + + private int ApplyEnemyAttackBonuses(EnemyInstance enemy, int value) + { + int modified = value; + if (IsRacePairLinked(enemy)) + { + modified += EnemyArchetypeCatalog.RacePairDamageBonus; + } + + if (enemy != null && enemy.RaceEnrageStacks > 0) + { + modified += enemy.RaceEnrageStacks; + } + + return modified; + } + private void TrySplitForkBomb(EnemyInstance source) { int totalForkBombs = _enemies.Count(enemy => enemy.ArchetypeId == source.ArchetypeId && !enemy.State.IsDefeated); @@ -1836,6 +2072,34 @@ namespace KernelPanic.Combat Log("fork bomb split"); } + private bool IsRacePairLinked(EnemyInstance enemy) + { + return enemy != null + && enemy.HasBehavior(EnemyBehaviorFlags.RacePair) + && RacePartner(enemy) != null; + } + + private EnemyInstance RacePartner(EnemyInstance enemy) + { + if (enemy == null || enemy.PairId < 0) + { + return null; + } + + for (int i = 0; i < _enemies.Count; i++) + { + EnemyInstance candidate = _enemies[i]; + if (candidate != enemy + && candidate.PairId == enemy.PairId + && !candidate.State.IsDefeated) + { + return candidate; + } + } + + return null; + } + private void DefendSelfAndAdjacent(int enemyIndex, int amount) { int applied = 0; @@ -1866,6 +2130,38 @@ namespace KernelPanic.Combat return mitigated; } + private int ResolveDamageResistance(DamageRequest request, int amount) + { + EnemyInstance target = FindEnemy(request.Target); + if (target == null + || !target.HasBehavior(EnemyBehaviorFlags.RootkitMasked) + || !HasOtherLivingEnemy(target)) + { + return amount; + } + + if (amount <= 0) + { + return 0; + } + + return Mathf.Max(1, Mathf.CeilToInt(amount * (EnemyArchetypeCatalog.RootkitMaskedDamagePercent / 100f))); + } + + private bool HasOtherLivingEnemy(EnemyInstance target) + { + for (int i = 0; i < _enemies.Count; i++) + { + EnemyInstance enemy = _enemies[i]; + if (enemy != target && enemy.State != null && !enemy.State.IsDefeated) + { + return true; + } + } + + return false; + } + private void ResolveDelayedEndOfTurnEffects() { for (int i = _delayedEffects.Count - 1; i >= 0; i--) diff --git a/Assets/Scripts/Combat/CombatTuning.cs b/Assets/Scripts/Combat/CombatTuning.cs index c5eb8c7..f923e77 100644 --- a/Assets/Scripts/Combat/CombatTuning.cs +++ b/Assets/Scripts/Combat/CombatTuning.cs @@ -37,11 +37,21 @@ namespace KernelPanic.Combat public const int EntropyStartWave = 5; public const int EntropyPerWave = 3; public const int StatUpgradeMaxCycles = 1; - public const int StatUpgradeMaxUptime = 4; - public const int StatUpgradeHeal = 5; + public const int StatUpgradeMaxUptimePercent = 20; + public const int StatUpgradeHealPercent = 30; public const int StatUpgradeRam = 1; public const int UpgradeMagnitudeBonus = 2; + public static int ScaleStatUpgradeMaxUptime(int maxUptime) + { + return System.Math.Max(1, ((System.Math.Max(1, maxUptime) * StatUpgradeMaxUptimePercent) + 99) / 100); + } + + public static int ScaleStatUpgradeHeal(int maxUptime) + { + return System.Math.Max(1, ((System.Math.Max(1, maxUptime) * StatUpgradeHealPercent) + 99) / 100); + } + // Combat pacing: gaps between phase transitions and per-item resolution steps so // players can follow drawing/queue/enemy resolution instead of seeing it all at once. // Skipped entirely when UIPreferences.ReducedMotion is set. diff --git a/Assets/Scripts/Combat/CombatantState.cs b/Assets/Scripts/Combat/CombatantState.cs index 2e48d3b..414cf3a 100644 --- a/Assets/Scripts/Combat/CombatantState.cs +++ b/Assets/Scripts/Combat/CombatantState.cs @@ -36,7 +36,7 @@ namespace KernelPanic.Combat public int IncomingAttackHalfCharges { get; set; } public int ArchBtwStacks { get; set; } public int ArchBtwDamagePerStack { get; set; } = 1; - public int ArchMakepkgBtwMultiplier { get; set; } = 2; + public int ArchMakepkgBtwMultiplier { get; set; } = 3; public int ArchRollingReleaseSavesRemaining { get; set; } public int ArchRollingReleaseShieldOnSave { get; set; } public int ArchRollingReleaseCyclesOnSave { get; set; } diff --git a/Assets/Scripts/Combat/DamagePipeline.cs b/Assets/Scripts/Combat/DamagePipeline.cs index 307a93b..7726892 100644 --- a/Assets/Scripts/Combat/DamagePipeline.cs +++ b/Assets/Scripts/Combat/DamagePipeline.cs @@ -11,7 +11,8 @@ namespace KernelPanic.Combat int amount, Language language, bool trueDamage, - bool canCrit) + bool canCrit, + bool applySourceModifiers = true) { Source = source; Target = target; @@ -19,6 +20,7 @@ namespace KernelPanic.Combat Language = language; TrueDamage = trueDamage; CanCrit = canCrit; + ApplySourceModifiers = applySourceModifiers; } public CombatantState Source { get; } @@ -27,6 +29,7 @@ namespace KernelPanic.Combat public Language Language { get; } public bool TrueDamage { get; } public bool CanCrit { get; } + public bool ApplySourceModifiers { get; } } public readonly struct DamageResult @@ -56,6 +59,8 @@ namespace KernelPanic.Combat /// public sealed class DamagePipeline { + public System.Func ResistanceResolver { get; set; } + public DamageResult DealDamage(DamageRequest request) { if (request.Target == null || request.Target.IsDefeated) @@ -64,9 +69,14 @@ namespace KernelPanic.Combat } int amount = Mathf.Max(0, request.Amount); - amount = ApplyFlatAdditions(amount, request); - amount = ApplyMultipliers(amount, request); - amount = ApplyCrit(amount, request, out bool wasCritical); + bool wasCritical = false; + if (request.ApplySourceModifiers) + { + amount = ApplyFlatAdditions(amount, request); + amount = ApplyMultipliers(amount, request); + amount = ApplyCrit(amount, request, out wasCritical); + } + amount = ApplyResistancesAndWeaknesses(amount, request); int finalAmount = ApplyShieldAndUptime(amount, request, out int absorbedAmount, out int shieldDamage, out int uptimeDamage); @@ -86,6 +96,11 @@ namespace KernelPanic.Combat private static int ApplyMultipliers(int amount, DamageRequest request) { + if (amount == int.MaxValue) + { + return amount; + } + if (request.Source != null && request.Source.IgnoreDamageMultipliers) { // Mint V1+ ignores multiplicative damage modifiers. Mint V4 still allows flat @@ -100,6 +115,11 @@ namespace KernelPanic.Combat private static int ApplyFlatAdditions(int amount, DamageRequest request) { + if (amount == int.MaxValue) + { + return amount; + } + if (request.Source != null && request.Language == Language.JavaScript) { amount += Mathf.Max(0, request.Source.JavaScriptFlatDamageBonus); @@ -116,7 +136,7 @@ namespace KernelPanic.Combat private static int ApplyCrit(int amount, DamageRequest request, out bool wasCritical) { wasCritical = false; - if (!request.CanCrit || amount <= 0 || request.Source != null && request.Source.ForceMaxRolls) + if (!request.CanCrit || amount <= 0 || amount == int.MaxValue || request.Source != null && request.Source.ForceMaxRolls) { return amount; } @@ -144,12 +164,12 @@ namespace KernelPanic.Combat target.ArchRollingReleaseRecoveredThisHit = true; } - private static int ApplyResistancesAndWeaknesses(int amount, DamageRequest request) + private int ApplyResistancesAndWeaknesses(int amount, DamageRequest request) { // TODO: Enemy resistances/reductions are not authored yet. Mint V5 should re-resolve // a once-per-combat reduced effect here once those reduction sources exist. // TODO: Apply enemy language resistances and weaknesses here. - return amount; + return ResistanceResolver == null ? amount : ResistanceResolver(request, amount); } private static int ApplyShieldAndUptime(int amount, DamageRequest request, out int absorbedAmount, out int shieldDamage, out int uptimeDamage) diff --git a/Assets/Scripts/Combat/EnemyArchetype.cs b/Assets/Scripts/Combat/EnemyArchetype.cs index e099803..0898d88 100644 --- a/Assets/Scripts/Combat/EnemyArchetype.cs +++ b/Assets/Scripts/Combat/EnemyArchetype.cs @@ -14,7 +14,12 @@ namespace KernelPanic.Combat Grow = 1 << 2, ObfuscateIntent = 1 << 3, DefendAllies = 1 << 4, - Countdown = 1 << 5 + Countdown = 1 << 5, + LeavesOrphan = 1 << 6, + SegfaultOnDeath = 1 << 7, + RacePair = 1 << 8, + RootkitMasked = 1 << 9, + Elite = 1 << 10 } public sealed class EnemyArchetypeDescriptor @@ -51,18 +56,23 @@ namespace KernelPanic.Combat { public const int ZombieReapThresholdPercent = 30; public const int MemoryLeakAttackGrowthPerTurn = 1; + public const int MemoryLeakAttackCapMin = 4; + public const int MemoryLeakAttackCapMax = 5; public const int ForkBombTotalCap = 5; + public const int RootkitMaskedDamagePercent = 10; + public const int RacePairDamageBonus = 2; public const int CronInitialCountdown = 2; + public const int SegfaultInitialCountdown = 2; private static readonly EnemyArchetypeDescriptor ZombieProcess = new( "zombie_process", "Zombie Process", 6, 10, - EnemyBehaviorFlags.Revive, + EnemyBehaviorFlags.Revive | EnemyBehaviorFlags.LeavesOrphan, new[] { - new EnemyIntent(EnemyIntentKind.Attack, 3, 4, Language.C, "attack", "!") + new EnemyIntent(EnemyIntentKind.Attack, 2, 3, Language.C, "attack", "!") }); private static readonly EnemyArchetypeDescriptor MemoryLeak = new( @@ -73,7 +83,7 @@ namespace KernelPanic.Combat EnemyBehaviorFlags.Grow, new[] { - new EnemyIntent(EnemyIntentKind.StatusAttack, 1, 1, Language.C, "leak", "!", false, StatusType.MemoryLeak, 1, CombatTuning.EnemyStatusDuration) + new EnemyIntent(EnemyIntentKind.StatusAttack, 1, 2, Language.C, "leak", "!", false, StatusType.MemoryLeak, 1, CombatTuning.EnemyStatusDuration) }); private static readonly EnemyArchetypeDescriptor ForkBomb = new( @@ -81,10 +91,10 @@ namespace KernelPanic.Combat "Fork Bomb", 3, 5, - EnemyBehaviorFlags.Split, + EnemyBehaviorFlags.Split | EnemyBehaviorFlags.LeavesOrphan, new[] { - new EnemyIntent(EnemyIntentKind.Special, 0, 0, Language.C, "fork", ":", false, StatusType.MemoryLeak, 0, -1, "split") + new EnemyIntent(EnemyIntentKind.Attack, 1, 2, Language.C, "fork hit", "!") }); private static readonly EnemyArchetypeDescriptor Daemon = new( @@ -92,7 +102,7 @@ namespace KernelPanic.Combat "Daemon", 10, 14, - EnemyBehaviorFlags.ObfuscateIntent, + EnemyBehaviorFlags.ObfuscateIntent | EnemyBehaviorFlags.Elite, new[] { new EnemyIntent(EnemyIntentKind.Attack, 6, 8, Language.C, "daemon hit", "!") @@ -108,7 +118,7 @@ namespace KernelPanic.Combat { new EnemyIntent(EnemyIntentKind.Defend, 3, 5, Language.C, "shield adjacent", "#"), new EnemyIntent(EnemyIntentKind.Defend, 3, 5, Language.C, "shield adjacent", "#"), - new EnemyIntent(EnemyIntentKind.Attack, 1, 2, Language.C, "poke", "!") + new EnemyIntent(EnemyIntentKind.Attack, 0, 1, Language.C, "poke", "!") }); private static readonly EnemyArchetypeDescriptor CronJob = new( @@ -122,6 +132,50 @@ namespace KernelPanic.Combat new EnemyIntent(EnemyIntentKind.Attack, 10, 12, Language.C, "cron fire", "@") }); + private static readonly EnemyArchetypeDescriptor OrphanProcess = new( + "orphan_process", + "Orphan Process", + 2, + 4, + EnemyBehaviorFlags.None, + new[] + { + new EnemyIntent(EnemyIntentKind.Attack, 1, 2, Language.C, "orphan chip", ".") + }); + + private static readonly EnemyArchetypeDescriptor Segfault = new( + "segfault", + "Segfault", + 2, + 4, + EnemyBehaviorFlags.SegfaultOnDeath, + new[] + { + new EnemyIntent(EnemyIntentKind.Attack, 2, 3, Language.C, "unstable hit", "!") + }); + + private static readonly EnemyArchetypeDescriptor RaceCondition = new( + "race_condition", + "Race Condition", + 5, + 7, + EnemyBehaviorFlags.RacePair, + new[] + { + new EnemyIntent(EnemyIntentKind.Attack, 2, 3, Language.C, "racy strike", "~") + }); + + private static readonly EnemyArchetypeDescriptor Rootkit = new( + "rootkit", + "Rootkit", + 14, + 18, + EnemyBehaviorFlags.RootkitMasked, + new[] + { + new EnemyIntent(EnemyIntentKind.Attack, 1, 2, Language.C, "masked hit", "?") + }); + public static EnemyArchetypeDescriptor Get(string id) { return id switch @@ -131,6 +185,10 @@ namespace KernelPanic.Combat "daemon" => Daemon, "firewalld" => Defender, "cron_job" => CronJob, + "orphan_process" => OrphanProcess, + "segfault" => Segfault, + "race_condition" => RaceCondition, + "rootkit" => Rootkit, _ => ZombieProcess }; } diff --git a/Assets/Scripts/Combat/EnemyInstance.cs b/Assets/Scripts/Combat/EnemyInstance.cs index b5bba6e..48e185b 100644 --- a/Assets/Scripts/Combat/EnemyInstance.cs +++ b/Assets/Scripts/Combat/EnemyInstance.cs @@ -24,7 +24,9 @@ namespace KernelPanic.Combat _intentPool.AddRange(Archetype.IntentPool); } - CountdownRemaining = HasBehavior(EnemyBehaviorFlags.Countdown) ? EnemyArchetypeCatalog.CronInitialCountdown : 0; + CountdownRemaining = HasBehavior(EnemyBehaviorFlags.Countdown) + ? EnemyArchetypeCatalog.CronInitialCountdown + : HasBehavior(EnemyBehaviorFlags.SegfaultOnDeath) ? EnemyArchetypeCatalog.SegfaultInitialCountdown : 0; } public string Name { get; } @@ -42,7 +44,16 @@ namespace KernelPanic.Combat public bool PendingRevive { get; private set; } public bool Reaped { get; private set; } public bool LethalHitThisTurn { get; private set; } + public int PairId { get; private set; } = -1; + public int RaceEnrageStacks { get; private set; } + public bool SpawnedFromDeath { get; private set; } public bool HasPendingMarker => PendingRevive || HasRevived; + public bool HasSpecialSignal => HasSplitSignal || HasSegfaultSignal || HasRaceSignal || HasRootkitSignal || HasEliteSignal || SpawnedFromDeath; + public bool HasSplitSignal => HasBehavior(EnemyBehaviorFlags.Split); + public bool HasSegfaultSignal => HasBehavior(EnemyBehaviorFlags.SegfaultOnDeath); + public bool HasRaceSignal => HasBehavior(EnemyBehaviorFlags.RacePair); + public bool HasRootkitSignal => HasBehavior(EnemyBehaviorFlags.RootkitMasked); + public bool HasEliteSignal => HasBehavior(EnemyBehaviorFlags.Elite); public EnemyIntent DisplayIntent => BuildDisplayIntent(); public void PickNextIntent() @@ -61,6 +72,12 @@ namespace KernelPanic.Combat return; } + if (HasBehavior(EnemyBehaviorFlags.SegfaultOnDeath) && CountdownRemaining > 0) + { + CurrentIntent = new EnemyIntent(EnemyIntentKind.Special, CountdownRemaining, CountdownRemaining, KernelPanic.Core.Language.C, "segfault in", "!"); + return; + } + if (_intentPool.Count == 0) { CurrentIntent = default; @@ -72,7 +89,9 @@ namespace KernelPanic.Combat if (HasBehavior(EnemyBehaviorFlags.Grow)) { int growth = TurnsAlive * EnemyArchetypeCatalog.MemoryLeakAttackGrowthPerTurn; - CurrentIntent = CurrentIntent.WithValues(CurrentIntent.MinValue + growth, CurrentIntent.MaxValue + growth, "growing leak"); + int minValue = UnityEngine.Mathf.Min(CurrentIntent.MinValue + growth, EnemyArchetypeCatalog.MemoryLeakAttackCapMin); + int maxValue = UnityEngine.Mathf.Min(CurrentIntent.MaxValue + growth, EnemyArchetypeCatalog.MemoryLeakAttackCapMax); + CurrentIntent = CurrentIntent.WithValues(minValue, maxValue, "growing leak"); } } @@ -94,6 +113,21 @@ namespace KernelPanic.Combat TurnsAlive++; } + public void SetPairId(int pairId) + { + PairId = pairId; + } + + public void MarkSpawnedFromDeath() + { + SpawnedFromDeath = true; + } + + public void EnrageRaceSurvivor() + { + RaceEnrageStacks++; + } + public void ResetTurnLethalMarker() { LethalHitThisTurn = false; @@ -142,6 +176,14 @@ namespace KernelPanic.Combat : UnityEngine.Mathf.Max(0, CountdownRemaining - 1); } + public void AdvanceSegfaultCountdown() + { + if (HasBehavior(EnemyBehaviorFlags.SegfaultOnDeath)) + { + CountdownRemaining = UnityEngine.Mathf.Max(0, CountdownRemaining - 1); + } + } + private EnemyIntent BuildDisplayIntent() { if (PendingRevive) diff --git a/Assets/Scripts/Combat/StatusEffectController.cs b/Assets/Scripts/Combat/StatusEffectController.cs index 1887358..e5e876c 100644 --- a/Assets/Scripts/Combat/StatusEffectController.cs +++ b/Assets/Scripts/Combat/StatusEffectController.cs @@ -13,7 +13,7 @@ namespace KernelPanic.Combat private static readonly Dictionary Descriptors = new() { [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.Segfault] = new(StatusType.Segfault, "Segfault", "segv", false, StatusStackingRule.Unique, StatusTickTiming.StartOfTurn, "Loses one cycle this turn. Segfault events corrupt a random hand card until fixed."), [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."), diff --git a/Assets/Scripts/Run/RepositoryOffer.cs b/Assets/Scripts/Run/RepositoryOffer.cs index 2973222..f4fc3de 100644 --- a/Assets/Scripts/Run/RepositoryOffer.cs +++ b/Assets/Scripts/Run/RepositoryOffer.cs @@ -131,8 +131,8 @@ namespace KernelPanic.Run 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.MaxUptime => $"+{CombatTuning.StatUpgradeMaxUptimePercent}% max uptime this run", + RunStatUpgradeKind.Heal => $"heal {CombatTuning.StatUpgradeHealPercent}% max uptime now", RunStatUpgradeKind.Ram => $"+{CombatTuning.StatUpgradeRam} RAM this run", _ => "--" }; diff --git a/Assets/Scripts/Run/RunManager.cs b/Assets/Scripts/Run/RunManager.cs index b33593f..12b8eac 100644 --- a/Assets/Scripts/Run/RunManager.cs +++ b/Assets/Scripts/Run/RunManager.cs @@ -496,10 +496,11 @@ namespace KernelPanic.Run } return true; case RunStatUpgradeKind.MaxUptime: - _maxUptimeBonus += CombatTuning.StatUpgradeMaxUptime; + int uptimeBonus = CombatTuning.ScaleStatUpgradeMaxUptime(playerState == null ? EffectiveMaxUptime() : playerState.MaxUptime); + _maxUptimeBonus += uptimeBonus; if (playerState != null) { - playerState.MaxUptime += CombatTuning.StatUpgradeMaxUptime; + playerState.MaxUptime += uptimeBonus; } return true; case RunStatUpgradeKind.Heal: @@ -508,7 +509,8 @@ namespace KernelPanic.Run return false; } - playerState.CurrentUptime = Mathf.Min(playerState.MaxUptime, playerState.CurrentUptime + CombatTuning.StatUpgradeHeal); + int healAmount = CombatTuning.ScaleStatUpgradeHeal(playerState.MaxUptime); + playerState.CurrentUptime = Mathf.Min(playerState.MaxUptime, playerState.CurrentUptime + healAmount); return true; case RunStatUpgradeKind.Ram: _ramBonus += CombatTuning.StatUpgradeRam; diff --git a/Assets/Scripts/UI/CombatSceneController.cs b/Assets/Scripts/UI/CombatSceneController.cs index 4a9eec5..607461e 100644 --- a/Assets/Scripts/UI/CombatSceneController.cs +++ b/Assets/Scripts/UI/CombatSceneController.cs @@ -12,7 +12,8 @@ namespace KernelPanic.UI { /// /// Runtime-built GameScene combat UI. Intent icons use a compact terminal vocabulary: - /// ! attack/status attack, # defend, + buff, * special, ? hidden, ~ reviving, : split, @ countdown. + /// ! attack/status attack/segfault, # defend, + buff, * special, ? hidden/rootkit, + /// ~ reviving/race link, : split, @ countdown, . orphan. /// [RequireComponent(typeof(UIDocument))] [RequireComponent(typeof(RunManager))] @@ -276,9 +277,12 @@ namespace KernelPanic.UI VisualElement enemiesPanel = CreatePanel("enemy processes", "intent telemetry", 438); main.Add(enemiesPanel); + ScrollView enemyScroll = new(ScrollViewMode.Vertical); + enemyScroll.AddToClassList("enemy-scroll"); + enemiesPanel.Add(enemyScroll); _enemyRow = new(); _enemyRow.AddToClassList("enemy-row"); - enemiesPanel.Add(_enemyRow); + enemyScroll.Add(_enemyRow); VisualElement bottom = new(); bottom.AddToClassList("bottom-layout"); @@ -445,6 +449,9 @@ namespace KernelPanic.UI { _enemyRow.Clear(); _combatantElements.Clear(); + int enemyCount = _combatManager.Enemies.Count; + bool compactEnemies = enemyCount >= 5; + _enemyRow.EnableInClassList("enemy-row-compact", compactEnemies); if (_combatManager.PlayerState != null) { _combatantElements[_combatManager.PlayerState] = _playerPanel; @@ -457,6 +464,7 @@ namespace KernelPanic.UI Button card = new(() => _combatManager.SelectEnemy(index)); card.text = string.Empty; card.AddToClassList("enemy-card"); + card.EnableInClassList("enemy-card-compact", compactEnemies); bool highlighted = _combatManager.PendingTargetCard != null || _combatManager.SelectedEnemyIndex == index; if (highlighted) { @@ -473,6 +481,11 @@ namespace KernelPanic.UI card.Add(marker); } + if (enemy.HasSpecialSignal) + { + card.Add(EnemySignalPanel(enemy, _combatManager.Enemies)); + } + _combatantElements[enemy.State] = card; DetectBeneficialResourceFeedback(enemy.State, card); ApplyStatusStateClasses(card, enemy.State); @@ -823,6 +836,12 @@ namespace KernelPanic.UI button.AddToClassList("hand-card-unaffordable"); } + if (card.IsBroken || card.IsLocked) + { + button.AddToClassList("hand-card-unaffordable"); + button.SetEnabled(false); + } + PopulateCardFace(button, card); return button; } @@ -853,6 +872,13 @@ namespace KernelPanic.UI top.Add(name); target.Add(top); + if (card.IsBroken || card.IsLocked) + { + Label state = new(card.IsBroken ? "segv corrupt" : "locked"); + state.AddToClassList("status-label"); + target.Add(state); + } + VisualElement tags = new(); tags.AddToClassList("tag-row"); tags.Add(Tag(card.Definition.Language.ToString(), LanguageClass(card.Definition.Language))); @@ -885,6 +911,97 @@ namespace KernelPanic.UI return panel; } + private static VisualElement EnemySignalPanel(EnemyInstance enemy, IReadOnlyList enemies) + { + VisualElement row = new(); + row.AddToClassList("status-row"); + + if (enemy.SpawnedFromDeath) + { + row.Add(Tag(". orphaned", "track-tag")); + } + + if (enemy.HasEliteSignal) + { + row.Add(Tag("! elite", "intent-attack")); + } + + if (enemy.HasSplitSignal) + { + string text = CountLivingArchetype(enemies, enemy.ArchetypeId) >= EnemyArchetypeCatalog.ForkBombTotalCap + ? ": split capped" + : ": split next"; + row.Add(Tag(text, "track-tag")); + } + + if (enemy.HasSegfaultSignal) + { + row.Add(Tag($"! corrupt in {enemy.CountdownRemaining}", "track-tag")); + } + + if (enemy.HasRaceSignal) + { + string text = HasLivingPair(enemies, enemy) ? "~ linked random" : "~ unlinked"; + row.Add(Tag(text, "track-tag")); + } + + if (enemy.HasRootkitSignal) + { + string text = HasOtherLivingEnemy(enemies, enemy) ? "? masked reduced" : "? exposed"; + row.Add(Tag(text, "track-tag")); + } + + return row; + } + + private static int CountLivingArchetype(IReadOnlyList enemies, string archetypeId) + { + int count = 0; + for (int i = 0; i < enemies.Count; i++) + { + EnemyInstance enemy = enemies[i]; + if (enemy.ArchetypeId == archetypeId && !enemy.State.IsDefeated) + { + count++; + } + } + + return count; + } + + private static bool HasLivingPair(IReadOnlyList enemies, EnemyInstance target) + { + if (target == null || target.PairId < 0) + { + return false; + } + + for (int i = 0; i < enemies.Count; i++) + { + EnemyInstance enemy = enemies[i]; + if (enemy != target && enemy.PairId == target.PairId && !enemy.State.IsDefeated) + { + return true; + } + } + + return false; + } + + private static bool HasOtherLivingEnemy(IReadOnlyList enemies, EnemyInstance target) + { + for (int i = 0; i < enemies.Count; i++) + { + EnemyInstance enemy = enemies[i]; + if (enemy != target && !enemy.State.IsDefeated) + { + return true; + } + } + + return false; + } + private static VisualElement MeterBlock(string name, int current, int max, MeterTone tone) { float ratio = max <= 0 ? 0f : Mathf.Clamp01((float)current / max);