sts2-bot/docs/research/05-failure-modes.md
0xrsydn 239a42c317 docs(research): record the defence failure mode and refresh the run log
Failure #35 is the largest defect found so far and it was invisible from the
code: 9 runs lost to one Act 1 boss, all with the same cause, all recorded in
the run files as damage taken, turns elapsed and potions spent. Recorded with
the reproduction, the fix, and the replay that verifies it.

Also adds a rule to the docs index: re-measure numbers before copying them.
Test counts, latencies and run totals in these notes have gone stale more than
once -- a hand-off summary recorded 29 and 118 assertions where the suites
actually printed 50 and 131.
2026-09-22 06:09:16 +07:00

24 KiB
Raw Blame History

05 — Failure modes

Every entry below was observed live, caused a real infinite loop or a stalled run, and has a fix in the code. None are theoretical.

The general lesson: this interface is full of actions that report {"status": "ok"} while doing nothing. Never trust ok. Trust the state.


1. claim_reward(index=0) forever

rewards.items[] is rebuilt and re-indexed from 0 after every claim. Claiming index 0 repeatedly reclaims the same slot forever.

Observed: 65 consecutive rejected claim_reward(index=0) calls.

Fix: claim right-to-left, items[-1].

2. A potion reward with full slots silently vanishes

When all potion slots are full, claim_reward on a potion reward returns ok and is silently dropped. The item never leaves items[], so the loop never terminates.

Observed:

potions: 3 of max_potion_slots 3
claim_reward(1) -> ok | Claiming reward: potion (Energy Potion)
   items now: [gold, potion]        <- unchanged
claim_reward(0) -> ok | Claiming reward: gold (19)
   items now: [potion]              <- re-indexed

Fix: if the last item is a potion and len(potions) >= max_potion_slots, discard the weakest potion first, then claim. Weakness comes from a small POTION_VALUE table in brain.py.

3. select_card twice on one index freezes the screen

On grid screens select_card toggles. The second call deselects, so the state never changes and the loop stalls.

Observed: select_card(index=0) twice, then 10 unchanged reads.

Fix: when preview_showing and can_confirm are true, the action is confirm_selection, never another select_card.

4. A stale preview makes confirm_selection a no-op

confirm_selection returns ok while changing nothing, when the preview was left over from a desynchronised state.

Observed:

confirm_selection -> ok  "Confirming selection from preview"
state_type before/after: card_select -> card_select
sig changed: False

Fix: if the identical card_select state is seen twice in a row, the confirm did not take effect. Send cancel_selection to reset, then re-select. Verified working:

cancel_selection -> preview False
select_card(0)   -> preview True
confirm_selection-> state_type becomes 'event'

5. rest_site options use name, not title

Reading options[].title yields None for every option, so the handler concluded there were no options and fell through to a rejected proceed.

Fix: read name (plus id), and honour is_enabled.

6. treasure claims a relic before the chest opens

The chest auto-opens. During opening the response has no relics key and no can_proceed, so claim_treasure_relic is rejected repeatedly.

Observed: 4 consecutive rejections.

Fix: wait while relics is absent; claim only when present.

7. Acting during transitions

The loop re-decided faster than the game animated:

  • choose_map_node fired 3 times in a row during a single travel.
  • end_turn fired repeatedly during the enemy turn.

Fix, two parts:

  • __wait__ when battle.is_play_phase is false.
  • An unchanged-state guard in run.py: if the state signature repeats and the last action succeeded, wait. If the last action was rejected, re-decide instead, so a different action can be tried.

The second condition matters: waiting on a rejected action would prevent recovery and stall until the stuck counter fires.

8. unknown and overlay treated as dead ends

Both are transitions, not terminal states. Returning "stop" ended the session while the game was mid-transition into combat.

Fix: wait and re-observe. The unchanged-state guard bounds this, so a genuine dead end still stops after 10 reads.

9. The confidence gate rejected a correct answer

Not a loop, but a wrong decision, and worth recording because the bug was in our gate rather than in the model.

Measured: 5 cards offered. Jev picked Bash at 0.61 probability with Defend at 0.29, and reported confidence 0.50. A fixed 0.55 floor rejected it and fell back to a heuristic that chose to block instead.

