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

649 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](02-system-one-jev.md).
---
## 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`.
```python
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
```python
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_incoming``turns_to_kill * incoming_damage`.
- `facts.CombatFacts.affordable_loss``hp - 30% of max_hp`.
- `facts.CombatFacts.must_block``projected_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](09-typesafe-best-practice.md) §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`:
```python
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:
```python
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.