fix(policy): reconcile session-owned action memory with game observations

This commit is contained in:
0xrsydn 2026-09-22 12:45:46 +07:00
commit bf41945ef9
9 changed files with 405 additions and 362 deletions

View file

@ -2,7 +2,7 @@
## Code map ## Code map
- `facts.py`: pure game-state parsing and arithmetic. - `facts.py`: pure game-state parsing and arithmetic.
- `brain.py`: decision policy; returns one `Decision` per observation. - `brain.py`: decision policy and session-owned `PolicyContext`; proposes one action per observation.
- `run.py`: observedecideact loop, captures, and session attribution. - `run.py`: observedecideact loop, captures, and session attribution.
- `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates. - `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates.
- `migrate.py`: dataset migration and integrity checks. - `migrate.py`: dataset migration and integrity checks.
@ -25,6 +25,7 @@ Prefer integration/end-to-end checks; keep only essential regression tests and u
## Invariants ## Invariants
- Execute one game action, then read a fresh observation. Card indices can change after each action. - Execute one game action, then read a fresh observation. Card indices can change after each action.
- Keep policy memory session-owned. Proposals do not record execution; reconcile accepted requests with fresh observations.
- Compute arithmetic and legality in code, not in Jev. Confidence does not prove correctness. - Compute arithmetic and legality in code, not in Jev. Confidence does not prove correctness.
- Keep deterministic fallbacks usable with `client=None`. Test model paths with stubs. - Keep deterministic fallbacks usable with `client=None`. Test model paths with stubs.
- Preserve session/step attribution. Run outcomes are not per-decision correctness labels. - Preserve session/step attribution. Run outcomes are not per-decision correctness labels.

View file

@ -28,6 +28,16 @@ A proposed next action. A decision does not establish that the game received or
**Action result**: **Action result**:
The game's response to an attempted action. Acceptance alone does not establish that the expected change occurred. The game's response to an attempted action. Acceptance alone does not establish that the expected change occurred.
**Pending game action**:
An accepted action request that has not yet been reconciled with a fresh observation.
_Avoid_: Completed action
**Accepted toggle**:
An accepted request to change a card's selection status. It does not prove that the card is selected.
**Screen flow**:
Related game screens that form one interaction, such as opening and skipping a card reward.
**Run deck**: **Run deck**:
The persistent collection of cards owned during a run, distinct from temporary combat cards and combat piles. The persistent collection of cards owned during a run, distinct from temporary combat cards and combat piles.
_Avoid_: Treating all visible combat cards as the persistent deck. _Avoid_: Treating all visible combat cards as the persistent deck.

296
brain.py
View file