confidence measures peakedness, so it falls as the option count rises: (5 × 0.61 1) / 4 = 0.50.

Fix: gate a Choice on margin over the runner-up (top >= 0.45 and top - runner >= 0.20), which is scale-free. See 02.


Checklist for a new state_type

  1. Capture the real shape. Do not code from documentation.
  2. Check which fields are absent in some states.
  3. Ask: what does this action return when it is a no-op?
  4. Prefer __wait__ over stopping for anything that looks transitional.
  5. Add a guard so a repeated identical state does not loop forever.

Checklist for a new action

  1. Does it report ok on a no-op? (Most do.)
  2. Does it re-index the collection it acts on?
  3. Is there a precondition the state exposes (can_confirm, can_proceed, is_stocked, can_afford) that should be checked first?
  4. Does it toggle?

Session 2 additions

10. An event option ended the run, and the gate allowed it

Measured on a deciphering event:

[102] jev chose Keep Deciphering    conf=0.28
[104] jev chose Lose Everything     conf=0.49

"Lose Everything" set the player's max HP to 1. The margin gate passed it, because the gate only measured how decisive the answer was, never how consequential the action was.

Fix, in two parts:

  1. A stricter gate for events: top >= 0.60 AND margin >= 0.30.
  2. A deterministic safety net. When the model is not confident, choose the option with the lowest event_safety_rank, which counts risk words ("everything", "keep", "continue", "gamble", "lose") minus stop words ("stop", "leave", "refuse", "decline", "take what").

A "does this risk losing the run?" Noul was tried and REMOVED. Measured on the same event:

Option Risk Noul
"Lose Everything" 0.46
"Keep Deciphering" 0.52
"Stop" 0.38

It ranked the run-ending option as less risky than a moderate one. A misleading signal is worse than no signal, so danger is detected by keywords instead. Keep the model for the confident case; use code for the dangerous one.

11. hp=1/1 was reported as "healthy"

An effect reduced max HP to 1. _hp_bucket bucketed by percentage alone, so 1/1 was 100% and returned healthy. The bot walked into a normal fight at 1 HP and died.

Fix: absolute HP is now part of the bucket. hp <= 5 is always critical.

def _hp_bucket(pct, hp=None):
    if hp is not None and hp <= 5:
        return HP_CRITICAL
    ...

12. hand_select blindly selected index 0

combat_select_card(index=0) failed with:

Card index 0 out of range (0 selectable cards)

hand_select has cards (still selectable) and selected_cards (already chosen). When cards is empty the only useful action is combat_confirm_selection.

Fix: confirm when nothing remains selectable; otherwise give up basic Strikes first, then Defends.

13. bundle_select has the same preview trap as card_select

A bundle preview is already open - confirm or cancel it first

Same shape (preview_showing, can_confirm) and same fix: confirm when a preview is showing, and reset with cancel_bundle_selection if the identical state repeats.

14. Error text lives in error, not message

ActionResult read only message, so every rejection printed as action rejected: with nothing after it. This hid four separate bugs for a whole session.

Fix: message = data.get("message") or data.get("error") or "".

Lesson: make failures loud before chasing them. A blank error message is worse than no error handling.

15. can_proceed is not reliable

For shops, shop.can_proceed was false while proceed() worked and moved the game to the map. Waiting on that flag stalls forever.

Fix: do not gate an exit on can_proceed. Attempt proceed and let the rejection counter bound the retries.

16. The unchanged-state guard was count-based

A boss death animation plus the rewards transition exceeded 10 reads, so the guard declared STUCK while the game was still animating.

Fix: the guard is now time-based (--stuck-seconds, default 25 s).

17. Transient rejections are normal

proceed at a rest site right after a heal is rejected for a moment and then succeeds. The retry loop was too impatient.

Fix: on rejection, back off 3x the normal pause, and allow 6 attempts.


Session 3 — the programmatic audit

Prompted by observing that the bot "was only upgrading common attack cards". These are usage bugs, not decision-quality issues, and are listed separately from the tuning items.

18. card_select fallback was hardcoded to cards[0]

The upgrade screen fell back to the first card in the list, and the list is ordered with basic Strikes first. So every fallback upgraded a Strike.

Measured across runs:

[156] [fallback] select_card(index=0)  # select the first card
[263] [fallback] select_card(index=0)  # low confidence; select the first
[378] [fallback] select_card(index=0)  # low confidence; select the first

The fallback fired often because a single Choice over a 13+ card deck dilutes, exactly like the shop.

Fix, two parts:

  1. Re-ranking: one absolute Noul per card, argmax in code.
  2. Screen-aware deterministic fallback (upgrade_rank, removal_rank) that never picks index 0 blindly:
    • upgrade prefers a non-basic card, then Bash, then Strikes, then Defends, and never an already-upgraded card
    • remove/transform invert the order: shed basic Strikes and Defends first, and never target an upgraded card

Verified live after the fix:

[197] jev chose Perfected Strike (noul=0.67)
[205] jev chose Bludgeon        (noul=0.66)
[212] jev chose Rampage         (noul=0.67)
[303] jev chose Bash            (noul=0.62)

19. hand_select fed good cards to a "choose any number" prompt

The give-up ranking returned 2 for anything that was not a Strike or Defend, so once the basics ran out it started offering real cards:

[023] give up Uppercut
[086] give up Stomp
[171] give up Bash        <- the deck's only Vulnerable source

Fix: only basic Strikes and Defends are candidates. When none remain, confirm and keep the good cards. A selection is forced only when can_confirm is false.

20. card_reward had a redundant gate that skipped almost everything

A want_any Noul ("does this deck want any of these?") gated the whole decision. When it was merely uncertain (0.540.59) the bot skipped, so it skipped nearly every card reward and ran a 10-card deck.

Fix: the per-card Nouls ARE the signal. Skip only when no card clears CARD_PICK_THRESHOLD.

21. A skipped card reward is NOT consumed — infinite loop

[409] skip_card_reward()          # no card cleared 0.6
[410] claim_reward(index=2)
[411] skip_card_reward()
[412] claim_reward(index=2)       ... forever

Skipping returns to the rewards screen with the card still listed. Claiming it again reopens the card screen, and the cycle repeats.

Verified directly: skip_card_reward -> ok, then the rewards list still contains [2] card: Add a card to your deck.

Fix: record that a card reward was skipped, and ignore card rewards on the rewards screen afterwards. rewards and card_reward are declared one screen group so the flag survives the hop between them — otherwise it is cleared on every transition and the loop returns.

22. Module-level guards leaked across screens

Found by the new test_brain.py. A fresh fake-merchant shop was reported as "unchanged after a purchase" because a shop signature from an earlier screen was still set.

Fix: _reset_screen_guards() clears all per-screen state whenever the screen group changes. Also made the shop guard precise: it now only fires when we actually purchased from that exact shop state.

23. fake_merchant nests its inventory one level deeper

The shop is at fake_merchant.shop.items, not shop.items. Reading only obs["shop"] made every fake-merchant shop look empty, so the bot left immediately without buying.

Fix: resolve obs["shop"], else obs["fake_merchant"]["shop"], else obs["fake_merchant"].

24. embark is rejected without a character selected

action rejected: Embark button not available — select a character first

An earlier version assumed embark defaults to the first unlocked character. It only worked once because a character happened to be selected already.

Fix: read the game's own message:

  • "Select a character." -> select one
  • "Selected The Ironclad. Use 'confirm' to embark." -> embark

25. relic_select used a diluted Choice and ignored can_skip

Verified shape: relic_select.relics[] with index/id/name/description/rarity plus can_skip. It now uses the same re-ranking pattern, honours can_skip, and falls back to the rarest relic rather than index 0.

New: test_brain.py

A structural regression suite, deliberately separate from decision quality. It asserts:

  1. Every state_type produces an action that is LEGAL for that state. Compares against sts2.LEGAL_ACTIONS. This is what would have caught a handler emitting end_turn during someone else's turn.
  2. Every action the decision layer can emit is declared somewhere, so a typo cannot silently produce an invalid action.
  3. Every fallback respects its inputs — removal does not target an upgraded card, upgrade does not target a basic one, card reward takes the rarest rather than index 0, hand_select gives up a Strike rather than Bash.

54 assertions, runs offline with client=None, no model calls, no game. Run it before every session.


Session 4 — the toggle and transition traps

Found by running the bot for long stretches. Every one of these was a stall or a crash, not a decision-quality issue.

