feat(state): scope card evidence and policy memory by reported run identity
This commit is contained in:
parent
484551047c
commit
ba519a850e
9 changed files with 343 additions and 82 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
117
docs/RUN_STATE.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
39
facts.py
39
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"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
|
|||
80
run.py
80
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",
|
||||
|
|
|
|||
87
run_state.py
Normal file
87
run_state.py
Normal 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
|
||||
75
test_run.py
75
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue