feat(state): scope card evidence and policy memory by reported run identity

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

View file

@ -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`: observedecideact 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.

View file

@ -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.

View file

@ -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

117
docs/RUN_STATE.md Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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"
# --------------------------------------------------------------------------

80
run.py
View file

@ -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",

87
run_state.py Normal file
View file

@ -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

View file

@ -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)