26. combat_select_card TOGGLES — re-selecting deselects

The same trap as card_select, in a different action. Measured: 18 consecutive combat_select_card(card_index=0) # give up Defend with the state never changing, because selecting an already-selected card deselects it.

hand_select.selected_cards is the authoritative list of what is already chosen. Exclude those from the candidate list.

Watch out: cards[].index and selected_cards[].index are different index spaces (different arrays, independent counters). Names are the only reliable way to match between them.

27. hand_select has a mode that only needs confirming

Measured state:

mode         = "upgrade_select"
prompt       = "Confirm Card to Upgrade"
cards        = [(0, "Defend")]
selected     = []
can_confirm  = true

Sending combat_select_card is a silent no-op here. The card is already picked; combat_confirm_selection closes the screen and moves to monster.

Fix: mode-aware.

mode action
upgrade_select combat_confirm_selection
simple_select select cards, then confirm when nothing selectable remains

28. Character select: no indicator, and flaky embark

Two problems at once:

  1. The state carries no "selected" indicator. The mod hardcodes result["message"] = "Select a character." whatever is chosen. Verified in AddCharacterSelectMenuState. So the message cannot be used to tell whether a character is picked.
  2. Embarking right after selecting is flaky. Measured three consecutive Embark button not available - select a character first rejections, because the selection had not registered yet.

Fix: ALTERNATE — select, embark, select, embark. A rejected embark is always followed by a fresh select, so the sequence is self-correcting regardless of timing.

An earlier attempt used signature comparison ("if the screen is unchanged, a character was already selected"). It failed because the signature keeps changing during the embark transition, restarting the cycle.

29. The run loop's guard blocked the brain's own recovery

run.py waited whenever the state was unchanged after a successful action, and it did so before calling brain.decide. That made it impossible for any handler to notice a repeated state and react differently — which is exactly how character_select and multi-line dialogue work.

Fix: move the check to AFTER deciding, and suppress only a repeated action, never acting in general. Proposing a different action is always allowed.

30. A bounded retry is required

Even the corrected guard stalled on Ancient dialogue: one click was issued, the state had not updated yet, and the guard then refused to retry forever.

Fix: --max-duplicate-waits (default 3). After that many suppressions on an unchanged state, re-execute the action. Some actions legitimately need repeating and some transitions are just slow.

31. unknown and overlay are transitions, not screens

Resetting the per-screen guards on them wiped state mid-flow. During embark the state flickers through unknown, which cleared the character-select guard and restarted the select/embark cycle.

Fix: _reset_screen_guards returns early for unknown and overlay.

32. menu_screen must be part of the screen key

main, singleplayer, character_select and tutorial_prompt all share state_type == "menu" but have completely different valid actions. Keying only on state_type meant moving from the main menu to character select did not reset anything.

Fix: _screen_group() returns menu:<menu_screen> for the menu state.

33. RemoteDisconnected crashed a run

http.client.RemoteDisconnected: Remote end closed connection without response

RemoteDisconnected is an http.client.HTTPException, not a URLError, so it escaped jev.py's transport handler and killed the process mid-fight.

Fix, both layers:

  • jev.py also catches http.client.HTTPException and OSError, and retries.
  • run.py wraps brain.decide in a broad except Exception, falls back to the deterministic handlers, and keeps playing.

34. Known, self-correcting: stale-state rejections

Occasionally the game rejects a card we chose:

Card 'Infection' cannot be played: HasUnplayableKeyword
Card 'Evil Eye' cannot be played: EnergyCostTooHigh

can_play comes from the game's own card.CanPlay(), so the state was correct when read. A Jev call takes ~0.75 s, so the state we decided on can be about a second stale by the time the action lands.

This is a race, not a logic bug, and it self-corrects: the rejection sets last_ok = False, so the loop re-decides immediately against a fresh state instead of waiting. Cost is one wasted action. No revalidation pass is needed.

Test coverage after this session

test_brain.py: 78 assertions. New this session:

  • indices must come from the data, never from list position (hostile-index cases where index deliberately differs from array position)
  • hand_select skips already-chosen cards, including duplicate names
  • hand_select confirms for upgrade_select
  • character select alternates select/embark and resets correctly
  • a StubClient makes the model paths testable offline and deterministically

