154 lines
7 KiB
Python
154 lines
7 KiB
Python
"""Shared proposal types and session-owned policy memory. No game or model calls."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
@dataclass
|
|
class Decision:
|
|
action: str
|
|
params: dict = field(default_factory=dict)
|
|
reason: str = ""
|
|
source: str = "code" # code | jev | fallback
|
|
confidence: float | None = None
|
|
|
|
def __str__(self) -> str:
|
|
params = ", ".join(f"{k}={v!r}" for k, v in self.params.items())
|
|
conf = f" conf={self.confidence:.2f}" if self.confidence is not None else ""
|
|
return f"[{self.source}{conf}] {self.action}({params}) # {self.reason}"
|
|
|
|
|
|
# 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.
|
|
SCREEN_GROUPS = {"rewards": "rewards_flow", "card_reward": "rewards_flow"}
|
|
|
|
|
|
def _screen_group(state_type: str | None, menu_screen: str | None = None) -> str | None:
|
|
"""
|
|
A stable key for "which screen am I on".
|
|
|
|
The `menu` state_type covers the main menu, mode select, character select
|
|
and the tutorial prompt. Those are different screens with different valid
|
|
actions, so the menu_screen is part of the key.
|
|
"""
|
|
if state_type == "menu":
|
|
return f"menu:{menu_screen}"
|
|
return SCREEN_GROUPS.get(state_type, state_type)
|
|
|
|
|
|
@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.
|
|
"""
|
|
|
|
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
|
|
|
|
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":
|
|
# Upgrade previews do not expose selected_cards. A ready upgrade
|
|
# confirmation is completion evidence for the accepted selection.
|
|
upgrade_ready = screen.get("mode") == "upgrade_select" and screen.get("can_confirm")
|
|
selection_grew = (len(screen.get("selected_cards") or [])
|
|
> len(pending.screen.get("selected_cards") or []))
|
|
if not upgrade_ready and not selection_grew:
|
|
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)
|