Enemy AI Revamp - Roadmap
Generated: June 29, 2026, last updated same day after Phase 2 landed
(GetSequencePriority folded into GetCardScore; single-pass scoring in Enemy.cs).
Phase 0 (the sequencing deadlock fix, PR #6) and Phase 1 (the
dead-card audit, PR #7) are both merged to main. This document scopes
what comes next and is written to be a standalone handoff - read the
"Handoff: state of the world" section first if picking this up cold.
Handoff: state of the world (read this first)
Repo: itsalovelyday/roguelike_demo. Working dir for all scripts
below: game-engine/ (the C# Godot project lives there; resources/,
scripts/Enemy.cs etc. are relative to it). Branch convention seen so
far: each session works a fresh claude/<slug> branch off main,
opens a draft PR, merges, done - no long-lived feature branch.
What's actually done: PR #6 fixed the Block-priority deadlock +
Maintenance Drone's 0-damage deck + an HP trim pass. PR #7 fixed 10
confirmed dead-for-enemy CardEffect classes' worth of dead cards across
9 enemy decks (full list in phase1_dead_card_audit.md), then audited 8
more suspicious-looking effect classes (Phase 1b) and found only 2 were
real bugs - both currently inert (see below). Phase 3 removed the
Maintenance Drone SortCardsByPriority() special-case (Enemy.cs:720) -
it now falls through to the general Defensive-personality path, which is
equivalent post-Phase-0. Phase 4 added scripts/test_combat_regression.py
(CI: .github/workflows/combat-regression.yml) - loads enemy/player decks
from live .tres via scripts/tres_parser.py, runs the combat simulator
from scripts/combat_sim.py, and asserts ceiling stall rate = 0%, floor
damage floor on both sides, and ceiling battle length <= 50 turns. Also
runs audit_dead_cards.py + audit_enemy_damage.py. Phase 2 collapsed
GetSequencePriority() into GetCardScore() - Glitch removal (+2000),
low-HP block-first (+1000), and block-scaling deprioritize (-500) are now
score bonuses/penalties in one pass; SortCardsByPriority() sorts by
GetCardScore() only (Defensive still adds raw block as a personality
tiebreaker). The game is
currently in a clean state: all three checks pass as of this writing.
Re-run python3 scripts/test_combat_regression.py from repo root first
if picking this up later (~2s, no Godot/dotnet needed).
Phase 1b verdict (now resolved, not just "deferred"): of the 8
flagged effect classes, full reads showed NextCardFreeEffect,
OverchargeEffect, HomeRemedyEffect, InfluenceScalingEffect,
EfficiencyFilterEffect, ConditionalSurgeEffect are all correctly
bilateral (if/else covers both player and enemy) - false positives from
grep-level pattern matching, same lesson as the earlier SkipNextDrawEffect
false alarm in Phase 1: always read the full Execute() before
concluding a guard is a bug. Only RetainBlockEffect.cs (if
(playerIsActor) {...}, no else) and InsulationEffect.cs (same shape)
are genuinely dead-for-enemy. Both are moot today: RetainBlockEffect
sets player.BlockRetentionThreshold, a field that only exists on
PlayerState, not Enemy (confirmed via grep across
scripts/PlayerState.cs and scripts/Enemy.cs) - so fixing it isn't a
one-line else, it needs an Enemy.cs field added first. The only two
cards using these effects (Insulation.tres, HedgeFund.tres) aren't in
any enemy's Deck array today, confirmed via grep -rl "HedgeFund.tres\|
Insulation.tres" resources/characters/ returning nothing. No action
needed unless/until a future deck edit puts one of those two cards into
an enemy deck - if that happens, add "RetainBlockEffect" and
"InsulationEffect" to the GUARDED set in scripts/audit_dead_cards.py
and decide then whether to add the Enemy.cs field or swap the card out.
Where the permanent audit tooling lives: scripts/audit_enemy_damage.py
and scripts/audit_dead_cards.py (committed in PR #7's follow-up,
promoted from one-off scratchpad scripts). Both are pure Python, no
dependencies, read .tres files via regex (no Godot needed). Re-run
after any deck or CardEffect change.
Why this is its own project
The deadlock fix in Phase 0 was a one-line gate on a single categorical
rule. It worked, but it didn't touch the underlying pattern that produced
the bug: Enemy.cs decides card order with a two-tier system -
GetSequencePriority() (categorical buckets: 2000 / 1000-or-0 / -500 / 0)
that always wins the sort, then GetCardScore() (a real weighted value
function) only as a tiebreaker within a bucket. Any time a categorical
bucket is too coarse for a real situation, GetCardScore()'s nuance is
silently discarded - which is exactly what happened with Block. There's no
reason to believe Block was the only rule with this problem; it's the
shape of the system, not a one-off.
This roadmap is a punch list, not a commitment to build all of it. Each
phase is independently shippable and independently a net positive - pick
up where it stops being worth it.
Phase 1: Audit the rest of GetSequencePriority() for the same shape of bug - DONE
Full writeup in phase1_dead_card_audit.md. Short version:
- Glitch removal = 2000 (always): confirmed fine as-is. Dead code in
Healthcare, and in the AI sector the two cards it forces are
independently strong, so the override rarely costs the AI anything. - Block-scaling damage (Quarterly Growth) = -500 (always last): not
re-investigated this pass: the "low but not bottom" question from Phase
0 is still open and worth a follow-up sim run. - The audit also surfaced a bigger, related bug one layer down: ~10
CardEffectsubclasses silently no-op when an enemy (rather than the
player) plays them, producing real dead cards in 9 enemy decks across
the AI and Energy sectors, including two complete zero-damage decks and
two bosses whose finisher cards never fired. All fixed via deck-content
swaps, same precedent as the Phase 0 Maintenance Drone fix. See the
report for the full list and a Phase 1b recommendation covering 8
more effect classes with the same suspicious asymmetric shape that
weren't audited yet.
Phase 1b: Finish the dead-card audit - DONE, no fix needed
Checked the remaining 8 suspicious effect classes. 6 were false positives
(correctly bilateral on a full read). The 2 real ones
(RetainBlockEffect, InsulationEffect) are currently unreachable - no
enemy deck contains either of the 2 cards that use them. See "Handoff"
section above for the full reasoning and what to do if that ever changes.
No further action needed unless a deck edit reintroduces the risk.
Phase 2: Collapse the two-tier system into one scoring pass - DONE
The categorical-priority-then-score-tiebreak split was the root design smell.
GetSequencePriority() is deleted; its three buckets are now constants on
GetCardScore():
- Glitch removal when
GlitchStacks > 0:+2000(large but not literally
undefeatable - lethal damage still gets+1000and may compete) - Block when
hpPercent < 0.5:+1000(same gate as the Phase 0 fix) - Block-scaling damage (Quarterly Growth):
-500(play after block is banked)
SortCardsByPriority() now sorts by GetCardScore() only. Defensive
personality still adds raw block amount on top as a tiebreaker (unchanged).
combat_sim.py mirrors the same single-pass scoring.
Chairman's bespoke sort branch remains (Phase 3) - intentional boss scripting.
Phase 3: Clean up the special-cased enemies - PARTIAL (Maintenance Drone done)
SortCardsByPriority() (Enemy.cs:688) still has a hardcoded if
(EnemyName.Contains("Chairman")) branch at line 696 bypassing the general
personality switch. Maintenance Drone's special case is deleted - Phase 0
gave it a real damage card and the general Defensive-personality path (now
HP-gated) handles it; verified by test_combat_regression.py passing post-delete.
Chairman's HP-threshold phase logic (line 370, MarketDominanceActive /
CurrentHealth / MaxHealth <= 0.30f) is more clearly intentional boss-script
behavior and is a separate question (script-driven boss patterns vs generic AI)
rather than a bug to merge away.
Phase 4: Turn simulate_ttk_healthcare.py into a standing regression check - DONE
scripts/test_combat_regression.py (run from repo root) is the standing check.
It loads healthcare enemies from the three phase .tres files and player
starter styles from resources/starter_styles/healthcare/ via
scripts/tres_parser.py (regex, no Godot). Card effect values for the sim
remain in scripts/combat_sim.CARDS - add an entry there when a new card
enters a healthcare deck. Assertions:
- audit_dead_cards.py + audit_enemy_damage.py pass (no dead/zero-damage decks)
- Ceiling mode stall rate = 0% (catches block-first / zero-damage deadlock shape)
- Floor mode: both sides deal >= 0.1 DPT (structural damage check; floor can
still grind long when the player is losing - that's Phase 5 deck balance)
- Ceiling battle length <= 50 turns
CI: .github/workflows/combat-regression.yml runs on PRs touching
game-engine/ or scripts/. scripts/simulate_ttk_healthcare.py is now a
reporting wrapper over the same modules (no more hand-copied enemy dicts).
Phase 5 (separate track, flagged in Phase 0's report, not AI's fault) - IN PROGRESS
Floor-mode (worst-case, no conditionals triggered) play had a 0% player win
rate in many Defensive-grunt matchups for Hustler and Plague Doctor starter
decks. First pass landed in PR for this branch:
scripts/analyze_difficulty.py+reports/phase5_difficulty_report.md—
tags all 78 healthcare matchups (sweet_spot / grind / unwinnable / etc.)- Maintenance Drone: HP 125→110, less block/heal, +2 Incomplete Form —
ceiling grinds eliminated (44t → ~17t) - Claims Adjuster: HP 80→75, less pure block, +damage cards —
floor grinds eliminated (119t Hustler matchup → ~15t)
Second pass (this branch) — all 4 severity-100 pacing_bug rows cleared:
- Grid Security Officer vs Nuclear Engineer (ceiling, 57-64t): Fracking
Op's self-damage was trippingLowHpBlockFirstBonuspanic-block, and NE's
weak floor damage never broke the spiral. Fix: swapped 1 Grid Shield → 1
Power Surge inCharacter_GridSecurity.tres(no self-damage, real ceiling
damage). NE matchup: 57-64t → ~17-21t. - Repair Relay vs Nuclear Engineer (floor, 100% stall): RR's deck was
76:20 block:damage by raw value. Fix: dropped Reinforced Pylon entirely,
cut Grid Shield/Emergency Protocol, boosted Fuel Injection/Power
Surge/Surge Pricing inCharacter_RepairRelay.tres. - Nuclear Physicist vs (built MidTier) Grid Operator (ceiling, 77.5%
stall): NP's deck was 60% block-by-count with extreme block values plus a
self-damaging finisher, mirroring a also-defensive built Grid Operator.
Fix: cut Reactor Core/Heat Shield, boosted Nuclear Meltdown in
Character_NuclearPhysicist.tres. - The Son vs The Hustler (floor, 0% win, 58.5t): not a deck issue — the
simulator's hand-maintained HealthcareCARDStable incombat_sim.pyhad
stale stats for Burnout Protocol (dmg=(10, 50), noself_dmg), instead of
the real.tresvalues (dmg=(0, 50),self_dmg=5from
SolvencyLostScalingDamageEffect). Fixing the simulator entry alone
dropped the matchup to 39.4t (no longer over the 50t floor-grind
threshold) — the real game's pacing was already fine, only the model was
wrong.
Root-cause bugs found and fixed along the way (these affect the live game,
not just the simulator):
card_sim_loader.pyhad several effect classes with no parsing branch at
all (cards silently modeled as zero block/zero damage):SurgeBlockEffect
(used by Repair Relay's/Fuel Dispenser's Reinforced Pylon),
WinningMomentumDamageEffect(Asset Liquidation), and
SolvencyLostScalingDamageEffect(Burnout Protocol). Also fixed
SurgeEffectto readEnergyAmount, not justStacks(Overtime Shift was
silently losing its energy gain).Enemy.cs'sGetCardScore()had the same blind spots in the real AI,
not just the simulator: block-scoring only checked
card.Effects.OfType<BlockEffect>(), missingDepletionBlockEffectand
SurgeBlockEffect(both extendCardEffectdirectly) — this under-scored
Repair Relay's Emergency Protocol/Reinforced Pylon. Damage-scoring never
accounted forWinningMomentumDamageEffect— this under-scored Mom & Pop
(Owners)'s Asset Liquidation. Both fixed;InsulationEffectwas
deliberately left out of the block-scoring fix since no enemy deck uses it
and itsExecute()is a no-op for enemy actors.combat_sim.py's hand-maintained HealthcareCARDS["Burnout Protocol"]
entry didn't matchBurnoutProtocol.tres(see above) — fixed.
Regression suite (scripts/test_combat_regression.py) and
scripts/analyze_difficulty.py --sector all both re-run clean after this
batch: 0 stalls, 0 pacing_bug rows, all ceiling battle lengths <= 50 turns.
Still open: Nurse/Plague Doctor starter decks need player-side damage buffs
for Family-tier investigate items (Mom & Pop boss, Son grunts — these are
expected_loss/investigate, not pacing bugs). Use
scripts/analyze_difficulty.py — starter losses vs MidTier/MegaCorp are now
tagged expected_loss (by design). Tier-entry built-deck scenarios in
scripts/deck_scenarios.py test whether shop/reward progression is sufficient.
See phase5_difficulty_report.md and balance_problem_matchups.md — current
top items are all severity-70 investigate rows (e.g. Platform Architect's
built MidTier deck not clearing defensive targets, Mr. & Mrs. Watts as a
Family-tier boss, Repair Relay's MegaCorp tier-entry build).
Suggested order
1 (done) → 1b (done) → 4 (done) → 2 (done) → 5 (in progress — multi-sector
difficulty analysis for Healthcare/Energy/AI, sim fidelity improvements; see
phase5_difficulty_report.md and balance_problem_matchups.md) → Chairman
special-case is the remaining Phase 3 item (optional boss-scripting decision).
Next concrete step for a new session: All severity-100 pacing_bug rows are
cleared. Work through the remaining severity-70 investigate rows in
balance_problem_matchups.md — mostly built-tier-entry progression gaps
(does the shop/reward sketch close the gap against defensive targets?) and
Family-tier boss tuning (Mr. & Mrs. Watts, Mom & Pop). Nurse/Plague Doctor
starter losses vs late-tier are tagged expected_loss by design — tier-entry
built-deck lens tests whether shop/reward progression closes the gap.