From bf41945ef95b19ffc93e4ff7df612cae7af0e7f4 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 22 Sep 2026 12:45:46 +0700 Subject: [PATCH] fix(policy): reconcile session-owned action memory with game observations --- AGENTS.md | 3 +- CONTEXT.md | 10 + brain.py | 294 ++++++++++++++------------- docs/research/12-correctness-pass.md | 3 +- docs/research/13-policy-state.md | 113 ++++++++++ run.py | 25 ++- test_brain.py | 197 ------------------ test_run.py | 103 +++++++++- utils/audit_prototype.py | 15 +- 9 files changed, 403 insertions(+), 360 deletions(-) create mode 100644 docs/research/13-policy-state.md diff --git a/AGENTS.md b/AGENTS.md index 4ec89e3..b0513a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Code map - `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`: observe–decide–act loop, captures, and session attribution. - `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates. - `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 - 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. - Keep deterministic fallbacks usable with `client=None`. Test model paths with stubs. - Preserve session/step attribution. Run outcomes are not per-decision correctness labels. diff --git a/CONTEXT.md b/CONTEXT.md index a7e5d19..683d7d7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -28,6 +28,16 @@ A proposed next action. A decision does not establish that the game received or **Action result**: 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**: 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. diff --git a/brain.py b/brain.py index 6ea03a1..242f91e 100644 --- a/brain.py +++ b/brain.py @@ -472,8 +472,6 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None) and skip_answer.yes and gate(skip_answer) ): - global _rewards_skipped_card - _rewards_skipped_card = True return Decision("skip_card_reward", {}, f"jev: skip all (noul={skip_answer.noul:.2f}); keep the deck lean", "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 # deck bloating when Jev is indifferent. if CARD_SKIP_POLICY == "combined" and can_skip and best_noul < CARD_PICK_THRESHOLD: - _rewards_skipped_card = True return Decision("skip_card_reward", {}, f"combined: jev said don't skip but best={best_noul:.2f} " 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 can_skip: - _rewards_skipped_card = True return Decision("skip_card_reward", {}, "no usable ratings; keeping the deck lean", "fallback") 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) -_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 # 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, confidence: float | None = None) -> Decision: - """Emit a select_card and remember that this index has been toggled on.""" - global _card_select_picked - _card_select_picked = True - if index not in _card_select_chosen: - _card_select_chosen.append(index) + """Propose a toggle. Only an accepted request enters policy memory.""" 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 -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. Two traps, both observed live: * `select_card` TOGGLES on grid screens. Calling it twice on one index deselects and freezes the screen. - * A preview left over from a desynced state makes `confirm_selection` - report ok while changing nothing. If the same screen reappears - unchanged, reset with cancel_selection instead of confirming again. + * A preview can make `confirm_selection` report ok without a visible + change. The context waits for the screen to close instead of repeating + 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 {} - sig = json.dumps(cs, sort_keys=True) - repeated = sig == _last_card_select_sig - _last_card_select_sig = sig + chosen = context.accepted_card_indices prompt = str(cs.get("prompt") or "Choose a card.") 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 # multi-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all # five are chosen. - if len(_card_select_chosen) >= need: - if cs.get("can_confirm"): - if _card_select_confirmed: - 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") + if cs.get("can_confirm") and (len(chosen) >= need or cs.get("preview_showing")): + return Decision("confirm_selection", {}, "confirm the selection", "code") + if len(chosen) >= need: # Enough chosen but the game has not enabled confirm yet. Selecting more # would overshoot, so wait. return Decision("__wait__", {}, - f"{len(_card_select_chosen)}/{need} chosen; waiting for confirm", + f"{len(chosen)}/{need} accepted toggles; waiting for confirm", "code") cards = [c for c in (cs.get("cards") or []) if isinstance(c, dict)] if not cards: return Decision("cancel_selection", {}, "no cards to select", "code") - # `select_card` TOGGLES, so never re-select an index we already toggled on. - remaining = [c for c in cards if c.get("index") not in _card_select_chosen] + # `select_card` TOGGLES. Do not repeat an accepted request for this grid. + remaining = [c for c in cards if c.get("index") not in chosen] if not remaining: if cs.get("can_confirm"): - _card_select_confirmed = True return Decision("confirm_selection", {}, "all selectable cards chosen", "code") 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") # 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. - # 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] kind = screen_kind(screen, prompt) questions: dict[str, dict] = {} @@ -1035,9 +1013,6 @@ def rest_site_decision(obs: dict) -> Decision: "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 # can legitimately be low for every candidate, so this is a floor, not a rank. 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 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. # Reading only obs["shop"] made every fake-merchant shop look empty, so the # 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. 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: 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) item = item_keys[best_key] - _last_shop_purchased = True return Decision("shop_purchase", {"index": item["index"]}, f"jev bought {shop_item_text(item)[0]} (noul={best_noul:.2f})", "jev", best_noul) @@ -1279,11 +1241,7 @@ def _weakest_potion_slot(obs: dict) -> int | None: return weakest.get("slot", 0) -_last_rewards_sig: str | None = None -_rewards_skipped_card = False - - -def rewards_decision(obs: dict) -> Decision: +def rewards_decision(obs: dict, context: PolicyContext) -> Decision: """ Reward screen. @@ -1299,7 +1257,7 @@ def rewards_decision(obs: dict) -> Decision: node = obs.get("rewards") or {} 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"] if not items: @@ -1321,30 +1279,18 @@ def rewards_decision(obs: dict) -> Decision: f"claim reward {idx} (right-to-left)", "code") -_last_bundle_sig: str | None = None - - def bundle_select_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Decision: """ Bundle choice: pick one of several 3-card bundles. - Same trap as `card_select`: `select_bundle` errors with "A bundle preview - is already open - confirm or cancel it first" once a preview is showing. - Confirm instead, and reset with cancel if a repeated state proves the - confirm did not apply. + `select_bundle` errors when a preview is already open. Confirm the preview + instead. The context waits for evidence after either accepted request; + an unchanged read does not prove that confirmation failed. """ - global _last_bundle_sig - 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 repeated: - return Decision("cancel_bundle_selection", {}, - "bundle preview did not apply; reset", "code") return Decision("confirm_bundle_selection", {}, "confirm the bundle", "code") 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") -_crystal_clicked: set[tuple[int, int]] = set() - - -def crystal_sphere_decision(obs: dict) -> Decision: +def crystal_sphere_decision(obs: dict, context: PolicyContext) -> Decision: """ 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. fresh = [ 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: @@ -1543,15 +1486,15 @@ def crystal_sphere_decision(obs: dict) -> Decision: 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)) - _crystal_clicked.add((cell.get("x"), cell.get("y"))) return Decision("crystal_sphere_click_cell", {"x": cell.get("x"), "y": cell.get("y")}, f"reveal ({cell.get('x')},{cell.get('y')})", "code") 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.""" + context = context if context is not None else PolicyContext() st = obs.get("state_type") 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 # consecutive "select a character first" rejections -- because the - # selection has not registered yet. So ALTERNATE: select, embark, - # select, embark. A rejected embark is always followed by a fresh - # select, which makes the sequence self-correcting whatever the timing. + # selection has not registered yet. Select after a rejected embark, + # but wait after an accepted embark. Proposals alone do not advance + # the sequence. if screen == "character_select": - global _charselect_phase - options = obs.get("options") or [] 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) - if _charselect_phase == 0 and pick is not None: - _charselect_phase = 1 + if not context.character_selected and pick is not None: return Decision("menu_select", {"option": pick}, f"select {pick}", "code") - _charselect_phase = 0 return Decision("menu_select", {"option": "embark"}, "embark (a rejected embark is followed by a re-select)", "code") @@ -1601,7 +1540,7 @@ def simple_decision(obs: dict, client: JevClient | None = None, return Decision("menu_select", {"option": "main_menu"}, "run ended", "code") if st == "rewards": - return rewards_decision(obs) + return rewards_decision(obs, context) if st == "card_reward": 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) if st == "card_select": - return card_select_decision(obs, client, deck) + return card_select_decision(obs, client, deck, context) if st == "bundle_select": return bundle_select_decision(obs, client, deck) 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; # 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 -_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 # 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. @@ -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) -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 - meaningful within a single screen. Left alone they leak across screens and - runs, so a fresh shop or card grid gets mistaken for a stalled one. Found - by test_brain.py: a fresh fake-merchant shop was reported as - "unchanged after a purchase" because a signature from an earlier case was - still set. + screen_group: str | None = None + accepted_card_indices: set[int] = field(default_factory=set) + accepted_card_grid: list[dict] | None = None + accepted_crystal_cells: set[tuple[int, int]] = field(default_factory=set) + rewards_skipped_card: bool = False + character_selected: bool = False + pending: PendingAction | None = None - `unknown` and `overlay` are TRANSITIONS, not screens. Resetting on them - 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. + def observe(self, obs: dict) -> None: + 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. """ - 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")) + 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"): return combat_decision(F.combat_facts(obs), client) - return simple_decision(obs, client, deck) + return simple_decision(obs, client, deck, context=context) diff --git a/docs/research/12-correctness-pass.md b/docs/research/12-correctness-pass.md index 55d0bbe..b284b92 100644 --- a/docs/research/12-correctness-pass.md +++ b/docs/research/12-correctness-pass.md @@ -117,7 +117,8 @@ The audit remains diagnostic, not a pass/fail suite. It also reports known issue ## 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. 2. Captures cover only combat and reuse filenames across sessions. diff --git a/docs/research/13-policy-state.md b/docs/research/13-policy-state.md new file mode 100644 index 0000000..14460d7 --- /dev/null +++ b/docs/research/13-policy-state.md @@ -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. diff --git a/run.py b/run.py index 1bd3302..575506b 100644 --- a/run.py +++ b/run.py @@ -365,6 +365,9 @@ def main() -> int: duplicate_waits = 0 jev_errors = 0 saw_a_run = False + policy_context = brain.PolicyContext() + pending_action = None + pending_since = time.monotonic() for step in range(1, args.steps + 1): try: @@ -428,9 +431,9 @@ def main() -> int: print(json.dumps(obs, indent=2)[:900]) return finish(1, "stuck") - # Only wait when our own action actually landed and the game is still - # animating. If the action was rejected, fall through and pick a - # different one instead of waiting out the stuck counter. + # Acceptance does not prove completion. The policy context handles + # pending selections and purchases. Rejections allow another proposal + # instead of waiting out the stuck counter. # # NOTE: this check now happens AFTER deciding, and only suppresses a # 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. client.last = None 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 # recovery. Only a successful model response resets the budget. 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 "fallback" and killed the session. try: - decision = brain.decide(obs, None, deck_snapshot) + decision = brain.decide(obs, None, deck_snapshot, context=policy_context) except Exception as inner: # noqa: BLE001 trace({"step": step, "event": "fallback_error", "error": str(inner)[:160]}) print(f"[{step:03d}] fallback also failed: {inner}") @@ -499,6 +502,13 @@ def main() -> int: print(json.dumps(obs, indent=2)[:800]) 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. if decision.action == "__wait__": waits += 1 @@ -508,8 +518,8 @@ def main() -> int: # Suppress DUPLICATE actions on an unchanged state, but only for a # bounded number of reads. Proposing a DIFFERENT action is always - # allowed, which is what makes select-then-embark and multi-purchase - # shops work. The bound matters because some actions legitimately need + # allowed here, but cannot bypass a pending policy action above. + # The bound matters because some actions legitimately need # repeating (multi-line Ancient dialogue) and some transitions are just # slow -- waiting forever on those stalls the run. 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]}) return finish(1, "action_error") + policy_context.record_result(obs, decision, accepted=result.ok) if not result.ok: print(f"[{step:03d}] action rejected: {result.message}") trace({"step": step, "event": "action_rejected", diff --git a/test_brain.py b/test_brain.py index 289818a..528d17a 100644 --- a/test_brain.py +++ b/test_brain.py @@ -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 ===") 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 # loops claim_reward -> card screen -> skip -> claim_reward forever. -force_reset() rewards_with_card = { "state_type": "rewards", "rewards": {"items": [{"index": 0, "type": "gold", "description": "18 Gold", @@ -405,31 +390,6 @@ rewards_with_card = { d = brain.decide(rewards_with_card, None) 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: """ 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() # card_select (upgrade) -force_reset() d = brain.decide( {"state_type": "card_select", "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 # exercised; otherwise a stub that says yes to everything always skips. take_stub = StubClient(noul_override={"skip_all": 0.10}) -force_reset() d = brain.decide( {"state_type": "card_reward", "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. skip_stub = StubClient(noul_override={"skip_all": 0.95}) -force_reset() d = brain.decide( {"state_type": "card_reward", "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. unsure_stub = StubClient(noul_override={"skip_all": 0.55}) -force_reset() d = brain.decide( {"state_type": "card_reward", "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") # `can_skip: false` must take a card even if Jev would skip. -force_reset() d = brain.decide( {"state_type": "card_reward", "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") # hand_select (in-combat selectable subset) -force_reset() d = brain.decide( {"state_type": "hand_select", "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) # rewards -force_reset() d = brain.decide( {"state_type": "rewards", "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) # shop -force_reset() d = brain.decide( {"state_type": "shop", "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) # map -force_reset() d = brain.decide( {"state_type": "map", "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) # bundle_select -force_reset() d = brain.decide( {"state_type": "bundle_select", "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) # relic_select -force_reset() d = brain.decide( {"state_type": "relic_select", "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: # any position-based code returns 0, which is not a valid index here. -force_reset() d = brain.decide( {"state_type": "card_select", "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) 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, # so the state never changes and the loop stalls. `selected_cards` is the # 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 # wave). Measured live: the bot emitted play_card(card_index=1) with no target # and the game rejected it with "Card requires a target". -force_reset() obs_no_enemies = combat(enemies=[]) d = brain.decide(obs_no_enemies, None) check("no enemies -> wait, do not play", d.action, "__wait__") # A target-requiring card must never be emitted without a target. -force_reset() obs_one_enemy = combat(hand=[card("Strike", 0)], enemies=[enemy()]) d = brain.decide(obs_one_enemy, None) check("a single enemy is auto-targeted", d.params.get("target"), "NIBBIT_0") -force_reset() d = brain.decide(obs_no_enemies, StubClient()) 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", (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 # five are picked, and card_select has no `selected_cards` field. Measured live: # 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", 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 # unconditional crystal_sphere_proceed was rejected and stalled the run. -force_reset() cs_state = { "state_type": "crystal_sphere", "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("...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. -force_reset() cs_done = dict(cs_state) cs_done["crystal_sphere"] = dict(cs_state["crystal_sphere"], can_proceed=True) 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("\"any number\" is unbounded", brain.hand_select_need("Choose any number of cards to replace."), None) -force_reset() d = brain.decide( {"state_type": "hand_select", "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") # An unsatisfied counted prompt keeps selecting. -force_reset() d = brain.decide( {"state_type": "hand_select", "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}) brain.CARD_SKIP_POLICY = "jev" - force_reset() d = brain.decide( {"state_type": "card_reward", "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") brain.CARD_SKIP_POLICY = "combined" - force_reset() d = brain.decide( {"state_type": "card_reward", "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}) for policy in ("jev", "combined"): brain.CARD_SKIP_POLICY = policy - force_reset() d = brain.decide( {"state_type": "card_reward", "card_reward": {"cards": [offset_card("Strong", 0)], "can_skip": True}, diff --git a/test_run.py b/test_run.py index 0dbb496..3906475 100755 --- a/test_run.py +++ b/test_run.py @@ -108,13 +108,14 @@ class FakeSts2: self.actions.append((a, k)) if 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): """Run the real loop with isolated files, no delays, and no live services.""" callbacks = [] - brain._reset_screen_guards("test-run-reset") with tempfile.TemporaryDirectory() as directory, contextlib.ExitStack() as stack: capdir = pathlib.Path(directory) / "capture" 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("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(f"=== {PASS} passed, {FAIL} failed ===") sys.exit(1 if FAIL else 0) diff --git a/utils/audit_prototype.py b/utils/audit_prototype.py index ff87527..83ce350 100644 --- a/utils/audit_prototype.py +++ b/utils/audit_prototype.py @@ -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: report = {} obs = observation([card(0)], enemies=[enemy("E0", 6, 0), enemy("E1", 6, 0)]) @@ -94,20 +89,19 @@ def synthetic_probes() -> dict: result = {"propagated": True} report["combat_model_error"] = {**result, "diagnostic": output.getvalue().strip()} - reset_policy() obs = { "state_type": "card_select", "card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.", "cards": [card(5)], "can_confirm": False, "can_cancel": True, "preview_showing": False}, } - first = brain.decide(obs, None) - second = brain.decide(obs, None) + context = brain.PolicyContext() + first = brain.decide(obs, None, context=context) + second = brain.decide(obs, None, context=context) report["selection_without_execution"] = { "first": first.action, "second": second.action, "reason": second.reason, "actual_game_actions": 0, } - reset_policy() try: 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") obs = json.loads(gzip.decompress(path.read_bytes())) types[obs.get("state_type")] += 1 - reset_policy() try: decision = brain.decide(obs, None) actions[decision.action if decision else "no_decision"] += 1 @@ -162,7 +155,6 @@ def corpus_audit() -> dict: status_in_deck += 1 except Exception as exc: exceptions.append({"path": row["path"], "error": f"{type(exc).__name__}: {exc}"}) - reset_policy() return { "state_types": dict(types), "combat_phases": dict(phases), "actions": dict(actions), "exceptions": exceptions, "forced_block_when_covered": covered, @@ -171,7 +163,6 @@ def corpus_audit() -> dict: def main() -> int: - reset_policy() report = {"synthetic": synthetic_probes(), "corpus": corpus_audit()} print(json.dumps(report, indent=2, sort_keys=True)) # Successful audit execution is not a clean bill of health. Read the report.