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
|
## Code map
|
||||||
- `facts.py`: pure game-state parsing and arithmetic.
|
- `facts.py`: pure game-state parsing and arithmetic.
|
||||||
- `brain.py`: decision policy; returns one `Decision` per observation.
|
- `brain.py`: decision policy and session-owned `PolicyContext`; proposes one action per observation.
|
||||||
- `run.py`: observe–decide–act loop, captures, and session attribution.
|
- `run.py`: observe–decide–act loop, captures, and session attribution.
|
||||||
- `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates.
|
- `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates.
|
||||||
- `migrate.py`: dataset migration and integrity checks.
|
- `migrate.py`: dataset migration and integrity checks.
|
||||||
|
|
@ -25,6 +25,7 @@ Prefer integration/end-to-end checks; keep only essential regression tests and u
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
- Execute one game action, then read a fresh observation. Card indices can change after each action.
|
- Execute one game action, then read a fresh observation. Card indices can change after each action.
|
||||||
|
- Keep policy memory session-owned. Proposals do not record execution; reconcile accepted requests with fresh observations.
|
||||||
- Compute arithmetic and legality in code, not in Jev. Confidence does not prove correctness.
|
- Compute arithmetic and legality in code, not in Jev. Confidence does not prove correctness.
|
||||||
- Keep deterministic fallbacks usable with `client=None`. Test model paths with stubs.
|
- Keep deterministic fallbacks usable with `client=None`. Test model paths with stubs.
|
||||||
- Preserve session/step attribution. Run outcomes are not per-decision correctness labels.
|
- Preserve session/step attribution. Run outcomes are not per-decision correctness labels.
|
||||||
|
|
|
||||||
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**:
|
**Action result**:
|
||||||
The game's response to an attempted action. Acceptance alone does not establish that the expected change occurred.
|
The game's response to an attempted action. Acceptance alone does not establish that the expected change occurred.
|
||||||
|
|
||||||
|
**Pending game action**:
|
||||||
|
An accepted action request that has not yet been reconciled with a fresh observation.
|
||||||
|
_Avoid_: Completed action
|
||||||
|
|
||||||
|
**Accepted toggle**:
|
||||||
|
An accepted request to change a card's selection status. It does not prove that the card is selected.
|
||||||
|
|
||||||
|
**Screen flow**:
|
||||||
|
Related game screens that form one interaction, such as opening and skipping a card reward.
|
||||||
|
|
||||||
**Run deck**:
|
**Run deck**:
|
||||||
The persistent collection of cards owned during a run, distinct from temporary combat cards and combat piles.
|
The persistent collection of cards owned during a run, distinct from temporary combat cards and combat piles.
|
||||||
_Avoid_: Treating all visible combat cards as the persistent deck.
|
_Avoid_: Treating all visible combat cards as the persistent deck.
|
||||||
|
|
|
||||||
294
brain.py
294
brain.py
|
|
@ -472,8 +472,6 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None)
|
||||||
and skip_answer.yes
|
and skip_answer.yes
|
||||||
and gate(skip_answer)
|
and gate(skip_answer)
|
||||||
):
|
):
|
||||||
global _rewards_skipped_card
|
|
||||||
_rewards_skipped_card = True
|
|
||||||
return Decision("skip_card_reward", {},
|
return Decision("skip_card_reward", {},
|
||||||
f"jev: skip all (noul={skip_answer.noul:.2f}); keep the deck lean",
|
f"jev: skip all (noul={skip_answer.noul:.2f}); keep the deck lean",
|
||||||
"jev", skip_answer.noul)
|
"jev", skip_answer.noul)
|
||||||
|
|
@ -485,7 +483,6 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None)
|
||||||
# floor the deck is better off lean. This is the safety net that stops the
|
# floor the deck is better off lean. This is the safety net that stops the
|
||||||
# deck bloating when Jev is indifferent.
|
# deck bloating when Jev is indifferent.
|
||||||
if CARD_SKIP_POLICY == "combined" and can_skip and best_noul < CARD_PICK_THRESHOLD:
|
if CARD_SKIP_POLICY == "combined" and can_skip and best_noul < CARD_PICK_THRESHOLD:
|
||||||
_rewards_skipped_card = True
|
|
||||||
return Decision("skip_card_reward", {},
|
return Decision("skip_card_reward", {},
|
||||||
f"combined: jev said don't skip but best={best_noul:.2f} "
|
f"combined: jev said don't skip but best={best_noul:.2f} "
|
||||||
f"< {CARD_PICK_THRESHOLD}; keep the deck lean",
|
f"< {CARD_PICK_THRESHOLD}; keep the deck lean",
|
||||||
|
|
@ -493,7 +490,6 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None)
|
||||||
|
|
||||||
if best_key is None:
|
if best_key is None:
|
||||||
if can_skip:
|
if can_skip:
|
||||||
_rewards_skipped_card = True
|
|
||||||
return Decision("skip_card_reward", {},
|
return Decision("skip_card_reward", {},
|
||||||
"no usable ratings; keeping the deck lean", "fallback")
|
"no usable ratings; keeping the deck lean", "fallback")
|
||||||
fallback_card = best_by_rarity(cards)
|
fallback_card = best_by_rarity(cards)
|
||||||
|
|
@ -691,11 +687,6 @@ def map_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Deci
|
||||||
f"jev chose {node.get('type')}", "jev", pick.confidence)
|
f"jev chose {node.get('type')}", "jev", pick.confidence)
|
||||||
|
|
||||||
|
|
||||||
_last_card_select_sig: str | None = None
|
|
||||||
_card_select_picked = False
|
|
||||||
_card_select_confirmed = False
|
|
||||||
_card_select_chosen: list[int] = []
|
|
||||||
|
|
||||||
# Deterministic fallbacks for a grid selection screen, used only when Jev does
|
# Deterministic fallbacks for a grid selection screen, used only when Jev does
|
||||||
# not clear the threshold. Lower rank wins.
|
# not clear the threshold. Lower rank wins.
|
||||||
#
|
#
|
||||||
|
|
@ -770,11 +761,7 @@ def card_select_fallback(cards: list[dict], screen: str,
|
||||||
|
|
||||||
def _card_select_pick(index: int, reason: str, source: str,
|
def _card_select_pick(index: int, reason: str, source: str,
|
||||||
confidence: float | None = None) -> Decision:
|
confidence: float | None = None) -> Decision:
|
||||||
"""Emit a select_card and remember that this index has been toggled on."""
|
"""Propose a toggle. Only an accepted request enters policy memory."""
|
||||||
global _card_select_picked
|
|
||||||
_card_select_picked = True
|
|
||||||
if index not in _card_select_chosen:
|
|
||||||
_card_select_chosen.append(index)
|
|
||||||
return Decision("select_card", {"index": index}, reason, source, confidence)
|
return Decision("select_card", {"index": index}, reason, source, confidence)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -794,23 +781,20 @@ def card_select_need(prompt: str) -> int:
|
||||||
return int(match.group(1)) if match else 1
|
return int(match.group(1)) if match else 1
|
||||||
|
|
||||||
|
|
||||||
def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Decision:
|
def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None,
|
||||||
|
context: PolicyContext) -> Decision:
|
||||||
"""
|
"""
|
||||||
Grid selection overlay: upgrade, transform, remove, choose-a-card.
|
Grid selection overlay: upgrade, transform, remove, choose-a-card.
|
||||||
|
|
||||||
Two traps, both observed live:
|
Two traps, both observed live:
|
||||||
* `select_card` TOGGLES on grid screens. Calling it twice on one index
|
* `select_card` TOGGLES on grid screens. Calling it twice on one index
|
||||||
deselects and freezes the screen.
|
deselects and freezes the screen.
|
||||||
* A preview left over from a desynced state makes `confirm_selection`
|
* A preview can make `confirm_selection` report ok without a visible
|
||||||
report ok while changing nothing. If the same screen reappears
|
change. The context waits for the screen to close instead of repeating
|
||||||
unchanged, reset with cancel_selection instead of confirming again.
|
or cancelling an action whose outcome is still unknown.
|
||||||
"""
|
"""
|
||||||
global _last_card_select_sig, _card_select_picked, _card_select_confirmed
|
|
||||||
|
|
||||||
cs = obs.get("card_select") or {}
|
cs = obs.get("card_select") or {}
|
||||||
sig = json.dumps(cs, sort_keys=True)
|
chosen = context.accepted_card_indices
|
||||||
repeated = sig == _last_card_select_sig
|
|
||||||
_last_card_select_sig = sig
|
|
||||||
|
|
||||||
prompt = str(cs.get("prompt") or "Choose a card.")
|
prompt = str(cs.get("prompt") or "Choose a card.")
|
||||||
screen = str(cs.get("screen_type") or "")
|
screen = str(cs.get("screen_type") or "")
|
||||||
|
|
@ -821,37 +805,31 @@ def card_select_decision(obs: dict, client: JevClient | None, deck: dict | None)
|
||||||
# selection with NO preview at all. And do NOT confirm early on a
|
# selection with NO preview at all. And do NOT confirm early on a
|
||||||
# multi-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all
|
# multi-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all
|
||||||
# five are chosen.
|
# five are chosen.
|
||||||
if len(_card_select_chosen) >= need:
|
if cs.get("can_confirm") and (len(chosen) >= need or cs.get("preview_showing")):
|
||||||
if cs.get("can_confirm"):
|
return Decision("confirm_selection", {}, "confirm the selection", "code")
|
||||||
if _card_select_confirmed:
|
if len(chosen) >= need:
|
||||||
return Decision("cancel_selection", {},
|
|
||||||
"confirm did not apply; reset the screen", "code")
|
|
||||||
_card_select_confirmed = True
|
|
||||||
return Decision("confirm_selection", {},
|
|
||||||
f"confirm {len(_card_select_chosen)}/{need} selected", "code")
|
|
||||||
# Enough chosen but the game has not enabled confirm yet. Selecting more
|
# Enough chosen but the game has not enabled confirm yet. Selecting more
|
||||||
# would overshoot, so wait.
|
# would overshoot, so wait.
|
||||||
return Decision("__wait__", {},
|
return Decision("__wait__", {},
|
||||||
f"{len(_card_select_chosen)}/{need} chosen; waiting for confirm",
|
f"{len(chosen)}/{need} accepted toggles; waiting for confirm",
|
||||||
"code")
|
"code")
|
||||||
|
|
||||||
cards = [c for c in (cs.get("cards") or []) if isinstance(c, dict)]
|
cards = [c for c in (cs.get("cards") or []) if isinstance(c, dict)]
|
||||||
if not cards:
|
if not cards:
|
||||||
return Decision("cancel_selection", {}, "no cards to select", "code")
|
return Decision("cancel_selection", {}, "no cards to select", "code")
|
||||||
|
|
||||||
# `select_card` TOGGLES, so never re-select an index we already toggled on.
|
# `select_card` TOGGLES. Do not repeat an accepted request for this grid.
|
||||||
remaining = [c for c in cards if c.get("index") not in _card_select_chosen]
|
remaining = [c for c in cards if c.get("index") not in chosen]
|
||||||
if not remaining:
|
if not remaining:
|
||||||
if cs.get("can_confirm"):
|
if cs.get("can_confirm"):
|
||||||
_card_select_confirmed = True
|
|
||||||
return Decision("confirm_selection", {}, "all selectable cards chosen", "code")
|
return Decision("confirm_selection", {}, "all selectable cards chosen", "code")
|
||||||
return Decision("__wait__", {},
|
return Decision("__wait__", {},
|
||||||
f"{len(_card_select_chosen)}/{need} chosen; nothing new to select",
|
f"{len(chosen)}/{need} accepted toggles; nothing new to select",
|
||||||
"code")
|
"code")
|
||||||
|
|
||||||
# One ABSOLUTE Noul per candidate, argmax in code. A single Choice over a
|
# One ABSOLUTE Noul per candidate, argmax in code. A single Choice over a
|
||||||
# 13+ card deck diluted badly and the fallback then always took index 0.
|
# 13+ card deck diluted badly and the fallback then always took index 0.
|
||||||
# Candidates are `remaining` -- never an index we already toggled on.
|
# Candidates exclude indices with accepted toggle requests.
|
||||||
keys = [f"card{c['index']}" for c in remaining]
|
keys = [f"card{c['index']}" for c in remaining]
|
||||||
kind = screen_kind(screen, prompt)
|
kind = screen_kind(screen, prompt)
|
||||||
questions: dict[str, dict] = {}
|
questions: dict[str, dict] = {}
|
||||||
|
|
@ -1035,9 +1013,6 @@ def rest_site_decision(obs: dict) -> Decision:
|
||||||
"first rest option", "fallback")
|
"first rest option", "fallback")
|
||||||
|
|
||||||
|
|
||||||
_last_shop_sig: str | None = None
|
|
||||||
_last_shop_purchased = False
|
|
||||||
|
|
||||||
# Minimum absolute Noul before buying anything in a shop. Absolute judgements
|
# Minimum absolute Noul before buying anything in a shop. Absolute judgements
|
||||||
# can legitimately be low for every candidate, so this is a floor, not a rank.
|
# can legitimately be low for every candidate, so this is a floor, not a rank.
|
||||||
SHOP_BUY_THRESHOLD = 0.60
|
SHOP_BUY_THRESHOLD = 0.60
|
||||||
|
|
@ -1083,8 +1058,6 @@ def shop_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Dec
|
||||||
The state carries `price`, `is_stocked` and `can_afford` per item, so no
|
The state carries `price`, `is_stocked` and `can_afford` per item, so no
|
||||||
affordability arithmetic needs to reach the model.
|
affordability arithmetic needs to reach the model.
|
||||||
"""
|
"""
|
||||||
global _last_shop_sig, _last_shop_purchased
|
|
||||||
|
|
||||||
# `fake_merchant` nests its inventory one level deeper: fake_merchant.shop.
|
# `fake_merchant` nests its inventory one level deeper: fake_merchant.shop.
|
||||||
# Reading only obs["shop"] made every fake-merchant shop look empty, so the
|
# Reading only obs["shop"] made every fake-merchant shop look empty, so the
|
||||||
# bot always left immediately without buying.
|
# bot always left immediately without buying.
|
||||||
|
|
@ -1105,16 +1078,6 @@ def shop_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Dec
|
||||||
# worked and moved the game to the map. Never wait on it indefinitely.
|
# worked and moved the game to the map. Never wait on it indefinitely.
|
||||||
return Decision("proceed", {}, "nothing affordable; leave", "code")
|
return Decision("proceed", {}, "nothing affordable; leave", "code")
|
||||||
|
|
||||||
# Only stop buying when we purchased from this EXACT shop state and it did
|
|
||||||
# not change. Keying on the signature alone made a fresh shop look stalled
|
|
||||||
# because module-level state leaked in from an earlier screen.
|
|
||||||
sig = json.dumps(node, sort_keys=True)
|
|
||||||
if sig != _last_shop_sig:
|
|
||||||
_last_shop_sig = sig
|
|
||||||
_last_shop_purchased = False
|
|
||||||
elif _last_shop_purchased:
|
|
||||||
return Decision("proceed", {}, "shop unchanged after a purchase; leave", "code")
|
|
||||||
|
|
||||||
if client is None:
|
if client is None:
|
||||||
return Decision("proceed", {}, "no jev; skip shop", "fallback")
|
return Decision("proceed", {}, "no jev; skip shop", "fallback")
|
||||||
|
|
||||||
|
|
@ -1178,7 +1141,6 @@ def shop_decision(obs: dict, client: JevClient | None, deck: dict | None) -> Dec
|
||||||
f"best item only noul={best_noul:.2f}; leave", "jev", best_noul)
|
f"best item only noul={best_noul:.2f}; leave", "jev", best_noul)
|
||||||
|
|
||||||
item = item_keys[best_key]
|
item = item_keys[best_key]
|
||||||
_last_shop_purchased = True
|
|
||||||
return Decision("shop_purchase", {"index": item["index"]},
|
return Decision("shop_purchase", {"index": item["index"]},
|
||||||
f"jev bought {shop_item_text(item)[0]} (noul={best_noul:.2f})",
|
f"jev bought {shop_item_text(item)[0]} (noul={best_noul:.2f})",
|
||||||
"jev", best_noul)
|
"jev", best_noul)
|
||||||
|
|
@ -1279,11 +1241,7 @@ def _weakest_potion_slot(obs: dict) -> int | None:
|
||||||
return weakest.get("slot", 0)
|
return weakest.get("slot", 0)
|
||||||
|
|
||||||
|
|
||||||
_last_rewards_sig: str | None = None
|
def rewards_decision(obs: dict, context: PolicyContext) -> Decision:
|
||||||
_rewards_skipped_card = False
|
|
||||||
|
|
||||||
|
|
||||||
def rewards_decision(obs: dict) -> Decision:
|
|
||||||
"""
|
"""
|
||||||
Reward screen.
|
Reward screen.
|
||||||
|
|
||||||
|
|
@ -1299,7 +1257,7 @@ def rewards_decision(obs: dict) -> Decision:
|
||||||
node = obs.get("rewards") or {}
|
node = obs.get("rewards") or {}
|
||||||
items = [i for i in (node.get("items") or []) if isinstance(i, dict)]
|
items = [i for i in (node.get("items") or []) if isinstance(i, dict)]
|
||||||
|
|
||||||
if _rewards_skipped_card:
|
if context.rewards_skipped_card:
|
||||||
items = [i for i in items if i.get("type") != "card"]
|
items = [i for i in items if i.get("type") != "card"]
|
||||||
|
|
||||||
if not items:
|
if not items:
|
||||||
|
|
@ -1321,30 +1279,18 @@ def rewards_decision(obs: dict) -> Decision:
|
||||||
f"claim reward {idx} (right-to-left)", "code")
|
f"claim reward {idx} (right-to-left)", "code")
|
||||||
|
|
||||||
|
|
||||||
_last_bundle_sig: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def bundle_select_decision(obs: dict, client: JevClient | None,
|
def bundle_select_decision(obs: dict, client: JevClient | None,
|
||||||
deck: dict | None) -> Decision:
|
deck: dict | None) -> Decision:
|
||||||
"""
|
"""
|
||||||
Bundle choice: pick one of several 3-card bundles.
|
Bundle choice: pick one of several 3-card bundles.
|
||||||
|
|
||||||
Same trap as `card_select`: `select_bundle` errors with "A bundle preview
|
`select_bundle` errors when a preview is already open. Confirm the preview
|
||||||
is already open - confirm or cancel it first" once a preview is showing.
|
instead. The context waits for evidence after either accepted request;
|
||||||
Confirm instead, and reset with cancel if a repeated state proves the
|
an unchanged read does not prove that confirmation failed.
|
||||||
confirm did not apply.
|
|
||||||
"""
|
"""
|
||||||
global _last_bundle_sig
|
|
||||||
|
|
||||||
bs = obs.get("bundle_select") or {}
|
bs = obs.get("bundle_select") or {}
|
||||||
sig = json.dumps(bs, sort_keys=True)
|
|
||||||
repeated = sig == _last_bundle_sig
|
|
||||||
_last_bundle_sig = sig
|
|
||||||
|
|
||||||
if bs.get("preview_showing") and bs.get("can_confirm"):
|
if bs.get("preview_showing") and bs.get("can_confirm"):
|
||||||
if repeated:
|
|
||||||
return Decision("cancel_bundle_selection", {},
|
|
||||||
"bundle preview did not apply; reset", "code")
|
|
||||||
return Decision("confirm_bundle_selection", {}, "confirm the bundle", "code")
|
return Decision("confirm_bundle_selection", {}, "confirm the bundle", "code")
|
||||||
|
|
||||||
bundles = [b for b in (bs.get("bundles") or []) if isinstance(b, dict)]
|
bundles = [b for b in (bs.get("bundles") or []) if isinstance(b, dict)]
|
||||||
|
|
@ -1507,10 +1453,7 @@ def hand_select_decision(obs: dict, client: JevClient | None,
|
||||||
f"forced selection; gave up {target.get('name')}", "fallback")
|
f"forced selection; gave up {target.get('name')}", "fallback")
|
||||||
|
|
||||||
|
|
||||||
_crystal_clicked: set[tuple[int, int]] = set()
|
def crystal_sphere_decision(obs: dict, context: PolicyContext) -> Decision:
|
||||||
|
|
||||||
|
|
||||||
def crystal_sphere_decision(obs: dict) -> Decision:
|
|
||||||
"""
|
"""
|
||||||
Crystal Sphere minigame.
|
Crystal Sphere minigame.
|
||||||
|
|
||||||
|
|
@ -1528,7 +1471,7 @@ def crystal_sphere_decision(obs: dict) -> Decision:
|
||||||
# Never re-click a cell: that wastes a divination and can loop.
|
# Never re-click a cell: that wastes a divination and can loop.
|
||||||
fresh = [
|
fresh = [
|
||||||
c for c in clickable
|
c for c in clickable
|
||||||
if (c.get("x"), c.get("y")) not in _crystal_clicked
|
if (c.get("x"), c.get("y")) not in context.accepted_crystal_cells
|
||||||
]
|
]
|
||||||
|
|
||||||
if not fresh:
|
if not fresh:
|
||||||
|
|
@ -1543,15 +1486,15 @@ def crystal_sphere_decision(obs: dict) -> Decision:
|
||||||
cx, cy = (width - 1) / 2, (height - 1) / 2
|
cx, cy = (width - 1) / 2, (height - 1) / 2
|
||||||
cell = min(fresh, key=lambda c: abs(c.get("x", 0) - cx) + abs(c.get("y", 0) - cy))
|
cell = min(fresh, key=lambda c: abs(c.get("x", 0) - cx) + abs(c.get("y", 0) - cy))
|
||||||
|
|
||||||
_crystal_clicked.add((cell.get("x"), cell.get("y")))
|
|
||||||
return Decision("crystal_sphere_click_cell",
|
return Decision("crystal_sphere_click_cell",
|
||||||
{"x": cell.get("x"), "y": cell.get("y")},
|
{"x": cell.get("x"), "y": cell.get("y")},
|
||||||
f"reveal ({cell.get('x')},{cell.get('y')})", "code")
|
f"reveal ({cell.get('x')},{cell.get('y')})", "code")
|
||||||
|
|
||||||
|
|
||||||
def simple_decision(obs: dict, client: JevClient | None = None,
|
def simple_decision(obs: dict, client: JevClient | None = None,
|
||||||
deck: dict | None = None) -> Decision | None:
|
deck: dict | None = None, *, context: PolicyContext | None = None) -> Decision | None:
|
||||||
"""Mechanical screens. Most need no model -- they are pure procedure."""
|
"""Mechanical screens. Most need no model -- they are pure procedure."""
|
||||||
|
context = context if context is not None else PolicyContext()
|
||||||
st = obs.get("state_type")
|
st = obs.get("state_type")
|
||||||
|
|
||||||
if st == "menu":
|
if st == "menu":
|
||||||
|
|
@ -1576,22 +1519,18 @@ def simple_decision(obs: dict, client: JevClient | None = None,
|
||||||
#
|
#
|
||||||
# Embarking immediately after selecting is also FLAKY -- measured, three
|
# Embarking immediately after selecting is also FLAKY -- measured, three
|
||||||
# consecutive "select a character first" rejections -- because the
|
# consecutive "select a character first" rejections -- because the
|
||||||
# selection has not registered yet. So ALTERNATE: select, embark,
|
# selection has not registered yet. Select after a rejected embark,
|
||||||
# select, embark. A rejected embark is always followed by a fresh
|
# but wait after an accepted embark. Proposals alone do not advance
|
||||||
# select, which makes the sequence self-correcting whatever the timing.
|
# the sequence.
|
||||||
if screen == "character_select":
|
if screen == "character_select":
|
||||||
global _charselect_phase
|
|
||||||
|
|
||||||
options = obs.get("options") or []
|
options = obs.get("options") or []
|
||||||
names = [o if isinstance(o, str) else o.get("name") for o in options]
|
names = [o if isinstance(o, str) else o.get("name") for o in options]
|
||||||
pick = next((c for c in ("IRONCLAD", "SILENT") if c in names), None)
|
pick = next((c for c in ("IRONCLAD", "SILENT") if c in names), None)
|
||||||
|
|
||||||
if _charselect_phase == 0 and pick is not None:
|
if not context.character_selected and pick is not None:
|
||||||
_charselect_phase = 1
|
|
||||||
return Decision("menu_select", {"option": pick},
|
return Decision("menu_select", {"option": pick},
|
||||||
f"select {pick}", "code")
|
f"select {pick}", "code")
|
||||||
|
|
||||||
_charselect_phase = 0
|
|
||||||
return Decision("menu_select", {"option": "embark"},
|
return Decision("menu_select", {"option": "embark"},
|
||||||
"embark (a rejected embark is followed by a re-select)",
|
"embark (a rejected embark is followed by a re-select)",
|
||||||
"code")
|
"code")
|
||||||
|
|
@ -1601,7 +1540,7 @@ def simple_decision(obs: dict, client: JevClient | None = None,
|
||||||
return Decision("menu_select", {"option": "main_menu"}, "run ended", "code")
|
return Decision("menu_select", {"option": "main_menu"}, "run ended", "code")
|
||||||
|
|
||||||
if st == "rewards":
|
if st == "rewards":
|
||||||
return rewards_decision(obs)
|
return rewards_decision(obs, context)
|
||||||
|
|
||||||
if st == "card_reward":
|
if st == "card_reward":
|
||||||
return card_reward_decision(obs, client, deck)
|
return card_reward_decision(obs, client, deck)
|
||||||
|
|
@ -1628,13 +1567,13 @@ def simple_decision(obs: dict, client: JevClient | None = None,
|
||||||
return hand_select_decision(obs, client, deck)
|
return hand_select_decision(obs, client, deck)
|
||||||
|
|
||||||
if st == "card_select":
|
if st == "card_select":
|
||||||
return card_select_decision(obs, client, deck)
|
return card_select_decision(obs, client, deck, context)
|
||||||
|
|
||||||
if st == "bundle_select":
|
if st == "bundle_select":
|
||||||
return bundle_select_decision(obs, client, deck)
|
return bundle_select_decision(obs, client, deck)
|
||||||
|
|
||||||
if st == "crystal_sphere":
|
if st == "crystal_sphere":
|
||||||
return crystal_sphere_decision(obs)
|
return crystal_sphere_decision(obs, context)
|
||||||
|
|
||||||
# Transitions and unhandled overlays are not dead ends. Wait and look again;
|
# Transitions and unhandled overlays are not dead ends. Wait and look again;
|
||||||
# run.py's unchanged-state guard bounds this so a real dead end still stops.
|
# run.py's unchanged-state guard bounds this so a real dead end still stops.
|
||||||
|
|
@ -1644,13 +1583,6 @@ def simple_decision(obs: dict, client: JevClient | None = None,
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_last_state_type: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
_last_charselect_sig: str | None = None
|
|
||||||
_charselect_seen = False
|
|
||||||
_charselect_phase = 0
|
|
||||||
|
|
||||||
# rewards and card_reward are two views of one flow: claiming a card reward
|
# rewards and card_reward are two views of one flow: claiming a card reward
|
||||||
# opens the card screen, and skipping returns to the rewards screen. Treat them
|
# opens the card screen, and skipping returns to the rewards screen. Treat them
|
||||||
# as ONE screen group so per-flow state is not cleared on every hop.
|
# as ONE screen group so per-flow state is not cleared on every hop.
|
||||||
|
|
@ -1670,51 +1602,133 @@ def _screen_group(state_type: str | None, menu_screen: str | None = None) -> str
|
||||||
return SCREEN_GROUPS.get(state_type, state_type)
|
return SCREEN_GROUPS.get(state_type, state_type)
|
||||||
|
|
||||||
|
|
||||||
def _reset_screen_guards(state_type: str | None, menu_screen: str | None = None) -> None:
|
@dataclass
|
||||||
|
class PendingAction:
|
||||||
|
"""An accepted request, not proof that the game completed it."""
|
||||||
|
|
||||||
|
decision: Decision
|
||||||
|
screen: dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PolicyContext:
|
||||||
|
"""Session-owned memory. Proposals never record execution.
|
||||||
|
|
||||||
|
Grid toggles and character selection lack observable selection fields in
|
||||||
|
the mod. Their accepted requests are tracked explicitly, not called facts.
|
||||||
|
Other guarded actions wait for relevant screen evidence before continuing.
|
||||||
"""
|
"""
|
||||||
Clear per-screen module state when the screen changes.
|
|
||||||
|
|
||||||
These guards detect "the same screen reappeared unchanged", which is only
|
screen_group: str | None = None
|
||||||
meaningful within a single screen. Left alone they leak across screens and
|
accepted_card_indices: set[int] = field(default_factory=set)
|
||||||
runs, so a fresh shop or card grid gets mistaken for a stalled one. Found
|
accepted_card_grid: list[dict] | None = None
|
||||||
by test_brain.py: a fresh fake-merchant shop was reported as
|
accepted_crystal_cells: set[tuple[int, int]] = field(default_factory=set)
|
||||||
"unchanged after a purchase" because a signature from an earlier case was
|
rewards_skipped_card: bool = False
|
||||||
still set.
|
character_selected: bool = False
|
||||||
|
pending: PendingAction | None = None
|
||||||
|
|
||||||
`unknown` and `overlay` are TRANSITIONS, not screens. Resetting on them
|
def observe(self, obs: dict) -> None:
|
||||||
wipes the state mid-flow -- during embark the state flickers through
|
st = obs.get("state_type")
|
||||||
`unknown`, which used to clear the character-select guard and restart the
|
if st in ("unknown", "overlay"):
|
||||||
select/embark cycle.
|
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
|
context = context if context is not None else PolicyContext()
|
||||||
global _last_shop_sig, _last_shop_purchased, _rewards_skipped_card
|
context.observe(obs)
|
||||||
global _charselect_seen, _charselect_phase
|
|
||||||
global _card_select_picked, _card_select_confirmed, _card_select_chosen
|
|
||||||
global _crystal_clicked
|
|
||||||
|
|
||||||
if state_type in ("unknown", "overlay"):
|
|
||||||
return
|
|
||||||
|
|
||||||
group = _screen_group(state_type, menu_screen)
|
|
||||||
if group == _last_state_type:
|
|
||||||
return
|
|
||||||
_last_state_type = group
|
|
||||||
_last_card_select_sig = None
|
|
||||||
_last_bundle_sig = None
|
|
||||||
_last_shop_sig = None
|
|
||||||
_last_shop_purchased = False
|
|
||||||
_rewards_skipped_card = False
|
|
||||||
_charselect_seen = False
|
|
||||||
_charselect_phase = 0
|
|
||||||
_card_select_picked = False
|
|
||||||
_card_select_confirmed = False
|
|
||||||
_card_select_chosen.clear()
|
|
||||||
_crystal_clicked.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def decide(obs: dict, client: JevClient | None, deck: dict | None = None) -> Decision | None:
|
|
||||||
_reset_screen_guards(obs.get("state_type"), obs.get("menu_screen"))
|
|
||||||
st = obs.get("state_type")
|
st = obs.get("state_type")
|
||||||
|
if context.pending is not None:
|
||||||
|
return Decision("__wait__", {},
|
||||||
|
f"awaiting screen evidence after {context.pending.decision.action}", "code")
|
||||||
|
if (st == "card_select" and context.accepted_card_grid is not None
|
||||||
|
and (obs.get("card_select") or {}).get("cards") != context.accepted_card_grid):
|
||||||
|
return Decision("__wait__", {}, "card grid changed; accepted indices cannot be mapped safely", "code")
|
||||||
if st in ("monster", "elite", "boss"):
|
if st in ("monster", "elite", "boss"):
|
||||||
return combat_decision(F.combat_facts(obs), client)
|
return combat_decision(F.combat_facts(obs), client)
|
||||||
return simple_decision(obs, client, deck)
|
return simple_decision(obs, client, deck, context=context)
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,8 @@ The audit remains diagnostic, not a pass/fail suite. It also reports known issue
|
||||||
|
|
||||||
## Next: policy state and recording
|
## Next: policy state and recording
|
||||||
|
|
||||||
Still open:
|
Update: [the policy-state pass](13-policy-state.md) addresses item 1 below.
|
||||||
|
The list records the remaining work at the end of this correctness pass:
|
||||||
|
|
||||||
1. Selection/shop/minigame memory can change when an action is proposed, before execution succeeds.
|
1. Selection/shop/minigame memory can change when an action is proposed, before execution succeeds.
|
||||||
2. Captures cover only combat and reuse filenames across sessions.
|
2. Captures cover only combat and reuse filenames across sessions.
|
||||||
|
|
|
||||||
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
|
duplicate_waits = 0
|
||||||
jev_errors = 0
|
jev_errors = 0
|
||||||
saw_a_run = False
|
saw_a_run = False
|
||||||
|
policy_context = brain.PolicyContext()
|
||||||
|
pending_action = None
|
||||||
|
pending_since = time.monotonic()
|
||||||
|
|
||||||
for step in range(1, args.steps + 1):
|
for step in range(1, args.steps + 1):
|
||||||
try:
|
try:
|
||||||
|
|
@ -428,9 +431,9 @@ def main() -> int:
|
||||||
print(json.dumps(obs, indent=2)[:900])
|
print(json.dumps(obs, indent=2)[:900])
|
||||||
return finish(1, "stuck")
|
return finish(1, "stuck")
|
||||||
|
|
||||||
# Only wait when our own action actually landed and the game is still
|
# Acceptance does not prove completion. The policy context handles
|
||||||
# animating. If the action was rejected, fall through and pick a
|
# pending selections and purchases. Rejections allow another proposal
|
||||||
# different one instead of waiting out the stuck counter.
|
# instead of waiting out the stuck counter.
|
||||||
#
|
#
|
||||||
# NOTE: this check now happens AFTER deciding, and only suppresses a
|
# NOTE: this check now happens AFTER deciding, and only suppresses a
|
||||||
# REPEATED action. Waiting on "state unchanged" alone blocked
|
# REPEATED action. Waiting on "state unchanged" alone blocked
|
||||||
|
|
@ -458,7 +461,7 @@ def main() -> int:
|
||||||
# must not inherit the previous step's answers in the log.
|
# must not inherit the previous step's answers in the log.
|
||||||
client.last = None
|
client.last = None
|
||||||
try:
|
try:
|
||||||
decision = brain.decide(obs, client, deck_snapshot)
|
decision = brain.decide(obs, client, deck_snapshot, context=policy_context)
|
||||||
# A procedural action or animation wait does not establish model
|
# A procedural action or animation wait does not establish model
|
||||||
# recovery. Only a successful model response resets the budget.
|
# recovery. Only a successful model response resets the budget.
|
||||||
if client is not None and client.last is not None:
|
if client is not None and client.last is not None:
|
||||||
|
|
@ -478,7 +481,7 @@ def main() -> int:
|
||||||
# the same failing request -- measured, a DNS blip re-raised out of
|
# the same failing request -- measured, a DNS blip re-raised out of
|
||||||
# the "fallback" and killed the session.
|
# the "fallback" and killed the session.
|
||||||
try:
|
try:
|
||||||
decision = brain.decide(obs, None, deck_snapshot)
|
decision = brain.decide(obs, None, deck_snapshot, context=policy_context)
|
||||||
except Exception as inner: # noqa: BLE001
|
except Exception as inner: # noqa: BLE001
|
||||||
trace({"step": step, "event": "fallback_error", "error": str(inner)[:160]})
|
trace({"step": step, "event": "fallback_error", "error": str(inner)[:160]})
|
||||||
print(f"[{step:03d}] fallback also failed: {inner}")
|
print(f"[{step:03d}] fallback also failed: {inner}")
|
||||||
|
|
@ -499,6 +502,13 @@ def main() -> int:
|
||||||
print(json.dumps(obs, indent=2)[:800])
|
print(json.dumps(obs, indent=2)[:800])
|
||||||
return finish(1, "no_decision")
|
return finish(1, "no_decision")
|
||||||
|
|
||||||
|
# Bound unresolved actions even if unrelated observation fields change.
|
||||||
|
if policy_context.pending is not pending_action:
|
||||||
|
pending_action = policy_context.pending
|
||||||
|
pending_since = time.monotonic()
|
||||||
|
if pending_action is not None and time.monotonic() - pending_since > args.stuck_seconds:
|
||||||
|
return finish(1, "pending_action_timeout")
|
||||||
|
|
||||||
# Between turns there is nothing to do but look again.
|
# Between turns there is nothing to do but look again.
|
||||||
if decision.action == "__wait__":
|
if decision.action == "__wait__":
|
||||||
waits += 1
|
waits += 1
|
||||||
|
|
@ -508,8 +518,8 @@ def main() -> int:
|
||||||
|
|
||||||
# Suppress DUPLICATE actions on an unchanged state, but only for a
|
# Suppress DUPLICATE actions on an unchanged state, but only for a
|
||||||
# bounded number of reads. Proposing a DIFFERENT action is always
|
# bounded number of reads. Proposing a DIFFERENT action is always
|
||||||
# allowed, which is what makes select-then-embark and multi-purchase
|
# allowed here, but cannot bypass a pending policy action above.
|
||||||
# shops work. The bound matters because some actions legitimately need
|
# The bound matters because some actions legitimately need
|
||||||
# repeating (multi-line Ancient dialogue) and some transitions are just
|
# repeating (multi-line Ancient dialogue) and some transitions are just
|
||||||
# slow -- waiting forever on those stalls the run.
|
# slow -- waiting forever on those stalls the run.
|
||||||
action_key = (decision.action, json.dumps(decision.params, sort_keys=True))
|
action_key = (decision.action, json.dumps(decision.params, sort_keys=True))
|
||||||
|
|
@ -543,6 +553,7 @@ def main() -> int:
|
||||||
"action": decision.action, "error": str(exc)[:200]})
|
"action": decision.action, "error": str(exc)[:200]})
|
||||||
return finish(1, "action_error")
|
return finish(1, "action_error")
|
||||||
|
|
||||||
|
policy_context.record_result(obs, decision, accepted=result.ok)
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
print(f"[{step:03d}] action rejected: {result.message}")
|
print(f"[{step:03d}] action rejected: {result.message}")
|
||||||
trace({"step": step, "event": "action_rejected",
|
trace({"step": step, "event": "action_rejected",
|
||||||
|
|
|
||||||
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 ===")
|
print("=== 1. every state_type produces a LEGAL action ===")
|
||||||
|
|
||||||
cases: list[tuple[str, dict]] = [
|
cases: list[tuple[str, dict]] = [
|
||||||
|
|
@ -391,7 +377,6 @@ check("fake_merchant reports nothing affordable",
|
||||||
|
|
||||||
# A skipped card reward is NOT consumed by the game. Without the guard the bot
|
# A skipped card reward is NOT consumed by the game. Without the guard the bot
|
||||||
# loops claim_reward -> card screen -> skip -> claim_reward forever.
|
# loops claim_reward -> card screen -> skip -> claim_reward forever.
|
||||||
force_reset()
|
|
||||||
rewards_with_card = {
|
rewards_with_card = {
|
||||||
"state_type": "rewards",
|
"state_type": "rewards",
|
||||||
"rewards": {"items": [{"index": 0, "type": "gold", "description": "18 Gold",
|
"rewards": {"items": [{"index": 0, "type": "gold", "description": "18 Gold",
|
||||||
|
|
@ -405,31 +390,6 @@ rewards_with_card = {
|
||||||
d = brain.decide(rewards_with_card, None)
|
d = brain.decide(rewards_with_card, None)
|
||||||
check("rewards claims the last item first", d.params.get("index"), 1)
|
check("rewards claims the last item first", d.params.get("index"), 1)
|
||||||
|
|
||||||
brain._rewards_skipped_card = True
|
|
||||||
d = brain.decide(rewards_with_card, None)
|
|
||||||
check("a skipped card reward is not re-claimed", d.action, "claim_reward")
|
|
||||||
check("...and the gold is taken instead", d.params.get("index"), 0)
|
|
||||||
|
|
||||||
brain._rewards_skipped_card = True
|
|
||||||
d = brain.decide(
|
|
||||||
{"state_type": "rewards",
|
|
||||||
"rewards": {"items": [{"index": 0, "type": "card",
|
|
||||||
"description": "Add a card to your deck."}],
|
|
||||||
"can_proceed": True},
|
|
||||||
"run": {"act": 1, "floor": 2}, "player": player()},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
check("rewards with only a skipped card proceeds", d.action, "proceed")
|
|
||||||
|
|
||||||
# rewards -> card_reward must stay in ONE screen group, or the skip flag is
|
|
||||||
# cleared on every hop and the loop returns.
|
|
||||||
brain._reset_screen_guards("rewards")
|
|
||||||
brain._rewards_skipped_card = True
|
|
||||||
brain._reset_screen_guards("card_reward")
|
|
||||||
check("skip flag survives rewards <-> card_reward", brain._rewards_skipped_card, True)
|
|
||||||
brain._reset_screen_guards("monster")
|
|
||||||
check("skip flag clears when leaving the flow", brain._rewards_skipped_card, False)
|
|
||||||
|
|
||||||
class StubClient:
|
class StubClient:
|
||||||
"""
|
"""
|
||||||
Deterministic stand-in for JevClient so the model paths can be tested
|
Deterministic stand-in for JevClient so the model paths can be tested
|
||||||
|
|
@ -487,7 +447,6 @@ valid_indices = {OFFSET, OFFSET + 1, OFFSET + 2}
|
||||||
stub = StubClient()
|
stub = StubClient()
|
||||||
|
|
||||||
# card_select (upgrade)
|
# card_select (upgrade)
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "card_select",
|
{"state_type": "card_select",
|
||||||
"card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.",
|
"card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.",
|
||||||
|
|
@ -504,7 +463,6 @@ check("...and picks by identity, not position",
|
||||||
# card_reward with the stub. `skip_all` is answered NO so the take path is
|
# card_reward with the stub. `skip_all` is answered NO so the take path is
|
||||||
# exercised; otherwise a stub that says yes to everything always skips.
|
# exercised; otherwise a stub that says yes to everything always skips.
|
||||||
take_stub = StubClient(noul_override={"skip_all": 0.10})
|
take_stub = StubClient(noul_override={"skip_all": 0.10})
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "card_reward",
|
{"state_type": "card_reward",
|
||||||
"card_reward": {"cards": [offset_card("Common A", 0),
|
"card_reward": {"cards": [offset_card("Common A", 0),
|
||||||
|
|
@ -517,7 +475,6 @@ check("card_reward emits the card's own index", d.params.get("card_index") in va
|
||||||
|
|
||||||
# And with `skip_all` answered YES, Jev's skip is honoured.
|
# And with `skip_all` answered YES, Jev's skip is honoured.
|
||||||
skip_stub = StubClient(noul_override={"skip_all": 0.95})
|
skip_stub = StubClient(noul_override={"skip_all": 0.95})
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "card_reward",
|
{"state_type": "card_reward",
|
||||||
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
||||||
|
|
@ -529,7 +486,6 @@ check("...and the reason cites jev", "jev:" in d.reason, True)
|
||||||
|
|
||||||
# An UNCERTAIN skip answer must not skip -- take the best card instead.
|
# An UNCERTAIN skip answer must not skip -- take the best card instead.
|
||||||
unsure_stub = StubClient(noul_override={"skip_all": 0.55})
|
unsure_stub = StubClient(noul_override={"skip_all": 0.55})
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "card_reward",
|
{"state_type": "card_reward",
|
||||||
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
||||||
|
|
@ -539,7 +495,6 @@ d = brain.decide(
|
||||||
check("an uncertain skip answer takes a card", d.action, "select_card_reward")
|
check("an uncertain skip answer takes a card", d.action, "select_card_reward")
|
||||||
|
|
||||||
# `can_skip: false` must take a card even if Jev would skip.
|
# `can_skip: false` must take a card even if Jev would skip.
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "card_reward",
|
{"state_type": "card_reward",
|
||||||
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": False},
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": False},
|
||||||
|
|
@ -549,7 +504,6 @@ d = brain.decide(
|
||||||
check("cannot skip -> takes a card anyway", d.action, "select_card_reward")
|
check("cannot skip -> takes a card anyway", d.action, "select_card_reward")
|
||||||
|
|
||||||
# hand_select (in-combat selectable subset)
|
# hand_select (in-combat selectable subset)
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "hand_select",
|
{"state_type": "hand_select",
|
||||||
"hand_select": {"mode": "simple_select", "prompt": "Choose cards to exhaust.",
|
"hand_select": {"mode": "simple_select", "prompt": "Choose cards to exhaust.",
|
||||||
|
|
@ -562,7 +516,6 @@ check("hand_select emits the selectable card's own index",
|
||||||
d.params.get("card_index"), OFFSET + 1)
|
d.params.get("card_index"), OFFSET + 1)
|
||||||
|
|
||||||
# rewards
|
# rewards
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "rewards",
|
{"state_type": "rewards",
|
||||||
"rewards": {"items": [{"index": OFFSET, "type": "gold", "description": "1 Gold"},
|
"rewards": {"items": [{"index": OFFSET, "type": "gold", "description": "1 Gold"},
|
||||||
|
|
@ -574,7 +527,6 @@ d = brain.decide(
|
||||||
check("rewards emits the item's own index", d.params.get("index"), OFFSET + 1)
|
check("rewards emits the item's own index", d.params.get("index"), OFFSET + 1)
|
||||||
|
|
||||||
# shop
|
# shop
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "shop",
|
{"state_type": "shop",
|
||||||
"shop": {"items": [{"index": OFFSET, "category": "relic", "price": 10,
|
"shop": {"items": [{"index": OFFSET, "category": "relic", "price": 10,
|
||||||
|
|
@ -590,7 +542,6 @@ d = brain.decide(
|
||||||
check("shop emits the item's own index", d.params.get("index") in valid_indices, True)
|
check("shop emits the item's own index", d.params.get("index") in valid_indices, True)
|
||||||
|
|
||||||
# map
|
# map
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "map",
|
{"state_type": "map",
|
||||||
"map": {"next_options": [{"index": OFFSET, "col": 0, "row": 1, "type": "Monster"},
|
"map": {"next_options": [{"index": OFFSET, "col": 0, "row": 1, "type": "Monster"},
|
||||||
|
|
@ -602,7 +553,6 @@ d = brain.decide(
|
||||||
check("map emits the node's own index", d.params.get("index") in valid_indices, True)
|
check("map emits the node's own index", d.params.get("index") in valid_indices, True)
|
||||||
|
|
||||||
# bundle_select
|
# bundle_select
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "bundle_select",
|
{"state_type": "bundle_select",
|
||||||
"bundle_select": {"screen_type": "bundle", "prompt": "Choose a bundle.",
|
"bundle_select": {"screen_type": "bundle", "prompt": "Choose a bundle.",
|
||||||
|
|
@ -615,7 +565,6 @@ d = brain.decide(
|
||||||
check("bundle_select emits the bundle's own index", d.params.get("index") in valid_indices, True)
|
check("bundle_select emits the bundle's own index", d.params.get("index") in valid_indices, True)
|
||||||
|
|
||||||
# relic_select
|
# relic_select
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "relic_select",
|
{"state_type": "relic_select",
|
||||||
"relic_select": {"prompt": "Choose a relic.",
|
"relic_select": {"prompt": "Choose a relic.",
|
||||||
|
|
@ -631,7 +580,6 @@ check("relic_select emits the relic's own index", d.params.get("index") in valid
|
||||||
|
|
||||||
# A state whose list positions are ALL zero-ish while the real indices are high:
|
# A state whose list positions are ALL zero-ish while the real indices are high:
|
||||||
# any position-based code returns 0, which is not a valid index here.
|
# any position-based code returns 0, which is not a valid index here.
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "card_select",
|
{"state_type": "card_select",
|
||||||
"card_select": {"screen_type": "remove", "prompt": "Choose a card to Remove.",
|
"card_select": {"screen_type": "remove", "prompt": "Choose a card to Remove.",
|
||||||
|
|
@ -644,66 +592,6 @@ check("a position-based pick of 0 would be invalid here",
|
||||||
d.params.get("index") != 0, True)
|
d.params.get("index") != 0, True)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=== 5. character select needs a select-then-embark sequence ===")
|
|
||||||
|
|
||||||
# The state carries NO "selected" indicator -- the mod hardcodes `message` to
|
|
||||||
# "Select a character." whatever is chosen. So the handler selects once and
|
|
||||||
# embarks when the screen comes back unchanged. Without this the bot selects
|
|
||||||
# the already-selected character forever and stalls.
|
|
||||||
char_select_state = {
|
|
||||||
"state_type": "menu", "menu_screen": "character_select",
|
|
||||||
"message": "Select a character.",
|
|
||||||
"options": [{"name": "IRONCLAD", "enabled": True},
|
|
||||||
{"name": "SILENT", "enabled": True},
|
|
||||||
{"name": "confirm", "enabled": True},
|
|
||||||
{"name": "embark", "enabled": True},
|
|
||||||
{"name": "back", "enabled": True}],
|
|
||||||
}
|
|
||||||
|
|
||||||
force_reset()
|
|
||||||
d1 = brain.decide(char_select_state, None)
|
|
||||||
check("character_select first selects a character", d1.params.get("option"), "IRONCLAD")
|
|
||||||
|
|
||||||
# Same screen unchanged -> a character is already selected -> embark.
|
|
||||||
d2 = brain.decide(char_select_state, None)
|
|
||||||
check("character_select then embarks", d2.params.get("option"), "embark")
|
|
||||||
|
|
||||||
# The sequence ALTERNATES, which is what makes it self-correcting: a rejected
|
|
||||||
# embark ("select a character first") is always followed by a fresh select.
|
|
||||||
d2b = brain.decide(char_select_state, None)
|
|
||||||
check("a third call re-selects", d2b.params.get("option"), "IRONCLAD")
|
|
||||||
d2c = brain.decide(char_select_state, None)
|
|
||||||
check("and then embarks again", d2c.params.get("option"), "embark")
|
|
||||||
|
|
||||||
# A changing signature must not derail the alternation.
|
|
||||||
changed = dict(char_select_state)
|
|
||||||
changed["characters"] = [{"name": "The Ironclad", "id": "IRONCLAD", "locked": False}]
|
|
||||||
d2d = brain.decide(changed, None)
|
|
||||||
check("a changing signature does not derail the cycle",
|
|
||||||
d2d.params.get("option"), "IRONCLAD")
|
|
||||||
|
|
||||||
# Both actions are legal for this screen.
|
|
||||||
check("select is legal", legal(char_select_state, d1), True)
|
|
||||||
check("embark is legal", legal(char_select_state, d2), True)
|
|
||||||
|
|
||||||
# Leaving the screen must clear the guard, or the next run embarks immediately
|
|
||||||
# without selecting anything.
|
|
||||||
brain._reset_screen_guards("menu", "main")
|
|
||||||
d3 = brain.decide(char_select_state, None)
|
|
||||||
check("guard resets when leaving character select", d3.params.get("option"), "IRONCLAD")
|
|
||||||
|
|
||||||
# The alternation must also reset, or a fresh run embarks without selecting.
|
|
||||||
brain._reset_screen_guards("menu", "main")
|
|
||||||
d3b = brain.decide(char_select_state, None)
|
|
||||||
check("alternation resets too", d3b.params.get("option"), "IRONCLAD")
|
|
||||||
|
|
||||||
# menu_screen must be part of the screen key: main menu and character select are
|
|
||||||
# both state_type "menu" but have different valid actions.
|
|
||||||
brain._reset_screen_guards("menu", "main")
|
|
||||||
check("main menu and character select are different screens",
|
|
||||||
brain._screen_group("menu", "main") != brain._screen_group("menu", "character_select"),
|
|
||||||
True)
|
|
||||||
|
|
||||||
# combat_select_card TOGGLES: re-selecting an already-chosen card deselects it,
|
# combat_select_card TOGGLES: re-selecting an already-chosen card deselects it,
|
||||||
# so the state never changes and the loop stalls. `selected_cards` is the
|
# so the state never changes and the loop stalls. `selected_cards` is the
|
||||||
# authoritative list of what is already chosen.
|
# authoritative list of what is already chosen.
|
||||||
|
|
@ -754,18 +642,15 @@ check("upgrade_select confirms instead of selecting", d.action, "combat_confirm_
|
||||||
# No enemies but combat still in progress (Phrog Parasite spawning the next
|
# No enemies but combat still in progress (Phrog Parasite spawning the next
|
||||||
# wave). Measured live: the bot emitted play_card(card_index=1) with no target
|
# wave). Measured live: the bot emitted play_card(card_index=1) with no target
|
||||||
# and the game rejected it with "Card requires a target".
|
# and the game rejected it with "Card requires a target".
|
||||||
force_reset()
|
|
||||||
obs_no_enemies = combat(enemies=[])
|
obs_no_enemies = combat(enemies=[])
|
||||||
d = brain.decide(obs_no_enemies, None)
|
d = brain.decide(obs_no_enemies, None)
|
||||||
check("no enemies -> wait, do not play", d.action, "__wait__")
|
check("no enemies -> wait, do not play", d.action, "__wait__")
|
||||||
|
|
||||||
# A target-requiring card must never be emitted without a target.
|
# A target-requiring card must never be emitted without a target.
|
||||||
force_reset()
|
|
||||||
obs_one_enemy = combat(hand=[card("Strike", 0)], enemies=[enemy()])
|
obs_one_enemy = combat(hand=[card("Strike", 0)], enemies=[enemy()])
|
||||||
d = brain.decide(obs_one_enemy, None)
|
d = brain.decide(obs_one_enemy, None)
|
||||||
check("a single enemy is auto-targeted", d.params.get("target"), "NIBBIT_0")
|
check("a single enemy is auto-targeted", d.params.get("target"), "NIBBIT_0")
|
||||||
|
|
||||||
force_reset()
|
|
||||||
d = brain.decide(obs_no_enemies, StubClient())
|
d = brain.decide(obs_no_enemies, StubClient())
|
||||||
check("even with jev, no enemies means wait", d.action, "__wait__")
|
check("even with jev, no enemies means wait", d.action, "__wait__")
|
||||||
|
|
||||||
|
|
@ -777,28 +662,6 @@ check("raw class name normalises to upgrade",
|
||||||
check("friendly names still work",
|
check("friendly names still work",
|
||||||
(brain.screen_kind("remove"), brain.screen_kind("transform")), ("remove", "transform"))
|
(brain.screen_kind("remove"), brain.screen_kind("transform")), ("remove", "transform"))
|
||||||
|
|
||||||
enchant_state = {
|
|
||||||
"state_type": "card_select",
|
|
||||||
"card_select": {"screen_type": "NDeckEnchantSelectScreen",
|
|
||||||
"prompt": "Choose a card to Enchant.",
|
|
||||||
"cards": [offset_card("Strike", 0), offset_card("Perfected Strike", 1)],
|
|
||||||
"preview_showing": False,
|
|
||||||
"can_cancel": False, "can_confirm": True},
|
|
||||||
"run": {"act": 1, "floor": 13}, "player": player(),
|
|
||||||
}
|
|
||||||
|
|
||||||
force_reset()
|
|
||||||
d1 = brain.decide(enchant_state, None)
|
|
||||||
check("enchant screen selects first", d1.action, "select_card")
|
|
||||||
|
|
||||||
d2 = brain.decide(enchant_state, None)
|
|
||||||
check("enchant screen then confirms", d2.action, "confirm_selection")
|
|
||||||
|
|
||||||
# The full flow must terminate: select, confirm, done.
|
|
||||||
force_reset()
|
|
||||||
seq = [brain.decide(enchant_state, None).action, brain.decide(enchant_state, None).action]
|
|
||||||
check("enchant flow terminates", seq, ["select_card", "confirm_selection"])
|
|
||||||
|
|
||||||
# MULTI-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all
|
# MULTI-select: "Choose 5 cards to Remove" keeps can_confirm FALSE until all
|
||||||
# five are picked, and card_select has no `selected_cards` field. Measured live:
|
# five are picked, and card_select has no `selected_cards` field. Measured live:
|
||||||
# the handler re-selected the same index forever and stalled.
|
# the handler re-selected the same index forever and stalled.
|
||||||
|
|
@ -817,57 +680,8 @@ check("an ADD screen is not an upgrade screen",
|
||||||
check("a REMOVE prompt beats a generic screen_type",
|
check("a REMOVE prompt beats a generic screen_type",
|
||||||
brain.screen_kind("select", "Choose 5 cards to Remove."), "remove")
|
brain.screen_kind("select", "Choose 5 cards to Remove."), "remove")
|
||||||
|
|
||||||
multi_state = {
|
|
||||||
"state_type": "card_select",
|
|
||||||
"card_select": {"screen_type": "select", "prompt": "Choose 5 cards to Remove.",
|
|
||||||
"cards": [offset_card(f"Card{i}", i) for i in range(8)],
|
|
||||||
"preview_showing": False,
|
|
||||||
"can_cancel": False, "can_confirm": False},
|
|
||||||
"run": {"act": 2, "floor": 18}, "player": player(),
|
|
||||||
}
|
|
||||||
|
|
||||||
force_reset()
|
|
||||||
seq = [brain.decide(multi_state, None).action for _ in range(6)]
|
|
||||||
check("multi-select picks 5 then waits",
|
|
||||||
seq, ["select_card"] * 5 + ["__wait__"])
|
|
||||||
|
|
||||||
# An ADD screen: 2 cards, then the game closes the screen by itself (no confirm).
|
|
||||||
force_reset()
|
|
||||||
add_state = {
|
|
||||||
"state_type": "card_select",
|
|
||||||
"card_select": {"screen_type": "simple_select",
|
|
||||||
"prompt": "Choose 2 Common Cards to Add to Your Deck.",
|
|
||||||
"cards": [offset_card(f"Card{i}", i) for i in range(8)],
|
|
||||||
"preview_showing": False,
|
|
||||||
"can_cancel": False, "can_confirm": False},
|
|
||||||
"run": {"act": 1, "floor": 5}, "player": player(),
|
|
||||||
}
|
|
||||||
add_seq = [brain.decide(add_state, None).action for _ in range(4)]
|
|
||||||
check("an ADD screen picks exactly 2 then waits",
|
|
||||||
add_seq, ["select_card", "select_card", "__wait__", "__wait__"])
|
|
||||||
|
|
||||||
# Distinct picks need a fresh screen, or the picks continue from above.
|
|
||||||
force_reset()
|
|
||||||
add_idxs = [brain.decide(add_state, None).params.get("index") for _ in range(2)]
|
|
||||||
check("ADD picks are distinct", len(set(add_idxs)), 2)
|
|
||||||
|
|
||||||
# Every pick must be a DIFFERENT index, or select_card toggles it back off.
|
|
||||||
force_reset()
|
|
||||||
idxs = [brain.decide(multi_state, None).params.get("index") for _ in range(5)]
|
|
||||||
check("each pick targets a different index", len(set(idxs)), 5)
|
|
||||||
|
|
||||||
# Once can_confirm turns true, it confirms.
|
|
||||||
force_reset()
|
|
||||||
for _ in range(5):
|
|
||||||
brain.decide(multi_state, None)
|
|
||||||
confirmable = dict(multi_state)
|
|
||||||
confirmable["card_select"] = dict(multi_state["card_select"], can_confirm=True)
|
|
||||||
d = brain.decide(confirmable, None)
|
|
||||||
check("multi-select confirms once the count is met", d.action, "confirm_selection")
|
|
||||||
|
|
||||||
# Crystal Sphere: can_proceed is FALSE until tiles are revealed. The old
|
# Crystal Sphere: can_proceed is FALSE until tiles are revealed. The old
|
||||||
# unconditional crystal_sphere_proceed was rejected and stalled the run.
|
# unconditional crystal_sphere_proceed was rejected and stalled the run.
|
||||||
force_reset()
|
|
||||||
cs_state = {
|
cs_state = {
|
||||||
"state_type": "crystal_sphere",
|
"state_type": "crystal_sphere",
|
||||||
"crystal_sphere": {"grid_width": 11, "grid_height": 11,
|
"crystal_sphere": {"grid_width": 11, "grid_height": 11,
|
||||||
|
|
@ -880,13 +694,7 @@ d = brain.decide(cs_state, None)
|
||||||
check("crystal sphere reveals a cell first", d.action, "crystal_sphere_click_cell")
|
check("crystal sphere reveals a cell first", d.action, "crystal_sphere_click_cell")
|
||||||
check("...starting from the centre", (d.params.get("x"), d.params.get("y")), (5, 5))
|
check("...starting from the centre", (d.params.get("x"), d.params.get("y")), (5, 5))
|
||||||
|
|
||||||
# It must not re-click the same cell.
|
|
||||||
d2 = brain.decide(cs_state, None)
|
|
||||||
check("crystal sphere does not repeat a cell",
|
|
||||||
(d2.params.get("x"), d2.params.get("y")) != (d.params.get("x"), d.params.get("y")), True)
|
|
||||||
|
|
||||||
# Once can_proceed unlocks, it proceeds.
|
# Once can_proceed unlocks, it proceeds.
|
||||||
force_reset()
|
|
||||||
cs_done = dict(cs_state)
|
cs_done = dict(cs_state)
|
||||||
cs_done["crystal_sphere"] = dict(cs_state["crystal_sphere"], can_proceed=True)
|
cs_done["crystal_sphere"] = dict(cs_state["crystal_sphere"], can_proceed=True)
|
||||||
d3 = brain.decide(cs_done, None)
|
d3 = brain.decide(cs_done, None)
|
||||||
|
|
@ -899,7 +707,6 @@ check("a singular prompt needs 1", brain.hand_select_need("Choose a card to Exha
|
||||||
check("a counted prompt needs N", brain.hand_select_need("Choose 2 cards to discard."), 2)
|
check("a counted prompt needs N", brain.hand_select_need("Choose 2 cards to discard."), 2)
|
||||||
check("\"any number\" is unbounded", brain.hand_select_need("Choose any number of cards to replace."), None)
|
check("\"any number\" is unbounded", brain.hand_select_need("Choose any number of cards to replace."), None)
|
||||||
|
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "hand_select",
|
{"state_type": "hand_select",
|
||||||
"hand_select": {"mode": "simple_select", "prompt": "Choose a card to Exhaust.",
|
"hand_select": {"mode": "simple_select", "prompt": "Choose a card to Exhaust.",
|
||||||
|
|
@ -911,7 +718,6 @@ d = brain.decide(
|
||||||
check("a satisfied singular prompt confirms", d.action, "combat_confirm_selection")
|
check("a satisfied singular prompt confirms", d.action, "combat_confirm_selection")
|
||||||
|
|
||||||
# An unsatisfied counted prompt keeps selecting.
|
# An unsatisfied counted prompt keeps selecting.
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "hand_select",
|
{"state_type": "hand_select",
|
||||||
"hand_select": {"mode": "simple_select", "prompt": "Choose 2 cards to discard.",
|
"hand_select": {"mode": "simple_select", "prompt": "Choose 2 cards to discard.",
|
||||||
|
|
@ -929,7 +735,6 @@ try:
|
||||||
weak_stub = StubClient(noul=0.50, noul_override={"skip_all": 0.10})
|
weak_stub = StubClient(noul=0.50, noul_override={"skip_all": 0.10})
|
||||||
|
|
||||||
brain.CARD_SKIP_POLICY = "jev"
|
brain.CARD_SKIP_POLICY = "jev"
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "card_reward",
|
{"state_type": "card_reward",
|
||||||
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
||||||
|
|
@ -939,7 +744,6 @@ try:
|
||||||
check("jev policy takes a weak card", d.action, "select_card_reward")
|
check("jev policy takes a weak card", d.action, "select_card_reward")
|
||||||
|
|
||||||
brain.CARD_SKIP_POLICY = "combined"
|
brain.CARD_SKIP_POLICY = "combined"
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "card_reward",
|
{"state_type": "card_reward",
|
||||||
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
"card_reward": {"cards": [offset_card("Common A", 0)], "can_skip": True},
|
||||||
|
|
@ -953,7 +757,6 @@ try:
|
||||||
strong_stub = StubClient(noul=0.80, noul_override={"skip_all": 0.10})
|
strong_stub = StubClient(noul=0.80, noul_override={"skip_all": 0.10})
|
||||||
for policy in ("jev", "combined"):
|
for policy in ("jev", "combined"):
|
||||||
brain.CARD_SKIP_POLICY = policy
|
brain.CARD_SKIP_POLICY = policy
|
||||||
force_reset()
|
|
||||||
d = brain.decide(
|
d = brain.decide(
|
||||||
{"state_type": "card_reward",
|
{"state_type": "card_reward",
|
||||||
"card_reward": {"cards": [offset_card("Strong", 0)], "can_skip": True},
|
"card_reward": {"cards": [offset_card("Strong", 0)], "can_skip": True},
|
||||||
|
|
|
||||||
103
test_run.py
103
test_run.py
|
|
@ -108,13 +108,14 @@ class FakeSts2:
|
||||||
self.actions.append((a, k))
|
self.actions.append((a, k))
|
||||||
if self.action_error:
|
if self.action_error:
|
||||||
raise self.action_error
|
raise self.action_error
|
||||||
return types.SimpleNamespace(ok=self.action_ok, message="rejected" if not self.action_ok else "")
|
ok = (self.action_ok[min(len(self.actions) - 1, len(self.action_ok) - 1)]
|
||||||
|
if isinstance(self.action_ok, list) else self.action_ok)
|
||||||
|
return types.SimpleNamespace(ok=ok, message="rejected" if not ok else "")
|
||||||
|
|
||||||
|
|
||||||
def invoke(fake, *flags, client=None, clock=None, decide=None):
|
def invoke(fake, *flags, client=None, clock=None, decide=None):
|
||||||
"""Run the real loop with isolated files, no delays, and no live services."""
|
"""Run the real loop with isolated files, no delays, and no live services."""
|
||||||
callbacks = []
|
callbacks = []
|
||||||
brain._reset_screen_guards("test-run-reset")
|
|
||||||
with tempfile.TemporaryDirectory() as directory, contextlib.ExitStack() as stack:
|
with tempfile.TemporaryDirectory() as directory, contextlib.ExitStack() as stack:
|
||||||
capdir = pathlib.Path(directory) / "capture"
|
capdir = pathlib.Path(directory) / "capture"
|
||||||
stack.enter_context(patch.object(run, "sts2", fake))
|
stack.enter_context(patch.object(run, "sts2", fake))
|
||||||
|
|
@ -291,6 +292,104 @@ result = invoke(FakeSts2([menu, combat]), "--max-jev-errors", "2", client=client
|
||||||
check("successful model call resets the failure budget", client.calls, 4)
|
check("successful model call resets the failure budget", client.calls, 4)
|
||||||
check("two failures after recovery abort", result.rc, 3)
|
check("two failures after recovery abort", result.rc, 3)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== 8. policy memory follows action results and fresh observations ===")
|
||||||
|
# Exercise the real policy and runner together. No global reset between sessions.
|
||||||
|
grid = {"state_type": "card_select", "card_select": {
|
||||||
|
"screen_type": "select", "prompt": "Choose 2 cards to Remove.",
|
||||||
|
"cards": [{"index": 0, "name": "Strike"}, {"index": 1, "name": "Defend"}],
|
||||||
|
"can_confirm": False, "preview_showing": False}}
|
||||||
|
confirmable = dict(grid, card_select=dict(grid["card_select"], can_confirm=True))
|
||||||
|
# One rejected toggle, two accepted toggles, a delayed confirm, then transition.
|
||||||
|
fake = FakeSts2([menu, grid, grid, grid, confirmable, confirmable,
|
||||||
|
{"state_type": "overlay"}, confirmable, menu],
|
||||||
|
action_ok=[False, True])
|
||||||
|
result = invoke(fake, "--no-jev", "--steps", "8", "--max-duplicate-waits", "0")
|
||||||
|
check("selection flow completes", result.rc, 0)
|
||||||
|
check("rejected selection is retried; accepted indices are not toggled twice",
|
||||||
|
fake.actions, [(("select_card",), {"index": 0}),
|
||||||
|
(("select_card",), {"index": 0}),
|
||||||
|
(("select_card",), {"index": 1}),
|
||||||
|
(("confirm_selection",), {}),
|
||||||
|
(("menu_select",), {"option": "singleplayer"})])
|
||||||
|
changed_grid = dict(grid, card_select=dict(grid["card_select"],
|
||||||
|
cards=[{"index": 0, "name": "Defend"}, {"index": 1, "name": "Strike"}]))
|
||||||
|
fake = FakeSts2([menu, grid, changed_grid])
|
||||||
|
result = invoke(fake, "--no-jev", "--steps", "2", "--max-duplicate-waits", "0")
|
||||||
|
check("changed grid does not reuse the old selection indices", len(fake.actions), 1)
|
||||||
|
for session in range(2):
|
||||||
|
fake = FakeSts2([menu, grid])
|
||||||
|
result = invoke(fake, "--no-jev", "--steps", "1")
|
||||||
|
check(f"session {session + 1} starts with fresh selection memory",
|
||||||
|
fake.actions, [(("select_card",), {"index": 0})])
|
||||||
|
fake = FakeSts2([menu, grid])
|
||||||
|
result = invoke(fake, "--no-jev", "--steps", "3", "--dry-run", "--max-duplicate-waits", "0")
|
||||||
|
check("dry-run sends no selection actions", fake.actions, [])
|
||||||
|
check("dry-run proposals never advance selection memory",
|
||||||
|
[r["params"] for r in result.rows if r["event"] == "decide"], [{"index": 0}] * 3)
|
||||||
|
|
||||||
|
shop = {"state_type": "shop", "shop": {"items": [
|
||||||
|
{"index": 0, "category": "card", "card_name": "Inflame",
|
||||||
|
"card_description": "Gain 2 Strength.", "price": 50,
|
||||||
|
"is_stocked": True, "can_afford": True}]}}
|
||||||
|
sold = {"state_type": "shop", "shop": {"items": []}}
|
||||||
|
affordability_only = dict(shop, shop={"items": [dict(shop["shop"]["items"][0], can_afford=False)]})
|
||||||
|
fake = FakeSts2([menu, shop, shop, affordability_only, sold])
|
||||||
|
result = invoke(fake, "--steps", "4", "--max-duplicate-waits", "0")
|
||||||
|
check("purchase waits through unchanged inventory and unrelated state changes",
|
||||||
|
fake.actions, [(("shop_purchase",), {"index": 0}), (("proceed",), {})])
|
||||||
|
|
||||||
|
bundle = {"state_type": "bundle_select", "bundle_select": {
|
||||||
|
"bundles": [{"index": 2, "cards": []}], "preview_showing": False}}
|
||||||
|
preview = dict(bundle, bundle_select=dict(bundle["bundle_select"],
|
||||||
|
preview_showing=True, can_confirm=True))
|
||||||
|
fake = FakeSts2([menu, bundle, bundle, preview, preview, menu])
|
||||||
|
result = invoke(fake, "--no-jev", "--steps", "5", "--max-duplicate-waits", "0")
|
||||||
|
check("bundle waits for preview and never cancels a delayed confirmation",
|
||||||
|
[a[0][0] for a in fake.actions], ["select_bundle", "confirm_bundle_selection", "menu_select"])
|
||||||
|
|
||||||
|
crystal = {"state_type": "crystal_sphere", "crystal_sphere": {
|
||||||
|
"grid_width": 3, "grid_height": 3, "clickable_cells": [{"x": 1, "y": 1}],
|
||||||
|
"can_proceed": False}}
|
||||||
|
revealed = dict(crystal, crystal_sphere=dict(crystal["crystal_sphere"], can_proceed=True))
|
||||||
|
fake = FakeSts2([menu, crystal, crystal, crystal, revealed], action_ok=[False, True])
|
||||||
|
result = invoke(fake, "--no-jev", "--steps", "4", "--max-duplicate-waits", "0")
|
||||||
|
check("rejected crystal click stays available; accepted click waits for evidence",
|
||||||
|
[a[0][0] for a in fake.actions],
|
||||||
|
["crystal_sphere_click_cell", "crystal_sphere_click_cell", "crystal_sphere_proceed"])
|
||||||
|
|
||||||
|
characters = {"state_type": "menu", "menu_screen": "character_select", "options": ["IRONCLAD", "embark"]}
|
||||||
|
fake = FakeSts2([menu, characters, characters, characters, characters, characters,
|
||||||
|
{"state_type": "unknown"}, characters, menu],
|
||||||
|
action_ok=[True, False, True])
|
||||||
|
result = invoke(fake, "--no-jev", "--steps", "8", "--max-duplicate-waits", "0")
|
||||||
|
check("rejected embark re-selects; accepted embark waits through transitions",
|
||||||
|
[a[1].get("option") for a in fake.actions],
|
||||||
|
["IRONCLAD", "embark", "IRONCLAD", "embark", "singleplayer"])
|
||||||
|
|
||||||
|
rewards = {"state_type": "rewards", "rewards": {"items": [{"index": 0, "type": "card"}]}}
|
||||||
|
fake = FakeSts2([menu, card_reward, card_reward, rewards, menu, rewards], action_ok=[False, True])
|
||||||
|
result = invoke(fake, "--steps", "5", "--max-duplicate-waits", "0")
|
||||||
|
check("skip memory follows acceptance and clears outside the reward flow",
|
||||||
|
[a[0][0] for a in fake.actions],
|
||||||
|
["skip_card_reward", "skip_card_reward", "proceed", "menu_select", "claim_reward"])
|
||||||
|
|
||||||
|
hand = {"state_type": "hand_select", "hand_select": {
|
||||||
|
"mode": "simple_select", "prompt": "Choose a card to Exhaust.",
|
||||||
|
"cards": [{"index": 2, "name": "Strike"}], "can_confirm": False}}
|
||||||
|
selected_hand = dict(hand, hand_select=dict(hand["hand_select"], can_confirm=True,
|
||||||
|
selected_cards=[{"index": 0, "name": "Strike"}]))
|
||||||
|
fake = FakeSts2([menu, hand, hand, selected_hand, selected_hand, menu])
|
||||||
|
result = invoke(fake, "--no-jev", "--steps", "5", "--max-duplicate-waits", "0")
|
||||||
|
check("hand selection waits for selected count, not the separate selected-card index",
|
||||||
|
[a[0][0] for a in fake.actions], ["combat_select_card", "combat_confirm_selection", "menu_select"])
|
||||||
|
|
||||||
|
fake = FakeSts2([menu] + [dict(shop, animation_tick=i) for i in range(100)])
|
||||||
|
result = invoke(fake, "--steps", "100", "--stuck-seconds", "10", clock=itertools.count())
|
||||||
|
check("pending purchase times out despite unrelated observation changes",
|
||||||
|
(result.rc, result.sessions[0]["stop_reason"], len(fake.actions)),
|
||||||
|
(1, "pending_action_timeout", 1))
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print(f"=== {PASS} passed, {FAIL} failed ===")
|
print(f"=== {PASS} passed, {FAIL} failed ===")
|
||||||
sys.exit(1 if FAIL else 0)
|
sys.exit(1 if FAIL else 0)
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,6 @@ def observation(cards: list, *, enemies=None, energy=1, hp=80, block=0) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def reset_policy() -> None:
|
|
||||||
# Each stored observation is independent. These are not ordered trajectories.
|
|
||||||
brain._reset_screen_guards("audit-reset")
|
|
||||||
|
|
||||||
|
|
||||||
def synthetic_probes() -> dict:
|
def synthetic_probes() -> dict:
|
||||||
report = {}
|
report = {}
|
||||||
obs = observation([card(0)], enemies=[enemy("E0", 6, 0), enemy("E1", 6, 0)])
|
obs = observation([card(0)], enemies=[enemy("E0", 6, 0), enemy("E1", 6, 0)])
|
||||||
|
|
@ -94,20 +89,19 @@ def synthetic_probes() -> dict:
|
||||||
result = {"propagated": True}
|
result = {"propagated": True}
|
||||||
report["combat_model_error"] = {**result, "diagnostic": output.getvalue().strip()}
|
report["combat_model_error"] = {**result, "diagnostic": output.getvalue().strip()}
|
||||||
|
|
||||||
reset_policy()
|
|
||||||
obs = {
|
obs = {
|
||||||
"state_type": "card_select",
|
"state_type": "card_select",
|
||||||
"card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.",
|
"card_select": {"screen_type": "upgrade", "prompt": "Choose a card to Upgrade.",
|
||||||
"cards": [card(5)], "can_confirm": False,
|
"cards": [card(5)], "can_confirm": False,
|
||||||
"can_cancel": True, "preview_showing": False},
|
"can_cancel": True, "preview_showing": False},
|
||||||
}
|
}
|
||||||
first = brain.decide(obs, None)
|
context = brain.PolicyContext()
|
||||||
second = brain.decide(obs, None)
|
first = brain.decide(obs, None, context=context)
|
||||||
|
second = brain.decide(obs, None, context=context)
|
||||||
report["selection_without_execution"] = {
|
report["selection_without_execution"] = {
|
||||||
"first": first.action, "second": second.action, "reason": second.reason,
|
"first": first.action, "second": second.action, "reason": second.reason,
|
||||||
"actual_game_actions": 0,
|
"actual_game_actions": 0,
|
||||||
}
|
}
|
||||||
reset_policy()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
answer = jev.JevClient._parse({"answers": {"test": {"type": "noul"}}}, 0)["test"]
|
answer = jev.JevClient._parse({"answers": {"test": {"type": "noul"}}}, 0)["test"]
|
||||||
|
|
@ -138,7 +132,6 @@ def corpus_audit() -> dict:
|
||||||
raise ValueError("State path leaves dataset directory")
|
raise ValueError("State path leaves dataset directory")
|
||||||
obs = json.loads(gzip.decompress(path.read_bytes()))
|
obs = json.loads(gzip.decompress(path.read_bytes()))
|
||||||
types[obs.get("state_type")] += 1
|
types[obs.get("state_type")] += 1
|
||||||
reset_policy()
|
|
||||||
try:
|
try:
|
||||||
decision = brain.decide(obs, None)
|
decision = brain.decide(obs, None)
|
||||||
actions[decision.action if decision else "no_decision"] += 1
|
actions[decision.action if decision else "no_decision"] += 1
|
||||||
|
|
@ -162,7 +155,6 @@ def corpus_audit() -> dict:
|
||||||
status_in_deck += 1
|
status_in_deck += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
exceptions.append({"path": row["path"], "error": f"{type(exc).__name__}: {exc}"})
|
exceptions.append({"path": row["path"], "error": f"{type(exc).__name__}: {exc}"})
|
||||||
reset_policy()
|
|
||||||
return {
|
return {
|
||||||
"state_types": dict(types), "combat_phases": dict(phases), "actions": dict(actions),
|
"state_types": dict(types), "combat_phases": dict(phases), "actions": dict(actions),
|
||||||
"exceptions": exceptions, "forced_block_when_covered": covered,
|
"exceptions": exceptions, "forced_block_when_covered": covered,
|
||||||
|
|
@ -171,7 +163,6 @@ def corpus_audit() -> dict:
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
reset_policy()
|
|
||||||
report = {"synthetic": synthetic_probes(), "corpus": corpus_audit()}
|
report = {"synthetic": synthetic_probes(), "corpus": corpus_audit()}
|
||||||
print(json.dumps(report, indent=2, sort_keys=True))
|
print(json.dumps(report, indent=2, sort_keys=True))
|
||||||
# Successful audit execution is not a clean bill of health. Read the report.
|
# Successful audit execution is not a clean bill of health. Read the report.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue