← All reports

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:

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():

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:

Second pass (this branch) — all 4 severity-100 pacing_bug rows cleared:

Root-cause bugs found and fixed along the way (these affect the live game,
not just the simulator):

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.