@ -472,8 +472,6 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None)
and skip_answer.yes and skip_answer.yes
and gate(skip_answer) and gate(skip_answer)
): ):
global _rewards_skipped_card
_rewards_skipped_card = True
return Decision("skip_card_reward", {}, return Decision("skip_card_reward", {},
f"jev: skip all (noul={skip_answer.noul:.2f}); keep the deck lean", f"jev: skip all (noul={skip_answer.noul:.2f}); keep the deck lean",
"jev", skip_answer.noul) "jev", skip_answer.noul)
@ -485,7 +483,6 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None)
# floor the deck is better off lean. This is the safety net that stops the # floor the deck is better off lean. This is the safety net that stops the
# deck bloating when Jev is indifferent. # deck bloating when Jev is indifferent.
if CARD_SKIP_POLICY == "combined" and can_skip and best_noul < CARD_PICK_THRESHOLD: if CARD_SKIP_POLICY == "combined" and can_skip and best_noul < CARD_PICK_THRESHOLD:
_rewards_skipped_card = True
return Decision("skip_card_reward", {}, return Decision("skip_card_reward", {},
f"combined: jev said don't skip but best={best_noul:.2f} " f"combined: jev said don't skip but best={best_noul:.2f} "
f"< {CARD_PICK_THRESHOLD}; keep the deck lean", f"< {CARD_PICK_THRESHOLD}; keep the deck lean",
@ -493,7 +490,6 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None)
if best_key is None: if best_key is None:
if can_skip: if can_skip:
_rewards_skipped_card = True
return Decision("skip_card_reward", {}, return Decision("skip_card_reward", {},
"no usable ratings; keeping the deck lean", "fallback") "no usable ratings; keeping the deck lean", "fallback")
fallback_card = best_by_rarity(cards) fallback_card = best_by_rarity(cards)
@ -691,11 +687,6 @@ def map_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Deci
f"jev chose {node.get('type')}", "jev", pick.confidence) f"jev chose {node.get('type')}", "jev", pick.confidence)
_last_card_select_sig: str | None = None
_card_select_picked = False
_card_select_confirmed = False
_card_select_chosen: list[int] = []
# Deterministic fallbacks for a grid selection screen, used only when Jev does # Deterministic fallbacks for a grid selection screen, used only when Jev does
# not clear the threshold. Lower rank wins. # not clear the threshold. Lower rank wins.
# #
@ -770,11 +761,7 @@ def card_select_fallback(cards: list[dict], screen: str,
def _card_select_pick(index: int, reason: str, source: str, def _card_select_pick(index: int, reason: str, source: str,
confidence: float | None = None) -> Decision: confidence: float | None = None) -> Decision:
"""Emit a select_card and remember that this index has been toggled on.""" """Propose a toggle. Only an accepted request enters policy memory."""
global _card_select_picked
_card_select_picked = True
if index not in _card_select_chosen:
_card_select_chosen.append(index)
return Decision("select_card", {"index": index}, reason, source, confidence) return Decision("select_card", {"index": index}, reason, source, confidence)
@ -794,23 +781,20 @@ def card_select_need(prompt: str) -> int:
return int(match.group(1)) if match else 1 return int(match.group(1)) if match else 1
def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Decision: def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None,
context: PolicyContext) -> Decision:
""" """
Grid selection overlay: upgrade, transform, remove, choose-a-card. Grid selection overlay: upgrade, transform, remove, choose-a-card.
Two traps, both observed live: Two traps, both observed live:
* `select_card` TOGGLES on grid screens. Calling it twice on one index * `select_card` TOGGLES on grid screens. Calling it twice on one index
deselects and freezes the screen. deselects and freezes the screen.
* A preview left over from a desynced state makes `confirm_selection` * A preview can make `confirm_selection` report ok without a visible
report ok while changing nothing. If the same screen reappears change. The context waits for the screen to close instead of repeating
unchanged, reset with cancel_selection instead of confirming again. or cancelling an action whose outcome is still unknown.
""" """
global _last_card_select_sig, _card_select_picked, _card_select_confirmed
cs = obs.get("card_select") or {} cs = obs.get("card_select") or {}
sig = json.dumps(cs, sort_keys=True) chosen = context.accepted_card_indices
repeated = sig == _last_card_select_sig
_last_card_select_sig = sig
prompt = str(cs.get("prompt") or "Choose a card.") prompt = str(cs.get("prompt") or "Choose a card.")
screen = str(cs.get("screen_type") or "") screen = str(cs.get("screen_type") or "")
@ -821,37 +805,31 @@ def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None)
# selection with NO preview at all. And do NOT confirm early on a # selection with NO preview at all. And do NOT confirm early on a
# multi-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all # multi-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all
# five are chosen. # five are chosen.
if len(_card_select_chosen) >= need: if cs.get("can_confirm") and (len(chosen) >= need or cs.get("preview_showing")):
if cs.get("can_confirm"): return Decision("confirm_selection", {}, "confirm the selection", "code")
if _card_select_confirmed: if len(chosen) >= need:
return Decision("cancel_selection", {},
"confirm did not apply; reset the screen", "code")
_card_select_confirmed = True
return Decision("confirm_selection", {},
f"confirm {len(_card_select_chosen)}/{need} selected", "code")
# Enough chosen but the game has not enabled confirm yet. Selecting more # Enough chosen but the game has not enabled confirm yet. Selecting more
# would overshoot, so wait. # would overshoot, so wait.
return Decision("__wait__", {}, return Decision("__wait__", {},
f"{len(_card_select_chosen)}/{need} chosen; waiting for confirm", f"{len(chosen)}/{need} accepted toggles; waiting for confirm",
"code") "code")
cards = [c for c in (cs.get("cards") or []) if isinstance(c, dict)] cards = [c for c in (cs.get("cards") or []) if isinstance(c, dict)]
if not cards: if not cards:
return Decision("cancel_selection", {}, "no cards to select", "code") return Decision("cancel_selection", {}, "no cards to select", "code")
# `select_card` TOGGLES, so never re-select an index we already toggled on. # `select_card` TOGGLES. Do not repeat an accepted request for this grid.
remaining = [c for c in cards if c.get("index") not in _card_select_chosen] remaining = [c for c in cards if c.get("index") not in chosen]
if not remaining: if not remaining:
if cs.get("can_confirm"): if cs.get("can_confirm"):
_card_select_confirmed = True
return Decision("confirm_selection", {}, "all selectable cards chosen", "code") return Decision("confirm_selection", {}, "all selectable cards chosen", "code")
return Decision("__wait__", {}, return Decision("__wait__", {},
f"{len(_card_select_chosen)}/{need} chosen; nothing new to select", f"{len(chosen)}/{need} accepted toggles; nothing new to select",
"code") "code")
# One ABSOLUTE Noul per candidate, argmax in code. A single Choice over a # One ABSOLUTE Noul per candidate, argmax in code. A single Choice over a
# 13+ card deck diluted badly and the fallback then always took index 0. # 13+ card deck diluted badly and the fallback then always took index 0.
# Candidates are `remaining` -- never an index we already toggled on. # Candidates exclude indices with accepted toggle requests.
keys = [f"card{c['index']}" for c in remaining] keys = [f"card{c['index']}" for c in remaining]
kind = screen_kind(screen, prompt) kind = screen_kind(screen, prompt)
questions: dict[str, dict] = {} questions: dict[str, dict] = {}
@ -1035,9 +1013,6 @@ def rest_site_decision(obs: dict) -> Decision:
"first rest option", "fallback") "first rest option", "fallback")
_last_shop_sig: str | None = None
_last_shop_purchased = False
# Minimum absolute Noul before buying anything in a shop. Absolute judgements # Minimum absolute Noul before buying anything in a shop. Absolute judgements
# can legitimately be low for every candidate, so this is a floor, not a rank. # can legitimately be low for every candidate, so this is a floor, not a rank.
SHOP_BUY_THRESHOLD = 0.60 SHOP_BUY_THRESHOLD = 0.60
@ -1083,8 +1058,6 @@ def shop_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Dec
The state carries `price`, `is_stocked` and `can_afford` per item, so no The state carries `price`, `is_stocked` and `can_afford` per item, so no
affordability arithmetic needs to reach the model. affordability arithmetic needs to reach the model.
""" """
global _last_shop_sig, _last_shop_purchased
# `fake_merchant` nests its inventory one level deeper: fake_merchant.shop. # `fake_merchant` nests its inventory one level deeper: fake_merchant.shop.
# Reading only obs["shop"] made every fake-merchant shop look empty, so the # Reading only obs["shop"] made every fake-merchant shop look empty, so the
# bot always left immediately without buying. # bot always left immediately without buying.
@ -1105,16 +1078,6 @@ def shop_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Dec
# worked and moved the game to the map. Never wait on it indefinitely. # worked and moved the game to the map. Never wait on it indefinitely.
return Decision("proceed", {}, "nothing affordable; leave", "code") return Decision("proceed", {}, "nothing affordable; leave", "code")
# Only stop buying when we purchased from this EXACT shop state and it did
# not change. Keying on the signature alone made a fresh shop look stalled
# because module-level state leaked in from an earlier screen.
sig = json.dumps(node, sort_keys=True)
if sig != _last_shop_sig:
_last_shop_sig = sig
_last_shop_purchased = False
elif _last_shop_purchased:
return Decision("proceed", {}, "shop unchanged after a purchase; leave", "code")
if client is None: if client is None:
return Decision("proceed", {}, "no jev; skip shop", "fallback") return Decision("proceed", {}, "no jev; skip shop", "fallback")
@ -1178,7 +1141,6 @@ def shop_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Dec
f"best item only noul={best_noul:.2f}; leave", "jev", best_noul) f"best item only noul={best_noul:.2f}; leave", "jev", best_noul)
item = item_keys[best_key] item = item_keys[best_key]
_last_shop_purchased = True
return Decision("shop_purchase", {"index": item["index"]}, return Decision("shop_purchase", {"index": item["index"]},
f"jev bought {shop_item_text(item)[0]} (noul={best_noul:.2f})", f"jev bought {shop_item_text(item)[0]} (noul={best_noul:.2f})",
"jev", best_noul) "jev", best_noul)
@ -1279,11 +1241,7 @@ def _weakest_potion_slot(obs: dict) -> int | None:
return weakest.get("slot", 0) return weakest.get("slot", 0)
_last_rewards_sig: str | None = None def rewards_decision(obs: dict, context: PolicyContext) -> Decision:
_rewards_skipped_card = False
def rewards_decision(obs: dict) -> Decision:
""" """
Reward screen. Reward screen.
@ -1299,7 +1257,7 @@ def rewards_decision(obs: dict) -> Decision:
node = obs.get("rewards") or {} node = obs.get("rewards") or {}
items = [i for i in (node.get("items") or []) if isinstance(i, dict)] items = [i for i in (node.get("items") or []) if isinstance(i, dict)]
if _rewards_skipped_card: if context.rewards_skipped_card:
items = [i for i in items if i.get("type") != "card"] items = [i for i in items if i.get("type") != "card"]
if not items: if not items:
@ -1321,30 +1279,18 @@ def rewards_decision(obs: dict) -> Decision:
f"claim reward {idx} (right-to-left)", "code") f"claim reward {idx} (right-to-left)", "code")
_last_bundle_sig: str | None = None
def bundle_select_decision(obs: dict, client: JevClient | None, def bundle_select_decision(obs: dict, client: JevClient | None,
deck: dict | None) -> Decision: deck: dict | None) -> Decision:
""" """
Bundle choice: pick one of several 3-card bundles. Bundle choice: pick one of several 3-card bundles.
Same trap as `card_select`: `select_bundle` errors with "A bundle preview `select_bundle` errors when a preview is already open. Confirm the preview
is already open - confirm or cancel it first" once a preview is showing. instead. The context waits for evidence after either accepted request;
Confirm instead, and reset with cancel if a repeated state proves the an unchanged read does not prove that confirmation failed.
confirm did not apply.
""" """
global _last_bundle_sig
bs = obs.get("bundle_select") or {} bs = obs.get("bundle_select") or {}
sig = json.dumps(bs, sort_keys=True)
repeated = sig == _last_bundle_sig
_last_bundle_sig = sig
if bs.get("preview_showing") and bs.get("can_confirm"): if bs.get("preview_showing") and bs.get("can_confirm"):
if repeated:
return Decision("cancel_bundle_selection", {},
"bundle preview did not apply; reset", "code")
return Decision("confirm_bundle_selection", {}, "confirm the bundle", "code") return Decision("confirm_bundle_selection", {}, "confirm the bundle", "code")
bundles = [b for b in (bs.get("bundles") or []) if isinstance(b, dict)] bundles = [b for b in (bs.get("bundles") or []) if isinstance(b, dict)]
@ -1507,10 +1453,7 @@ def hand_select_decision(obs: dict, client: JevClient | None,
f"forced selection; gave up {target.get('name')}", "fallback") f"forced selection; gave up {target.get('name')}", "fallback")
_crystal_clicked: set[tuple[int, int]] = set() def crystal_sphere_decision(obs: dict, context: PolicyContext) -> Decision:
def crystal_sphere_decision(obs: dict) -> Decision:
""" """
Crystal Sphere minigame. Crystal Sphere minigame.
@ -1528,7 +1471,7 @@ def crystal_sphere_decision(obs: dict) -> Decision:
# Never re-click a cell: that wastes a divination and can loop. # Never re-click a cell: that wastes a divination and can loop.
fresh = [ fresh = [
c for c in clickable c for c in clickable
if (c.get("x"), c.get("y")) not in _crystal_clicked if (c.get("x"), c.get("y")) not in context.accepted_crystal_cells
] ]
if not fresh: if not fresh:
@ -1543,15 +1486,15 @@ def crystal_sphere_decision(obs: dict) -> Decision:
cx, cy = (width - 1) / 2, (height - 1) / 2 cx, cy = (width - 1) / 2, (height - 1) / 2
cell = min(fresh, key=lambda c: abs(c.get("x", 0) - cx) + abs(c.get("y", 0) - cy)) cell = min(fresh, key=lambda c: abs(c.get("x", 0) - cx) + abs(c.get("y", 0) - cy))
_crystal_clicked.add((cell.get("x"), cell.get("y")))
return Decision("crystal_sphere_click_cell", return Decision("crystal_sphere_click_cell",
{"x": cell.get("x"), "y": cell.get("y")}, {"x": cell.get("x"), "y": cell.get("y")},
f"reveal ({cell.get('x')},{cell.get('y')})", "code") f"reveal ({cell.get('x')},{cell.get('y')})", "code")
def simple_decision(obs: dict, client: JevClient | None = None, def simple_decision(obs: dict, client: JevClient | None = None,
deck: dict | None = None) -> Decision | None: deck: dict | None = None, *, context: PolicyContext | None = None) -> Decision | None:
"""Mechanical screens. Most need no model -- they are pure procedure.""" """Mechanical screens. Most need no model -- they are pure procedure."""
context = context if context is not None else PolicyContext()
st = obs.get("state_type") st = obs.get("state_type")
if st == "menu": if st == "menu":
@ -1576,22 +1519,18 @@ def simple_decision(obs: dict, client: JevClient | None = None,
# #
# Embarking immediately after selecting is also FLAKY -- measured, three # Embarking immediately after selecting is also FLAKY -- measured, three
# consecutive "select a character first" rejections -- because the # consecutive "select a character first" rejections -- because the
# selection has not registered yet. So ALTERNATE: select, embark, # selection has not registered yet. Select after a rejected embark,
# select, embark. A rejected embark is always followed by a fresh # but wait after an accepted embark. Proposals alone do not advance
# select, which makes the sequence self-correcting whatever the timing. # the sequence.
if screen == "character_select": if screen == "character_select":
global _charselect_phase
options = obs.get("options") or [] options = obs.get("options") or []
names = [o if isinstance(o, str) else o.get("name") for o in options] names = [o if isinstance(o, str) else o.get("name") for o in options]
pick = next((c for c in ("IRONCLAD", "SILENT") if c in names), None) pick = next((c for c in ("IRONCLAD", "SILENT") if c in names), None)
if _charselect_phase == 0 and pick is not None: if not context.character_selected and pick is not None:
_charselect_phase = 1
return Decision("menu_select", {"option": pick}, return Decision("menu_select", {"option": pick},
f"select {pick}", "code") f"select {pick}", "code")
_charselect_phase = 0
return Decision("menu_select", {"option": "embark"}, return Decision("menu_select", {"option": "embark"},
"embark (a rejected embark is followed by a re-select)", "embark (a rejected embark is followed by a re-select)",
"code") "code")
@ -1601,7 +1540,7 @@ def simple_decision(obs: dict, client: JevClient | None = None,
return Decision("menu_select", {"option": "main_menu"}, "run ended", "code") return Decision("menu_select", {"option": "main_menu"}, "run ended", "code")
if st == "rewards": if st == "rewards":
return rewards_decision(obs) return rewards_decision(obs, context)
if st == "card_reward": if st == "card_reward":
return card_reward_decision(obs, client, deck) return card_reward_decision(obs, client, deck)
@ -1628,13 +1567,13 @@ def simple_decision(obs: dict, client: JevClient | None = None,
return hand_select_decision(obs, client, deck) return hand_select_decision(obs, client, deck)
if st == "card_select": if st == "card_select":
return card_select_decision(obs, client, deck) return card_select_decision(obs, client, deck, context)
if st == "bundle_select": if st == "bundle_select":
return bundle_select_decision(obs, client, deck) return bundle_select_decision(obs, client, deck)
if st == "crystal_sphere": if st == "crystal_sphere":
return crystal_sphere_decision(obs) return crystal_sphere_decision(obs, context)
# Transitions and unhandled overlays are not dead ends. Wait and look again; # Transitions and unhandled overlays are not dead ends. Wait and look again;
# run.py's unchanged-state guard bounds this so a real dead end still stops. # run.py's unchanged-state guard bounds this so a real dead end still stops.
@ -1644,13 +1583,6 @@ def simple_decision(obs: dict, client: JevClient | None = None,
return None return None
_last_state_type: str | None = None
_last_charselect_sig: str | None = None
_charselect_seen = False
_charselect_phase = 0
# rewards and card_reward are two views of one flow: claiming a card reward # rewards and card_reward are two views of one flow: claiming a card reward
# opens the card screen, and skipping returns to the rewards screen. Treat them # opens the card screen, and skipping returns to the rewards screen. Treat them
# as ONE screen group so per-flow state is not cleared on every hop. # as ONE screen group so per-flow state is not cleared on every hop.
@ -1670,51 +1602,133 @@ def _screen_group(state_type: str | None, menu_screen: str | None = None) -> str
return SCREEN_GROUPS.get(state_type, state_type) return SCREEN_GROUPS.get(state_type, state_type)
def _reset_screen_guards(state_type: str | None, menu_screen: str | None = None) -> None: @dataclass
class PendingAction:
"""An accepted request, not proof that the game completed it."""
decision: Decision
screen: dict
@dataclass
class PolicyContext:
"""Session-owned memory. Proposals never record execution.
Grid toggles and character selection lack observable selection fields in
the mod. Their accepted requests are tracked explicitly, not called facts.
Other guarded actions wait for relevant screen evidence before continuing.
""" """
Clear per-screen module state when the screen changes.
These guards detect "the same screen reappeared unchanged", which is only screen_group: str | None = None
meaningful within a single screen. Left alone they leak across screens and accepted_card_indices: set[int] = field(default_factory=set)
runs, so a fresh shop or card grid gets mistaken for a stalled one. Found accepted_card_grid: list[dict] | None = None
by test_brain.py: a fresh fake-merchant shop was reported as accepted_crystal_cells: set[tuple[int, int]] = field(default_factory=set)
"unchanged after a purchase" because a signature from an earlier case was rewards_skipped_card: bool = False
still set. character_selected: bool = False
pending: PendingAction | None = None
`unknown` and `overlay` are TRANSITIONS, not screens. Resetting on them def observe(self, obs: dict) -> None:
wipes the state mid-flow -- during embark the state flickers through
`unknown`, which used to clear the character-select guard and restart the
select/embark cycle.
"""
global _last_state_type, _last_card_select_sig, _last_bundle_sig
global _last_shop_sig, _last_shop_purchased, _rewards_skipped_card
global _charselect_seen, _charselect_phase
global _card_select_picked, _card_select_confirmed, _card_select_chosen
global _crystal_clicked
if state_type in ("unknown", "overlay"):
return
group = _screen_group(state_type, menu_screen)
if group == _last_state_type:
return
_last_state_type = group
_last_card_select_sig = None
_last_bundle_sig = None
_last_shop_sig = None
_last_shop_purchased = False
_rewards_skipped_card = False
_charselect_seen = False
_charselect_phase = 0
_card_select_picked = False
_card_select_confirmed = False
_card_select_chosen.clear()
_crystal_clicked.clear()
def decide(obs: dict, client: JevClient | None, deck: dict | None = None) -> Decision | None:
_reset_screen_guards(obs.get("state_type"), obs.get("menu_screen"))
st = obs.get("state_type") st = obs.get("state_type")
if st in ("unknown", "overlay"):
return # A transient overlay is not evidence of completion.
group = _screen_group(st, obs.get("menu_screen"))
if group != self.screen_group:
self.screen_group = group
self.accepted_card_indices.clear()
self.accepted_card_grid = None
self.accepted_crystal_cells.clear()
self.rewards_skipped_card = False
self.character_selected = False
self.pending = None
return
pending = self.pending
if pending is None:
return
action, params = pending.decision.action, pending.decision.params
screen = obs.get(st) or {}
if action == "select_card" and pending.screen.get("screen_type") != "choose":
# The API exposes no selected indices. Reserve accepted toggles
# after a fresh read, even when their effect is not visible yet.
if screen.get("cards") != pending.screen.get("cards"):
return # Cannot map a toggle safely onto a changed grid.
self.accepted_card_indices.add(params["index"])
self.accepted_card_grid = pending.screen.get("cards")
elif action == "menu_select" and params.get("option") != "embark":
self.character_selected = True
elif action == "skip_card_reward":
if st != "rewards":
return
self.rewards_skipped_card = True
elif action in ("confirm_selection", "cancel_selection",
"confirm_bundle_selection", "cancel_bundle_selection",
"combat_confirm_selection"):
return # Wait for the screen to close; do not cancel a slow confirm.
elif action == "crystal_sphere_click_cell":
cell = (params["x"], params["y"])
clickable = {(c.get("x"), c.get("y"))
for c in screen.get("clickable_cells", [])}
if cell in clickable and not screen.get("can_proceed"):
return
self.accepted_crystal_cells.add(cell)
elif action == "shop_purchase":
# Gold alone can change for unrelated reasons. Require this item
# to change/disappear, or a screen transition (e.g. card removal).
before = pending.screen.get("shop", pending.screen)
after = screen.get("shop", screen)
index = params["index"]
old = next((i for i in before.get("items", []) if i.get("index") == index), None)
new = next((i for i in after.get("items", []) if i.get("index") == index), None)
def inventory_item(item):
return {k: v for k, v in item.items() if k not in ("can_afford", "price")} if item else None
if inventory_item(old) == inventory_item(new):
return
elif action == "combat_select_card":
if len(screen.get("selected_cards") or []) <= len(pending.screen.get("selected_cards") or []):
return
elif action == "select_bundle":
if not screen.get("preview_showing") or not screen.get("can_confirm"):
return
else:
return # Direct choices and embark require a screen transition.
self.pending = None
def record_result(self, obs: dict, decision: Decision, *, accepted: bool) -> None:
"""Called only after a real action result. Transport errors stop the runner."""
if not accepted:
self.pending = None
if decision.action == "menu_select" and decision.params.get("option") == "embark":
self.character_selected = False
return
st = obs.get("state_type")
guarded = decision.action in {
"select_card", "confirm_selection", "cancel_selection",
"select_bundle", "confirm_bundle_selection", "cancel_bundle_selection",
"shop_purchase", "skip_card_reward", "crystal_sphere_click_cell",
"combat_select_card", "combat_confirm_selection",
} or (st == "menu" and obs.get("menu_screen") == "character_select")
if guarded:
# Detach from mutable fixtures/callers. This is a small screen node,
# not another copy of the entire combat observation.
screen = json.loads(json.dumps(obs.get(st) or {}))
self.pending = PendingAction(decision, screen)
def decide(obs: dict, client: JevClient | None, deck: dict | None = None,
*, context: PolicyContext | None = None) -> Decision | None:
"""Reconcile a fresh observation, then propose one action.
Omit context for independent snapshot analysis. Runners must retain one
context and report action results; repeated proposals alone advance nothing.
"""
context = context if context is not None else PolicyContext()
context.observe(obs)
st = obs.get("state_type")
if context.pending is not None:
return Decision("__wait__", {},
f"awaiting screen evidence after {context.pending.decision.action}", "code")
if (st == "card_select" and context.accepted_card_grid is not None
and (obs.get("card_select") or {}).get("cards") != context.accepted_card_grid):
return Decision("__wait__", {}, "card grid changed; accepted indices cannot be mapped safely", "code")
if st in ("monster", "elite", "boss"): if st in ("monster", "elite", "boss"):
return combat_decision(F.combat_facts(obs), client) return combat_decision(F.combat_facts(obs), client)
return simple_decision(obs, client, deck) return simple_decision(obs, client, deck, context=context)

View file

@ -117,7 +117,8 @@ The audit remains diagnostic, not a pass/fail suite. It also reports known issue
## Next: policy state and recording ## Next: policy state and recording
Still open: Update: [the policy-state pass](13-policy-state.md) addresses item 1 below.
The list records the remaining work at the end of this correctness pass:
1. Selection/shop/minigame memory can change when an action is proposed, before execution succeeds. 1. Selection/shop/minigame memory can change when an action is proposed, before execution succeeds.
2. Captures cover only combat and reuse filenames across sessions. 2. Captures cover only combat and reuse filenames across sessions.

View file

@ -0,0 +1,113 @@
# Session-owned policy state
## Scope
This pass replaces per-screen globals with explicit session memory. It follows
[the correctness pass](12-correctness-pass.md). It does not redesign recording,
identify runs, or establish persistent-deck provenance.
`brain.py` remains one file. Moving policy code and changing its state behavior
at the same time would make failures harder to diagnose. A later extraction
can preserve the interface and the runner-level tests.
## Lifecycle
1. `run.main()` creates one `brain.PolicyContext` for the session.
2. `brain.decide(..., context=context)` reconciles the fresh observation.
3. The policy returns a `Decision`, which is only a proposal.
4. The runner executes the action unless this is a dry-run or a suppressed duplicate.
5. The runner calls `context.record_result(..., accepted=result.ok)`.
6. The next observation supplies evidence before further guarded actions.
A rejected action does not reserve a card index, consume a crystal cell, or
mark a reward as skipped. A rejected embark clears the accepted character
selection, so the next proposal selects the character again.
Transport errors stop the runner. They can leave the game's action outcome
unknown. The runner does not retry those POSTs automatically.
Calling `decide` without a context performs independent snapshot analysis.
Sequential callers must retain a context and report real action results.
Direct screen handlers are policy helpers, not a replacement for this lifecycle.
## Evidence and limits
An accepted response does not prove completion. `PendingAction` represents an
accepted request awaiting reconciliation, not a successful game effect.
| Action | Condition for further progress |
| --- | --- |
| Grid toggle | A fresh read of the same grid reserves the accepted index. Selection itself remains unverified. |
| Direct choose-card screen | A screen-flow transition. |
| Hand selection | The observed selected-card count increases, or the screen changes. Selected-list indices use a separate index space. |
| Grid, hand, or bundle confirmation/cancellation | A screen-flow transition. No automatic repeated confirm or speculative cancel. |
| Bundle selection | An observable preview with confirmation enabled, or a screen transition. |
| Purchase | The targeted inventory item changes or disappears, or the screen changes. Price and affordability changes alone do not release the guard. |
| Crystal click | The cell is no longer clickable, progression unlocks, or the screen changes. |
| Character selection | A fresh read releases the accepted request. The mod does not expose the selected character. |
| Embark | A screen transition. Rejection instead allows re-selection. |
| Card-reward skip | The reward list reappears before the policy marks the reward as skipped. |
### Missing selection information
The vendored mod's `BuildCardSelectState` exposes grid cards, preview state,
and confirmation availability. It does not expose all selected card indices.
`ExecuteSelectCard` reports that it emitted a toggle signal, not that the
selection is complete.
The policy therefore stores **accepted toggles**, not confirmed selections.
This permits multi-select screens whose observation remains unchanged between
selections. It excludes accepted indices from later proposals in the same flow.
If the grid changes while accepted indices remain in use, the policy waits
rather than mapping old indices onto the changed grid.
This is a limited inference, not a full game-state model. A lost accepted toggle
can stall the flow. Restarting the bot on a partially selected grid also loses
the acceptance ledger. Reliable recovery needs selected-card identities from
the mod, or an explicit operator reset. This pass adds neither.
### Screen boundaries and waiting
- `rewards` and `card_reward` share one flow, so skipping survives the return to rewards.
- Menu screen names distinguish character selection from the main menu.
- `unknown` and `overlay` do not reset memory or prove completion.
- Other observed flow changes clear screen memory and pending requests.
- An unchanged screen does not prove an action failed. The policy waits instead of cancelling a slow confirmation.
- The existing stuck limit bounds unchanged observations. A separate pending-action deadline uses the same `--stuck-seconds` limit, even when unrelated fields change.
- A bounded session can still end at its step limit with an unresolved action. Exit zero for that mode does not claim action completion.
Same-type screen replacements are not uniquely identified. There is no run or
screen-instance identifier in the context yet. Recovery is conservative: an
ambiguous flow can stop rather than risk another purchase or toggle.
## Validation
No live game connection, paid model calls, new dependencies, or new test files.
- Existing scripts: 68 facts, 110 policy, and 84 runner assertions passed; 262 total.
- Runner flows cover rejected and delayed selections, confirmation, purchases,
bundles, crystal clicks, character selection, reward skipping, session
isolation, dry-run, and pending-action timeout under unrelated state changes.
- Old tests that advanced policy memory by merely proposing actions were removed.
The combined policy/runner test code is smaller than before this pass.
- Thirteen temporary whole-process scenarios passed with real HTTP clients and
loopback fixture servers. These include the eight prior correctness scenarios
and five selection, dry-run, purchase, bundle, and crystal flows.
- The offline audit replayed 346 stored observations without exceptions.
Repeated proposals with one retained context no longer consume a selection.
- Dataset integrity: 37 runs, 1,052 decisions, and 346 observations passed.
- Shell syntax checks passed.
Temporary probes are not part of the repository. The permanent state regressions
use the actual runner and policy together, with isolated game/model boundaries.
## Next boundaries
The state interface now gives a stable boundary for extracting combat and
selection policy without changing execution behavior. Keep a small dispatcher;
no plugin framework or class hierarchy is needed.
The next state work is run identity and deck provenance. Recording should then
link observations, proposals, action attempts, results, and reconciliation.
Session finalization, capture-name collisions, and actual policy-gate metadata
remain separate work.

25
run.py
View file

@ -365,6 +365,9 @@ def main() -> int:
duplicate_waits = 0 duplicate_waits = 0
jev_errors = 0 jev_errors = 0
saw_a_run = False saw_a_run = False
policy_context = brain.PolicyContext()
pending_action = None
pending_since = time.monotonic()
for step in range(1, args.steps + 1): for step in range(1, args.steps + 1):
try: try:
@ -428,9 +431,9 @@ def main() -> int:
print(json.dumps(obs, indent=2)[:900]) print(json.dumps(obs, indent=2)[:900])
return finish(1, "stuck") return finish(1, "stuck")
# Only wait when our own action actually landed and the game is still # Acceptance does not prove completion. The policy context handles
# animating. If the action was rejected, fall through and pick a # pending selections and purchases. Rejections allow another proposal
# different one instead of waiting out the stuck counter. # instead of waiting out the stuck counter.
# #
# NOTE: this check now happens AFTER deciding, and only suppresses a # NOTE: this check now happens AFTER deciding, and only suppresses a
# REPEATED action. Waiting on "state unchanged" alone blocked # REPEATED action. Waiting on "state unchanged" alone blocked
@ -458,7 +461,7 @@ def main() -> int:
# must not inherit the previous step's answers in the log. # must not inherit the previous step's answers in the log.
client.last = None client.last = None
try: try:
decision = brain.decide(obs, client, deck_snapshot) decision = brain.decide(obs, client, deck_snapshot, context=policy_context)
# A procedural action or animation wait does not establish model # A procedural action or animation wait does not establish model
# recovery. Only a successful model response resets the budget. # recovery. Only a successful model response resets the budget.
if client is not None and client.last is not None: if client is not None and client.last is not None:
@ -478,7 +481,7 @@ def main() -> int:
# the same failing request -- measured, a DNS blip re-raised out of # the same failing request -- measured, a DNS blip re-raised out of
# the "fallback" and killed the session. # the "fallback" and killed the session.
try: try:
decision = brain.decide(obs, None, deck_snapshot) decision = brain.decide(obs, None, deck_snapshot, context=policy_context)
except Exception as inner: # noqa: BLE001 except Exception as inner: # noqa: BLE001
trace({"step": step, "event": "fallback_error", "error": str(inner)[:160]}) trace({"step": step, "event": "fallback_error", "error": str(inner)[:160]})
print(f"[{step:03d}] fallback also failed: {inner}") print(f"[{step:03d}] fallback also failed: {inner}")
@ -499,6 +502,13 @@ def main() -> int:
print(json.dumps(obs, indent=2)[:800]) print(json.dumps(obs, indent=2)[:800])
return finish(1, "no_decision") return finish(1, "no_decision")
# Bound unresolved actions even if unrelated observation fields change.
if policy_context.pending is not pending_action:
pending_action = policy_context.pending
pending_since = time.monotonic()
if pending_action is not None and time.monotonic() - pending_since > args.stuck_seconds:
return finish(1, "pending_action_timeout")
# Between turns there is nothing to do but look again. # Between turns there is nothing to do but look again.
if decision.action == "__wait__": if decision.action == "__wait__":
waits += 1 waits += 1
@ -508,8 +518,8 @@ def main() -> int:
# Suppress DUPLICATE actions on an unchanged state, but only for a # Suppress DUPLICATE actions on an unchanged state, but only for a
# bounded number of reads. Proposing a DIFFERENT action is always # bounded number of reads. Proposing a DIFFERENT action is always
# allowed, which is what makes select-then-embark and multi-purchase # allowed here, but cannot bypass a pending policy action above.
# shops work. The bound matters because some actions legitimately need # The bound matters because some actions legitimately need
# repeating (multi-line Ancient dialogue) and some transitions are just # repeating (multi-line Ancient dialogue) and some transitions are just
# slow -- waiting forever on those stalls the run. # slow -- waiting forever on those stalls the run.
action_key = (decision.action, json.dumps(decision.params, sort_keys=True)) action_key = (decision.action, json.dumps(decision.params, sort_keys=True))
@ -543,6 +553,7 @@ def main() -> int:
"action": decision.action, "error": str(exc)[:200]}) "action": decision.action, "error": str(exc)[:200]})
return finish(1, "action_error") return finish(1, "action_error")
policy_context.record_result(obs, decision, accepted=result.ok)
if not result.ok: if not result.ok:
print(f"[{step:03d}] action rejected: {result.message}") print(f"[{step:03d}] action rejected: {result.message}")
trace({"step": step, "event": "action_rejected", trace({"step": step, "event": "action_rejected",

View file

@ -114,20 +114,6 @@ def combat(state_type="monster", hand=None, enemies=None, **pkw) -> dict:
} }
_reset_counter = 0
def force_reset() -> None:
"""Force a screen-group change so every per-screen guard is cleared.
`_reset_screen_guards` only resets on a GROUP CHANGE, so calling it with
the same group twice is a deliberate no-op. Tests need a guaranteed reset.
"""
global _reset_counter
_reset_counter += 1
brain._reset_screen_guards(f"__test{_reset_counter}__")
print("=== 1. every state_type produces a LEGAL action ===") print("=== 1. every state_type produces a LEGAL action ===")
cases: list[tuple[str, dict]] = [ cases: list[tuple[str, dict]] = [
@ -391,7 +377,6 @@ check("fake_merchant reports nothing affordable",
# A skipped card reward is NOT consumed by the game. Without the guard the bot # A skipped card reward is NOT consumed by the game. Without the guard the bot
# loops claim_reward -> card screen -> skip -> claim_reward forever. # loops claim_reward -> card screen -> skip -> claim_reward forever.
force_reset()
rewards_with_card = { rewards_with_card = {
"state_type": "rewards", "state_type": "rewards",
"rewards": {"items": [{"index": 0, "type": "gold", "description": "18 Gold", "rewards": {"items": [{"index": 0, "type": "gold", "description": "18 Gold",
@ -405,31 +390,6 @@ rewards_with_card = {
d = brain.decide(rewards_with_card, None) d = brain.decide(rewards_with_card, None)
check("rewards claims the last item first", d.params.get("index"), 1) check("rewards claims the last item first", d.params.get("index"), 1)
brain._rewards_skipped_card = True
d = brain.decide(rewards_with_card, None)
check("a skipped card reward is not re-claimed", d.action, "claim_reward")
check("...and the gold is taken instead", d.params.get("index"), 0)
brain._rewards_skipped_card = True
d = brain.decide(
{"state_type": "rewards",
"rewards": {"items": [{"index": 0, "type": "card",
"description": "Add a card to your deck."}],
"can_proceed": True},
"run": {"act": 1, "floor": 2}, "player": player()},
None,
)
check("rewards with only a skipped card proceeds", d.action, "proceed")
# rewards -> card_reward must stay in ONE screen group, or the skip flag is
# cleared on every hop and the loop returns.
brain._reset_screen_guards("rewards")
brain._rewards_skipped_card = True
brain._reset_screen_guards("card_reward")
check("skip flag survives rewards <-> card_reward", brain._rewards_skipped_card, True)
brain._reset_screen_guards("monster")
check("skip flag clears when leaving the flow", brain._rewards_skipped_card, False)
class StubClient: class StubClient:
""" """
Deterministic stand-in for JevClient so the model paths can be tested Deterministic stand-in for JevClient so the model paths can be tested
@ -487,7 +447,6 @@ valid_indices = {OFFSET, OFFSET + 1, OFFSET + 2}
stub = StubClient() stub = StubClient()
# card_select (upgrade) # card_select (upgrade)
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "card_select", {"state_type": "card_select",
"card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.", "card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.",
@ -504,7 +463,6 @@ check("...and picks by identity, not position",
# card_reward with the stub. `skip_all` is answered NO so the take path is # card_reward with the stub. `skip_all` is answered NO so the take path is
# exercised; otherwise a stub that says yes to everything always skips. # exercised; otherwise a stub that says yes to everything always skips.
take_stub = StubClient(noul_override={"skip_all": 0.10}) take_stub = StubClient(noul_override={"skip_all": 0.10})
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "card_reward", {"state_type": "card_reward",
"card_reward": {"cards": [offset_card("Common A", 0), "card_reward": {"cards": [offset_card("Common A", 0),
@ -517,7 +475,6 @@ check("card_reward emits the card's own index", d.params.get("card_index") in va
# And with `skip_all` answered YES, Jev's skip is honoured. # And with `skip_all` answered YES, Jev's skip is honoured.
skip_stub = StubClient(noul_override={"skip_all": 0.95}) skip_stub = StubClient(noul_override={"skip_all": 0.95})
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "card_reward", {"state_type": "card_reward",
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True}, "card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
@ -529,7 +486,6 @@ check("...and the reason cites jev", "jev:" in d.reason, True)
# An UNCERTAIN skip answer must not skip -- take the best card instead. # An UNCERTAIN skip answer must not skip -- take the best card instead.
unsure_stub = StubClient(noul_override={"skip_all": 0.55}) unsure_stub = StubClient(noul_override={"skip_all": 0.55})
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "card_reward", {"state_type": "card_reward",
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True}, "card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
@ -539,7 +495,6 @@ d = brain.decide(
check("an uncertain skip answer takes a card", d.action, "select_card_reward") check("an uncertain skip answer takes a card", d.action, "select_card_reward")
# `can_skip: false` must take a card even if Jev would skip. # `can_skip: false` must take a card even if Jev would skip.
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "card_reward", {"state_type": "card_reward",
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": False}, "card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": False},
@ -549,7 +504,6 @@ d = brain.decide(
check("cannot skip -> takes a card anyway", d.action, "select_card_reward") check("cannot skip -> takes a card anyway", d.action, "select_card_reward")
# hand_select (in-combat selectable subset) # hand_select (in-combat selectable subset)
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "hand_select", {"state_type": "hand_select",
"hand_select": {"mode": "simple_select", "prompt": "Choose cards to exhaust.", "hand_select": {"mode": "simple_select", "prompt": "Choose cards to exhaust.",
@ -562,7 +516,6 @@ check("hand_select emits the selectable card's own index",
d.params.get("card_index"), OFFSET + 1) d.params.get("card_index"), OFFSET + 1)
# rewards # rewards
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "rewards", {"state_type": "rewards",
"rewards": {"items": [{"index": OFFSET, "type": "gold", "description": "1 Gold"}, "rewards": {"items": [{"index": OFFSET, "type": "gold", "description": "1 Gold"},
@ -574,7 +527,6 @@ d = brain.decide(
check("rewards emits the item's own index", d.params.get("index"), OFFSET + 1) check("rewards emits the item's own index", d.params.get("index"), OFFSET + 1)
# shop # shop
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "shop", {"state_type": "shop",
"shop": {"items": [{"index": OFFSET, "category": "relic", "price": 10, "shop": {"items": [{"index": OFFSET, "category": "relic", "price": 10,
@ -590,7 +542,6 @@ d = brain.decide(
check("shop emits the item's own index", d.params.get("index") in valid_indices, True) check("shop emits the item's own index", d.params.get("index") in valid_indices, True)
# map # map
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "map", {"state_type": "map",
"map": {"next_options": [{"index": OFFSET, "col": 0, "row": 1, "type": "Monster"}, "map": {"next_options": [{"index": OFFSET, "col": 0, "row": 1, "type": "Monster"},
@ -602,7 +553,6 @@ d = brain.decide(
check("map emits the node's own index", d.params.get("index") in valid_indices, True) check("map emits the node's own index", d.params.get("index") in valid_indices, True)
# bundle_select # bundle_select
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "bundle_select", {"state_type": "bundle_select",
"bundle_select": {"screen_type": "bundle", "prompt": "Choose a bundle.", "bundle_select": {"screen_type": "bundle", "prompt": "Choose a bundle.",
@ -615,7 +565,6 @@ d = brain.decide(
check("bundle_select emits the bundle's own index", d.params.get("index") in valid_indices, True) check("bundle_select emits the bundle's own index", d.params.get("index") in valid_indices, True)
# relic_select # relic_select
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "relic_select", {"state_type": "relic_select",
"relic_select": {"prompt": "Choose a relic.", "relic_select": {"prompt": "Choose a relic.",
@ -631,7 +580,6 @@ check("relic_select emits the relic's own index", d.params.get("index") in valid
# A state whose list positions are ALL zero-ish while the real indices are high: # A state whose list positions are ALL zero-ish while the real indices are high:
# any position-based code returns 0, which is not a valid index here. # any position-based code returns 0, which is not a valid index here.
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "card_select", {"state_type": "card_select",
"card_select": {"screen_type": "remove", "prompt": "Choose a card to Remove.", "card_select": {"screen_type": "remove", "prompt": "Choose a card to Remove.",
@ -644,66 +592,6 @@ check("a position-based pick of 0 would be invalid here",
d.params.get("index") != 0, True) d.params.get("index") != 0, True)
print() print()
print("=== 5. character select needs a select-then-embark sequence ===")
# The state carries NO "selected" indicator -- the mod hardcodes `message` to
# "Select a character." whatever is chosen. So the handler selects once and
# embarks when the screen comes back unchanged. Without this the bot selects
# the already-selected character forever and stalls.
char_select_state = {
"state_type": "menu", "menu_screen": "character_select",
"message": "Select a character.",
"options": [{"name": "IRONCLAD", "enabled": True},
{"name": "SILENT", "enabled": True},
{"name": "confirm", "enabled": True},
{"name": "embark", "enabled": True},
{"name": "back", "enabled": True}],
}
force_reset()
d1 = brain.decide(char_select_state, None)
check("character_select first selects a character", d1.params.get("option"), "IRONCLAD")
# Same screen unchanged -> a character is already selected -> embark.
d2 = brain.decide(char_select_state, None)
check("character_select then embarks", d2.params.get("option"), "embark")
# The sequence ALTERNATES, which is what makes it self-correcting: a rejected
# embark ("select a character first") is always followed by a fresh select.
d2b = brain.decide(char_select_state, None)
check("a third call re-selects", d2b.params.get("option"), "IRONCLAD")
d2c = brain.decide(char_select_state, None)
check("and then embarks again", d2c.params.get("option"), "embark")
# A changing signature must not derail the alternation.
changed = dict(char_select_state)
changed["characters"] = [{"name": "The Ironclad", "id": "IRONCLAD", "locked": False}]
d2d = brain.decide(changed, None)
check("a changing signature does not derail the cycle",
d2d.params.get("option"), "IRONCLAD")
# Both actions are legal for this screen.
check("select is legal", legal(char_select_state, d1), True)
check("embark is legal", legal(char_select_state, d2), True)
# Leaving the screen must clear the guard, or the next run embarks immediately
# without selecting anything.
brain._reset_screen_guards("menu", "main")
d3 = brain.decide(char_select_state, None)
check("guard resets when leaving character select", d3.params.get("option"), "IRONCLAD")
# The alternation must also reset, or a fresh run embarks without selecting.
brain._reset_screen_guards("menu", "main")
d3b = brain.decide(char_select_state, None)
check("alternation resets too", d3b.params.get("option"), "IRONCLAD")
# menu_screen must be part of the screen key: main menu and character select are
# both state_type "menu" but have different valid actions.
brain._reset_screen_guards("menu", "main")
check("main menu and character select are different screens",
brain._screen_group("menu", "main") != brain._screen_group("menu", "character_select"),
True)
# combat_select_card TOGGLES: re-selecting an already-chosen card deselects it, # combat_select_card TOGGLES: re-selecting an already-chosen card deselects it,
# so the state never changes and the loop stalls. `selected_cards` is the # so the state never changes and the loop stalls. `selected_cards` is the
# authoritative list of what is already chosen. # authoritative list of what is already chosen.
@ -754,18 +642,15 @@ check("upgrade_select confirms instead of selecting", d.action, "combat_confirm_
# No enemies but combat still in progress (Phrog Parasite spawning the next # No enemies but combat still in progress (Phrog Parasite spawning the next
# wave). Measured live: the bot emitted play_card(card_index=1) with no target # wave). Measured live: the bot emitted play_card(card_index=1) with no target
# and the game rejected it with "Card requires a target". # and the game rejected it with "Card requires a target".
force_reset()
obs_no_enemies = combat(enemies=[]) obs_no_enemies = combat(enemies=[])
d = brain.decide(obs_no_enemies, None) d = brain.decide(obs_no_enemies, None)
check("no enemies -> wait, do not play", d.action, "__wait__") check("no enemies -> wait, do not play", d.action, "__wait__")
# A target-requiring card must never be emitted without a target. # A target-requiring card must never be emitted without a target.
force_reset()
obs_one_enemy = combat(hand=[card("Strike", 0)], enemies=[enemy()]) obs_one_enemy = combat(hand=[card("Strike", 0)], enemies=[enemy()])
d = brain.decide(obs_one_enemy, None) d = brain.decide(obs_one_enemy, None)
check("a single enemy is auto-targeted", d.params.get("target"), "NIBBIT_0") check("a single enemy is auto-targeted", d.params.get("target"), "NIBBIT_0")
force_reset()
d = brain.decide(obs_no_enemies, StubClient()) d = brain.decide(obs_no_enemies, StubClient())
check("even with jev, no enemies means wait", d.action, "__wait__") check("even with jev, no enemies means wait", d.action, "__wait__")
@ -777,28 +662,6 @@ check("raw class name normalises to upgrade",
check("friendly names still work", check("friendly names still work",
(brain.screen_kind("remove"), brain.screen_kind("transform")), ("remove", "transform")) (brain.screen_kind("remove"), brain.screen_kind("transform")), ("remove", "transform"))
enchant_state = {
"state_type": "card_select",
"card_select": {"screen_type": "NDeckEnchantSelectScreen",
"prompt": "Choose a card to Enchant.",
"cards": [offset_card("Strike", 0), offset_card("Perfected Strike", 1)],
"preview_showing": False,
"can_cancel": False, "can_confirm": True},
"run": {"act": 1, "floor": 13}, "player": player(),
}
force_reset()
d1 = brain.decide(enchant_state, None)
check("enchant screen selects first", d1.action, "select_card")
d2 = brain.decide(enchant_state, None)
check("enchant screen then confirms", d2.action, "confirm_selection")
# The full flow must terminate: select, confirm, done.
force_reset()
seq = [brain.decide(enchant_state, None).action, brain.decide(enchant_state, None).action]
check("enchant flow terminates", seq, ["select_card", "confirm_selection"])
# MULTI-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all # MULTI-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all
# five are picked, and card_select has no `selected_cards` field. Measured live: # five are picked, and card_select has no `selected_cards` field. Measured live:
# the handler re-selected the same index forever and stalled. # the handler re-selected the same index forever and stalled.
@ -817,57 +680,8 @@ check("an ADD screen is not an upgrade screen",
check("a REMOVE prompt beats a generic screen_type", check("a REMOVE prompt beats a generic screen_type",
brain.screen_kind("select", "Choose 5 cards to Remove."), "remove") brain.screen_kind("select", "Choose 5 cards to Remove."), "remove")
multi_state = {
"state_type": "card_select",
"card_select": {"screen_type": "select", "prompt": "Choose 5 cards to Remove.",
"cards": [offset_card(f"Card{i}", i) for i in range(8)],
"preview_showing": False,
"can_cancel": False, "can_confirm": False},
"run": {"act": 2, "floor": 18}, "player": player(),
}
force_reset()
seq = [brain.decide(multi_state, None).action for _ in range(6)]
check("multi-select picks 5 then waits",
seq, ["select_card"] * 5 + ["__wait__"])
# An ADD screen: 2 cards, then the game closes the screen by itself (no confirm).
force_reset()
add_state = {
"state_type": "card_select",
"card_select": {"screen_type": "simple_select",
"prompt": "Choose 2 Common Cards to Add to Your Deck.",
"cards": [offset_card(f"Card{i}", i) for i in range(8)],
"preview_showing": False,
"can_cancel": False, "can_confirm": False},
"run": {"act": 1, "floor": 5}, "player": player(),
}
add_seq = [brain.decide(add_state, None).action for _ in range(4)]
check("an ADD screen picks exactly 2 then waits",
add_seq, ["select_card", "select_card", "__wait__", "__wait__"])
# Distinct picks need a fresh screen, or the picks continue from above.
force_reset()
add_idxs = [brain.decide(add_state, None).params.get("index") for _ in range(2)]
check("ADD picks are distinct", len(set(add_idxs)), 2)
# Every pick must be a DIFFERENT index, or select_card toggles it back off.
force_reset()
idxs = [brain.decide(multi_state, None).params.get("index") for _ in range(5)]
check("each pick targets a different index", len(set(idxs)), 5)
# Once can_confirm turns true, it confirms.
force_reset()
for _ in range(5):
brain.decide(multi_state, None)
confirmable = dict(multi_state)
confirmable["card_select"] = dict(multi_state["card_select"], can_confirm=True)
d = brain.decide(confirmable, None)
check("multi-select confirms once the count is met", d.action, "confirm_selection")
# Crystal Sphere: can_proceed is FALSE until tiles are revealed. The old # Crystal Sphere: can_proceed is FALSE until tiles are revealed. The old
# unconditional crystal_sphere_proceed was rejected and stalled the run. # unconditional crystal_sphere_proceed was rejected and stalled the run.
force_reset()
cs_state = { cs_state = {
"state_type": "crystal_sphere", "state_type": "crystal_sphere",
"crystal_sphere": {"grid_width": 11, "grid_height": 11, "crystal_sphere": {"grid_width": 11, "grid_height": 11,
@ -880,13 +694,7 @@ d = brain.decide(cs_state, None)
check("crystal sphere reveals a cell first", d.action, "crystal_sphere_click_cell") check("crystal sphere reveals a cell first", d.action, "crystal_sphere_click_cell")
check("...starting from the centre", (d.params.get("x"), d.params.get("y")), (5, 5)) check("...starting from the centre", (d.params.get("x"), d.params.get("y")), (5, 5))
# It must not re-click the same cell.
d2 = brain.decide(cs_state, None)
check("crystal sphere does not repeat a cell",
(d2.params.get("x"), d2.params.get("y")) != (d.params.get("x"), d.params.get("y")), True)
# Once can_proceed unlocks, it proceeds. # Once can_proceed unlocks, it proceeds.
force_reset()
cs_done = dict(cs_state) cs_done = dict(cs_state)
cs_done["crystal_sphere"] = dict(cs_state["crystal_sphere"], can_proceed=True) cs_done["crystal_sphere"] = dict(cs_state["crystal_sphere"], can_proceed=True)
d3 = brain.decide(cs_done, None) d3 = brain.decide(cs_done, None)
@ -899,7 +707,6 @@ check("a singular prompt needs 1", brain.hand_select_need("Choose a card to Exha
check("a counted prompt needs N", brain.hand_select_need("Choose 2 cards to discard."), 2) check("a counted prompt needs N", brain.hand_select_need("Choose 2 cards to discard."), 2)
check("\"any number\" is unbounded", brain.hand_select_need("Choose any number of cards to replace."), None) check("\"any number\" is unbounded", brain.hand_select_need("Choose any number of cards to replace."), None)
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "hand_select", {"state_type": "hand_select",
"hand_select": {"mode": "simple_select", "prompt": "Choose a card to Exhaust.", "hand_select": {"mode": "simple_select", "prompt": "Choose a card to Exhaust.",
@ -911,7 +718,6 @@ d = brain.decide(
check("a satisfied singular prompt confirms", d.action, "combat_confirm_selection") check("a satisfied singular prompt confirms", d.action, "combat_confirm_selection")
# An unsatisfied counted prompt keeps selecting. # An unsatisfied counted prompt keeps selecting.
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "hand_select", {"state_type": "hand_select",
"hand_select": {"mode": "simple_select", "prompt": "Choose 2 cards to discard.", "hand_select": {"mode": "simple_select", "prompt": "Choose 2 cards to discard.",
@ -929,7 +735,6 @@ try:
weak_stub = StubClient(noul=0.50, noul_override={"skip_all": 0.10}) weak_stub = StubClient(noul=0.50, noul_override={"skip_all": 0.10})
brain.CARD_SKIP_POLICY = "jev" brain.CARD_SKIP_POLICY = "jev"
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "card_reward", {"state_type": "card_reward",
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True}, "card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
@ -939,7 +744,6 @@ try:
check("jev policy takes a weak card", d.action, "select_card_reward") check("jev policy takes a weak card", d.action, "select_card_reward")
brain.CARD_SKIP_POLICY = "combined" brain.CARD_SKIP_POLICY = "combined"
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "card_reward", {"state_type": "card_reward",
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True}, "card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
@ -953,7 +757,6 @@ try:
strong_stub = StubClient(noul=0.80, noul_override={"skip_all": 0.10}) strong_stub = StubClient(noul=0.80, noul_override={"skip_all": 0.10})
for policy in ("jev", "combined"): for policy in ("jev", "combined"):
brain.CARD_SKIP_POLICY = policy brain.CARD_SKIP_POLICY = policy
force_reset()
d = brain.decide( d = brain.decide(
{"state_type": "card_reward", {"state_type": "card_reward",
"card_reward": {"cards": [offset_card("Strong", 0)], "can_skip": True}, "card_reward": {"cards": [offset_card("Strong", 0)], "can_skip": True},

View file

@ -108,13 +108,14 @@ class FakeSts2:
self.actions.append((a, k)) self.actions.append((a, k))
if self.action_error: if self.action_error:
raise self.action_error raise self.action_error
return types.SimpleNamespace(ok=self.action_ok, message="rejected" if not self.action_ok else "") ok = (self.action_ok[min(len(self.actions) - 1, len(self.action_ok) - 1)]
if isinstance(self.action_ok, list) else self.action_ok)
return types.SimpleNamespace(ok=ok, message="rejected" if not ok else "")
def invoke(fake, *flags, client=None, clock=None, decide=None): def invoke(fake, *flags, client=None, clock=None, decide=None):
"""Run the real loop with isolated files, no delays, and no live services.""" """Run the real loop with isolated files, no delays, and no live services."""
callbacks = [] callbacks = []
brain._reset_screen_guards("test-run-reset")
with tempfile.TemporaryDirectory() as directory, contextlib.ExitStack() as stack: with tempfile.TemporaryDirectory() as directory, contextlib.ExitStack() as stack:
capdir = pathlib.Path(directory) / "capture" capdir = pathlib.Path(directory) / "capture"
stack.enter_context(patch.object(run, "sts2", fake)) stack.enter_context(patch.object(run, "sts2", fake))
@ -291,6 +292,104 @@ result = invoke(FakeSts2([menu, combat]), "--max-jev-errors", "2", client=client
check("successful model call resets the failure budget", client.calls, 4) check("successful model call resets the failure budget", client.calls, 4)
check("two failures after recovery abort", result.rc, 3) check("two failures after recovery abort", result.rc, 3)
print()
print("=== 8. policy memory follows action results and fresh observations ===")
# Exercise the real policy and runner together. No global reset between sessions.
grid = {"state_type": "card_select", "card_select": {
"screen_type": "select", "prompt": "Choose 2 cards to Remove.",
"cards": [{"index": 0, "name": "Strike"}, {"index": 1, "name": "Defend"}],
"can_confirm": False, "preview_showing": False}}
confirmable = dict(grid, card_select=dict(grid["card_select"], can_confirm=True))
# One rejected toggle, two accepted toggles, a delayed confirm, then transition.
fake = FakeSts2([menu, grid, grid, grid, confirmable, confirmable,
{"state_type": "overlay"}, confirmable, menu],
action_ok=[False, True])
result = invoke(fake, "--no-jev", "--steps", "8", "--max-duplicate-waits", "0")
check("selection flow completes", result.rc, 0)
check("rejected selection is retried; accepted indices are not toggled twice",
fake.actions, [(("select_card",), {"index": 0}),
(("select_card",), {"index": 0}),
(("select_card",), {"index": 1}),
(("confirm_selection",), {}),
(("menu_select",), {"option": "singleplayer"})])
changed_grid = dict(grid, card_select=dict(grid["card_select"],
cards=[{"index": 0, "name": "Defend"}, {"index": 1, "name": "Strike"}]))
fake = FakeSts2([menu, grid, changed_grid])
result = invoke(fake, "--no-jev", "--steps", "2", "--max-duplicate-waits", "0")
check("changed grid does not reuse the old selection indices", len(fake.actions), 1)
for session in range(2):
fake = FakeSts2([menu, grid])
result = invoke(fake, "--no-jev", "--steps", "1")
check(f"session {session + 1} starts with fresh selection memory",
fake.actions, [(("select_card",), {"index": 0})])
fake = FakeSts2([menu, grid])
result = invoke(fake, "--no-jev", "--steps", "3", "--dry-run", "--max-duplicate-waits", "0")
check("dry-run sends no selection actions", fake.actions, [])
check("dry-run proposals never advance selection memory",
[r["params"] for r in result.rows if r["event"] == "decide"], [{"index": 0}] * 3)
shop = {"state_type": "shop", "shop": {"items": [
{"index": 0, "category": "card", "card_name": "Inflame",
"card_description": "Gain 2 Strength.", "price": 50,
"is_stocked": True, "can_afford": True}]}}
sold = {"state_type": "shop", "shop": {"items": []}}
affordability_only = dict(shop, shop={"items": [dict(shop["shop"]["items"][0], can_afford=False)]})
fake = FakeSts2([menu, shop, shop, affordability_only, sold])
result = invoke(fake, "--steps", "4", "--max-duplicate-waits", "0")
check("purchase waits through unchanged inventory and unrelated state changes",
fake.actions, [(("shop_purchase",), {"index": 0}), (("proceed",), {})])
bundle = {"state_type": "bundle_select", "bundle_select": {
"bundles": [{"index": 2, "cards": []}], "preview_showing": False}}
preview = dict(bundle, bundle_select=dict(bundle["bundle_select"],
preview_showing=True, can_confirm=True))
fake = FakeSts2([menu, bundle, bundle, preview, preview, menu])
result = invoke(fake, "--no-jev", "--steps", "5", "--max-duplicate-waits", "0")
check("bundle waits for preview and never cancels a delayed confirmation",
[a[0][0] for a in fake.actions], ["select_bundle", "confirm_bundle_selection", "menu_select"])
crystal = {"state_type": "crystal_sphere", "crystal_sphere": {
"grid_width": 3, "grid_height": 3, "clickable_cells": [{"x": 1, "y": 1}],
"can_proceed": False}}
revealed = dict(crystal, crystal_sphere=dict(crystal["crystal_sphere"], can_proceed=True))
fake = FakeSts2([menu, crystal, crystal, crystal, revealed], action_ok=[False, True])
result = invoke(fake, "--no-jev", "--steps", "4", "--max-duplicate-waits", "0")
check("rejected crystal click stays available; accepted click waits for evidence",
[a[0][0] for a in fake.actions],
["crystal_sphere_click_cell", "crystal_sphere_click_cell", "crystal_sphere_proceed"])
characters = {"state_type": "menu", "menu_screen": "character_select", "options": ["IRONCLAD", "embark"]}
fake = FakeSts2([menu, characters, characters, characters, characters, characters,
{"state_type": "unknown"}, characters, menu],
action_ok=[True, False, True])
result = invoke(fake, "--no-jev", "--steps", "8", "--max-duplicate-waits", "0")
check("rejected embark re-selects; accepted embark waits through transitions",
[a[1].get("option") for a in fake.actions],
["IRONCLAD", "embark", "IRONCLAD", "embark", "singleplayer"])
rewards = {"state_type": "rewards", "rewards": {"items": [{"index": 0, "type": "card"}]}}
fake = FakeSts2([menu, card_reward, card_reward, rewards, menu, rewards], action_ok=[False, True])
result = invoke(fake, "--steps", "5", "--max-duplicate-waits", "0")
check("skip memory follows acceptance and clears outside the reward flow",
[a[0][0] for a in fake.actions],
["skip_card_reward", "skip_card_reward", "proceed", "menu_select", "claim_reward"])
hand = {"state_type": "hand_select", "hand_select": {
"mode": "simple_select", "prompt": "Choose a card to Exhaust.",
"cards": [{"index": 2, "name": "Strike"}], "can_confirm": False}}
selected_hand = dict(hand, hand_select=dict(hand["hand_select"], can_confirm=True,
selected_cards=[{"index": 0, "name": "Strike"}]))
fake = FakeSts2([menu, hand, hand, selected_hand, selected_hand, menu])
result = invoke(fake, "--no-jev", "--steps", "5", "--max-duplicate-waits", "0")
check("hand selection waits for selected count, not the separate selected-card index",
[a[0][0] for a in fake.actions], ["combat_select_card", "combat_confirm_selection", "menu_select"])
fake = FakeSts2([menu] + [dict(shop, animation_tick=i) for i in range(100)])
result = invoke(fake, "--steps", "100", "--stuck-seconds", "10", clock=itertools.count())
check("pending purchase times out despite unrelated observation changes",
(result.rc, result.sessions[0]["stop_reason"], len(fake.actions)),
(1, "pending_action_timeout", 1))
print() print()
print(f"=== {PASS} passed, {FAIL} failed ===") print(f"=== {PASS} passed, {FAIL} failed ===")
sys.exit(1 if FAIL else 0) sys.exit(1 if FAIL else 0)

View file

@ -53,11 +53,6 @@ def observation(cards: list, *, enemies=None, energy=1, hp=80, block=0) -> dict:
} }
def reset_policy() -> None:
# Each stored observation is independent. These are not ordered trajectories.
brain._reset_screen_guards("audit-reset")
def synthetic_probes() -> dict: def synthetic_probes() -> dict:
report = {} report = {}
obs = observation([card(0)], enemies=[enemy("E0", 6, 0), enemy("E1", 6, 0)]) obs = observation([card(0)], enemies=[enemy("E0", 6, 0), enemy("E1", 6, 0)])
@ -94,20 +89,19 @@ def synthetic_probes() -> dict:
result = {"propagated": True} result = {"propagated": True}
report["combat_model_error"] = {**result, "diagnostic": output.getvalue().strip()} report["combat_model_error"] = {**result, "diagnostic": output.getvalue().strip()}
reset_policy()
obs = { obs = {
"state_type": "card_select", "state_type": "card_select",
"card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.", "card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.",
"cards": [card(5)], "can_confirm": False, "cards": [card(5)], "can_confirm": False,
"can_cancel": True, "preview_showing": False}, "can_cancel": True, "preview_showing": False},
} }
first = brain.decide(obs, None) context = brain.PolicyContext()
second = brain.decide(obs, None) first = brain.decide(obs, None, context=context)
second = brain.decide(obs, None, context=context)
report["selection_without_execution"] = { report["selection_without_execution"] = {
"first": first.action, "second": second.action, "reason": second.reason, "first": first.action, "second": second.action, "reason": second.reason,
"actual_game_actions": 0, "actual_game_actions": 0,
} }
reset_policy()
try: try:
answer = jev.JevClient._parse({"answers": {"test": {"type": "noul"}}}, 0)["test"] answer = jev.JevClient._parse({"answers": {"test": {"type": "noul"}}}, 0)["test"]
@ -138,7 +132,6 @@ def corpus_audit() -> dict:
raise ValueError("State path leaves dataset directory") raise ValueError("State path leaves dataset directory")
obs = json.loads(gzip.decompress(path.read_bytes())) obs = json.loads(gzip.decompress(path.read_bytes()))
types[obs.get("state_type")] += 1 types[obs.get("state_type")] += 1
reset_policy()
try: try:
decision = brain.decide(obs, None) decision = brain.decide(obs, None)
actions[decision.action if decision else "no_decision"] += 1 actions[decision.action if decision else "no_decision"] += 1
@ -162,7 +155,6 @@ def corpus_audit() -> dict:
status_in_deck += 1 status_in_deck += 1
except Exception as exc: except Exception as exc:
exceptions.append({"path": row["path"], "error": f"{type(exc).__name__}: {exc}"}) exceptions.append({"path": row["path"], "error": f"{type(exc).__name__}: {exc}"})
reset_policy()
return { return {
"state_types": dict(types), "combat_phases": dict(phases), "actions": dict(actions), "state_types": dict(types), "combat_phases": dict(phases), "actions": dict(actions),
"exceptions": exceptions, "forced_block_when_covered": covered, "exceptions": exceptions, "forced_block_when_covered": covered,
@ -171,7 +163,6 @@ def corpus_audit() -> dict:
def main() -> int: def main() -> int:
reset_policy()
report = {"synthetic": synthetic_probes(), "corpus": corpus_audit()} report = {"synthetic": synthetic_probes(), "corpus": corpus_audit()}
print(json.dumps(report, indent=2, sort_keys=True)) print(json.dumps(report, indent=2, sort_keys=True))
# Successful audit execution is not a clean bill of health. Read the report. # Successful audit execution is not a clean bill of health. Read the report.