35. Combat had no defense policy at all (the biggest one)

Found by mining the 37 run files, not by reading the code.

Symptom. 81% of runs (30/37) died in Act 1. THE_KIN_BOSS alone killed 9. Every one of those fights ran 5-10 turns and cost 44-80 HP, i.e. ~10-13 a turn, with block cards in hand the entire time.

damage taken: [44, 50, 50, 53, 63, 64, 70, 75, 80]
turns:        [6, 9, 10, 6, 6, 10, 9, 7, 5]

Cause A. facts.py classes a hit of <= 15% of max HP as THREAT_CHIP. At 80 max HP that is 12. _fallback_combat then required

must_respect = threat in (HEAVY, SEVERE, LETHAL) or hp_bucket in (WOUNDED, CRITICAL)

so at 74/80 HP (HEALTHY) a 12-damage hit was ignored and the bot attacked. The boss's main attack sits exactly on that boundary. Measured:

  hp  incoming   threat  hp_bucket   old play
  74        12     chip    healthy   Bash (dmg)
  60        12     chip    healthy   Bash (dmg)
  45        12     chip    wounded   Defend     <- only now, 29 HP already gone

Cause B. _jev_combat asked a should_defend Noul on every combat turn and never read it. grep -rn should_defend *.py returned exactly one line — the one that created it. Pure latency cost.

Cause C. With that question dead, defense fell entirely to choice("Which single play best advances winning this fight?") — a phrasing biased to damage, in the Choice shape already measured as diluting with option count. Measured against the real Kin state, Jev answered Bash at 0.42 confidence. 0.42 is below the 0.45 gate, so it fell through to the fallback, which also chose damage. Both paths agreed on the wrong answer.

Fix. Blocking is arithmetic, so it is decided in code before Jev is asked:

  • facts.CombatFacts.turns_to_kill — remaining fight length from this turn's reachable damage.
  • facts.CombatFacts.projected_incomingturns_to_kill * incoming_damage.
  • facts.CombatFacts.affordable_losshp - 30% of max_hp.
  • facts.CombatFacts.must_blockprojected_incoming > affordable_loss.
  • combat_decision now forces a block when block_urgent and a blocker is in hand, after the lethal check, before Jev.
  • should_defend deleted.

Verification. Replayed all 600 real combat captures. The rule changes 8 of 68 in-play turns (11.8%), and stays silent on short fights and when nothing is incoming:

live_162_combat.json    80/80  12 in   6 turns  proj 72 > afford 56  -> Defend
live_238_combat.json    78/80  17 in  20 turns  proj 340 > afford 54 -> Rage
trivial (15 HP enemy)                                           -> still attacks

Tests: 50 in test_facts.py, 131 in test_brain.py (the two lethal-search regressions in 09 §5 are included).

Lesson. The bug was invisible from the code and obvious from the data. Nine runs died the same way, and the run files recorded the damage, the turn count and the potions spent. Mine the history before theorising about the meta.

36. relic_select asked one question id and read another

relic_select_decision builds its ranking questions keyed good_relicN:

keys = [f"relic{r.get('index', 0)}" for r in relics]
questions = {f"good_{k}": noul(...) for k in keys}

but then read the answers under the unprefixed ids:

best_key, best_noul = best_by_noul(response, keys, CARD_PICK_THRESHOLD)

best_by_noul looks each key up with response.get(key), so every lookup returned None, ranked was empty, and the function returned (None, 0.0) for every state. The relic path could therefore only ever take the rarest relic — Jev's answer was silently discarded on every boss and elite relic offer. The same shape is correct in card_reward_decision and card_select_decision, which keep the good_ prefix end to end; relic_select was the one that did not.

Nothing in the trace caught it, because no session in decisions.jsonl ever reached a relic_select state. Found by auditing every best_by_noul call site against the ids it asked for, not by a run.

Fix: rank [f"good_{k}" for k in keys] and map the winner back with the same prefix. Regression test in test_brain.py: the highest-rated relic is deliberately the Common one, so the rarity fallback cannot produce the expected answer by accident — the test fails before the fix and passes after.

Lesson. A question id that does not match its answer id fails silently and looks exactly like "the model had nothing to say". Every best_by_noul call site must be checked against the ids it actually asked for.