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

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

294
brain.py
View file

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