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/combat.py` and `policy/selection.py`: combat and selection proposals.
- `policy/context.py`: shared `Decision`, pending actions, and session-owned `PolicyContext`. - `policy/context.py`: shared `Decision`, pending actions, and session-owned `PolicyContext`.
- `run.py`: observedecideact loop, captures, and session attribution. - `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. - `sts2.py`: local game HTTP client. `jev.py`: TypeSafe model client and gates.
- `migrate.py`: dataset migration and integrity checks. - `migrate.py`: dataset migration and integrity checks.
- `CONTEXT.md`: domain terms. `docs/POLICY.md`: policy boundaries. `docs/DATASET.md`: dataset schema and limits. - `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 ## Safety and scope
- Default to offline tests. Ask before live game actions, model calls, or evaluation batches. - 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. - 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. - 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. - `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**: **Screen flow**:
Related game screens that form one interaction, such as opening and skipping a card reward. 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**: **Run deck**:
The persistent collection of cards owned during a run, distinct from temporary combat cards and combat piles. 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. _Avoid_: Treating all visible combat cards as the persistent deck.

View file

@ -4,6 +4,7 @@
```text ```text
run.py observe, execute, report results, record the session 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 brain.py entry point, dispatch, navigation, shops, minigames
policy/ policy/
__init__.py package marker; no registration or initialization __init__.py package marker; no registration or initialization
@ -13,8 +14,9 @@ policy/
facts.py observation parsing and combat calculations facts.py observation parsing and combat calculations
``` ```
`brain.decide()` remains the policy entry point. The runner owns one `brain.decide()` remains the policy entry point. The runner owns a `RunContext`,
`PolicyContext` per session and reports action results to it. 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 `brain.Decision` and `brain.PolicyContext` remain available as direct imports
of the shared types, so the runner interface does not change. 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. shops, and minigames stay together until a further split helps development.
The extraction does not change game decisions, confidence gates, fallbacks, The extraction does not change game decisions, confidence gates, fallbacks,
state reconciliation, or recording. Run identity and persistent-deck provenance state reconciliation, or recording. The subsequent
remain separate work. [run-state pass](RUN_STATE.md) adds reported identity and card-evidence provenance.
## Extraction verification ## 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 behavior. See [the current policy structure](../POLICY.md). No plugin framework
or class hierarchy was added. or class hierarchy was added.
The next state work is run identity and deck provenance. Recording should then [Run identity and card-evidence provenance](../RUN_STATE.md) have since been added.
link observations, proposals, action attempts, results, and reconciliation. Recording should next link observations, proposals, action attempts, results, and reconciliation.
Session finalization, capture-name collisions, and actual policy-gate metadata Session finalization, capture-name collisions, and actual policy-gate metadata
remain separate work. 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"block_available={self.max_block_available} survives_with_cards={self.survives_with_cards}",
f"potions={[p.get('name') for p in self.potions]}", 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"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: for e in self.enemies:
lines.append( lines.append(
@ -620,22 +620,12 @@ def enemy_status_names(enemy: EnemyFact) -> list:
def deck_summary(player: dict) -> dict: def deck_summary(player: dict) -> dict:
""" """Aggregate visible combat piles, not the persistent run deck.
Stable aggregates over ALL FOUR PILES, for macro decisions.
The reward, shop and map states do NOT expose the deck, so this travels with Generated cards, Status cards, temporary upgrades, and missing pile data
the composition snapshot taken during combat. It is deliberately a limit this evidence. These counts can change during combat. Cost is omitted
*supplement* to the name->count map, never a replacement: card identities because temporary modifiers make displayed costs unsuitable for a base-cost
are the signal for synergy, redundancy and upgrades, and only counts can aggregate. The historical function name is retained for existing callers.
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.
""" """
names: list[str] = [] names: list[str] = []
attacks = blocks = other = upgraded = 0 attacks = blocks = other = upgraded = 0
@ -668,22 +658,19 @@ def deck_summary(player: dict) -> dict:
def deck_context(deck: Any) -> Any: def deck_context(deck: Any) -> Any:
""" """Supply card evidence with its limits; unscoped legacy data is unknown."""
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.
"""
if not deck: if not deck:
return "unknown" return "unknown"
if isinstance(deck, dict) and "counts" in deck: if isinstance(deck, dict) and "counts" in deck:
counts = deck.get("counts") or {} counts = deck.get("counts") or {}
if not counts: if not counts:
return "unknown" return "unknown"
return {"cards": counts, **({"summary": deck["summary"]} provenance = deck.get("provenance")
if deck.get("summary") else {})} if not isinstance(provenance, dict) or not provenance.get("run_id"):
if isinstance(deck, dict): return "unknown"
return {"cards": deck} return {"cards": counts, "provenance": provenance,
return deck **({"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 facts as F
import sts2 import sts2
from jev import JevClient, JevError, answer_record from jev import JevClient, JevError, answer_record
from run_state import RunContext
DECK_FILE = pathlib.Path("deck.json")
# Where the game writes its own run records. This is the OUTCOME source: it # 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 # 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: def observe() -> dict:
return sts2.state() return sts2.state()
@ -243,8 +220,8 @@ def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--steps", type=int, default=20) ap.add_argument("--steps", type=int, default=20)
ap.add_argument("--dry-run", action="store_true", ap.add_argument("--dry-run", action="store_true",
help="send no action POSTs and do not update deck.json; " help="send no action POSTs; "
"state reads, model calls, and capture logs still run") "state/identity reads, model calls, and capture logs still run")
ap.add_argument("--no-jev", action="store_true") ap.add_argument("--no-jev", action="store_true")
ap.add_argument("--pause", type=float, default=0.6, ap.add_argument("--pause", type=float, default=0.6,
help="seconds to wait after each action") help="seconds to wait after each action")
@ -349,12 +326,9 @@ def main() -> int:
atexit.register(write_session_row) atexit.register(write_session_row)
# The card_reward state does not expose the deck, but combat states expose # Never load the old unscoped deck.json. Visible combat piles are limited
# all four piles. Snapshot composition during combat and persist it so it # evidence, not a persistent deck, and remain local to this session/run.
# survives a restart. run_context = RunContext()
deck_snapshot: dict | None = load_deck()
if deck_snapshot:
print(f"deck snapshot loaded: {deck_snapshot}")
waits = 0 waits = 0
rejected = 0 rejected = 0
last_sig: str | None = None last_sig: str | None = None
@ -365,7 +339,6 @@ def main() -> int:
duplicate_waits = 0 duplicate_waits = 0
jev_errors = 0 jev_errors = 0
saw_a_run = False saw_a_run = False
policy_context = brain.PolicyContext()
pending_action = None pending_action = None
pending_since = time.monotonic() pending_since = time.monotonic()
@ -377,6 +350,28 @@ def main() -> int:
return finish(1, "state_error") return finish(1, "state_error")
st = obs.get("state_type") 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 # 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 # 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]}") print(f"[{step:03d}] COMBAT {f.describe().splitlines()[0]}")
for line in f.describe().splitlines()[1:]: for line in f.describe().splitlines()[1:]:
print(f" {line}") print(f" {line}")
# The snapshot carries the name->count map (card identities, which run_context.capture_combat(obs, f, step)
# 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)
decision = None decision = None
decide_error = None decide_error = None
@ -461,7 +450,7 @@ def main() -> int:
# must not inherit the previous step's answers in the log. # must not inherit the previous step's answers in the log.
client.last = None client.last = None
try: 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 # A procedural action or animation wait does not establish model
# recovery. Only a successful model response resets the budget. # recovery. Only a successful model response resets the budget.
if client is not None and client.last is not None: 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 same failing request -- measured, a DNS blip re-raised out of
# the "fallback" and killed the session. # the "fallback" and killed the session.
try: 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 except Exception as inner: # noqa: BLE001
trace({"step": step, "event": "fallback_error", "error": str(inner)[:160]}) trace({"step": step, "event": "fallback_error", "error": str(inner)[:160]})
print(f"[{step:03d}] fallback also failed: {inner}") print(f"[{step:03d}] fallback also failed: {inner}")
@ -539,7 +528,10 @@ def main() -> int:
stats[decision.source] = stats.get(decision.source, 0) + 1 stats[decision.source] = stats.get(decision.source, 0) + 1
print(f"[{step:03d}] DECIDE {decision}") 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 last_action_key = action_key
if args.dry_run: if args.dry_run:
@ -553,7 +545,7 @@ def main() -> int:
"action": decision.action, "error": str(exc)[:200]}) "action": decision.action, "error": str(exc)[:200]})
return finish(1, "action_error") 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: if not result.ok:
print(f"[{step:03d}] action rejected: {result.message}") print(f"[{step:03d}] action rejected: {result.message}")
trace({"step": step, "event": "action_rejected", 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 io
import itertools import itertools
import json import json
import os
import pathlib import pathlib
import sys import sys
import tempfile import tempfile
@ -55,12 +56,14 @@ class StubClient:
def __init__(self, noul: float = 0.9): def __init__(self, noul: float = 0.9):
self.noul = noul self.noul = noul
self.calls = 0 self.calls = 0
self.requests = []
def __repr__(self) -> str: def __repr__(self) -> str:
return "StubClient()" return "StubClient()"
def ask(self, state, questions, model=None): def ask(self, state, questions, model=None):
self.calls += 1 self.calls += 1
self.requests.append((state, questions))
answers = {} answers = {}
for qid, q in questions.items(): for qid, q in questions.items():
if q.get("type") == "choice": if q.get("type") == "choice":
@ -87,12 +90,14 @@ class FakeSts2:
BASE = "offline://game" 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.states = states
self.i = 0 self.i = 0
self.actions = [] self.actions = []
self.action_ok = action_ok self.action_ok = action_ok
self.action_error = action_error 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: def is_up(self) -> bool:
return True return True
@ -104,6 +109,13 @@ class FakeSts2:
raise state raise state
return 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): def act(self, *a, **k):
self.actions.append((a, k)) self.actions.append((a, k))
if self.action_error: 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, "sts2", fake))
stack.enter_context(patch.object(run, "JevClient", return_value=client or StubClient())) 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, "history_snapshot", return_value=set()))
stack.enter_context(patch.object(run, "load_deck", return_value=None)) stack.callback(os.chdir, os.getcwd())
save = stack.enter_context(patch.object(run, "save_deck")) 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.atexit, "register", side_effect=callbacks.append))
stack.enter_context(patch.object(run.time, "sleep")) stack.enter_context(patch.object(run.time, "sleep"))
stack.enter_context(patch.object(sys, "argv", ["run.py", "--steps", "10", "--pause", "0", 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 path = capdir / name
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else [] return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
return types.SimpleNamespace(rc=rc, rows=rows("decisions.jsonl"), 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()) 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)), (result.rc, result.sessions[0]["stop_reason"], len(fake.actions)),
(1, "pending_action_timeout", 1)) (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()
print(f"=== {PASS} passed, {FAIL} failed ===") print(f"=== {PASS} passed, {FAIL} failed ===")
sys.exit(1 if FAIL else 0) sys.exit(1 if FAIL else 0)