fix(policy): reconcile session-owned action memory with game observations
This commit is contained in:
parent
3f243eaeee
commit
bf41945ef9
9 changed files with 405 additions and 362 deletions
|
|
@ -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.
|
||||
|
|
|
|||
10
CONTEXT.md
10
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.
|
||||
|
|
|
|||
296
brain.py
296
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.
|
||||
"""
|
||||
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"))
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
113
docs/research/13-policy-state.md
Normal file
113
docs/research/13-policy-state.md
Normal 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
25
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",
|
||||
|
|
|
|||
197
test_brain.py
197
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},
|
||||
|
|
|
|||
103
test_run.py
103
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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue