"""Run-scoped policy memory and explicitly limited card evidence. No file or network I/O.""" from __future__ import annotations from dataclasses import dataclass, field import facts as F from policy.context import Decision, PolicyContext @dataclass class RunContext: policy: PolicyContext = field(default_factory=PolicyContext) run_id: str | None = None seed: str | None = None last_known_id: str | None = None active: bool = False deck: dict | None = None def observe(self, obs: dict, current_run: dict | None) -> None: """Bind evidence to reported identity, never to a seed or a local save path. Missing metadata discards card evidence, but retains action guards. Clearing accepted toggles on a transient metadata failure is unsafe. A different known run ID or an observed run exit resets those guards. """ st = obs.get("state_type") if st in ("unknown", "overlay"): return if st in ("menu", "game_over"): if self.active: self.policy = PolicyContext() self.run_id = self.seed = self.last_known_id = None self.deck = None self.active = False return self.active = True identity = current_run if isinstance(current_run, dict) else {} run_id = identity.get("run_id") if identity.get("is_in_progress") is not True or not isinstance(run_id, str) or not run_id.strip(): self.run_id = self.seed = None self.deck = None return if self.last_known_id is not None and run_id != self.last_known_id: self.policy = PolicyContext() self.deck = None self.run_id = self.last_known_id = run_id self.seed = identity.get("seed") if isinstance(identity.get("seed"), str) else None if self.deck is not None: self.deck["provenance"]["freshness"] = "historical_combat_piles" # Room changes make this weak evidence even less useful. Do not # carry it into a later room as a substitute for the run deck. location = {k: (obs.get("run") or {}).get(k) for k in ("act", "floor")} if any(value is None for value in location.values()) or location != self.deck["provenance"]["location"]: self.deck = None def capture_combat(self, obs: dict, facts: F.CombatFacts, step: int) -> None: """Visible piles include temporary cards and can omit unseen cards.""" if self.run_id is None or not facts.deck_counts: self.deck = None return self.deck = { "counts": facts.deck_counts, "summary": facts.deck_summary, "provenance": { "source": "combat_piles", "run_id": self.run_id, "observed_step": step, "location": {k: (obs.get("run") or {}).get(k) for k in ("act", "floor")}, "persistent_deck": False, "pile_lists_present": [name for name in ("hand", "draw_pile", "discard_pile", "exhaust_pile") if isinstance((obs.get("player") or {}).get(name), list)], "freshness": "observed_combat_piles", }, } def record_result(self, obs: dict, decision: Decision, *, accepted: bool) -> None: self.policy.record_result(obs, decision, accepted=accepted) if not accepted or obs.get("state_type") in ("monster", "elite", "boss"): return # Keep limited evidence while opening/skipping a card reward. Other # accepted non-combat actions can change the deck or leave this room. card_reward = decision.action == "claim_reward" and any( item.get("index") == decision.params.get("index") and item.get("type") == "card" for item in (obs.get("rewards") or {}).get("items", []) ) if not card_reward and decision.action != "skip_card_reward": self.deck = None