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
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 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue