From ba519a850e9eec55a2d8f3d8af502e62212a5d1b Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 22 Sep 2026 12:45:46 +0700 Subject: [PATCH] feat(state): scope card evidence and policy memory by reported run identity --- AGENTS.md | 4 +- CONTEXT.md | 7 ++ docs/POLICY.md | 10 +-- docs/RUN_STATE.md | 117 +++++++++++++++++++++++++++++++ docs/research/13-policy-state.md | 4 +- facts.py | 39 ++++------- run.py | 80 ++++++++++----------- run_state.py | 87 +++++++++++++++++++++++ test_run.py | 75 ++++++++++++++++++-- 9 files changed, 342 insertions(+), 81 deletions(-) create mode 100644 docs/RUN_STATE.md create mode 100644 run_state.py diff --git a/AGENTS.md b/AGENTS.md index 427aaef..6614ecb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - `policy/combat.py` and `policy/selection.py`: combat and selection proposals. - `policy/context.py`: shared `Decision`, pending actions, and session-owned `PolicyContext`. - `run.py`: observe–decide–act loop, captures, and session attribution. +- `run_state.py`: reported run identity and combat-pile provenance; see `docs/RUN_STATE.md`. - `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates. - `migrate.py`: dataset migration and integrity checks. - `CONTEXT.md`: domain terms. `docs/POLICY.md`: policy boundaries. `docs/DATASET.md`: dataset schema and limits. @@ -35,7 +36,8 @@ Prefer integration/end-to-end checks; keep only essential regression tests and u ## Safety and scope - Default to offline tests. Ask before live game actions, model calls, or evaluation batches. -- `run.py --dry-run` sends no action POSTs or deck-cache writes; state reads, model calls, and logs still run. +- `run.py --dry-run` sends no action POSTs; state/identity reads, model calls, and logs still run. +- The runner ignores legacy `deck.json`. Combat-pile evidence is session-local and is not the persistent deck. - Never print or commit credentials. Do not read secret files for development checks. - Do not rebuild `dataset/`, change captures, or edit `vendor/` unless the task requires it. - `migrate.py --verify` rebuilds data; use `--check-only` for routine verification. diff --git a/CONTEXT.md b/CONTEXT.md index 683d7d7..b29b427 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -38,6 +38,13 @@ An accepted request to change a card's selection status. It does not prove that **Screen flow**: Related game screens that form one interaction, such as opening and skipping a card reward. +**Reported run identity**: +The run identifier supplied by the game service. It identifies a reported attempt, not a seed or an atomic state snapshot. + +**Combat-pile snapshot**: +The cards visible in combat piles at one observation. It can include temporary cards and omit unavailable cards. +_Avoid_: Persistent deck, complete deck + **Run deck**: 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. diff --git a/docs/POLICY.md b/docs/POLICY.md index 63d6139..96bfaa8 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -4,6 +4,7 @@ ```text run.py observe, execute, report results, record the session +run_state.py reported run identity and scoped combat-pile evidence brain.py entry point, dispatch, navigation, shops, minigames policy/ __init__.py package marker; no registration or initialization @@ -13,8 +14,9 @@ policy/ facts.py observation parsing and combat calculations ``` -`brain.decide()` remains the policy entry point. The runner owns one -`PolicyContext` per session and reports action results to it. +`brain.decide()` remains the policy entry point. The runner owns a `RunContext`, +which holds policy memory and replaces it on reported run changes. The runner +reports action results through that context. `brain.Decision` and `brain.PolicyContext` remain available as direct imports of the shared types, so the runner interface does not change. @@ -47,8 +49,8 @@ These are plain modules, not a plugin framework or class hierarchy. Navigation, shops, and minigames stay together until a further split helps development. The extraction does not change game decisions, confidence gates, fallbacks, -state reconciliation, or recording. Run identity and persistent-deck provenance -remain separate work. +state reconciliation, or recording. The subsequent +[run-state pass](RUN_STATE.md) adds reported identity and card-evidence provenance. ## Extraction verification diff --git a/docs/RUN_STATE.md b/docs/RUN_STATE.md new file mode 100644 index 0000000..837f57d --- /dev/null +++ b/docs/RUN_STATE.md @@ -0,0 +1,117 @@ +# Run identity and card-evidence provenance + +## What is available + +The vendored mod exposes `current_run.run_id` through `GET /api/v1/compendium`. +Its format includes save scope, profile, and start time. The seed describes +content; it is not the identity of an attempt. + +The live player serializer exposes hand, draw, discard, and exhaust piles during +combat. It does not expose the persistent run deck. The compendium exposes a +save path, but the runner does not follow that path or read the save file. + +Sources checked: + +- `vendor/STS2MCP/McpMod.Compendium.cs`: `BuildCurrentRunContext`. +- `vendor/STS2MCP/McpMod.StateBuilder.cs`: `BuildPlayerState`. + +## Ownership and identity + +`run.py` owns one `run_state.RunContext` per invocation. The context owns the +current `PolicyContext` and optional combat-pile evidence. The context performs +no network or file operations. + +The runner requests identity after each stable in-run observation, before +proposing an action. Menu, game-over, unknown, and overlay observations do not +trigger this request. Dry-run still performs identity reads. + +This deliberately uses the existing full compendium endpoint. It adds one local +HTTP read per stable in-run step. A smaller identity endpoint would reduce cost, +but this pass does not modify the mod or add a polling cache with stale identities. +Live latency has not been measured. + +A usable identity requires `is_in_progress: true` and a nonempty string `run_id`. +Missing or malformed identity remains unknown. The runner never substitutes a +seed, character name, or floor number as an identity. + +- A different reported run ID resets policy memory, duplicate-action guards, + rejection counts, and card evidence. +- `--stop-on-run-end` stops before acting in the replacement run, with reason + `run_changed`. This reports a run boundary, not victory or defeat. +- Observed menu/game-over transitions clear run evidence and active-run policy memory. +- Unknown states and overlays preserve pending-action guards. +- Missing identity or a transport error discards card evidence but retains action + guards. Erasing accepted toggles during a metadata outage could toggle them again. +- The last known ID is retained privately across a metadata outage, so recovery + with a different ID can still reset state. Decision rows use null during the outage. + +### Identity is reported, not atomic + +The mod derives identity from save metadata. The observation and compendium are +separate requests. They are not an atomic snapshot, and save metadata can lag. +The runner cannot prove that both responses describe exactly the same instant. +A live observation-level run ID would provide a stronger association. + +Do not infer exact attribution or game-action completion from this identity alone. +Externally switching runs while the bot acts remains a coordination risk. + +## Card evidence + +The runner no longer reads or writes `deck.json`. Existing files remain untouched. +No card evidence survives a runner restart. Unscoped legacy input to +`facts.deck_context` becomes `unknown`. + +A combat-pile snapshot is retained only when a reported run ID is available. +Its provenance includes: + +- `source: combat_piles`; +- reported `run_id`; +- `observed_step` within the current session; +- observed act and floor; +- `persistent_deck: false`; +- which pile lists were present; +- freshness: `observed_combat_piles` or `historical_combat_piles`. + +The counts retain visible temporary cards, including Status cards. Filtering out +Status cards would not reconstruct the persistent deck: generated Attack cards, +temporary upgrades, and unavailable card data would still remain problems. +Pile-list presence does not establish complete coverage. + +Evidence can carry into immediate reward decisions in the same reported room. +Opening a card reward or skipping it preserves that limited evidence. Other +accepted non-combat actions discard it because they can change cards or leave the +room. Rejected requests do not establish such a change. + +A room change, missing room coordinates, identity failure, new reported run ID, +or observed run exit discards evidence. The policy receives `unknown` when no +scoped evidence remains. This reduces available synergy context intentionally; +it is safer than presenting stale combat piles as the current persistent deck. + +An exact persistent-deck source is still unavailable. This pass labels that +limitation; it does not implement a complete deck tracker or a save-file parser. + +## Minimal trace additions + +Decision rows now include reported `run_id`, `run_seed`, and available +`deck_provenance`. The provenance describes available macro-policy evidence; +it does not mean every handler used it. Combat policy uses the combat observation directly. + +Identity changes and identity transport failures have separate trace events. +Full observation/action linkage, unique capture names, session identity, and +session finalization remain recording work. + +## Validation + +- 279 existing-script assertions passed: 68 facts, 110 policy, and 101 runner. + This includes two upgrade-selection assertions added concurrently. +- Runner integration flows cover same-seed run replacement, single-run stopping, + metadata outages, temporary cards, provenance, mutation invalidation, room + boundaries, unknown rooms, and restart/cache isolation. +- Eighteen temporary whole-process scenarios passed with real HTTP clients and + loopback fixtures. Five exercise the new identity/provenance behavior. +- The offline audit replayed 346 stored observations without policy exceptions. +- Dataset integrity passed for 37 runs, 1,052 decisions, and 346 observations. +- Python compilation and shell syntax checks passed. + +No live game actions, paid model calls, dependencies, vendor edits, or dataset +rewrites were required. Temporary probes were not added to the repository. diff --git a/docs/research/13-policy-state.md b/docs/research/13-policy-state.md index 6742596..d417056 100644 --- a/docs/research/13-policy-state.md +++ b/docs/research/13-policy-state.md @@ -107,7 +107,7 @@ Combat and selection policy have since been extracted without changing execution behavior. See [the current policy structure](../POLICY.md). No plugin framework or class hierarchy was added. -The next state work is run identity and deck provenance. Recording should then -link observations, proposals, action attempts, results, and reconciliation. +[Run identity and card-evidence provenance](../RUN_STATE.md) have since been added. +Recording should next link observations, proposals, action attempts, results, and reconciliation. Session finalization, capture-name collisions, and actual policy-gate metadata remain separate work. diff --git a/facts.py b/facts.py index 6c2e760..f8d8304 100644 --- a/facts.py +++ b/facts.py @@ -538,7 +538,7 @@ class CombatFacts: f"block_available={self.max_block_available} survives_with_cards={self.survives_with_cards}", f"potions={[p.get('name') for p in self.potions]}", f"piles: draw={self.draw_pile_count} discard={self.discard_pile_count} exhaust={self.exhaust_pile_count}", - f"deck: {self.deck_counts}", + f"visible combat piles: {self.deck_counts}", ] for e in self.enemies: lines.append( @@ -620,22 +620,12 @@ def enemy_status_names(enemy: EnemyFact) -> list: def deck_summary(player: dict) -> dict: - """ - Stable aggregates over ALL FOUR PILES, for macro decisions. + """Aggregate visible combat piles, not the persistent run deck. - The reward, shop and map states do NOT expose the deck, so this travels with - the composition snapshot taken during combat. It is deliberately a - *supplement* to the name->count map, never a replacement: card identities - are the signal for synergy, redundancy and upgrades, and only counts can - carry them. These aggregates are the part that is arithmetic, so it is - computed here and never asked of the model. - - Every field is computed over the whole deck and is stable across snapshots - of the same deck. There is deliberately no cost aggregate: the state does - not expose a card's BASE cost, and a temporary in-combat cost modifier - applies to the copy in hand. Measured over 600 captures, one Strike read - `cost: "0"` in hand while the same Strike read `cost: "1"` in the draw pile, - so an average cost would differ between two snapshots of an identical deck. + Generated cards, Status cards, temporary upgrades, and missing pile data + limit this evidence. These counts can change during combat. Cost is omitted + because temporary modifiers make displayed costs unsuitable for a base-cost + aggregate. The historical function name is retained for existing callers. """ names: list[str] = [] attacks = blocks = other = upgraded = 0 @@ -668,22 +658,19 @@ def deck_summary(player: dict) -> dict: def deck_context(deck: Any) -> Any: - """ - What a macro question receives as deck context: the full name->count map - (card identities, which carry the synergy and redundancy signal) plus the - stable aggregates. Tolerates a legacy flat name->count snapshot. - """ + """Supply card evidence with its limits; unscoped legacy data is unknown.""" if not deck: return "unknown" if isinstance(deck, dict) and "counts" in deck: counts = deck.get("counts") or {} if not counts: return "unknown" - return {"cards": counts, **({"summary": deck["summary"]} - if deck.get("summary") else {})} - if isinstance(deck, dict): - return {"cards": deck} - return deck + provenance = deck.get("provenance") + if not isinstance(provenance, dict) or not provenance.get("run_id"): + return "unknown" + return {"cards": counts, "provenance": provenance, + **({"summary": deck["summary"]} if deck.get("summary") else {})} + return "unknown" # -------------------------------------------------------------------------- diff --git a/run.py b/run.py index 575506b..c3c0684 100644 --- a/run.py +++ b/run.py @@ -28,8 +28,7 @@ import brain import facts as F import sts2 from jev import JevClient, JevError, answer_record - -DECK_FILE = pathlib.Path("deck.json") +from run_state import RunContext # Where the game writes its own run records. This is the OUTCOME source: it # carries win/loss, the killer, the seed and the final deck. Override with @@ -163,28 +162,6 @@ def decision_record(step: int, state_type: str, run_state, decision, } -# Known starting decks, used only until the first combat exposes the real one. -# Source: the character_select state, which lists starting_deck per character. -STARTING_DECKS: dict[str, dict[str, int]] = { - "The Ironclad": {"Strike": 5, "Defend": 4, "Bash": 1}, - "The Silent": {"Strike": 5, "Defend": 5, "Neutralize": 1, "Survivor": 1}, - "The Regent": {"Strike": 4, "Defend": 4, "Falling Star": 1, "Venerate": 1}, -} - - -def load_deck() -> dict | None: - if DECK_FILE.exists(): - try: - return json.loads(DECK_FILE.read_text()) - except (json.JSONDecodeError, OSError): - return None - return None - - -def save_deck(deck: dict) -> None: - DECK_FILE.write_text(json.dumps(deck, indent=2, sort_keys=True)) - - def observe() -> dict: return sts2.state() @@ -243,8 +220,8 @@ def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--steps", type=int, default=20) ap.add_argument("--dry-run", action="store_true", - help="send no action POSTs and do not update deck.json; " - "state reads, model calls, and capture logs still run") + help="send no action POSTs; " + "state/identity reads, model calls, and capture logs still run") ap.add_argument("--no-jev", action="store_true") ap.add_argument("--pause", type=float, default=0.6, help="seconds to wait after each action") @@ -349,12 +326,9 @@ def main() -> int: atexit.register(write_session_row) - # The card_reward state does not expose the deck, but combat states expose - # all four piles. Snapshot composition during combat and persist it so it - # survives a restart. - deck_snapshot: dict | None = load_deck() - if deck_snapshot: - print(f"deck snapshot loaded: {deck_snapshot}") + # Never load the old unscoped deck.json. Visible combat piles are limited + # evidence, not a persistent deck, and remain local to this session/run. + run_context = RunContext() waits = 0 rejected = 0 last_sig: str | None = None @@ -365,7 +339,6 @@ def main() -> int: duplicate_waits = 0 jev_errors = 0 saw_a_run = False - policy_context = brain.PolicyContext() pending_action = None pending_since = time.monotonic() @@ -377,6 +350,28 @@ def main() -> int: return finish(1, "state_error") st = obs.get("state_type") + previous_id = run_context.run_id + previous_known_id = run_context.last_known_id + identity = None + if st not in ("menu", "game_over", "unknown", "overlay"): + try: + identity = sts2.compendium().get("current_run") + except sts2.Sts2Error as exc: + trace({"step": step, "event": "run_identity_error", "error": str(exc)[:160]}) + run_context.observe(obs, identity) + if run_context.run_id != previous_id: + trace({"step": step, "event": "run_identity", "run_id": run_context.run_id, + "seed": run_context.seed, "source": "compendium.current_run"}) + run_changed = (previous_known_id is not None and run_context.run_id is not None + and previous_known_id != run_context.run_id) + if run_changed: + if args.stop_on_run_end: + return finish(0, "run_changed") + last_sig = last_action_key = None + same_state = rejected = duplicate_waits = 0 + same_state_since = time.monotonic() + last_ok = True + policy_context = run_context.policy # One session = one run, when asked. Otherwise the bot dies, returns to # the menu and starts a fresh run inside the same session, so a single @@ -446,13 +441,7 @@ def main() -> int: print(f"[{step:03d}] COMBAT {f.describe().splitlines()[0]}") for line in f.describe().splitlines()[1:]: print(f" {line}") - # The snapshot carries the name->count map (card identities, which - # are the synergy signal) plus stable all-pile aggregates. Only the - # counts decide whether the deck actually changed. - if f.deck_counts and f.deck_counts != (deck_snapshot or {}).get("counts"): - deck_snapshot = {"counts": f.deck_counts, "summary": f.deck_summary} - if not args.dry_run: - save_deck(deck_snapshot) + run_context.capture_combat(obs, f, step) decision = None decide_error = None @@ -461,7 +450,7 @@ def main() -> int: # must not inherit the previous step's answers in the log. client.last = None try: - decision = brain.decide(obs, client, deck_snapshot, context=policy_context) + decision = brain.decide(obs, client, run_context.deck, context=policy_context) # A procedural action or animation wait does not establish model # recovery. Only a successful model response resets the budget. if client is not None and client.last is not None: @@ -481,7 +470,7 @@ def main() -> int: # the same failing request -- measured, a DNS blip re-raised out of # the "fallback" and killed the session. try: - decision = brain.decide(obs, None, deck_snapshot, context=policy_context) + decision = brain.decide(obs, None, run_context.deck, context=policy_context) except Exception as inner: # noqa: BLE001 trace({"step": step, "event": "fallback_error", "error": str(inner)[:160]}) print(f"[{step:03d}] fallback also failed: {inner}") @@ -539,7 +528,10 @@ def main() -> int: stats[decision.source] = stats.get(decision.source, 0) + 1 print(f"[{step:03d}] DECIDE {decision}") - trace(decision_record(step, st, obs.get("run"), decision, decide_error, client)) + record = decision_record(step, st, obs.get("run"), decision, decide_error, client) + record.update(run_id=run_context.run_id, run_seed=run_context.seed, + deck_provenance=(run_context.deck or {}).get("provenance")) + trace(record) last_action_key = action_key if args.dry_run: @@ -553,7 +545,7 @@ def main() -> int: "action": decision.action, "error": str(exc)[:200]}) return finish(1, "action_error") - policy_context.record_result(obs, decision, accepted=result.ok) + run_context.record_result(obs, decision, accepted=result.ok) if not result.ok: print(f"[{step:03d}] action rejected: {result.message}") trace({"step": step, "event": "action_rejected", diff --git a/run_state.py b/run_state.py new file mode 100644 index 0000000..73b1163 --- /dev/null +++ b/run_state.py @@ -0,0 +1,87 @@ +"""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 diff --git a/test_run.py b/test_run.py index 3906475..620aaa4 100755 --- a/test_run.py +++ b/test_run.py @@ -22,6 +22,7 @@ import contextlib import io import itertools import json +import os import pathlib import sys import tempfile @@ -55,12 +56,14 @@ class StubClient: def __init__(self, noul: float = 0.9): self.noul = noul self.calls = 0 + self.requests = [] def __repr__(self) -> str: return "StubClient()" def ask(self, state, questions, model=None): self.calls += 1 + self.requests.append((state, questions)) answers = {} for qid, q in questions.items(): if q.get("type") == "choice": @@ -87,12 +90,14 @@ class FakeSts2: BASE = "offline://game" - def __init__(self, states, *, action_ok=True, action_error=None): + def __init__(self, states, *, action_ok=True, action_error=None, identities=None): self.states = states self.i = 0 self.actions = [] self.action_ok = action_ok self.action_error = action_error + self.identities = identities or [{"is_in_progress": True, "run_id": "fixture:A", "seed": "same-seed"}] + self.identity_reads = 0 def is_up(self) -> bool: return True @@ -104,6 +109,13 @@ class FakeSts2: raise state return state + def compendium(self): + identity = self.identities[min(self.identity_reads, len(self.identities) - 1)] + self.identity_reads += 1 + if isinstance(identity, Exception): + raise identity + return {"current_run": identity} + def act(self, *a, **k): self.actions.append((a, k)) if self.action_error: @@ -121,8 +133,11 @@ def invoke(fake, *flags, client=None, clock=None, decide=None): stack.enter_context(patch.object(run, "sts2", fake)) stack.enter_context(patch.object(run, "JevClient", return_value=client or StubClient())) stack.enter_context(patch.object(run, "history_snapshot", return_value=set())) - stack.enter_context(patch.object(run, "load_deck", return_value=None)) - save = stack.enter_context(patch.object(run, "save_deck")) + stack.callback(os.chdir, os.getcwd()) + os.chdir(directory) + legacy = pathlib.Path(directory) / "deck.json" + legacy_text = '{"counts": {"STALE CARD": 999}}' + legacy.write_text(legacy_text) stack.enter_context(patch.object(run.atexit, "register", side_effect=callbacks.append)) stack.enter_context(patch.object(run.time, "sleep")) stack.enter_context(patch.object(sys, "argv", ["run.py", "--steps", "10", "--pause", "0", @@ -139,7 +154,7 @@ def invoke(fake, *flags, client=None, clock=None, decide=None): path = capdir / name return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] return types.SimpleNamespace(rc=rc, rows=rows("decisions.jsonl"), - sessions=rows("sessions.jsonl"), saved=save.call_count, + sessions=rows("sessions.jsonl"), saved=int(not legacy.exists() or legacy.read_text() != legacy_text), output=output.getvalue()) @@ -390,6 +405,58 @@ 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("=== 9. run identity and card-evidence provenance ===") +run_a = {"is_in_progress": True, "run_id": "fixture:A", "seed": "same-seed"} +run_b = dict(run_a, run_id="fixture:B") +fake = FakeSts2([menu, grid], identities=[run_a, run_b]) +result = invoke(fake, "--no-jev", "--steps", "2") +check("different run IDs reset selection and duplicate guards despite identical seeds/screens", + fake.actions, [(("select_card",), {"index": 0})] * 2) +check("decision rows carry reported run identity", + [r["run_id"] for r in result.rows if r["event"] == "decide"], ["fixture:A", "fixture:B"]) +fake = FakeSts2([menu, grid], identities=[run_a, run_b]) +result = invoke(fake, "--no-jev", "--steps", "2", "--stop-on-run-end") +check("single-run mode never acts in the replacement run", + (result.rc, result.sessions[0]["stop_reason"], len(fake.actions)), (0, "run_changed", 1)) + +fake = FakeSts2([menu, grid, grid, confirmable], + identities=[run_a, FakeSts2.Sts2Error("metadata unavailable"), run_a]) +result = invoke(fake, "--no-jev", "--steps", "3") +check("metadata failure preserves accepted toggles instead of selecting them again", + fake.actions, [(("select_card",), {"index": 0}), (("select_card",), {"index": 1}), + (("confirm_selection",), {})]) +check("metadata failure is traced without inventing identity", + [r["run_id"] for r in result.rows if r["event"] == "decide"], ["fixture:A", None, "fixture:A"]) + +reward_here = dict(card_reward, run=combat["run"]) +with_status = dict(combat, player=dict(combat["player"], hand=combat["player"]["hand"] + [ + {"index": 1, "name": "Wound", "type": "Status", "cost": "1", "description": "Unplayable.", "can_play": False}])) +client = StubClient(.1) # Declines skipping; selects a card, then invalidates old evidence. +result = invoke(FakeSts2([menu, with_status, reward_here, reward_here]), "--steps", "3", client=client) +evidence = [state["deck_composition"] for state, _ in client.requests if "deck_composition" in state] +check("temporary Status cards remain labeled combat evidence, not a persistent deck", + (evidence[0]["cards"].get("Wound"), evidence[0]["provenance"]["persistent_deck"], + evidence[0]["provenance"]["source"]), (1, False, "combat_piles")) +check("carried card evidence records run, step, age, and exposed piles", + {k: evidence[0]["provenance"][k] for k in ("run_id", "observed_step", "freshness", "pile_lists_present")}, + {"run_id": "fixture:A", "observed_step": 1, "freshness": "historical_combat_piles", "pile_lists_present": ["hand"]}) +check("accepted card addition invalidates old composition", evidence[1], "unknown") +check("legacy deck.json stays untouched even during an executing session", result.saved, 0) + +for label, states, identities in [ + ("another run", [menu, combat, reward_here], [run_a, run_b]), + ("missing identity", [menu, combat, reward_here], [None]), + ("identity read failure", [menu, combat, reward_here], [run_a, FakeSts2.Sts2Error("unavailable")]), + ("another room", [menu, combat, card_reward], [run_a]), + ("unknown room", [menu, combat, dict(reward_here, run=None)], [run_a]), + ("fresh session with a legacy cache", [menu, reward_here], [run_a]), +]: + client = StubClient() + result = invoke(FakeSts2(states, identities=identities), "--steps", str(len(states) - 1), client=client) + evidence = [state["deck_composition"] for state, _ in client.requests if "deck_composition" in state] + check(label + " receives unknown deck context", evidence[-1], "unknown") + print() print(f"=== {PASS} passed, {FAIL} failed ===") sys.exit(1 if FAIL else 0)