From 772362fc4128b05caf57c91b32be322988d5283b Mon Sep 17 00:00:00 2001 From: lgray Date: Fri, 7 Aug 2026 18:44:33 -0500 Subject: [PATCH 01/44] fix(engine): rehydrate the persisted RNG stream in the native restore chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PersistedGameState::into_game_state` did not call `rehydrate_rng()`, so a native restore resumed with a word-0 ChaCha20 stream under a non-zero saved `rng_word_pos`. Any drive that shuffled then tripped `ResolvedRngReplayInvariantError::HighWaterRegression` (measured: `current: 313` on the Dina board, `current: 379` on the tracked F4 dump). The repair lived only in `engine-wasm`'s `restore_game_state`, so a load that ENDED at the chokepoint was left rewound; the chokepoint now does it and WASM's own call is an idempotent repeat (`rehydrate_rng` makes two absolute assignments). This does NOT make the shipped load paths equivalent. `server-core`'s `from_persisted` re-seeds with a fresh seed AFTER the chokepoint and never zeros `state.rng_word_pos`, so the server restore remains broken — a pre-existing gap this change neither caused nor repairs, disclosed in-code at the chokepoint and queued as a follow-up. A fourth caller, `phase-ai`'s `load_saved_game_state`, inherits the repair; it is offline bench/test tooling, so no shipped gameplay ingress changes behavior here. Adds `dina_noff_turn5_4p.json.gz` as a tracked fixture so the rows are runnable from the repo alone, derived from the archived pristine capture (844846 B, sha256 9843d5165cbbf7dd7bca4171c7888c190b7eba7e52a2ed095b44ff76fadd7886). Corrects the documentation this change falsifies. Three pre-existing loader doc comments described the chokepoint as covering both shipped ingresses without qualification. Two `engine-wasm` comments were falsified outright: one claimed `restore_game_state` "rewinds the stream to position 0", which `rehydrate_rng` never did; the other documented a revert-probe asserting that deleting either the export capture or the restore rehydration reds `export_then_restore_resumes_live_rng_stream_through_wasm_bridge`. Measured with five probe runs, neither restore-side deletion discriminates on its own — only deleting both does — so the comment now names the export capture as the single discriminator and discloses the double coverage. No `engine-wasm` code changed. Also drops a stale `types/game_state.rs:9024` coordinate in `triggers.rs` for a symbol anchor, and moves the new RNG rows out from under the R18 doc block they had silently detached from its test. Assisted-by: ClaudeCode:claude-opus-4.8 --- crates/engine-wasm/src/lib.rs | 37 +++- crates/engine/src/game/triggers.rs | 2 +- crates/engine/src/types/game_state.rs | 24 +++ .../integration/dina_noff_turn5_loader.rs | 158 ++++++++++++++++++ .../fantastic_four_bounded_loop.rs | 58 +++++++ .../kilo_live_offer_from_real_dump.rs | 8 +- crates/engine/tests/integration/main.rs | 1 + .../sprout_inalla_realistic_offer.rs | 8 +- 8 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/integration/dina_noff_turn5_loader.rs diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 7e1a930825..1ba085d4f1 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -2058,11 +2058,20 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { /// /// Differs from `restore_game_state` in two load-bearing ways: /// -/// 1. **Fresh RNG seed.** `restore_game_state` re-seeds from the saved -/// `rng_seed`, which rewinds the ChaCha20 stream to position 0 — -/// correct for undo (replay from origin) but wrong for resume -/// (subsequent draws would replay the pre-save sequence). This -/// function stamps a fresh seed so continued play diverges. +/// 1. **Fresh RNG seed.** `restore_game_state` re-seeds from the SAVED +/// `rng_seed` and fast-forwards to the saved `rng_word_pos`, so the +/// restored game continues the very stream the snapshot was taken on — +/// correct for undo, wrong for resume, where continued play must not +/// re-draw the values the pre-save timeline already committed to. This +/// function stamps a FRESH seed and resets `rng_word_pos` to 0 so the +/// resumed host diverges instead. +/// +/// It does NOT rewind to position 0: that was true only before issue +/// #5466 taught the restore path to carry the offset, and it survives +/// today just for snapshots written back then, which carry +/// `rng_word_pos == 0`. Both the shared decode chokepoint +/// (`PersistedGameState::into_game_state`) and `restore_game_state`'s +/// own repeat call `rehydrate_rng`. /// 2. **Atomic multiplayer-flag flip.** Sets `MULTIPLAYER_MODE` in the /// same call that loads state, so there's no window where a stray /// `restore_game_state` (undo) would be accepted on the resumed @@ -5045,9 +5054,21 @@ mod rng_restore_bridge_tests { // fast-forward the reseeded stream to it, so a restored game draws the // values that would have come NEXT — not a replay from origin. This test // drives the real bridge entry points (nothing calls the engine seam - // directly): deleting `state.capture_rng_word_pos()` in export or - // `state.rehydrate_rng()` in restore turns it red. Asserts on consumed - // randomness, not the stored `rng_word_pos` integer. + // directly). Asserts on consumed randomness, not the stored + // `rng_word_pos` integer. + // + // REVERT-PROBES, all four RUN, not reasoned: + // * delete `state.capture_rng_word_pos()` in `export_game_state_json` + // ⇒ RED. That is the single-deletion discriminator. + // * the restore-side rehydration is DOUBLE-COVERED and therefore has + // no single-deletion discriminator: `restore_game_state` calls + // `rehydrate_rng` itself AND its `decode_restored_game_state` now + // routes through `PersistedGameState::into_game_state`, which + // rehydrates first. Deleting the bridge's own call ⇒ GREEN; + // deleting the chokepoint's ⇒ GREEN; deleting BOTH ⇒ RED. + // The bridge's own call is thus a harmless idempotent repeat, kept + // because `rehydrate_rng` is two absolute assignments from persisted + // fields. Do not read this test as covering it in isolation. clear_game_state(); load_minimal_test_card_database(); diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 7734860a56..725c008bc1 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -8709,7 +8709,7 @@ pub(crate) fn filter_consumed_trigger_events( /// CR 724 end-the-turn / end-the-combat-phase EFFECTS, and by elimination /// — NOT by the turn boundary. /// * R2 — deserialized states bypass the recorder. -/// `PersistedGameState::into_game_state` (`types/game_state.rs:9024`) +/// `PersistedGameState::into_game_state` (`types/game_state.rs`) /// reconstructs `ZoneChanged` straight into live buffers with /// `#[serde(default)]` indices, so a restored state can carry index `0` on /// distinct occurrences. Pre-existing and out of scope here. diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 5d4a5910e9..9651b72ab0 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -9825,6 +9825,30 @@ impl PersistedGameState { // one (it is lowered to the activator at creation), so this only sanitizes // corrupt/forged snapshots and closes the fail-open path in both consumers. state.drop_unresolved_source_controller_restrictions(); + // Issue #5466: `rng` is `#[serde(skip)]`, so a decode leaves the LIVE ChaCha20 stream at + // word 0 while the serialized high-water `rng_word_pos` keeps its saved offset. A + // `capture_rng_word_pos` on that state — the production library shuffle performs one, see + // `game/library.rs` — then `.expect`-panics `HighWaterRegression { current: , + // requested: 0 }`, and any shuffle before it replays entropy the game already consumed. + // + // Rehydrating HERE means every caller of the chokepoint inherits the repair instead of + // owing its own. It does NOT make the load paths equivalent: what each caller does + // AFTERWARDS is that caller's own policy, and they differ. + // * `engine-wasm`'s `restore_game_state` still calls `rehydrate_rng` itself, so that + // path now runs it twice. Harmless — `rehydrate_rng` is idempotent, both of its + // statements being absolute assignments from persisted fields. + // * `engine-wasm`'s `resume_multiplayer_host_state` deliberately re-seeds and sets + // `rng_word_pos = 0` so a resumed host does not replay saved randomness. That is a + // resume policy choice, not a bug, and it leaves live position and high-water agreed. + // * `server-core`'s `GameSession::from_persisted` re-seeds `rng` from fresh entropy and + // does NOT zero `rng_word_pos`, so immediately after this call it is back at live 0 / + // high-water and the next `capture_rng_word_pos` still panics. The server + // restore path therefore REMAINS BROKEN. That gap is pre-existing and untouched here — + // the server has never zeroed the offset, before or after this call existed — so this + // call must not be read as repairing it. The repair is a behavior change owed its own + // commit; disclosed and queued as a follow-up. + // Offline tooling (`phase-ai`'s `load_saved_game_state`) simply inherits the repair. + state.rehydrate_rng(); state } } diff --git a/crates/engine/tests/integration/dina_noff_turn5_loader.rs b/crates/engine/tests/integration/dina_noff_turn5_loader.rs new file mode 100644 index 0000000000..3f53c29f73 --- /dev/null +++ b/crates/engine/tests/integration/dina_noff_turn5_loader.rs @@ -0,0 +1,158 @@ +//! The Dina "offers, no fast-forward" turn-5 4p board, loaded through the production restore +//! chokepoint — and the row that makes every later acceptance row on this board possible. +//! +//! # Fixture provenance +//! +//! `../fixtures/dina_noff_turn5_4p.json.gz` is derived, not captured. Root of trust is the +//! read-only pristine dump root, NOT any working-directory copy: +//! +//! | artifact | bytes | sha256 | +//! |---|---|---| +//! | `/home/lgray/vibe-coding/combofb-dumps-pristine/dina-conqueror-offers-no-ff.zip` (canonical; the sole entry, line 1, of that directory's `MANIFEST.sha256`) | 4 334 390 | `4a285dbf5184545507c0d80183c4b831b3d21738f96728bb9e8eaa942a007d43` | +//! | member `game-state-turn-5-2026-08-05T21-53-17-125Z.json` | 21 442 451 | `14e2fe515310ea34f6c1f52087a0ab274842a3bf69f951b3ceacb93c9a0ca660` | +//! | derived `dina_noff_turn5_4p.json.gz` (this fixture) | 844 846 | `9843d5165cbbf7dd7bca4171c7888c190b7eba7e52a2ed095b44ff76fadd7886` | +//! +//! Regeneration re-gzips from the archive and must reproduce those bytes exactly — `-n` is +//! load-bearing, since without it gzip stamps an mtime and the digest never lands: +//! +//! ```text +//! unzip -p game-state-turn-5-2026-08-05T21-53-17-125Z.json \ +//! | jq -c '{gameState}' | gzip -9 -n > crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz +//! ``` +//! +//! The raw member is 21.4 MB and is deliberately NOT tracked; only the 845 KB `.json.gz` is. + +use engine::types::game_state::{GameState, PersistedGameState}; + +fn gunzip(gz: &[u8]) -> String { + use std::io::Read; + let mut json = String::new(); + flate2::read::GzDecoder::new(gz) + .read_to_string(&mut json) + .expect("fixture .json.gz must inflate to UTF-8 JSON"); + json +} + +/// Load the dump's `["gameState"]` through the REAL production restore chokepoint +/// `PersistedGameState::into_game_state` — never a bare `GameState` decode, which would skip +/// `reject_legacy_raw_prompt_authority` and `decode_persisted_resolution_state`. +fn load_dina_noff() -> GameState { + let json = gunzip(include_bytes!("../fixtures/dina_noff_turn5_4p.json.gz")); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + serde_json::from_value::(envelope["gameState"].clone()) + .expect("gameState deserializes through the production decoder") + .into_game_state() +} + +/// The saved ChaCha20 high-water this board carries (`gameState.rng_word_pos`). Also the +/// `current:` in the `HighWaterRegression` this board used to panic with on every load. +const DINA_NOFF_RNG_WORD_POS: u128 = 313; + +/// **Row 9, positive arm.** The chokepoint rehydrates: a load that ENDS at +/// `PersistedGameState::into_game_state`, as `load_dina_noff` does, leaves the LIVE stream at the +/// saved high-water, so a later export-time capture is legal instead of a rewind. Scope: that is +/// the chokepoint's own postcondition, not a claim about every shipped ingress — `server-core`'s +/// `from_persisted` re-seeds afterwards without zeroing `rng_word_pos` and still panics there +/// (pre-existing, disclosed at the chokepoint, not repaired by this change). +/// +/// Non-vacuity: the reach-guard below pins the real board (4 seats, turn 5, the captured life +/// vector, a NON-ZERO saved high-water), so "no panic" cannot be satisfied by a degenerate or +/// empty state. Discrimination: deleting `state.rehydrate_rng()` from +/// `PersistedGameState::into_game_state` leaves the live stream at word 0 while `rng_word_pos` +/// stays 313, and `assert_eq!(live, state.rng_word_pos)` reds with `0 != 313` — measured, not +/// asserted. `capture_rng_word_pos` then `.expect`-panics on the same revert. +#[test] +fn c0_the_native_loader_rehydrates_the_persisted_rng_stream() { + let mut state = load_dina_noff(); + + // Reach-guard: this is the captured 4p board, not a default or empty state. + assert_eq!(state.players.len(), 4, "the real 4p board must have loaded"); + assert_eq!(state.turn_number, 5, "captured on turn 5"); + assert_eq!( + state.players.iter().map(|p| p.life).collect::>(), + vec![51, 29, 34, 34], + "the captured life vector identifies this exact board", + ); + assert_eq!( + state.rng_word_pos, DINA_NOFF_RNG_WORD_POS, + "the board must carry a NON-ZERO saved high-water, or the row measures nothing", + ); + + // The invariant the panic was only a signature of: live stream position == persisted + // high-water. Measuring it directly is deliberate rather than driving the board to a shuffle: + // every shuffle source it holds (Terramorphic Expanse, Evolving Wilds, Fabled Passage) is an + // ACTIVATED ability, so a pass-only driver reaches none of them, and a drive-based row here + // was measured VACUOUS twice before this one replaced it. + assert_eq!( + state.rng.get_word_pos(), + state.rng_word_pos, + "into_game_state must fast-forward the live ChaCha20 stream to the saved high-water", + ); + + // The production consequence, through the `pub` engine seam that used to blow up: the + // export-time capture every subsequent save performs. + state.capture_rng_word_pos(); + assert_eq!( + state.rng_word_pos, DINA_NOFF_RNG_WORD_POS, + "a capture at the restored position must not move the high-water", + ); +} + +/// **Row 9, WASM arm.** The WASM restore keeps its own `state.rehydrate_rng()` after the +/// chokepoint (in `engine-wasm`'s `restore_game_state`), so the load path now runs it twice. Safe +/// only because `rehydrate_rng` is idempotent — both of its statements are absolute assignments +/// from persisted fields (`rng = seed_from_u64(rng_seed)`, then `set_word_pos(rng_word_pos)`), so +/// it neither accumulates nor advances. This row measures that instead of trusting it, which is +/// why no `engine-wasm` edit is owed. +/// +/// Discrimination (RUN, both mutations): drop the reseed and make the fast-forward RELATIVE — +/// `self.rng.set_word_pos(self.rng.get_word_pos() + self.rng_word_pos)` — and the position +/// assertion reds `626 != 313` with the draw comparison behind it, because the second run +/// accumulates. Note that an absolute-but-wrong fast-forward (`set_word_pos(rng_word_pos + 1)`) +/// does NOT red here and is not what this row claims to catch: idempotence is a property of +/// ASSIGNMENT, so only an accumulating form can break it. +#[test] +fn c0_a_second_rehydrate_is_a_no_op_so_the_wasm_restore_may_repeat_it() { + use rand::RngCore; + + let mut once = load_dina_noff(); + let mut twice = load_dina_noff(); + twice.rehydrate_rng(); // the WASM restore's own repeat, on top of the chokepoint's + + assert_eq!( + twice.rng.get_word_pos(), + once.rng.get_word_pos(), + "a repeated rehydrate must not move the stream", + ); + assert_eq!( + twice.rng_word_pos, once.rng_word_pos, + "a repeated rehydrate must not move the persisted high-water", + ); + + // Position equality alone would survive a same-position-different-keystream bug, so compare + // the values the two streams actually produce. + for draw in 0..5 { + assert_eq!( + twice.rng.next_u32(), + once.rng.next_u32(), + "double-rehydrated stream diverged at draw {draw}", + ); + } +} + +/// **Row 9, negative control.** The same board, one axis changed: the live stream rewound to word +/// 0, which is exactly the state a decode produced before the chokepoint rehydrated. It panics. +/// +/// Without this arm the positive arm above is a claim that a call does not panic, with no evidence +/// that it ever could on this board. +#[test] +#[should_panic(expected = "HighWaterRegression")] +fn c0_an_unrehydrated_stream_on_the_same_board_still_panics() { + let mut state = load_dina_noff(); + // Re-create the pre-rehydrate live position. `advance_rng_high_water` rejects on + // `requested < rng_word_pos`, and `requested` is read from the live stream — so the position + // is the whole failing condition. + state.rng.set_word_pos(0); + state.capture_rng_word_pos(); +} diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index 9ee39ca1c9..a3f7c8d57c 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -87,6 +87,14 @@ fn gunzip(gz: &[u8]) -> String { /// `decode_restored_game_state` funnel through it) — never a bare `GameState` decode, which /// would skip `reject_legacy_raw_prompt_authority` and `decode_persisted_resolution_state`. /// +/// The chokepoint now rehydrates the `#[serde(skip)]` ChaCha20 stream, which it did not always: +/// the repair lived only in `engine-wasm`'s `restore_game_state`, so a load that ENDED at the +/// chokepoint — as `load_f4` does — left the live stream rewound to word 0 under a saved +/// `rng_word_pos` of 379. Every caller now inherits it and WASM's own call became an idempotent +/// repeat. This does NOT equalize the shipped load paths: `server-core`'s `from_persisted` +/// re-seeds afterwards without zeroing `rng_word_pos`, a pre-existing gap that this change neither +/// caused nor repairs (disclosed at `PersistedGameState::into_game_state`). +/// /// The dump was captured with the detector OFF; every row here is about the CR 732.2a /// interactive offer, so the mode is set to `Interactive` at load — the same thing the user's /// own toggle does. @@ -353,6 +361,56 @@ fn replay_at_priority(state: &GameState, proposer: PlayerId) -> GameState { replay } +// ───────────────────────────────────────────────────────────────────────────────────────── +// C0 — the tracked F4 dump's RNG stream survives the restore chokepoint +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// **Row 9, tracked-loader arm.** The RNG chokepoint gap was never confined to the untracked Dina +/// board: this TRACKED dump carries `rng_word_pos = 379` and used to restore with the live stream +/// at word 0, so the very next export-time `capture_rng_word_pos` panicked +/// `HighWaterRegression { current: 379, requested: 0 }`. Every row in this file loads through +/// `load_f4`, so the gap sat under all of them. Scope: this row measures the CHOKEPOINT's +/// postcondition. It is not a claim that every shipped ingress is now sound — `server-core`'s +/// `from_persisted` re-seeds after the chokepoint without zeroing `rng_word_pos` and still hits +/// this panic (pre-existing, disclosed at `PersistedGameState::into_game_state`, not repaired here). +/// +/// Two-sided on one axis, like its Dina sibling: the restored stream is AT the high-water and the +/// capture is legal; the same board with the live position rewound to 0 — the exact pre-fix decode +/// state — still panics (`c0_the_unrehydrated_tracked_f4_dump_still_panics`). Revert-probe: +/// deleting `state.rehydrate_rng()` from `PersistedGameState::into_game_state` reds the +/// `get_word_pos() == rng_word_pos` assertion with `0 != 379`. +#[test] +fn c0_the_tracked_f4_dump_restores_a_coherent_rng_stream() { + let mut state = load_f4(); + + // Reach-guard: the real board, carrying a NON-ZERO saved high-water. + assert_eq!(state.players.len(), 4, "the real 4p board must have loaded"); + assert_eq!( + state.rng_word_pos, 379, + "the tracked F4 dump's captured ChaCha20 high-water", + ); + assert_eq!( + state.rng.get_word_pos(), + state.rng_word_pos, + "into_game_state must fast-forward the live stream on the TRACKED dump too", + ); + + state.capture_rng_word_pos(); + assert_eq!( + state.rng_word_pos, 379, + "a capture at the restored position must not move the high-water", + ); +} + +/// The negative control for the row above: without the rehydrate the same board panics. +#[test] +#[should_panic(expected = "HighWaterRegression")] +fn c0_the_unrehydrated_tracked_f4_dump_still_panics() { + let mut state = load_f4(); + state.rng.set_word_pos(0); + state.capture_rng_word_pos(); +} + // ───────────────────────────────────────────────────────────────────────────────────────── // R18 — fail-loud fixture name resolution // ───────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs index e3f526191d..eba2e8830a 100644 --- a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs +++ b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs @@ -88,7 +88,13 @@ fn gunzip(gz: &[u8]) -> String { /// Load the real 4p dump's `["gameState"]` and route it through the REAL production restore /// chokepoint `PersistedGameState::into_game_state` (both server `from_persisted` and WASM -/// `decode_restored_game_state` funnel through it). The sequence deserializes NORMALLY (len 6), +/// `decode_restored_game_state` funnel through it). The chokepoint now rehydrates the ChaCha20 +/// stream, which only `engine-wasm`'s `restore_game_state` used to do on its own — a load that +/// ENDED at the chokepoint, as this one does, was left with a word-0 stream under this dump's +/// saved `rng_word_pos` of 293. WASM's own call is now an idempotent repeat. Callers may still +/// diverge afterwards: `from_persisted` re-seeds without zeroing `rng_word_pos`, a pre-existing +/// gap disclosed at `PersistedGameState::into_game_state` and not repaired here. +/// The sequence deserializes NORMALLY (len 6), /// then `GameState::migrate_transient_loop_sequence` DROPS it because the dump was captured at /// empty-stack `Priority` (NOT a shortcut window) — exactly the production load behavior. Reverting /// the migration (or its `Priority`-drops-it branch) leaves the 6 stale pinless steps intact ⇒ the diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 8e86b7e7b2..f4f9e15420 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -175,6 +175,7 @@ mod dig_impossible_keep_count; mod dig_rest_pile_stranding_on_etb_pause; mod diligent_farmhand_counts_as_named; mod diluvian_primordial_6754; +mod dina_noff_turn5_loader; mod disjunctive_state_change_head_coverage_honesty; mod disorder_in_the_court_5955; mod divine_visitation_token_substitution; diff --git a/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs b/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs index 931e7d12da..0a3a3ab7f5 100644 --- a/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs +++ b/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs @@ -51,7 +51,13 @@ fn gunzip(gz: &[u8]) -> String { /// Load the realistic 4p dump's `["gameState"]` through the REAL production restore chokepoint /// `PersistedGameState::into_game_state` (the same path server `from_persisted` and WASM -/// `decode_restored_game_state` funnel through). The migration drops the primed loop sequence +/// `decode_restored_game_state` funnel through). The chokepoint now rehydrates the ChaCha20 +/// stream, which only `engine-wasm`'s `restore_game_state` used to do on its own — a load that +/// ENDED at the chokepoint, like this one, was left with a word-0 stream under this dump's saved +/// `rng_word_pos` of 291. WASM's own call is now an idempotent repeat. Callers may still diverge +/// afterwards: `from_persisted` re-seeds without zeroing `rng_word_pos`, a pre-existing gap +/// disclosed at `PersistedGameState::into_game_state` and not repaired here. +/// The migration drops the primed loop sequence /// because the dump sits at empty-stack Priority (NOT a shortcut window), so the offer must be /// rebuilt by a live cast below. fn load_realistic_dump() -> GameState { From 80df4c8eff796dacaf27e8192a1c8d65899d514d Mon Sep 17 00:00:00 2001 From: lgray Date: Fri, 7 Aug 2026 23:19:40 -0500 Subject: [PATCH 02/44] fix(server): drop the saved RNG stream position when restoring a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GameSession::from_persisted` re-seeds `rng` from fresh entropy so restored games do not all share one deterministic sequence, but left `rng_word_pos` carrying the OLD stream's high-water. A fresh ChaCha20 stream starts at word 0, so every restored server session that had ever shuffled came back with live 0 / high-water , and its next `capture_rng_word_pos` — which `game::library::resolve_and_apply_library_shuffle` performs before every shuffle — `.expect`-panicked `HighWaterRegression`. Measured on the new row: `HighWaterRegression { current: 291, requested: 0 }`. A word offset is meaningless against a different keystream, so keeping it was never conservative, it was incoherent. Zeroing it alongside the re-seed makes the server restore structurally identical to `engine-wasm`'s `resume_multiplayer_host_state`, which already implements this policy. This closes the follow-up the preceding commit disclosed at `PersistedGameState::into_game_state`. The six comments that described the server path as broken are rewritten to describe what it now does, including the two loader docs on the tracked F4 dump and the citation-gate-enrolled Kilo loader; none is left pointing at a disclosure that no longer exists. Two rows, both mutant-proofed by execution rather than argument. Deleting the new statement reds `restore_reseeds_the_rng_and_drops_the_saved_stream_position` with `291 != 0`; deleting it together with the guarding assertions reaches the shuffle and reproduces the panic itself. The paired `#[should_panic]` row proves that panic is reachable through the production seam on a restored session, so "the shuffle succeeds" is evidence rather than a statement about a call that could never have failed. `cargo test -p server-core` is covered by no Tilt resource and was run directly: 344 passed. DISCLOSED, NOT FIXED: `engine-wasm`'s `get_ai_scored_candidates` has the same shape, in a worse form. It re-seeds `state.rng` inside `with_state_mut`, so the rewind persists into the `GAME_STATE` thread-local, and it writes neither `rng_word_pos` nor `rng_seed` — the state it leaves is incoherent on two axes, and a later `rehydrate_rng` on it reconstructs the OLD stream, discarding the scoring re-seed entirely. It is outside this change's frozen scope. Reachability was measured rather than left open. No shipped client path reaches the panic: `AiWorkerPool.getAiScoredCandidates` awaits `restoreState` on every worker before each scoring call, `restore_game_state` rehydrates the full triple, and the pool never hands out its workers. The exposure is the public `#[wasm_bindgen]` surface — `EngineWorkerClient` does expose `exportState`, so a host that exports a worker after scoring reaches `capture_rng_word_pos` and panics. No bug on the shipped path today; a trap for the next caller. The class-level repair is a single `GameState` method owning seed and offset together. Four sites assign `state.rng` from a seed, but only three are resume-class: `game::visibility::filter_state_for_viewer` is wire redaction and already writes the whole triple deliberately. Of the three resume-class sites this commit leaves two correct and only `get_ai_scored_candidates` defective, so the helper is worth more now than when it was first declined — but this scope reaches exactly one of them, so it stays a follow-up. Assisted-by: ClaudeCode:claude-opus-4.8 --- crates/engine/src/types/game_state.rs | 14 +- .../integration/dina_noff_turn5_loader.rs | 7 +- .../fantastic_four_bounded_loop.rs | 12 +- .../kilo_live_offer_from_real_dump.rs | 4 +- .../sprout_inalla_realistic_offer.rs | 4 +- crates/server-core/src/session.rs | 120 ++++++++++++++++++ 6 files changed, 142 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 9651b72ab0..e881f51c27 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -9841,12 +9841,14 @@ impl PersistedGameState { // `rng_word_pos = 0` so a resumed host does not replay saved randomness. That is a // resume policy choice, not a bug, and it leaves live position and high-water agreed. // * `server-core`'s `GameSession::from_persisted` re-seeds `rng` from fresh entropy and - // does NOT zero `rng_word_pos`, so immediately after this call it is back at live 0 / - // high-water and the next `capture_rng_word_pos` still panics. The server - // restore path therefore REMAINS BROKEN. That gap is pre-existing and untouched here — - // the server has never zeroed the offset, before or after this call existed — so this - // call must not be read as repairing it. The repair is a behavior change owed its own - // commit; disclosed and queued as a follow-up. + // zeroes `rng_word_pos` in the same step, so it lands on the same agreed live-0 / + // high-water-0 pair as the host resume above. It therefore DISCARDS the position this + // call just restored, deliberately: a resumed server game must not replay randomness + // the pre-save game already consumed. That makes this call's rehydrate inert on the + // server path — the chokepoint hands every caller a coherent stream, and a caller + // wanting a different one overwrites BOTH halves rather than half of one. Re-seeding + // WITHOUT zeroing the offset was the earlier server bug: it left live 0 / high-water + // , so the next `capture_rng_word_pos` `.expect`-panicked `HighWaterRegression`. // Offline tooling (`phase-ai`'s `load_saved_game_state`) simply inherits the repair. state.rehydrate_rng(); state diff --git a/crates/engine/tests/integration/dina_noff_turn5_loader.rs b/crates/engine/tests/integration/dina_noff_turn5_loader.rs index 3f53c29f73..c492f05058 100644 --- a/crates/engine/tests/integration/dina_noff_turn5_loader.rs +++ b/crates/engine/tests/integration/dina_noff_turn5_loader.rs @@ -52,9 +52,10 @@ const DINA_NOFF_RNG_WORD_POS: u128 = 313; /// **Row 9, positive arm.** The chokepoint rehydrates: a load that ENDS at /// `PersistedGameState::into_game_state`, as `load_dina_noff` does, leaves the LIVE stream at the /// saved high-water, so a later export-time capture is legal instead of a rewind. Scope: that is -/// the chokepoint's own postcondition, not a claim about every shipped ingress — `server-core`'s -/// `from_persisted` re-seeds afterwards without zeroing `rng_word_pos` and still panics there -/// (pre-existing, disclosed at the chokepoint, not repaired by this change). +/// the chokepoint's own postcondition, not a claim that every shipped ingress ends here — +/// `server-core`'s `GameSession::from_persisted` re-seeds afterwards and zeroes `rng_word_pos` +/// with it, so the server ends at an agreed live-0 / high-water-0 pair rather than at this +/// resumed position. /// /// Non-vacuity: the reach-guard below pins the real board (4 seats, turn 5, the captured life /// vector, a NON-ZERO saved high-water), so "no panic" cannot be satisfied by a degenerate or diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index a3f7c8d57c..e6cf212172 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -91,9 +91,9 @@ fn gunzip(gz: &[u8]) -> String { /// the repair lived only in `engine-wasm`'s `restore_game_state`, so a load that ENDED at the /// chokepoint — as `load_f4` does — left the live stream rewound to word 0 under a saved /// `rng_word_pos` of 379. Every caller now inherits it and WASM's own call became an idempotent -/// repeat. This does NOT equalize the shipped load paths: `server-core`'s `from_persisted` -/// re-seeds afterwards without zeroing `rng_word_pos`, a pre-existing gap that this change neither -/// caused nor repairs (disclosed at `PersistedGameState::into_game_state`). +/// repeat. It does NOT make the shipped load paths identical: `server-core`'s +/// `GameSession::from_persisted` re-seeds afterwards and zeroes `rng_word_pos` with it, so the +/// server deliberately DISCARDS the saved position instead of resuming it as `load_f4` does. /// /// The dump was captured with the detector OFF; every row here is about the CR 732.2a /// interactive offer, so the mode is set to `Interactive` at load — the same thing the user's @@ -370,9 +370,9 @@ fn replay_at_priority(state: &GameState, proposer: PlayerId) -> GameState { /// at word 0, so the very next export-time `capture_rng_word_pos` panicked /// `HighWaterRegression { current: 379, requested: 0 }`. Every row in this file loads through /// `load_f4`, so the gap sat under all of them. Scope: this row measures the CHOKEPOINT's -/// postcondition. It is not a claim that every shipped ingress is now sound — `server-core`'s -/// `from_persisted` re-seeds after the chokepoint without zeroing `rng_word_pos` and still hits -/// this panic (pre-existing, disclosed at `PersistedGameState::into_game_state`, not repaired here). +/// postcondition, which is not every shipped ingress's postcondition — `server-core`'s +/// `GameSession::from_persisted` re-seeds after the chokepoint and zeroes `rng_word_pos` with it, +/// ending at an agreed live-0 / high-water-0 pair rather than at this row's resumed position. /// /// Two-sided on one axis, like its Dina sibling: the restored stream is AT the high-water and the /// capture is legal; the same board with the live position rewound to 0 — the exact pre-fix decode diff --git a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs index eba2e8830a..8eeb224413 100644 --- a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs +++ b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs @@ -92,8 +92,8 @@ fn gunzip(gz: &[u8]) -> String { /// stream, which only `engine-wasm`'s `restore_game_state` used to do on its own — a load that /// ENDED at the chokepoint, as this one does, was left with a word-0 stream under this dump's /// saved `rng_word_pos` of 293. WASM's own call is now an idempotent repeat. Callers may still -/// diverge afterwards: `from_persisted` re-seeds without zeroing `rng_word_pos`, a pre-existing -/// gap disclosed at `PersistedGameState::into_game_state` and not repaired here. +/// diverge afterwards: `GameSession::from_persisted` re-seeds and zeroes `rng_word_pos` with it, +/// discarding the saved position rather than resuming it as this load does. /// The sequence deserializes NORMALLY (len 6), /// then `GameState::migrate_transient_loop_sequence` DROPS it because the dump was captured at /// empty-stack `Priority` (NOT a shortcut window) — exactly the production load behavior. Reverting diff --git a/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs b/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs index 0a3a3ab7f5..819453f8da 100644 --- a/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs +++ b/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs @@ -55,8 +55,8 @@ fn gunzip(gz: &[u8]) -> String { /// stream, which only `engine-wasm`'s `restore_game_state` used to do on its own — a load that /// ENDED at the chokepoint, like this one, was left with a word-0 stream under this dump's saved /// `rng_word_pos` of 291. WASM's own call is now an idempotent repeat. Callers may still diverge -/// afterwards: `from_persisted` re-seeds without zeroing `rng_word_pos`, a pre-existing gap -/// disclosed at `PersistedGameState::into_game_state` and not repaired here. +/// afterwards: `GameSession::from_persisted` re-seeds and zeroes `rng_word_pos` with it, +/// discarding the saved position rather than resuming it as this load does. /// The migration drops the primed loop sequence /// because the dump sits at empty-stack Priority (NOT a shortcut window), so the offer must be /// rebuilt by a live cast below. diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index 58a1132623..f9aebd2542 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -926,6 +926,10 @@ impl GameSession { /// - card characteristics from the card database /// - `log_player_names` from the persisted display names /// - `rng` re-seeded with fresh randomness + /// + /// The fresh seed also resets `rng_word_pos` — a `#[serde(default)]` field, not a skipped + /// one. It is the saved high-water of the stream the old seed generated, and has no + /// meaning against the new one. pub fn from_persisted(ps: PersistedSession, db: &CardDatabase) -> Result { let mut state = ps.state.into_game_state(); state @@ -945,6 +949,14 @@ impl GameSession { let fresh_seed: u64 = rand::rng().random(); state.rng_seed = fresh_seed; state.rng = rand_chacha::ChaCha20Rng::seed_from_u64(fresh_seed); + // A fresh stream starts at word 0, so the saved high-water — which indexes into the + // OLD keystream and is meaningless against this one — has to go with the old seed. + // Leaving it behind is what broke restore: the chokepoint's `rehydrate_rng` had put + // live and high-water in agreement, the re-seed above rewound live to 0, and the next + // `capture_rng_word_pos` (`game::library::resolve_and_apply_library_shuffle` performs + // one before every shuffle) `.expect`-panicked `HighWaterRegression`. Same three- + // statement resume policy as `engine-wasm`'s `resume_multiplayer_host_state`. + state.rng_word_pos = 0; finalize_public_state(&mut state); // Re-bind rather than trusting any id the blob carries, on the same // principle that `restore_session` re-stamps `hosting` and revokes an @@ -4783,6 +4795,114 @@ mod tests { assert_eq!(session.state.debug_permitted, BTreeSet::from([PlayerId(0)])); } + /// The high-water this row plants before persisting. Deliberately NOT block-aligned + /// (ChaCha20 block 18, word 3), so a fast-forward that only lands on block boundaries + /// cannot pass by accident. `set_word_pos`/`get_word_pos` round-trip any position exactly. + const SAVED_WORD_POS: u128 = 291; + + /// `from_persisted` re-seeds `rng` from fresh entropy — deliberately, so restored games do + /// not all share one deterministic sequence — and must drop `rng_word_pos` in the same step. + /// A fresh stream starts at word 0, so a surviving high-water leaves `advance_rng_high_water` + /// guarding a position the live cursor is BEHIND, and the next `capture_rng_word_pos` + /// `.expect`-panics `HighWaterRegression`. `resolve_and_apply_library_shuffle` performs that + /// capture before every shuffle, so this bit every restored server game that had shuffled. + /// + /// Non-vacuity: the premise assertions measure that the blob really carried a NON-ZERO + /// position AND that the engine chokepoint really resumed it, so the `== 0` below can only be + /// this function discarding it — not serde dropping a field that was never there. + /// Discrimination: deleting `state.rng_word_pos = 0` reds the high-water assertion with + /// `291 != 0` and panics the shuffle underneath it. + #[test] + fn restore_reseeds_the_rng_and_drops_the_saved_stream_position() { + let db = engine::database::CardDatabase::default(); + let mut mgr = SessionManager::new(); + let code = single_ai_opponent_game(&mut mgr); + + let session = mgr.sessions.get_mut(&code).unwrap(); + // Guard, not an assumption: if setup ever consumes past the planted position the + // capture below would panic in the fixture rather than in the code under test. + assert!( + session.state.rng_word_pos < SAVED_WORD_POS, + "fixture premise: setup must leave the high-water below the planted position", + ); + // Plant it the way a shuffle does — advance the live cursor, then promote it through + // the engine's own monotonic primitive. Never by writing the field. + session.state.rng.set_word_pos(SAVED_WORD_POS); + session.state.capture_rng_word_pos(); + let saved_seed = session.state.rng_seed; + assert_eq!( + session.state.rng_word_pos, SAVED_WORD_POS, + "fixture premise: a NON-ZERO saved high-water, or this row measures nothing", + ); + + let json = serde_json::to_string(&mgr.sessions.get(&code).unwrap().to_persisted()).unwrap(); + let blob: crate::persist::PersistedSession = serde_json::from_str(&json).unwrap(); + + // Premise 2, measured: the position survives disk AND the chokepoint resumes it. + let chokepoint_only = blob.state.clone().into_game_state(); + assert_eq!( + chokepoint_only.rng_word_pos, SAVED_WORD_POS, + "premise: the persisted blob carries the position across disk", + ); + assert_eq!( + chokepoint_only.rng.get_word_pos(), + chokepoint_only.rng_word_pos, + "premise: `into_game_state` resumes it, so a zero below is this session's own \ + policy and not a lost field", + ); + + let mut restored = + GameSession::from_persisted(blob, &db).expect("supported persisted format config"); + + assert_ne!( + restored.state.rng_seed, saved_seed, + "the fresh-seed policy must survive this fix", + ); + assert_eq!( + restored.state.rng_word_pos, 0, + "a freshly seeded stream must not inherit the old stream's high-water", + ); + assert_eq!( + restored.state.rng.get_word_pos(), + restored.state.rng_word_pos, + "live cursor and persisted high-water must agree after restore", + ); + + assert!( + !restored.state.players[0].library.is_empty(), + "reach-guard: the shuffle below must have a library to act on", + ); + engine::game::library::resolve_and_apply_library_shuffle( + &mut restored.state, + PlayerId(0), + &mut Vec::new(), + ) + .expect("a restored session must be able to shuffle"); + } + + /// Paired reach-guard. It does NOT red when the fix is reverted, and is not meant to: its job + /// is to prove the panic is REACHABLE through the production shuffle seam on a restored + /// session, so the row above's "the shuffle succeeds" is evidence rather than a statement + /// about a call that could never have failed. + #[test] + #[should_panic(expected = "HighWaterRegression")] + fn a_restored_session_that_kept_the_saved_stream_position_panics_on_its_next_shuffle() { + let db = engine::database::CardDatabase::default(); + let mut mgr = SessionManager::new(); + let code = single_ai_opponent_game(&mut mgr); + let mut restored = round_trip_through_disk(mgr.sessions.get(&code).unwrap(), &db); + + // Re-create the pre-fix pairing on a RESTORED state: a fresh word-0 stream under a + // surviving high-water. Exactly what `from_persisted` used to hand back. + restored.state.rng_word_pos = SAVED_WORD_POS; + engine::game::library::resolve_and_apply_library_shuffle( + &mut restored.state, + PlayerId(0), + &mut Vec::new(), + ) + .expect("unreachable: the capture panics first"); + } + // CR 107.1c: "remove any number of counters" — a human's intermediate submit // ("remove 2 of 3") is not one of the coarse AI candidates (remove-none / // remove-all), but the engine validates the full legal space directly. From 454ad718f42377b5c0f2765abacae574da02ed66 Mon Sep 17 00:00:00 2001 From: lgray Date: Sat, 8 Aug 2026 09:41:04 -0500 Subject: [PATCH 03/44] fix(engine-wasm): re-seed the whole RNG identity triple for worker scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_ai_scored_candidates` re-seeded a pool worker's entropy stream by writing `state.rng` alone. `rng` is `#[serde(skip)]`, so `rng_seed` and `rng_word_pos` are its only carriers across a serialization boundary — writing one without the others splits the stream identity in two. The concrete failure: a fresh ChaCha20 stream starts at word 0, so a restored state's surviving high-water leaves `advance_rng_high_water` guarding a position the live cursor is behind. The next `capture_rng_word_pos` then `.expect`-panics `HighWaterRegression` — and every simulated library shuffle performs one, as does `export_game_state_json`. In the shipped build that surfaces as a worker-pool failure and a silent fall back to degraded AI. Reachability is verified in-call rather than argued: a test captures the panic's backtrace inside the scoring call, along `score_candidates_for_parallel_worker` -> `PlannerServices::quiesce` -> `apply_as_current_for_simulation` -> `shuffle_library` -> `resolve_and_apply_library_shuffle` -> `capture_rng_word_pos`. `scored_candidates_inner` is split out so native tests drive the real scoring path: the `#[wasm_bindgen]` shell returns through `to_js`, which calls the real `JSON.parse` binding and panics outside a wasm32 runtime — the same reason `resolve_all_inner` exists. The new tests are `#[cfg(test)]`, deliberately not `#[cfg(all(test, target_arch = "wasm32"))]`: that block's assertions never execute in the native suite and no CI job runs `wasm-pack test`. Executed mutant results, 25/25 cells as predicted (whole revert = M1+M2): M1 delete `rng_word_pos = 0` A RED B RED C1 green C2 RED R3 RED M2 delete `rng_seed = rng_seed` A green B green C1 green C2 RED R3 green M3 delete `rng = seed_from_u64` A green B green C1 RED C2 RED R3 green M4 delete all three A green B green C1 RED C2 RED R3 green whole revert (M1+M2) A RED B RED C1 green C2 RED R3 RED M1 and the whole revert fail with `HighWaterRegression { current: 291, requested: 0 }` — 291 the planted saved position, 0 the fresh stream's word. M2 breaks only the round-trip carrier: the restored stream is `ORIGINAL_SEED`-derived rather than the worker's. Deferred and disclosed rather than claimed: the three-statement reseed is now spelled out at all three resume-class sites — `resume_multiplayer_host_state` and this one in `engine-wasm`, `GameSession::from_persisted` in `server-core` — with no shared helper. A `GameState` method would unify them, but `crates/engine/src/types/game_state.rs` is outside this commit's frozen scope. Two further sites are deliberately NOT in that class: `rehydrate_rng` (restores a persisted position rather than starting a fresh stream) and `filter_state_for_viewer` (produces a view, never a resumable state). Assisted-by: ClaudeCode:claude-opus-4.8 --- crates/engine-wasm/src/lib.rs | 403 ++++++++++++++++++++++++++++++++-- 1 file changed, 390 insertions(+), 13 deletions(-) diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 1ba085d4f1..dbafbcbc25 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -2643,6 +2643,39 @@ pub fn get_ai_tactical_action_proposal_with_diagnostics( })? } +/// Score one parallel-worker sample against the thread-local state. +/// +/// Split out of [`get_ai_scored_candidates`] so native tests can drive the real +/// scoring path: the `#[wasm_bindgen]` shell returns through `to_js`, which calls +/// the real `JSON.parse` binding and panics outside a wasm32 runtime (same reason +/// `resolve_all_inner` exists). +fn scored_candidates_inner( + state: &mut GameState, + difficulty: AiDifficulty, + ai_player: PlayerId, + rng_seed: u64, +) -> Vec<(GameAction, f64)> { + engine::game::layers::flush_layers(state); + + // A pool worker scores on its OWN entropy stream so root-parallel samples + // diverge (`AiWorkerPool` passes `baseSeed + index`); `score_candidates_with_session` + // names this the WASM divergence channel. `rng` is `#[serde(skip)]`, so + // `rng_seed` + `rng_word_pos` are its only carriers: writing one without the + // others splits the stream identity in two. A fresh ChaCha20 stream starts at + // word 0, so a surviving high-water leaves `advance_rng_high_water` guarding a + // position the live cursor is BEHIND and the next `capture_rng_word_pos` + // `.expect`-panics `HighWaterRegression` — which every simulated library + // shuffle performs, and so does `export_game_state_json`. Overwrite all three, + // exactly as `resume_multiplayer_host_state` does. + state.rng_seed = rng_seed; + state.rng = ChaCha20Rng::seed_from_u64(rng_seed); + state.rng_word_pos = 0; + + let config = create_config_for_players(difficulty, Platform::Wasm, state.players.len() as u8); + let session = ai_session_for(state); + score_candidates_for_parallel_worker(state, ai_player, &config, Some(&session)) +} + /// Score candidates inside an isolated AI worker. These are plain, /// serializable hints rather than capabilities: they cannot cross the action /// boundary until the live main engine reissues an exact proposal. @@ -2653,19 +2686,10 @@ pub fn get_ai_scored_candidates( rng_seed: u64, ) -> Result { let difficulty = AiDifficulty::from_label(difficulty); - with_state_mut(|state| { - engine::game::layers::flush_layers(state); - state.rng = ChaCha20Rng::seed_from_u64(rng_seed); - let config = - create_config_for_players(difficulty, Platform::Wasm, state.players.len() as u8); - let session = ai_session_for(state); - Ok(to_js(&score_candidates_for_parallel_worker( - state, - PlayerId(player_id), - &config, - Some(&session), - ))) - })? + let scores = with_state_mut(|state| { + scored_candidates_inner(state, difficulty, PlayerId(player_id), rng_seed) + })?; + Ok(to_js(&scores)) } /// Convert score-only worker output into an authority-bound proposal. @@ -5112,3 +5136,356 @@ mod rng_restore_bridge_tests { clear_game_state(); } } + +/// Native coverage for the AI-scoring bridge's per-worker RNG re-seed. +/// +/// These are `#[cfg(test)]`, not `#[cfg(all(test, target_arch = "wasm32"))]`: the +/// `wasm32`-gated `mod tests` never executes in the native suite, and no Tilt +/// resource or CI job runs `wasm-pack test`. They drive `scored_candidates_inner` +/// rather than the `#[wasm_bindgen]` shell because the shell returns through +/// `to_js`, which calls the real `JSON.parse` binding and panics outside a wasm32 +/// runtime. +/// +/// The seam under test: `get_ai_scored_candidates` re-seeds the worker's entropy +/// stream. `rng` is `#[serde(skip)]`, so `rng_seed` + `rng_word_pos` are its only +/// carriers across a snapshot — writing one without the others splits the stream +/// identity in two, and the resulting high-water regression `.expect`-panics in +/// `GameState::capture_rng_word_pos`, which both `export_game_state_json` and +/// every simulated library shuffle perform. +#[cfg(test)] +mod ai_scoring_rng_bridge_tests { + use super::*; + use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, ResolvedAbility}; + use engine::types::game_state::{StackEntry, StackEntryKind}; + use engine::types::identifiers::CardId; + use engine::types::zones::Zone; + use rand::RngCore; + + /// Carried over verbatim from `server-core`'s `GameSession::from_persisted` + /// rows: deliberately NOT block-aligned (ChaCha20 block 18, word 3), so a + /// fast-forward that only lands on block boundaries cannot pass by accident. + const SAVED_WORD_POS: u128 = 291; + const ORIGINAL_SEED: u64 = 0x0C0D_5EED; + const WORKER_SEED: u64 = 0x0C0E_5EED; + /// Equal seeds would make the C1/C2 rows vacuous. Compile-time, at module + /// scope, so no row can bypass it by skipping a helper. + const _: () = assert!(ORIGINAL_SEED != WORKER_SEED); + + /// Steps 1-7 of the fixture: plant the exact state a pool worker is handed. + /// Deliberately performs **no** scoring call, so the `#[should_panic]` row can + /// reuse it by omitting a call rather than by reconstructing setup. + fn plant_restored_worker_state() { + clear_game_state(); + + let mut state = GameState::new_two_player(ORIGINAL_SEED); + for offset in 0..3u64 { + engine::game::zones::create_object( + &mut state, + CardId(900 + offset), + PlayerId(0), + format!("Planted Library Card {offset}"), + Zone::Library, + ); + } + + // Premise 1: the planted high-water must be something a re-seed can + // regress past, or the rows below cannot discriminate. + assert!( + state.rng_word_pos < SAVED_WORD_POS, + "premise: a fresh state must start below the planted high-water" + ); + + // Plant it the way a shuffle does — advance the live stream, then capture + // it. Never a raw field write. + state.rng.set_word_pos(SAVED_WORD_POS); + state.capture_rng_word_pos(); + + // Scoreable position. This reproduces `resolve_all_tests::priority_state`'s + // recipe rather than calling it: that helper is a private `fn`, so a + // sibling test module cannot name it. + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + state.priority_player = PlayerId(0); + + GAME_STATE.with(|cell| cell.set(Some(state))); + + // The exact shipped plant: `AiWorkerPool` calls `worker.restoreState(..)` + // before every scoring call, and `restore_game_state` rehydrates the full + // triple. + let json = export_game_state_json().expect("planting must be exportable"); + clear_game_state(); + restore_game_state(&json).expect("planting must be restorable"); + + // Premise 2, measured: the production restore resumed the saved position, + // so a zero observed below is this entry point's own policy rather than a + // lost serde field. + with_state(|state| { + assert_eq!( + state.rng_word_pos, SAVED_WORD_POS, + "premise: restore must resume the saved high-water" + ); + assert_eq!( + state.rng.get_word_pos(), + state.rng_word_pos, + "premise: restore must leave the live cursor on the saved high-water" + ); + }) + .expect("GAME_STATE must be initialized after restore"); + } + + /// Step 8 and nothing else: drive the real scoring path. + fn drive_scoring() -> Vec<(GameAction, f64)> { + with_state_mut(|state| { + scored_candidates_inner(state, AiDifficulty::VeryHard, PlayerId(0), WORKER_SEED) + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state") + } + + /// Row A. Revert-probe (RUN): deleting `state.rng_word_pos = 0;` — or the + /// whole commit — reds this row with + /// `HighWaterRegression { current: 291, requested: 0 }`. + #[test] + fn scoring_leaves_a_state_that_can_still_export() { + plant_restored_worker_state(); + drive_scoring(); + + // A production entry point on the very worker objects the pool holds: + // `exportState` is a live `EngineWorkerClient` message type. + export_game_state_json().expect("a scored worker must still be exportable"); + + clear_game_state(); + } + + /// Row B. Same mutant column as Row A by construction — this row buys the + /// *second* production seam (the route the AI simulation itself takes), not + /// extra discrimination. It is its own `#[test]` on fresh state because both + /// seams reach the same `.expect`-ing `capture_rng_word_pos`: sharing a test, + /// whichever ran first would abort the other. + #[test] + fn scoring_leaves_a_state_that_can_still_shuffle() { + plant_restored_worker_state(); + drive_scoring(); + + with_state_mut(|state| { + assert!( + !state.players[0].library.is_empty(), + "reach-guard: the shuffle below must have a library to act on" + ); + engine::game::library::resolve_and_apply_library_shuffle( + state, + PlayerId(0), + &mut Vec::new(), + ) + .expect("a scored worker must be able to shuffle"); + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + + clear_game_state(); + } + + /// Row C1. Behavioral (consumed randomness), not a field read, so writing the + /// field without moving the stream cannot satisfy it. + /// + /// Revert-probe (RUN): this row's probe is the **partial** revert — deleting + /// `state.rng = ..` or all three statements. It is GREEN under the + /// whole-commit revert, which leaves the live stream at `WORKER_SEED`@0. Do + /// not read it as whole-commit coverage. + #[test] + fn the_caller_supplied_seed_reaches_the_live_stream() { + plant_restored_worker_state(); + drive_scoring(); + + // `score_candidates_for_parallel_worker` takes `&GameState` and `GameState` + // carries no interior mutability, so nothing at or below the scoring call + // can advance the live stream: this reads back exactly what the entry + // point last wrote. + let mut expected = ChaCha20Rng::seed_from_u64(WORKER_SEED); + let expected_draws: Vec = (0..4).map(|_| expected.next_u32()).collect(); + + let live_draws: Vec = + with_state_mut(|state| (0..4).map(|_| state.rng.next_u32()).collect::>()) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + + assert_eq!( + live_draws, expected_draws, + "the live stream must be the caller's seed from origin, not the restored snapshot's" + ); + + clear_game_state(); + } + + /// Row C2 — the universal discriminator: RED on every mutant and on the + /// whole-commit revert. Both C rows compare against a stream freshly built + /// from `WORKER_SEED`, never against a clone of the post-scoring live stream: + /// a live-vs-restored comparison only proves internal consistency, which the + /// "delete all three" mutant also satisfies. + #[test] + fn the_scored_triple_round_trips_through_the_bridge() { + plant_restored_worker_state(); + drive_scoring(); + + let mut expected = ChaCha20Rng::seed_from_u64(WORKER_SEED); + let expected_draws: Vec = (0..4).map(|_| expected.next_u32()).collect(); + + let json = export_game_state_json().expect("a scored worker must still be exportable"); + clear_game_state(); + restore_game_state(&json).expect("a scored worker's export must be restorable"); + + let restored_draws: Vec = + with_state_mut(|state| (0..4).map(|_| state.rng.next_u32()).collect::>()) + .expect("GAME_STATE must be initialized after restore"); + + assert_eq!( + restored_draws, expected_draws, + "the round-tripped stream must be the caller's seed from origin" + ); + + clear_game_state(); + } + + /// Row 2 — the paired reach-guard. It does **not** red when the fix is + /// reverted and is not meant to: its job is to prove the panic is genuinely + /// reachable through the production shuffle seam from a triple of exactly this + /// shape, so the rows above are evidence rather than assertions about a call + /// that could never have failed. + /// + /// It deliberately omits `drive_scoring()` — calling the scoring entry point + /// first would let a panic from *that* call satisfy the `should_panic`. + /// Residual, stated rather than engineered away: `#[should_panic]` still + /// cannot prove which line panicked; the four sibling rows are what detect a + /// regression in the shared helper. + #[test] + #[should_panic(expected = "HighWaterRegression")] + fn an_incoherent_worker_triple_panics_on_its_next_shuffle() { + plant_restored_worker_state(); + + // Literally the pre-fix line, applied to the restored state. + with_state_mut(|state| state.rng = ChaCha20Rng::seed_from_u64(WORKER_SEED)) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + + with_state_mut(|state| { + engine::game::library::resolve_and_apply_library_shuffle( + state, + PlayerId(0), + &mut Vec::new(), + ) + .expect("unreachable: the incoherent triple must panic before this"); + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + } + + /// Row 3's extra fixture shape, applied to the already-restored state between + /// the plant and the scoring call. Ends by re-asserting the RNG triple is + /// untouched — object creation must not have moved the stream, or Row 3's + /// premise is gone. + fn shape_for_in_call_reach() { + with_state_mut(|state| { + state.active_player = PlayerId(0); + state.priority_passes.clear(); + + // The opponent needs a library for the resolved shuffle to act on. + for offset in 0..3u64 { + engine::game::zones::create_object( + state, + CardId(910 + offset), + PlayerId(1), + format!("Opponent Library Card {offset}"), + Zone::Library, + ); + } + + // Two player-0 battlefield permanents, each carrying one zero-cost + // activated `Effect::NoOp` ability. Three issued candidates keeps + // `deterministic_choice`'s `actions.len() == 1` arm from firing. + for offset in 0..2u64 { + let id = engine::game::zones::create_object( + state, + CardId(920 + offset), + PlayerId(0), + format!("Idle Permanent {offset}"), + Zone::Battlefield, + ); + if let Some(object) = state.objects.get_mut(&id) { + object.abilities = Arc::new(vec![AbilityDefinition::new( + AbilityKind::Activated, + Effect::NoOp, + )]); + } + } + + // The stack entry is OPPONENT-controlled: with an AI-owned stack, + // `low_value_priority_pass_from_actions` computes + // `owns_entire_stack == true` and `score_candidates_core` returns + // `[(PassPriority, 1.0)]` before any simulation runs. + let source_id = engine::game::zones::create_object( + state, + CardId(930), + PlayerId(1), + "Opponent Shuffle Source".to_string(), + Zone::Battlefield, + ); + state.stack = vec![StackEntry { + id: source_id, + source_id, + controller: PlayerId(1), + kind: StackEntryKind::ActivatedAbility { + source_id, + ability: Box::new(ResolvedAbility::new( + Effect::Shuffle { + target: engine::types::ability::TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(1), + )), + }, + }] + .into_iter() + .collect(); + + assert_eq!( + state.rng_word_pos, SAVED_WORD_POS, + "premise: shaping the fixture must not move the saved high-water" + ); + assert_eq!( + state.rng.get_word_pos(), + state.rng_word_pos, + "premise: shaping the fixture must not move the live cursor" + ); + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + } + + /// Row 3 — the in-call reach: the panic fires *inside* the scoring call, which + /// is what makes the shipped symptom (a silently degraded AI via the worker + /// pool's failure fallback) real rather than a trap for the next caller. + #[test] + fn scoring_itself_survives_a_simulated_opponent_shuffle() { + plant_restored_worker_state(); + shape_for_in_call_reach(); + + let issued = with_state_mut(|state| { + // Measure the list `score_candidates_core` will see, not the one it + // would have seen a flush ago: `scored_candidates_inner`'s FIRST + // statement is `flush_layers`, and `score_candidates_core` binds + // `build_decision_context_for_semantic_owner` downstream of it. + // `flush_layers` is idempotent (its `mem::replace` leaves the lattice + // `Clean`, and no arm re-dirties), so `drive_scoring()`'s own flush is + // a provable no-op and cannot move the candidate set between the two. + engine::game::layers::flush_layers(state); + engine::ai_support::build_decision_context_for_semantic_owner(state, PlayerId(0)) + .candidates + .len() + }) + .expect("GAME_STATE must be initialized by plant_restored_worker_state"); + assert!( + issued >= 2, + "premise: gate #10's `actions.len() == 1` arm must not fire; engine issued {issued} candidates" + ); + + drive_scoring(); + + clear_game_state(); + } +} From 492a9ad4e8db676cc7d54009955fd14d52a15642 Mon Sep 17 00:00:00 2001 From: lgray Date: Sat, 8 Aug 2026 13:17:24 -0500 Subject: [PATCH 04/44] fix(engine): refuse a persisted LoopShortcut offer that narrows its bound while recording its proposer's own period `reject_zero_bound_shortcut_offer` accepted a wire state carrying `schema.is_bounded()` together with a `last_loop_action_sequence` controlled by the offer's own proposer. No producer mints that pair: - the object-growth mint (`reconcile_terminal_result`) and the Path A drain mint (`interactive_loop_bridge`) both publish `MAX_SHORTCUT_CYCLES`, so neither is ever `is_bounded()`; - the bounded mint (`certified_bounded_cycle_offer`) is bounded by construction via its closed-range refusal, but gate (1b) in `bounded_cycle_offer` refuses it outright while the proposer's own driving period is accumulating. Accepting the pair is not inert. A declare passes, and accept routes through `materialize_fixed_shortcut` to the SITE C early return into the object-growth materializer -- committing *zero* of the agreed cycles while the CR 732.2b response window is spent. The engine names that misroute in two places already. The guard is seat-relative (`== Some(*proposer)`), never a global rescan, and it is deliberately NOT keyed on `per_cycle`: omitting `max_iterations` defaults to 1000, i.e. unbounded, and unbounded-plus-own-period is the *legitimate* object-growth shape -- so evading this conjunct dissolves the harm instead of hiding it. No CR annotation, deliberately. CR 732.2a's Example is itself a bounded ("999,999 more times") own-period proposal, so the rejected class is CR-LEGAL; this enforces an engine reachability invariant, not a rule. Annotating it would misattribute a rules licence to a producer-reachability fact. The shipped comment carries that argument so the absence defends itself. Test: `a_wire_bounded_offer_carrying_the_proposers_own_period_fails_the_load`, 6 arms over two real captures, no new fixture or helper. Four measured revert probes, three single-conjunct reverts producing three distinct first-failing arms plus an ordering probe: delete the block -> A1 fails (bounded + own period must not load) delete `is_bounded() &&` -> A3 fails (own period alone stays legal) delete the period conjunct-> A2 fails (a narrowed bound alone stays legal) hoist above the zero block-> A6 fails (the zero-bound guard answers first) Residual disclosed in-code: `RespondToShortcut` carries the same live harm and is not covered -- `ShortcutProposal` has no `schema`/`max_iterations`, so no bound-keyed conjunct can see it. Filed, not silently omitted. Assisted-by: ClaudeCode:claude-opus-4.8 --- crates/engine/src/types/game_state.rs | 68 ++++++ .../engine/tests/integration/loop_shortcut.rs | 209 ++++++++++++++++++ 2 files changed, 277 insertions(+) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e881f51c27..d23e3efa64 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -9512,6 +9512,7 @@ fn reject_zero_bound_shortcut_offer(state: &GameState) -> Result<(), String> { if let WaitingFor::LoopShortcut { schema, certificate, + proposer, .. } = &state.waiting_for { @@ -9522,6 +9523,73 @@ fn reject_zero_bound_shortcut_offer(state: &GameState) -> Result<(), String> { .to_string(), ); } + // THE PAIR NO PRODUCER MINTS. `is_bounded()` says the offer's producer NARROWED the + // repetition bound below `MAX_SHORTCUT_CYCLES`; `loop_period_controller()` says a driving + // period belonging to THIS proposer is recorded. The engine's three `LoopShortcut` mints + // partition that cross-product and none of them lands in this cell: + // + // * the object-growth mint (`reconcile_terminal_result`, schema from + // `try_offer_object_growth_shortcut`) and the Path A drain mint + // (`interactive_loop_bridge`) both hand `build_shortcut_schema` the global + // `MAX_SHORTCUT_CYCLES` verbatim, so neither is EVER `is_bounded()` — the growth mint + // is the one that REQUIRES its proposer's own period, and it is unbounded by + // construction; + // * the bounded mint (`certified_bounded_cycle_offer`) is `is_bounded()` by construction + // — it refuses `NoNarrowedLegalCount` unless `(1..MAX_SHORTCUT_CYCLES)` contains the + // bound — but its caller's gate (1b) (`bounded_cycle_offer`) returns + // `BoundedOfferRefusal::ProposerHasDrivingPeriod` while that seat's own period is + // accumulating, so it can never mint INTO this cell; + // * `visibility.rs`'s per-viewer re-wrap copies `max_iterations` verbatim off an offer + // one of the three already minted. + // + // No live beat can join the two afterwards either. Nothing assigns `schema` or + // `max_iterations` in place anywhere in the engine, so an unbounded offer cannot ACQUIRE a + // narrowed bound; and every writer that GROWS `last_loop_action_sequence` is priority-side + // — the `TapLandForMana` / `ActivateManaSource` / `ActivateAbility` `WaitingFor::Priority` + // arms (`accumulate_loop_action_step` and the token-creating `vec![step]` beside it) and + // the cast finalize. A pending offer reaches none of them: its only reducer arms are + // `DeclareShortcut` and `DeclineShortcut`. + // + // WHAT IT COSTS TO ACCEPT IT: `materialize_fixed_shortcut` (SITE C) dispatches on period + // ownership ALONE and early-returns the accepted proposal into + // `materialize_object_growth_shortcut`, committing ZERO of the agreed cycles — the silent + // misroute gate (1b)'s own doc block exists to prevent, entering through the restore door + // instead of the producer door. + // + // ⚠ DELIBERATELY CARRIES NO `CR` ANNOTATION, and the measurement for that absence travels + // with it so a later reader does not "fix" the omission. CR 732.2a's own Example is a + // proposer repeating THEIR OWN activation a SPECIFIED 999,999 more times — bounded, own + // period — so this state class is LEGAL AT THE TABLE and the rules license nothing here to + // enforce. What is violated is a producer-reachability fact about this engine, not a rule. + // Same call, same reason, same file family as `handle_declare_shortcut`'s IMPLEMENTATION + // BUDGET BOUND note: "a maintainer applying the CR 732.2a iff to a branch that wears a CR + // number will either trust it wrongly or delete it wrongly." + // + // BOTH conjuncts are required. Own period ALONE is the object-growth route's own admission + // condition, so rejecting it would refuse every legitimate growth capture; a narrowed bound + // ALONE is the ordinary bounded offer. + // + // ⚠ AFTER THE ZERO-BOUND CHECK, DELIBERATELY: `0 < MAX_SHORTCUT_CYCLES`, so a zero bound is + // ALSO `is_bounded()` and hoisting this block would relabel a corrupt zero with the wrong + // invariant. Observed, not assumed — see the zero-bound-plus-own-period arm of + // `a_wire_bounded_offer_carrying_the_proposers_own_period_fails_the_load`. + // + // ⚠ THIS BLOCK COVERS ONE OF THE HARM'S TWO WIRE HOSTS, and unlike the zero-bound sibling + // above the residual is NOT empty. A persisted `WaitingFor::RespondToShortcut { proposal }` + // whose `proposal.proposer` owns the recorded period reaches the SAME SITE C misroute via + // `apply_confirmed_shortcut`. No bound-keyed conjunct can see it — `ShortcutProposal` + // carries no `schema`/`max_iterations` at all (the scoping note on the zero-bound guard + // above). The candidate discriminator on that host is `proposal.per_cycle.is_some()`; it is + // filed rather than shipped because "`per_cycle: Some` ⟺ the bounded mint" is not yet + // measured per branch, and a guard on an inherited marker is what this seam must not carry. + if schema.is_bounded() && state.loop_period_controller() == Some(*proposer) { + return Err( + "persisted LoopShortcut offer narrows its repetition bound while recording the \ + proposer's own driving period; no producer mints that pair, and accepting it \ + routes the agreed cycles to the object-growth materializer, committing none" + .to_string(), + ); + } // The SIBLING wire zero. `max_iterations` says how many repetitions there are; // `frames_per_period` says what one repetition IS, and a wire-supplied 0 corrupts the // second question exactly as a 0 bound corrupts the first. diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 5ce7c08f5b..2816e9349f 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -5738,6 +5738,215 @@ fn a_wire_zero_shortcut_bound_fails_the_load_and_a_wire_five_does_not() { ); } +/// R0e — the wire pair NO PRODUCER MINTS: a persisted `LoopShortcut` offer that NARROWS its +/// repetition bound (`schema.is_bounded()`) while recording the PROPOSER'S OWN driving period +/// (`loop_period_controller() == Some(proposer)`) must fail the load. +/// +/// The engine's three mints partition that cross-product and none lands in this cell: the +/// object-growth and Path A drain mints both publish `MAX_SHORTCUT_CYCLES` (never +/// `is_bounded()`), and the bounded mint's gate (1b) refuses `ProposerHasDrivingPeriod`. Accepting +/// the pair anyway routes the accepted proposal through `materialize_fixed_shortcut`'s +/// period-ownership early return into `materialize_object_growth_shortcut` — the table agreed to +/// `n` cycles and gets NONE. +/// +/// ⚠ NOT A CR REFUSAL, and the row asserts on the engine-invariant message accordingly. CR 732.2a's +/// Example is a proposer repeating THEIR OWN activation a specified 999,999 more times, so this +/// state class is legal at the table; what it violates is producer reachability in this engine. +/// +/// THE PERIOD IS A PRODUCTION-SERIALIZED VALUE lifted whole out of the real object-growth capture, +/// never hand-authored JSON — the discipline the `frames_per_period` row above states. +/// +/// MATCHED REVERT-PROBE TABLE — each conjunct has its own failing arm, and the three +/// single-conjunct reverts produce three DISTINCT failing sets: +/// +/// | mutation to `reject_zero_bound_shortcut_offer` | flips | stays green | +/// |---|---|---| +/// | delete the whole own-period `if` block | A1 → `Ok` | A2, A3, A4, A5, A6 | +/// | delete `schema.is_bounded() &&` | A3, A5 → `Err` | A1, A2, A4, A6 | +/// | delete `&& loop_period_controller() == …` | A2, A4 → `Err` | A1, A3, A5, A6 | +/// | hoist the block ABOVE the `max_iterations == 0` block | A6's message | A1–A5 | +#[test] +fn a_wire_bounded_offer_carrying_the_proposers_own_period_fails_the_load() { + let json = gunzip_dump(include_bytes!( + "../fixtures/tenacity_exquisite_blood_4p.json.gz" + )); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + let base = envelope["gameState"].clone(); + + // ── REACH-GUARDS ON THE BASE: both splices below must CREATE their key ────────────── + assert_eq!( + base["waiting_for"]["type"].as_str(), + Some("LoopShortcut"), + "the invariant is scoped to the one variant that carries a schema AND a proposer" + ); + assert!( + base["waiting_for"]["data"]["schema"].is_object(), + "the tenacity offer carries a schema object for the bound to live on" + ); + assert!( + base["waiting_for"]["data"]["schema"] + .get("max_iterations") + .is_none(), + "the fixture predates the field, so the bound splice CREATES the key (absent ⇒ \ + MAX_SHORTCUT_CYCLES ⇒ NOT is_bounded, which is what arms A3/A5 rest on)" + ); + assert!( + base.get("last_loop_action_sequence").is_none(), + "the fixture records no driving period, so the period splice CREATES the key" + ); + let proposer = base["waiting_for"]["data"]["proposer"].clone(); + + let donor_json = gunzip_dump(include_bytes!( + "../fixtures/combo_infinite_pile_4p_offer.json.gz" + )); + // The combo capture is BARE (no `gameState` envelope) — it is the other decode ingress, and + // A5 rides it as itself below. + let donor_state: serde_json::Value = + serde_json::from_str(&donor_json).expect("the combo dump parses as JSON"); + let donor_period = donor_state["last_loop_action_sequence"].clone(); + let donor_steps = donor_period + .as_array() + .expect("the real object-growth capture records a driving period to donate"); + assert!( + !donor_steps.is_empty(), + "an empty donated sequence would make loop_period_controller() None and every arm vacuous" + ); + assert!( + donor_steps + .iter() + .all(|step| step["controller"] == proposer), + "the donated period must belong to the SAME seat as the tenacity proposer, or A1 would \ + be testing a FOREIGN period — which is A4's job, not A1's" + ); + + let spliced = |bound: Option, period: Option<&serde_json::Value>| { + let mut v = base.clone(); + if let Some(n) = bound { + v["waiting_for"]["data"]["schema"]["max_iterations"] = serde_json::json!(n); + assert_eq!( + v["waiting_for"]["data"]["schema"]["max_iterations"].as_u64(), + Some(n), + "the bound splice must reach schema.max_iterations" + ); + } + if let Some(seq) = period { + v["last_loop_action_sequence"] = seq.clone(); + assert_eq!( + &v["last_loop_action_sequence"], seq, + "the period splice must reach last_loop_action_sequence" + ); + } + v + }; + let decode_persisted = |value: serde_json::Value| { + serde_json::from_value::(value) + }; + + // ── A1 — THE GUARD FIRES. Also the reach-guard for A2/A3/A4: the predicate reads the period + // from the state AS DECODED FROM THE WIRE, so an `Err` here is proof the splice landed and + // survived `decode_persisted_resolution_state`. Were it dropped, this would be `Ok` and the + // three `Ok` arms below would mean nothing. + let message = decode_persisted(spliced(Some(5), Some(&donor_period))) + .expect_err("a narrowed bound carrying the proposer's own period must fail the load") + .to_string(); + assert!( + message.contains("narrows its repetition bound"), + "the rejection must NAME the invariant it enforces and must not be either sibling zero \ + guard firing instead, got: {message}" + ); + + // ── A6 — ORDERING PROBE. `0 < MAX_SHORTCUT_CYCLES`, so a zero bound is ALSO `is_bounded()`: + // the two blocks are not disjoint and the zero check must keep answering first. No pre-existing + // row observes this — the sibling zero row's fixture carries no period, so the new predicate is + // false there regardless of order. + let message = decode_persisted(spliced(Some(0), Some(&donor_period))) + .expect_err("a zero bound must still fail the load when a period rides with it") + .to_string(); + assert!( + message.contains("max_iterations 0"), + "ORDERING: hoisting the own-period block above the zero-bound block relabels a corrupt \ + zero with the wrong invariant, got: {message}" + ); + + // ── A2 — THE PERIOD CONJUNCT. A narrowed bound ALONE is the ordinary bounded offer. + assert!( + decode_persisted(spliced(Some(5), None)).is_ok(), + "a narrowed bound with NO recorded period is exactly what the bounded mint publishes" + ); + + // ── A3 — THE `is_bounded()` CONJUNCT. Own period ALONE is the object-growth route's own + // admission condition; rejecting it would refuse every legitimate growth capture. + assert!( + decode_persisted(spliced(None, Some(&donor_period))).is_ok(), + "an UNNARROWED offer (absent bound ⇒ MAX_SHORTCUT_CYCLES) carrying the proposer's own \ + period is the legitimate object-growth shape and must still load" + ); + + // ── A4 — SEAT-RELATIVITY. It must be THIS proposer's period, not merely A period. + let foreign_period = { + let mut seq = donor_period.clone(); + for step in seq + .as_array_mut() + .expect("the donated period is an array of steps") + { + step["controller"] = serde_json::json!(1); + } + assert!( + seq.as_array() + .expect("still an array") + .iter() + .all(|step| step["controller"] != proposer), + "the controller rewrite must reach every step, or A4 would re-run A1" + ); + seq + }; + assert!( + decode_persisted(spliced(Some(5), Some(&foreign_period))).is_ok(), + "a period recorded from a DIFFERENT seat describes no sequence this proposer can take \ + (SITE B's seat-relative form), so it must not reject the offer" + ); + + // ── A5 — THE REAL OBJECT-GROWTH CAPTURE, UNMUTATED, ON THE OTHER GUARDED INGRESS ────── + // `reject_zero_bound_shortcut_offer` is called from BOTH decoders; A1-A4 ride + // `decode_persisted_resolution_state`, this one rides `GameStateDecode::decode` through + // `impl Deserialize for GameState`. + let combo: GameState = serde_json::from_str(&donor_json) + .expect("the real object-growth capture must still load through the bare ingress"); + // REACH-GUARD, INLINE — A1 cannot stand in for it (different fixture, different ingress). + // Without these three, `Ok` would also be explained by the period never surviving THIS + // decode, and the `is_bounded()` revert (delete it ⇒ this arm must flip to `Err`) would not + // fire. + let WaitingFor::LoopShortcut { + proposer: combo_proposer, + schema, + .. + } = &combo.waiting_for + else { + panic!( + "fixture precondition: the combo capture is AT a LoopShortcut offer, got {:?}", + combo.waiting_for + ) + }; + assert!( + !combo.last_loop_action_sequence.is_empty(), + "the period must SURVIVE this decode, or A5's Ok is unattributable" + ); + assert!( + combo + .last_loop_action_sequence + .iter() + .all(|step| step.controller == *combo_proposer), + "the surviving period must be homogeneous on the PROPOSER's seat — that is what makes \ + loop_period_controller() == Some(proposer) and puts this arm on the guard's own predicate" + ); + assert!( + !schema.is_bounded(), + "and the offer must be UNNARROWED, so A5's Ok is attributable to the is_bounded() \ + conjunct alone rather than to a missing period" + ); +} + /// Opponents the ENGINE considers living. `Player::is_eliminated` is the authority the /// CR 732.2a detector uses when it builds its `living` set — `eliminated_players` and /// `life > 0` are not sufficient on their own, so this reads the field the detector reads. From 1a86789b67cfadffb44332da5bac1c3e2f927045 Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 10 Aug 2026 04:55:16 -0500 Subject: [PATCH 05/44] chore(engine): re-baseline the GameState stack-budget ceiling on a fresh measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `game_state_size.rs` asserts a compile-time stack budget for `GameState` because `phase-server` moves it by value through the action + AI path, where an overrun is an uncatchable guard-page abort rather than a catchable panic. The recorded measurement had gone stale, leaving the ceiling calibrated to a size the type no longer has. Re-measured at `a1bfc88d8`: RUSTFLAGS="-Zprint-type-sizes" cargo build -p phase-engine --lib types::game_state::GameState: 12784 bytes, alignment: 16 types::game_state::WaitingFor: 1696 bytes, alignment: 8 toolchain `nightly-2026-04-19` (rustc 1.97.0-nightly), host `x86_64-unknown-linux-gnu`, isolated target dir. The module's own formula — `measured.next_multiple_of(256) + 256`, one full bucket of deliberate slack — gives `12,784 → 12,800 → 13,056`, so the ceiling moves 12,800 → 13,056 and the table row records 12,784. This is the file's documented maintenance branch, not its forbidden one. The assert bans widening "to make a build pass"; the build passes at either ceiling, so nothing here is bought by the change. What it buys is the slack the module says the ceiling exists to carry: at 12,800 the next author to add any inline field to `GameState` would trip a gate with no context for it, which the module's docs name as the failure this calibration is meant to prevent. The stale row is PRE-EXISTING, not introduced here. Measured on plain `main` (55eb20b48), same instrument and same platform: `GameState` is 12,784 there too, identical to this branch, so no commit in this series contributes a byte. The cause of the gap is NOT established and is deliberately not claimed: the prior row was taken on aarch64-apple-darwin and this one on x86_64-unknown-linux-gnu, and that difference alone could account for it. The platform line now states which row was measured where rather than implying one platform for all four; the other three rows are carried forward unchanged and un-re-measured. Also fixes the reproduce command the module prescribes for exactly this maintenance. It read `cargo build -p engine --lib`, which cannot work: the package is `phase-engine` (`crates/engine/Cargo.toml:2`) and `engine` (`:12`) is only the lib target name, so the command errors with `package ID specification 'engine' did not match any packages`. A maintenance procedure that cannot be executed as written is a plausible mechanism for a measurement going stale unnoticed, though this commit does not claim to have established that as the cause. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/types/game_state_size.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/types/game_state_size.rs b/crates/engine/src/types/game_state_size.rs index f7ef80dc73..c8738cd514 100644 --- a/crates/engine/src/types/game_state_size.rs +++ b/crates/engine/src/types/game_state_size.rs @@ -34,11 +34,12 @@ //! | grep 'print-type-size type: `types::game_state::GameState`:' //! ``` //! -//! Measured on `nightly-2026-04-19`, aarch64-apple-darwin: +//! Measured on `nightly-2026-04-19` — `GameState` on x86_64-unknown-linux-gnu, +//! the other three rows on aarch64-apple-darwin (not re-measured): //! //! | Type | before boxing | after | ceiling | //! |---|---:|---:|---:| -//! | `GameState` | 30,112 | 12,464 | 12,800 | +//! | `GameState` | 30,112 | 12,784 | 13,056 | //! | `StackEntry` | 5,336 | 344 | 768 | //! | `PendingCast` | 6,632 | 1,376 | 1,792 | //! | `PendingTrigger` | 6,000 | 744 | 1,024 | @@ -52,7 +53,7 @@ const _: () = { use core::mem::size_of; assert!( - size_of::() <= 12_800, + size_of::() <= 13_056, "GameState grew past its stack budget. It is moved by value through the \ phase-server action + AI path, so an overrun is an uncatchable \ guard-page abort, not a panic. Re-run the -Zprint-type-sizes command in \ From 2b611b78e7db75d5f874c3c638442a0c578e7809 Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 10 Aug 2026 05:53:41 -0500 Subject: [PATCH 06/44] feat(engine): journal CR 603.5 "may" answers so a bounded shortcut can be declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bounded loop-shortcut offer publishes a `MayChoice` decision point for every open CR 603.5 "may" in the cycle, and declaring the shortcut requires an answer for each one. Those answers were never recorded: the ring that samples the loop is cleared before the declare handler runs, so a declare-time recompute returns `None` on every board. This adds the journal the declaration will be built from; the builder itself ships with the field it writes into, in the following commit. The journal is keyed by `(DecisionSource, PlayerId)` and holds `Uniform { take }` until a second, differing answer for the same pair latches it to `Conflicted` — permanently, within the window. Recording happens at the `DecideOptionalEffect` reducer beat, before `handle_optional_effect_choice` resolves the ability, because the key reads the source object's incarnation (CR 400.7) and resolution can move or destroy that object. On the seat component of the key, stated so it is not over-read: this is DEFENSE IN DEPTH PLUS A CODE DELETION, NOT A LIVE-BUG FIX. It replaces a runtime `player == proposer` guard that was vacuous where it was testable — the publisher filters on `prompt_player == proposer` before any point is published, so the guard was never consulted — and harmful where it was reachable, latching `Conflicted` on a board whose proposer answered identically every time. No board has been measured on which the seat component changes an offer; the one multi-seat board that exists journals two seats and mints no offer at all. The claim this key earns is "cannot be worse, and removes a runtime guard". Storage follows the ring it derives from: `#[serde(skip, default)]`, excluded from `impl PartialEq for GameState`, and cleared at all eight ring-clear sites on the same receiver — three of which are `clone`/`self` rather than `state`. It is boxed because `GameState` is moved by value through the server action path and carries a compile-time stack budget; the box keeps the field at 8 bytes. Evidence. Every claim below was mutated and re-run, not predicted. Collapsing the key to a bare source reds the two-seat row; deleting the conflict arm reds the latch row while its idempotence sibling stays green; inverting that arm's inequality guard reds the sibling while the latch row stays green; dropping the clear in `normalize_for_loop` reds the follows-the-ring row; neutralizing the write site reds all six journal rows at once, which is the direct proof that none of them passes on an empty journal. Each journal row asserts population before it asserts content, because the detection mode defaults to `Off` and an unpopulated journal would otherwise satisfy every negative assertion trivially. Also corrects `LoopCertificate.mandatory`'s documentation, which described the field as recording whether the cycle contained an optional choice. It records the producer's own measurement that no living player can be forced not to continue (CR 732.5), which is a different proposition: the tracked four-player board is `mandatory = true` with two published `MayChoice` points. `detect_loop`'s parameter doc restated the same error and is corrected with it. The `game_state_size.rs` measurement row moves 12,784 -> 12,800 for the field this commit adds. The ceiling is untouched: 13,056 is the formula's answer at both measurements. DISCLOSED, NOT CLOSED: ring-clear sites 1-4 and 8 have no driven fixture on the boards this commit uses; they are covered structurally by a source census that fails if any clear site lacks its journal pair, and the row says so rather than implying driven coverage. `loop_answer` returns `None` both for "never answered" and for "no journal", so the consumer in the next commit must treat `None` and `Conflicted` identically; the accessor documents this, but nothing enforces it until that exhaustive match exists. Assisted-by: ClaudeCode:claude-opus-5 --- .../engine/src/analysis/decision_template.rs | 30 ++ crates/engine/src/analysis/loop_check.rs | 16 +- crates/engine/src/game/engine.rs | 126 ++++-- crates/engine/src/types/game_state.rs | 126 +++++- crates/engine/src/types/game_state_size.rs | 2 +- .../fantastic_four_bounded_loop.rs | 411 ++++++++++++++++++ .../tests/integration/natural_balance.rs | 190 ++++++++ 7 files changed, 867 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index 0337696d4b..5466341c18 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -153,6 +153,36 @@ pub enum UnlessPaymentOption { Decline, } +/// The observed answer to ONE published CR 603.5 "may" source, from ONE seat, across the +/// current loop-detection window. Journalled under the key `(DecisionSource, PlayerId)` +/// (`GameState::record_loop_answer`), so a seat can only ever answer for itself. +/// +/// CR 732.2a says a shortcut proposal describes "a sequence of game choices, for all +/// players, that may be legally taken based on the current game state and the predictable +/// results of the sequence of choices", and that this sequence "may be a non-repetitive +/// series of choices, a loop that repeats a specified number of times, multiple loops, or +/// nested loops, and may even cross multiple turns". A series whose answers DIFFER between +/// iterations is therefore not, by itself, the conditional action the rule bars; what the +/// rule actually bars is narrower — "It can't include conditional actions, where the +/// outcome of a game event determines the next action a player takes." +/// +/// This engine refuses on a differing answer anyway. That is an ENGINE-CAPABILITY LIMIT, +/// DELIBERATELY MORE CONSERVATIVE THAN CR 732.2a REQUIRES — not a rule the CR states. +/// [`DecisionTemplate`] pins exactly one `MayChoice` per published slot per cycle, so a +/// non-uniform series has no representation here; it is the same "a choice a player could +/// only make reactively is one they cannot pin" disposition [`predictability_gate`]'s +/// CR 732.2a firewall doc already records. Failing to offer is the fail-closed direction: +/// strictly fewer offers, never a wrong pin. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoopAnswer { + /// Every observed iteration answered this (source, seat) pair identically. + Uniform { take: MayChoiceOption }, + /// Two observed iterations of the same (source, seat) pair disagreed. Latched: + /// never returns to `Uniform`. See the type doc — the refusal this produces is this + /// engine's conservative policy, NOT a CR 732.2a mandate. + Conflicted, +} + /// One pinned decision. Variants are distinct CR choice KINDS (ordering / targeting / /// modal / optional-"may" / "[A] unless [B]" break), not a parameterization axis. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] diff --git a/crates/engine/src/analysis/loop_check.rs b/crates/engine/src/analysis/loop_check.rs index 10619c60f5..ae6d034ef7 100644 --- a/crates/engine/src/analysis/loop_check.rs +++ b/crates/engine/src/analysis/loop_check.rs @@ -123,10 +123,15 @@ pub struct LoopCertificate { pub unbounded: Vec, /// The classified win condition derived from `unbounded`. pub win_kind: WinKind, - /// CR 104.4b vs CR 732.2a/CR 732.6: whether the cycle is all-mandatory (no - /// "may"/choice once started). `true` ⇒ a forced loop the live path would draw - /// (CR 732.4) absent a net resource; `false` ⇒ an optional loop a player chooses - /// to repeat. The detector cannot infer optionality from two states alone, so + /// CR 732.5 / CR 732.2b: whether NO living player has a meaningful priority action + /// that could break the loop — the producer's own measurement, not a property of the + /// cycle's contents. (`game::engine::interactive_loop_bridge` assigns it from + /// `no_living_player_has_meaningful_priority_action`, which probes EVERY living player + /// as the priority holder.) CR 732.5 is why that is the right question: no player can + /// be forced to take an action that would end a loop, so a loop is unbreakable exactly + /// when nobody HAS such an action to take voluntarily; CR 732.2b is the shortcut-side + /// counterpart — the window in which another player would name a different choice. + /// The detector cannot infer optionality from two states alone, so /// the caller (which drives the actions) supplies it. pub mandatory: bool, /// CR 110.1: non-recycled per-cycle remainder of battlefield permanents (the "+1 @@ -234,7 +239,8 @@ pub enum ShortcutResponse { /// `controller` is the loop's controlling player (so the consumed-axis constraint /// is scoped to *their* life/mana and opponent depletion reads as progress, and /// the win classifier can tell an opponent loss from self-mill/lifegain), and -/// `mandatory` records whether the driven cycle contained an optional choice. The +/// `mandatory` records whether no living player had a meaningful priority action that +/// could break the loop (CR 732.5). The /// caller, which drove the actions, knows both. pub fn detect_loop( cycle_start: &GameState, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 9bdcc6ddb5..b25fce37cc 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -3272,6 +3272,8 @@ fn until_lethal_fallback( // sampler with no seat semantics, so it clears unconditionally; the period is evidence about // the seat that recorded it, so only the proposer's own is theirs to discard. state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; if state.loop_period_controller() == Some(proposer) { state.last_loop_action_sequence.clear(); } @@ -3949,6 +3951,8 @@ fn materialize_fixed_shortcut( // beat re-detects genuinely. *state = committed; state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; priority::reset_priority(state); state.waiting_for = WaitingFor::Priority { player: living_priority_seat(state), @@ -5128,6 +5132,8 @@ fn materialize_object_growth_shortcut( } } state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; state.last_loop_action_sequence.clear(); priority::reset_priority(state); state.waiting_for = WaitingFor::Priority { @@ -6345,6 +6351,8 @@ fn pass_priority_once_with_pipeline( state.record_loop_detect_sample(); } else if !wf.is_forced_cascade_window() { state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; } // CR 603.3b/603.3d/603.5/608.2/903.9a + CR 703.1/117.3a + CR 732.2a: leave the // ring intact on every FORCED PRE-PRIORITY window, not just trigger ordering. @@ -7173,6 +7181,8 @@ fn apply_action( ) && !answering_forced_window { state.loop_detect_ring.clear(); + // CR 603.5: the recorded "may" answers describe the window that just ended. + state.loop_answer_journal = None; } // Keep the semantic owner of the prompt before reducing it. Under turn @@ -8648,7 +8658,32 @@ fn apply_action( GameAction::CancelCast, ) => engine_casting::cancel_pending_cast(state, *player, pending_cast, &mut events)?, // CR 608.2d: Player decided whether to perform an optional effect ("You may X"). - (WaitingFor::OptionalEffectChoice { .. }, GameAction::DecideOptionalEffect { accept }) => { + ( + WaitingFor::OptionalEffectChoice { + player, source_id, .. + }, + GameAction::DecideOptionalEffect { accept }, + ) => { + // CR 603.5 + CR 732.2a: journal the answer BEFORE the handler runs — it + // replaces `waiting_for`, so the prompt's own seat and source are only + // readable here. The key comes from `object_decision_source`, the same + // producer `entry_publishes_pin_slots` uses, so publish-side and record-side + // keys agree by construction rather than by coincidence. `record_loop_answer` + // carries the `samples() && !in_simulation_probe()` gate. + let (answering_player, may_source) = (*player, *source_id); + if let Some(source) = object_decision_source(state, may_source) { + state.record_loop_answer( + source, + answering_player, + crate::analysis::decision_template::LoopAnswer::Uniform { + take: if accept { + crate::analysis::decision_template::MayChoiceOption::Take + } else { + crate::analysis::decision_template::MayChoiceOption::Decline + }, + }, + ); + } engine_payment_choices::handle_optional_effect_choice(state, accept, &mut events)? } ( @@ -15910,6 +15945,29 @@ mod stage2_injector_tests { /// adds nothing below; (3) the total stays **37** and the partition stays **5/7/25**, so /// neither a producer nor a reader was gained or lost. Same set, one new line number ⇒ /// benign, re-baselined here. + /// + /// ⚠ **RE-ADJUDICATED BY C1 (the CR 603.5 may-answer journal), NOT RELAXED.** `37 ⇒ 38`, + /// partition `5/7/25 ⇒ 5/8/25`. The PRODUCER half is unchanged at **5** and four of the + /// five coordinates did not move at all. The `+1` READER is **`game/engine.rs:8626`** — + /// `apply_action`'s `(OptionalEffectChoice, DecideOptionalEffect)` arm, which C1 widened + /// from `{ .. }` to bind `player` and `source_id` so it can journal the answer under + /// `(DecisionSource, PlayerId)`. It READS the (cloned) `state.waiting_for` scrutinee and + /// never writes it, so it is a reader by this instrument's own rule, and it is the same + /// benign class as U4's `inject_pinned_answer` arm. Note WHY it became visible at all: + /// the instrument deliberately skips multi-line read destructures by excluding lines + /// containing `..`, and rustfmt puts `..` on the needle's own line only while the + /// pattern body is narrow — adding two bindings pushes it to the next line. The + /// exclusion is an approximation, and this is it losing one case, not a new prompt. + /// + /// The fifth producer's coordinate moved `engine.rs:11942 ⇒ :11977`, and the shift is + /// measured rather than assumed: `git diff -U0 HEAD -- game/engine.rs` has + /// exactly six hunks above it — five `+2` journal clears paired with the ring clears at + /// `:3274/:3951/:5130/:6334/:7139`, and `+25` for the reducer arm above — summing to + /// **+35**, so predicted `11942 + 35 = 11977` equals the observed coordinate exactly. + /// Identity re-established, not assumed: the line is **sha256-identical** + /// (`8a544e87…5cc7d63`) at the old coordinate in the pre-C1 tree and at the new one + /// here, and it is still inside `begin_pending_trigger_target_selection`. C1 adds no + /// line matching the needle in a producing position anywhere. #[test] fn the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event() { /// Every `.rs` under the crate's `src`, and the `#[cfg(test)]`-attributed @@ -16005,7 +16063,7 @@ mod stage2_injector_tests { assert_eq!( producers.len() + readers.len() + in_test, - 37, + 38, "CR 603.5 prompt census drifted. A new PRODUCER must have its recipient bound \ somewhere — the mint's conjunct (a) covers exactly ONE of them. A new READER is \ the benign case (U4's own consumption arm was one): adjudicate it in this doc and \ @@ -16014,10 +16072,11 @@ mod stage2_injector_tests { ); assert_eq!( (producers.len(), readers.len(), in_test), - (5, 7, 25), - "the partition, not just the total: five PRODUCTION producers, seven PRODUCTION \ + (5, 8, 25), + "the partition, not just the total: five PRODUCTION producers, eight PRODUCTION \ readers (they read `state.waiting_for` and never write it — the seventh is U4's \ - `inject_pinned_answer` arm), 25 `#[cfg(test)]` lines.\nproducers={producers:#?}\n\ + `inject_pinned_answer` arm, the eighth is C1's journalling `apply_action` arm), \ + 25 `#[cfg(test)]` lines.\nproducers={producers:#?}\n\ readers={readers:#?}" ); assert_eq!( @@ -16438,28 +16497,43 @@ mod stage2_injector_tests { // card entry boundary. The producer remains byte-identical; only its coordinate moves. // // SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and - // neither does this branch — total still 37, partition still 5/7/25. + // neither does this branch. + // + // REBASE — this coordinate has absorbed THREE independent shifts and all are folded + // here rather than each overwriting the last. From the merge base at `:12004`: + // upstream #7303 round 3: -1 (the `ReturnAsAuraTarget` resume arm's two raw + // attach calls became one call to the entering-Aura attachment authority, + // `-8 +7`, in a hunk ABOVE this producer) + // upstream #4155: +5 (seven lines for abandoned-cast finalization, less two + // removed by its deferred-resume cleanup — also entirely above this producer) + // lane C1 (CR 603.5 may-answer journal): +35 (five `+2` journal clears paired + // with the five ring clears, plus `+25` for the `DecideOptionalEffect` arm) + // No two of those hunks overlap, so the shifts compose: 12004 -1 +5 +35 = 12043. + // The value below is MEASURED in the rebased file by content digest, never computed + // from that sum; the sum is retained only as the prediction it agreed with, and it + // did agree. The offset from the enclosing fn is the control and is unchanged at 134. + // + // Producer identity re-established rather than assumed: the line at the new + // coordinate is byte-identical to the base's `:12004` and to upstream's `:12003` + // (`return Ok(Some(WaitingFor::OptionalEffectChoice {`), and it is still inside + // `begin_pending_trigger_target_selection`. // - // #7303 fix round 3: `:12004 ⇒ :12003`, −1, and ONLY this entry moved. - // Re-derived, not assumed. `git diff -U0` on this file has exactly ONE hunk, - // `@@ -9943,8 +9943,7 @@` inside `apply_action` — the `ReturnAsAuraTarget` - // resume arm's two raw attach calls replaced by one call to the entering-Aura - // attachment authority plus its four-line rationale (`-8 +7`). It sits ABOVE - // this producer, and the whole-file delta is also `-1`, so nothing was - // inserted or removed below it. Predicted `12004-1` equals the observed - // coordinate exactly. IDENTITY re-established rather than assumed: the - // producer at its new coordinate is md5-identical to `a0bca5197:engine.rs` - // at its old one, and so is its ±6-line window (`4f7522fc…`) — the window is - // what discriminates here, since the same one-line mint text appears at - // several coordinates in the crate. The other four entries did not move and - // were re-read in place. SET PRESERVATION: the two asserts above this one ran - // FIRST and both fired GREEN on the run that caught this — total still 37, - // partition still 5/7/25. The change constructs no `WaitingFor` of any kind; - // it threads an attachment-legality authority through an existing call. - // #4155 adds seven lines above this producer for abandoned-cast - // finalization, while its deferred-resume cleanup removes two; - // the net +5 moves this coordinate to `:12008`. - "game/engine.rs:12012".to_string(), + // TO BE UNAMBIGUOUS FOR THE NEXT READER: the `+1` in `apply_action`'s + // `DecideOptionalEffect` arm is a READER, NOT A SIXTH PRODUCER. It destructures the + // cloned `state.waiting_for` scrutinee to journal the answer and never assigns + // `state.waiting_for`; the producer count in this vec is still five and this branch + // mints no new prompt. Total moves 37 => 38 and the partition 5/7/25 => 5/8/25 for + // the READER half only — adjudicated in this row's doc. + // ⚠ RE-REBASE onto upstream `7127326673`: `:12038 ⇒ :12043`, located by content + // digest, offset from `begin_pending_trigger_target_selection` unchanged at 134. + // ⚠ RE-REBASE onto upstream `635c51ec4` (#7382, pre-entry opponent controller): + // `:12043 ⇒ :12047`, +4 entirely above this producer. MEASURED in the rebased file, + // not computed: the enclosing-fn offset is the control and is STILL 134, which is + // what re-establishes producer identity — the same mint text appears at several + // coordinates in this crate, so the offset discriminates where the text cannot. + // This rebase surfaced as a CONFLICT in this very literal, which is the drift class + // FU-4 (content-hash coordinate anchor) exists to end; logged there, not re-argued here. + "game/engine.rs:12047".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index d23e3efa64..fe42e8b46e 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14529,6 +14529,50 @@ declare_game_state! { /// dedup on semantically-identical positions is unaffected. #[serde(skip, default)] pub loop_detect_ring: std::collections::VecDeque>, + /// CR 603.5 + CR 732.2a: the answers given to published "may" sources during the + /// window `loop_detect_ring` is sampling, so the CR 732.2a declaration can pin the + /// choice each iteration actually made instead of guessing one. + /// + /// KEYED BY THE PAIR `(source, seat)`, not by the source alone. CR 603.5 routes a + /// "may" to whichever seat the effect names, and `game::effects` really does prompt + /// several seats for ONE source inside one window (the scoped-search acceptance + /// cascade). A seat can therefore only ever answer for itself: no seat's answer can + /// fill another's slot, and no two seats can manufacture a false disagreement. The + /// seat authority lives in the key type rather than in a consumer-side guard. + /// + /// STATED SO IT IS NOT OVER-READ: this is DEFENSE IN DEPTH PLUS A CODE DELETION, NOT + /// A LIVE-BUG FIX. Both harms a source-only key could produce are already firewalled + /// downstream — the pin injector reads the recipient off the prompt in hand and aborts + /// the replay when it does not match the template owner (`game::engine`'s + /// `WaitingFor::OptionalEffectChoice` arm, `if *player != template.owner`). NO BOARD + /// HAS BEEN MEASURED on which the seat component changes an offer; the multi-seat + /// board that does exist journals two seats and mints no offer at all + /// (`tests/integration/natural_balance.rs`). The claim this key earns is "cannot be + /// worse, and removes a runtime guard", not "prevents a reachable wrong declaration". + /// + /// TRANSIENT DERIVED STATE with `loop_detect_ring`'s exact treatment — same + /// `#[serde(skip, default)]`, same omission from `impl PartialEq for GameState` + /// (rebuilt from play; comparing it would break AI-search dedup on + /// semantically-identical positions), and cleared at every one of the ring's clear + /// sites on the same receiver. `#[serde(skip)]` is also load-bearing rather than + /// merely tidy: a `BTreeMap` with a tuple key has no JSON object form and + /// [`LoopAnswer`] deliberately derives no `Serialize`, so a future attempt to + /// persist this fails at compile time instead of silently emitting a stale window. + /// + /// `Option>` for `game_state_size.rs`'s stated reason — "box it if it is a + /// large rarely-populated one" — costing 8 B inline like the `life_safety_probe` + /// neighbour below. NOTHING TESTS THE BOXING: at the current ceiling an unboxed + /// `BTreeMap` (24 B inline) also fits, so this rationale is a convention here, not a + /// guarded invariant. + #[serde(skip, default)] + pub(crate) loop_answer_journal: Option< + Box< + std::collections::BTreeMap< + (crate::analysis::decision_template::DecisionSource, PlayerId), + crate::analysis::decision_template::LoopAnswer, + >, + >, + >, /// Live-only authority for the finite pre-cast shortcut. It is absent from /// raw/public serialization; trusted persistence uses the explicit codec /// envelope in `game::precast_copy_shortcut`. @@ -19977,6 +20021,7 @@ impl GameState { static_source_index: StaticSourceIndex::default(), static_mode_presence: crate::types::statics::StaticModePresence::all_present(), loop_detect_ring: std::collections::VecDeque::new(), + loop_answer_journal: None, precast_shortcut_runtime: PrecastShortcutRuntime::default(), life_safety_probe: Box::default(), next_timestamp: 1, @@ -20873,6 +20918,9 @@ impl GameState { // the live ring → recursive/quadratic growth. Cleared ⇒ every stored snapshot // has clone depth 1. Does not affect any comparison (the ring is eq-excluded). clone.loop_detect_ring.clear(); + // CR 603.5: the "may"-answer journal belongs to the LIVE window, not to a stored + // position sample. Cleared with the ring, on this same receiver. + clone.loop_answer_journal = None; // Private shortcut capabilities are live interaction state, never part // of a CR 104.4b position sample. clone.precast_shortcut_runtime = PrecastShortcutRuntime::default(); @@ -21063,11 +21111,15 @@ impl GameState { /// /// The ring clear is mandatory and is `normalize_for_loop`'s own reason: samples are /// produced from the live state, so without it each stored sample would carry a clone - /// of the live ring ⇒ recursive/quadratic growth. **Nothing else is touched** — every - /// other field is what makes this half the evaluable one. + /// of the live ring ⇒ recursive/quadratic growth. The CR 603.5 `loop_answer_journal` + /// is cleared alongside it for a DIFFERENT reason — not recursion, but ownership: the + /// journal records the live window's answers, and a stored sample must not carry them. + /// **Nothing else is touched** — every other field is what makes this half the + /// evaluable one. pub(crate) fn loop_detect_live_sample(&self) -> GameState { let mut clone = self.clone(); clone.loop_detect_ring.clear(); + clone.loop_answer_journal = None; clone } @@ -21119,7 +21171,70 @@ impl GameState { .any(|(p, &before)| p.life != before) { self.loop_detect_ring.clear(); + // CR 603.5: the answers belong to the window the ring just lost. + self.loop_answer_journal = None; + } + } + + /// CR 603.5: record ONE seat's answer to ONE "may" source for the current + /// loop-detection window. A second, DIFFERENT answer from THE SAME SEAT for THE SAME + /// SOURCE latches [`LoopAnswer::Conflicted`] (see that type — an engine-capability + /// refusal, not a CR mandate). A different seat occupies a DIFFERENT KEY and can + /// neither conflict with, nor be read in place of, this seat's answer. + /// + /// Gated exactly like `game::engine::record_loop_pin` + /// (`samples() && !in_simulation_probe()`), so the #4603-Off build never records and + /// the detection/materialize drive replays without re-recording. + pub(crate) fn record_loop_answer( + &mut self, + source: crate::analysis::decision_template::DecisionSource, + player: PlayerId, + answer: crate::analysis::decision_template::LoopAnswer, + ) { + use crate::analysis::decision_template::LoopAnswer; + use std::collections::btree_map::Entry; + if !self.loop_detection.samples() || crate::game::engine::in_simulation_probe() { + return; } + match self + .loop_answer_journal + .get_or_insert_default() + .entry((source, player)) + { + Entry::Vacant(v) => { + v.insert(answer); + } + Entry::Occupied(mut o) => { + if *o.get() != answer { + o.insert(LoopAnswer::Conflicted); + } + } + } + } + + /// The observed answer for one published may-source AS ANSWERED BY `player`. `None` = + /// that seat never answered this source in this window ⇒ a declaration must refuse, + /// exactly as [`LoopAnswer::Conflicted`] does. + pub fn loop_answer( + &self, + source: &crate::analysis::decision_template::DecisionSource, + player: PlayerId, + ) -> Option { + // `BTreeMap` keys by the owned tuple and no `Borrow` shape spans a tuple, so the + // key is built. This is the seam's own idiom — `entry_publishes_pin_slots` builds + // its slot with `source: source.clone()`. One clone per published may point at + // declaration-build time, never per iteration. + self.loop_answer_journal + .as_ref()? + .get(&(source.clone(), player)) + .copied() + } + + /// How many distinct (source, seat) pairs this window has answered. `None` and an + /// empty map are indistinguishable here BY DESIGN — no caller may branch on the + /// `Option`. + pub fn loop_answers_recorded(&self) -> usize { + self.loop_answer_journal.as_ref().map_or(0, |m| m.len()) } /// CR 732.2a: record that an unbounded (net-progress) loop under `controller` @@ -21669,6 +21784,13 @@ fn _gamestate_partition_is_total(s: &GameState) { static_source_index: _, static_mode_presence: _, loop_detect_ring: _, + // CR 603.5 + CR 732.2a "may"-answer journal: EXCLUDED from `impl PartialEq for + // GameState` for `loop_detect_ring`'s reason — transient derived state rebuilt + // from play, and comparing it would split two semantically-identical positions + // in AI-search dedup. It cannot become a hidden per-cycle accumulator riding a + // covering pair: `project_out_resources` opens with `normalize_for_loop`, which + // is one of the ring-clear sites this field follows, so the projection clears it. + loop_answer_journal: _, precast_shortcut_runtime: _, life_safety_probe: _, next_timestamp: _, diff --git a/crates/engine/src/types/game_state_size.rs b/crates/engine/src/types/game_state_size.rs index c8738cd514..a73839ae8e 100644 --- a/crates/engine/src/types/game_state_size.rs +++ b/crates/engine/src/types/game_state_size.rs @@ -39,7 +39,7 @@ //! //! | Type | before boxing | after | ceiling | //! |---|---:|---:|---:| -//! | `GameState` | 30,112 | 12,784 | 13,056 | +//! | `GameState` | 30,112 | 12,800 | 13,056 | //! | `StackEntry` | 5,336 | 344 | 768 | //! | `PendingCast` | 6,632 | 1,376 | 1,792 | //! | `PendingTrigger` | 6,000 | 744 | 1,024 | diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index e6cf212172..d387efa5f2 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -1369,6 +1369,417 @@ fn r27_a1_the_f4_dumps_recorded_sample_keeps_a_live_half_normalization_would_hav ); } +// ───────────────────────────────────────────────────────────────────────────────────────── +// C1 — the CR 603.5 "may"-answer journal +// +// TIER, stated so no row here is read as covering more than it does: C1 ships the journal +// (record + read) and nothing that CONSUMES it. `build_bounded_declaration` and the offer's +// published `declaration` arrive with C2, so every row below asserts at the JOURNAL, never +// at a minted-or-refused declaration. +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// The key the journal uses, built the way `game::engine::object_decision_source` builds it +/// (CR 400.7: `ThisObject` bound to the object's CURRENT incarnation, `trigger_description` +/// held `None`). Reconstructed here rather than called because the engine's helper is +/// `pub(crate)`; every row that uses it asserts the reconstruction is faithful by requiring +/// the production write site to have stored something under it. +fn may_source_key( + state: &GameState, + source_id: ObjectId, +) -> engine::types::game_state::YieldTarget { + engine::types::game_state::YieldTarget::ThisObject { + source_id, + incarnation: Some(state.objects[&source_id].incarnation), + trigger_description: None, + } +} + +/// How the drive answers CR 603.5 "may" prompts. Typed rather than a pair of `bool`s: the +/// three rows below need three genuinely different drive shapes, and each is named. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MayPolicy { + /// Take every prompt and drive on to the bounded offer — the shipped F4 policy. + TakeAll, + /// Take every prompt, and STOP at the first prompt that repeats a (source, seat) pair. + TakeUntilRepeat, + /// Take every prompt, then DECLINE the first prompt that repeats a (source, seat) pair, + /// and stop there. + DeclineOnRepeat, +} + +/// One answered "may" prompt, as the drive saw it. +struct MayBeat { + key: engine::types::game_state::YieldTarget, + seat: PlayerId, + take: bool, + /// The journal entry for this (source, seat) pair BEFORE this beat was answered — the + /// evidence that a "repeat" beat really is a repeat. + before: Option, +} + +/// Drive the F4 dump under `policy`, answering "may" prompts directly (so the row controls +/// the answer) and delegating every other beat to [`f4_drive_one_beat`]. +/// +/// The repeat-stopping policies stop AT the beat that lands, deliberately: a later +/// deliberate action or non-forced window would clear the ring, and the journal follows it. +fn drive_f4_may_beats(state: &mut GameState, cap: u32, policy: MayPolicy) -> Vec { + let mut beats: Vec = Vec::new(); + for _ in 0..cap { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + return beats; + } + let prompt = match &state.waiting_for { + WaitingFor::OptionalEffectChoice { + player, source_id, .. + } => Some((*player, *source_id)), + _ => None, + }; + let Some((seat, source_id)) = prompt else { + if f4_drive_one_beat(state).is_err() { + return beats; + } + continue; + }; + let key = may_source_key(state, source_id); + let repeat = beats.iter().any(|b| b.key == key && b.seat == seat); + let take = !(repeat && policy == MayPolicy::DeclineOnRepeat); + let before = state.loop_answer(&key, seat); + if apply( + state, + seat, + GameAction::DecideOptionalEffect { accept: take }, + ) + .is_err() + { + return beats; + } + beats.push(MayBeat { + key, + seat, + take, + before, + }); + if repeat && policy != MayPolicy::TakeAll { + return beats; + } + } + beats +} + +/// **Row 1′.** CR 603.5 + CR 732.2a: at the real F4 bounded offer, every published +/// `MayChoice` point's source has a journal entry UNDER THE PROPOSER'S OWN KEY. +/// +/// `proposer` is bound from the minted `WaitingFor::LoopShortcut`, never hard-coded: the +/// publisher filters the published may slot on `gate.prompt_player == proposer`, so +/// `(source, proposer)` is precisely the key that is supposed to exist, and a hard-coded +/// seat would read `None` and red this row for the wrong reason. +/// +/// # Discrimination +/// +/// Delete the `record_loop_answer` call from the `DecideOptionalEffect` reducer arm ⇒ the +/// journal stays empty ⇒ `loop_answers_recorded() > 0` fails and every lookup returns +/// `None`. Weaken the gate the other way (record under a fixed seat) ⇒ the per-point +/// lookups fail for any board whose prompt seat is not the proposer. +/// +/// # Reach-guards +/// +/// * the restored dump starts with an EMPTY journal, so every entry is one this drive wrote; +/// * the drive really answered at least one "may" prompt; +/// * the offer really published at least one `MayChoice` point — without this the `for` loop +/// below is empty and the row would pass on a board it never tested. +#[test] +fn c1_row1_the_may_journal_is_populated_at_the_f4_offer_under_the_proposers_own_key() { + use engine::analysis::decision_template::{LoopAnswer, MayChoiceOption}; + + let mut state = load_f4(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: the restored dump starts with an EMPTY journal" + ); + + let beats = drive_f4_may_beats(&mut state, 400, MayPolicy::TakeAll); + assert!( + !beats.is_empty(), + "reach-guard: the drive must have answered at least one CR 603.5 `may` prompt, else \ + there is no write for this row to observe" + ); + + let (proposer, _certificate, schema) = offer_parts(&state); + let may_sources: Vec<_> = schema + .points + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::MayChoice)) + .map(|p| p.slot.source.clone()) + .collect(); + assert!( + !may_sources.is_empty(), + "reach-guard: the offer must publish at least one MayChoice point (r1b measures \ + three points on this board), else the per-point assertions below are vacuous" + ); + assert!( + state.loop_answers_recorded() > 0, + "CR 603.5: the offer beat must carry the answers the drive gave" + ); + for source in &may_sources { + assert_eq!( + state.loop_answer(source, proposer), + Some(LoopAnswer::Uniform { + take: MayChoiceOption::Take + }), + "every published may point's source must be journalled under the PROPOSER's own \ + key; source {source:?}, proposer {proposer:?}, journal holds {} entries", + state.loop_answers_recorded() + ); + } +} + +/// **Row 2b — JOURNAL TIER.** CR 603.5: ONE seat answering ONE source two different ways +/// inside one detection window latches [`LoopAnswer::Conflicted`]. +/// +/// ⚠ TIER LIMIT, stated rather than implied: C1 ships no declaration consumer, so this row +/// asserts the LATCH, not a refused declaration. The declaration-tier half — that a +/// `Conflicted` entry makes `build_bounded_declaration` return `None` on this same board — +/// belongs to C2 and is NOT covered here. +/// +/// The same-seat constraint is asserted in the body, not assumed: under the pair key two +/// DIFFERENT seats answering one source land in two entries and the `Entry::Occupied` arm is +/// never entered at all, which would make this row vacuous. +/// +/// # Discrimination +/// +/// Delete `record_loop_answer`'s `Entry::Occupied` conflict arm (let a second write be +/// ignored, or overwrite) ⇒ the entry stays `Uniform { take: Take }` ⇒ the final assertion +/// flips. MEASURED, not predicted — see this row's companion probe in the implementation +/// report. +/// +/// # Paired positive / reach-guard +/// +/// `before` on the conflicting beat must already be `Uniform { Take }`: that proves the beat +/// really was a REPEAT of an already-journalled pair, so a drive that never repeated cannot +/// satisfy this row. +#[test] +fn c1_row2b_one_seat_answering_one_source_two_ways_latches_conflicted() { + use engine::analysis::decision_template::{LoopAnswer, MayChoiceOption}; + + let mut state = load_f4(); + let beats = drive_f4_may_beats(&mut state, 400, MayPolicy::DeclineOnRepeat); + let last = beats + .last() + .expect("the drive must have answered at least one `may` prompt"); + assert!( + !last.take, + "reach-guard: the drive must have REACHED a repeated (source, seat) prompt and \ + declined it; it answered {} prompts and the last was a Take", + beats.len() + ); + + let first = beats + .iter() + .find(|b| b.key == last.key && b.seat == last.seat && b.take) + .expect("the repeat's own first answer must be in the drive's record"); + assert_eq!( + first.seat, last.seat, + "SAME-SEAT CONSTRAINT: both answers must come from one seat. Two seats occupy two \ + keys, never enter the conflict arm, and would make this row vacuous" + ); + assert_eq!( + last.before, + Some(LoopAnswer::Uniform { + take: MayChoiceOption::Take + }), + "paired positive: the FIRST answer was journalled as Uniform{{Take}} before the \ + differing one landed" + ); + assert_eq!( + state.loop_answer(&last.key, last.seat), + Some(LoopAnswer::Conflicted), + "CR 603.5: a second, DIFFERENT answer from the same seat for the same source latches \ + Conflicted (an engine-capability refusal, not a CR 732.2a mandate)" + ); +} + +/// **Row 2b sibling — idempotence.** The latch fires on DISAGREEMENT, not on repetition: the +/// same seat answering the same source the same way twice stays `Uniform`. +/// +/// Without this sibling, a `record_loop_answer` that latched `Conflicted` on EVERY repeat +/// would pass row 2b and destroy every real board — the F4 drive answers each may source +/// once per iteration. +/// +/// Discrimination: replace the conflict arm's `if *o.get() != answer` with an unconditional +/// `o.insert(LoopAnswer::Conflicted)` ⇒ this row reds while row 2b stays green. +#[test] +fn c1_row2b_sibling_an_identical_second_answer_stays_uniform() { + use engine::analysis::decision_template::{LoopAnswer, MayChoiceOption}; + + let mut state = load_f4(); + let beats = drive_f4_may_beats(&mut state, 400, MayPolicy::TakeUntilRepeat); + let last = beats + .last() + .expect("the drive must have answered at least one `may` prompt"); + assert_eq!( + last.before, + Some(LoopAnswer::Uniform { + take: MayChoiceOption::Take + }), + "reach-guard: the last beat must be a REPEAT of an already-journalled pair, else this \ + row asserts idempotence over a single write" + ); + assert_eq!( + state.loop_answer(&last.key, last.seat), + Some(LoopAnswer::Uniform { + take: MayChoiceOption::Take + }), + "an identical second answer must not latch Conflicted" + ); +} + +/// **Row 7b″.** The journal is invalidated with `loop_detect_ring`, ON THE SAME RECEIVER. +/// +/// Three of the eight ring-clear sites act on a `clone`/`self` rather than on `state`, so a +/// journal clear applied to the wrong receiver would leave a stored sample carrying the live +/// window's answers. Sites 6 and 7 are only observable downstream, through +/// `LoopDetectSample`'s `pub normalized` / `pub live` halves on the ring — this row asserts +/// there, simultaneously with the LIVE state being non-empty, so no single-receiver bug +/// satisfies both halves. +/// +/// Site 5 (`apply_action`'s pre-action clear, a `state` receiver) is driven directly. +/// Sites 1–4 and 8 are covered structurally instead, by +/// [`c1_every_ring_clear_site_also_clears_the_may_journal`] — stated here so the coverage of +/// this row is not read as more than it is. +/// +/// # Discrimination +/// +/// Delete `clone.loop_answer_journal = None;` from `normalize_for_loop` or from +/// `loop_detect_live_sample` ⇒ the corresponding per-sample assertion flips. Delete it from +/// `apply_action`'s clear block ⇒ the final assertion flips. +#[test] +fn c1_row7b_the_may_journal_follows_the_ring_on_the_same_receiver() { + let mut state = load_f4(); + drive_f4_may_beats(&mut state, 400, MayPolicy::TakeAll); + let (proposer, _certificate, _schema) = offer_parts(&state); + + assert!( + state.loop_answers_recorded() > 0, + "paired positive: the LIVE state must carry answers at the offer beat, else every \ + zero below is satisfied by a journal that was never written" + ); + assert!( + !state.loop_detect_ring.is_empty(), + "reach-guard: there must be stored samples to inspect" + ); + for (i, sample) in state.loop_detect_ring.iter().enumerate() { + assert_eq!( + sample.normalized.loop_answers_recorded(), + 0, + "site 6 (`normalize_for_loop`, CLONE receiver): stored sample {i}'s normalized \ + half must not carry the live window's answers" + ); + assert_eq!( + sample.live.loop_answers_recorded(), + 0, + "site 7 (`loop_detect_live_sample`, CLONE receiver): stored sample {i}'s live \ + half must not carry the live window's answers" + ); + } + + apply(&mut state, proposer, GameAction::DeclineShortcut) + .expect("declining the offer is always legal for the proposer"); + assert!( + state.loop_detect_ring.is_empty(), + "reach-guard: site 5's ring clear must actually have fired on this action, else the \ + journal zero below is not evidence about that clear" + ); + assert_eq!( + state.loop_answers_recorded(), + 0, + "site 5 (`apply_action`, STATE receiver): the journal follows the ring" + ); +} + +/// **Row 7c.** The journal never crosses save/load as stale data. +/// +/// `last_loop_action_sequence` fell into exactly this trap once; `#[serde(skip, default)]` +/// is the bar, and this row asserts BOTH halves of it — the field is absent from the encoded +/// payload, and a decode of a populated board restores an empty journal. +/// +/// Discrimination: drop `skip` from the field's serde attribute ⇒ the key appears in the +/// encoded value ⇒ the first assertion flips (and `LoopAnswer` derives no `Serialize`, so +/// that edit does not even compile — which is the point of the note on the field). +#[test] +fn c1_row7c_the_may_journal_does_not_cross_save_load() { + let mut state = load_f4(); + drive_f4_may_beats(&mut state, 400, MayPolicy::TakeAll); + assert!( + state.loop_answers_recorded() > 0, + "reach-guard: the board being serialized must have a POPULATED journal, else the \ + empty restore below proves nothing" + ); + + let encoded = serde_json::to_value(&state).expect("a live GameState serializes"); + assert!( + encoded.get("loop_answer_journal").is_none(), + "`#[serde(skip)]`: the journal must be absent from the encoded payload entirely" + ); + let restored = serde_json::from_value::(encoded) + .expect("the encoded board decodes through the production decoder") + .into_game_state(); + assert_eq!( + restored.loop_answers_recorded(), + 0, + "a restored board must start its own window with no inherited answers" + ); +} + +/// **Row 7b″, structural half.** EVERY production `loop_detect_ring.clear()` is paired with +/// a `loop_answer_journal = None` on the same receiver, at all eight sites. +/// +/// The driven row above reaches sites 5, 6 and 7 on the F4 board; sites 1–4 and 8 need +/// materialize / until-lethal / pipeline / unobserved-life-move boards that this fixture does +/// not produce. A source-level census covers the whole set at the only tier that can, and +/// fails loudly if a NINTH clear site is added without the journal, which is the actual +/// regression this guards. +/// +/// Discrimination: delete any one `loop_answer_journal = None;` that follows a ring clear ⇒ +/// the pairing count drops and this row reds naming the file and line. +#[test] +fn c1_every_ring_clear_site_also_clears_the_may_journal() { + use std::path::Path; + + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut unpaired: Vec = Vec::new(); + let mut paired = 0usize; + for rel in ["game/engine.rs", "types/game_state.rs"] { + let path = src.join(rel); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let lines: Vec<&str> = text.lines().collect(); + for (i, line) in lines.iter().enumerate() { + if !line.contains("loop_detect_ring.clear()") { + continue; + } + // The journal assignment sits within the same block, immediately after the ring + // clear (a comment line may separate them). + let window = lines[i + 1..(i + 5).min(lines.len())].join("\n"); + if window.contains("loop_answer_journal = None") { + paired += 1; + } else { + unpaired.push(format!("{rel}:{}", i + 1)); + } + } + } + assert!( + unpaired.is_empty(), + "every ring-clear site must also clear the CR 603.5 may-answer journal; unpaired: \ + {unpaired:?}" + ); + assert_eq!( + paired, 8, + "the ring has EIGHT production clear sites (5 in game/engine.rs, 3 in \ + types/game_state.rs). A different count means a site was added or removed and this \ + census must be re-derived, not re-numbered" + ); +} + // ───────────────────────────────────────────────────────────────────────────────────────── // helpers used by more than one row // ───────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/engine/tests/integration/natural_balance.rs b/crates/engine/tests/integration/natural_balance.rs index ca698ac20b..9532d3f316 100644 --- a/crates/engine/tests/integration/natural_balance.rs +++ b/crates/engine/tests/integration/natural_balance.rs @@ -477,3 +477,193 @@ fn natural_balance_collects_two_local_x_searches_before_one_shuffle_each() { "each accepted searcher must shuffle exactly once; a duplicate completion would add another shuffle" ); } + +/// **Row 2c′ — WIRE TIER.** CR 603.5 + CR 732.2a: two seats answering the SAME "may" +/// source inside ONE loop-detection window keep two independent journal entries, so no +/// seat's answer can fill another's slot and no two seats can manufacture a false +/// disagreement. +/// +/// # Why this board, and what it does and does not prove +/// +/// This is a production path, not a storage-contract unit: Natural Balance's scoped +/// acceptance cascade (`game::effects::scoped_library_search::advance_acceptance`) captures +/// ONE `source_id` from the pending ability and mints one +/// `WaitingFor::OptionalEffectChoice { player, source_id, .. }` per scoped seat, so both +/// answers reach the reducer's `DecideOptionalEffect` arm — the journal's only write site — +/// through `apply()`. Both land in ONE window: `OptionalEffectChoice` is a member of +/// `WaitingFor::is_forced_cascade_window`, and `apply_action`'s pre-action clear is skipped +/// for a forced window, so the ring (and with it the journal) is not cleared between them. +/// This row asserts that chain rather than assuming it: the two prompts are asserted to +/// carry the SAME `source_id` and DIFFERENT seats before either is answered. +/// +/// **NOT CLAIMED:** that the seat component changes an OFFER. That additionally requires +/// the publisher to publish a `MayChoice` point for this source in a bounded window, which +/// this board does not do — MEASURED here, as the final assertion: no +/// `WaitingFor::LoopShortcut` is minted anywhere in this drive. The offer-level claim is +/// therefore unmeasured in either direction and this row does not make it. The pair key is +/// DEFENSE IN DEPTH PLUS A CODE DELETION, not a live-bug fix: the pin injector already +/// aborts a replay whose prompt recipient differs from the template owner, so a +/// source-only key's two harms are firewalled downstream even on a board that reached +/// them. What this row proves is the storage invariant, on a production path. +/// +/// # Discrimination +/// +/// Collapse the journal key to the bare `DecisionSource` (keep both signatures; build the +/// key from `source` alone in `record_loop_answer`/`loop_answer`) and the two writes land +/// in ONE entry: `loop_answers_recorded()` is 1, not 2, and the second write's differing +/// value latches `Conflicted`, so the per-seat lookups no longer hold either. The +/// cardinality assertion is value-independent and is asserted FIRST, so an empty journal — +/// what a missing `loop_detection` setting would produce — fails the row before any +/// content assertion can pass vacuously. +#[test] +fn natural_balance_two_scoped_seats_journal_one_may_source_under_two_independent_keys() { + use engine::analysis::decision_template::{LoopAnswer, MayChoiceOption}; + use engine::types::game_state::{LoopDetectionMode, YieldTarget}; + + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + + let mut spell = scenario.add_spell_to_hand_from_oracle( + P0, + "Natural Balance", + false, + NATURAL_BALANCE_ORACLE, + ); + spell.with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green, ManaCostShard::Green], + generic: 2, + }); + let natural_balance = spell.id(); + + // P0 sacrifices down to exactly five lands, so P0 is never a searcher and the two + // prompts below belong to the scoped seats alone. + let kept: Vec = (0..6) + .map(|_| scenario.add_basic_land(P0, engine::types::mana::ManaColor::Green)) + .collect(); + for _ in 0..4 { + scenario.add_basic_land(P1, engine::types::mana::ManaColor::Blue); + } + for _ in 0..3 { + scenario.add_basic_land(P2, engine::types::mana::ManaColor::White); + } + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Green, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Green, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ], + ); + + let mut runner = scenario.build(); + add_basic_land_to_library(runner.state_mut(), P1); + add_basic_land_to_library(runner.state_mut(), P2); + // The journal is written only while the detector samples; without this the board is + // identical and every journal assertion below would pass on an empty map. + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + + // ── the shared source, captured at the prompt beats rather than reconstructed ── + let decision_source = |state: &GameState, id: ObjectId| YieldTarget::ThisObject { + source_id: id, + incarnation: Some(state.objects[&id].incarnation), + trigger_description: None, + }; + + let outcome = runner.cast(natural_balance).resolve(); + assert!( + matches!( + outcome.final_waiting_for(), + WaitingFor::KeepExactPermanentsChoice { .. } + ), + "reach-guard: the six-land seat's exact-keeper choice is the beat that precedes the \ + scoped searches; got {:?}", + outcome.final_waiting_for() + ); + drop(outcome); + runner + .act(GameAction::ChooseKeptPermanents { + kept: kept[..5].to_vec(), + }) + .expect("five distinct controlled lands must be a legal exact keeper choice"); + + let WaitingFor::OptionalEffectChoice { + player: first_seat, + source_id: first_source, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "the first four-or-fewer-land player must receive the scoped optional search, got {:?}", + runner.state().waiting_for + ); + }; + let first_key = decision_source(runner.state(), first_source); + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("the first scoped seat may accept its library search"); + + let WaitingFor::OptionalEffectChoice { + player: second_seat, + source_id: second_source, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "every scoped seat is prompted before any library is exposed, got {:?}", + runner.state().waiting_for + ); + }; + let second_key = decision_source(runner.state(), second_source); + + // ── the multi-seat premise, asserted before it is relied on ── + assert_eq!( + first_source, second_source, + "CR 101.4: the scoped acceptance cascade prompts every seat for ONE source; two \ + source ids would make this row a two-key test and prove nothing about seats" + ); + assert_eq!( + first_key, second_key, + "one source and no intervening zone change ⇒ one CR 400.7 incarnation ⇒ one \ + DecisionSource, so the seat is the only axis separating the two entries" + ); + assert_ne!( + first_seat, second_seat, + "the two prompts must go to DIFFERENT seats, else there is no seat axis to test" + ); + + runner + .act(GameAction::DecideOptionalEffect { accept: false }) + .expect("the second scoped seat may decline its library search"); + + // ── cardinality first: this is the value-independent bar a collapsed key fails ── + assert_eq!( + runner.state().loop_answers_recorded(), + 2, + "two seats answering one source must occupy TWO (source, seat) keys. A journal \ + keyed by the source alone holds 1; an unsampled detector holds 0" + ); + assert_eq!( + runner.state().loop_answer(&first_key, first_seat), + Some(LoopAnswer::Uniform { + take: MayChoiceOption::Take + }), + "the accepting seat's own entry records Take" + ); + assert_eq!( + runner.state().loop_answer(&second_key, second_seat), + Some(LoopAnswer::Uniform { + take: MayChoiceOption::Decline + }), + "the declining seat's own entry records Decline, uncorrupted by the other seat's \ + differing answer — under a source-only key this second write would instead latch \ + Conflicted over the first" + ); + + // ── the offer-mint non-claim, measured rather than asserted in prose ── + assert!( + !matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }), + "this board journals two seats but publishes no CR 732.2a offer, which is why this \ + row's claim stops at the journal" + ); +} From afa464f35f68cf0cd7ba176d4c42a27e6a5d4c81 Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 10 Aug 2026 11:05:04 -0500 Subject: [PATCH 07/44] feat(engine): journal CR 608.2b target answers on the same loop-answer journal The may-answer journal alone cannot declare a bounded shortcut. Every bounded board measured publishes a `DecisionPointKind::Targets` point alongside its `MayChoice` points -- three real 4p dumps give May/May/Targets, May/Targets, May/May/Targets -- and a declaration that cannot resolve one published point resolves none of them, because `predictability_gate` builds its required set from every point in the schema. So the journal that shipped one commit ago would have produced `None` on every board it was built for. This adds the second value axis. `LoopAnswer::Uniform` now carries a `LoopAnswerValue`, either `May(MayChoiceOption)` per CR 603.5 or `Targets(Vec)` per CR 608.2b, and both `TriggerTargetSelection` reducer arms record through one writer. The announcement is bound at the announcing beat (CR 601.2c, reached for a trigger via CR 603.3d) rather than rescanned later, because CR 400.7 makes the source a different object once it changes zones. Answering published points is only half of it. CR 732.2a describes a shortcut as a sequence of *game choices*, and CR 601.2c makes a target a choice the player *announces*. When exactly one legal assignment exists the player announces nothing -- `auto_select_targets_for_ability` returns it and `prepare_trigger_targets` routes it to `AutoAssigned`, so no `TriggerTargetSelection` is ever raised and the writer above is never reached. Publishing a `Targets` point for that case creates a requirement nothing can discharge: `predictability_gate` demands an answer for every published point, and no beat exists at which one could be given. The single-legal-target board -- a two-player game, or any pod whose other seats have been eliminated -- would stay undeclarable for exactly the reason this commit exists to remove. So `entry_publishes_pin_slots` withholds the slot when targeting is forced, publishing the CR 603.5 `may` alone or nothing at all. The verdict comes from `analysis::resource::forced_unique_targeting`, which already answered this question for the ordering-input relief and is now `pub(crate)` rather than copied -- two authorities that can disagree is the failure mode being avoided. It is evaluated against the pair's own carrying frame, never the live board: the doc there records that a target legal on the frame but gone from `current` would collapse to "forced" and relieve a choice that is not forced. Relief is not lost, because gate (3)'s second disjunct asks that same function. This is visibly the same judgement the publisher already makes one level up, where `ControllerRef::You` is rejected as "a single forced seat, not a per-opponent choice". Withholding the point must not change what the loop COSTS. Publication answers a CR 732.2a question -- is this a game choice the player makes? -- while the elimination bound answers a CR 704.5a one: which seat is charged, and how much. A forced victim loses the life either way; whether the player chose the target is irrelevant to the magnitude. Those two had been derived from one place. `declarable_victims` and `victim_slot` both read the published point set, so withholding a point silently dropped the forced victim into the cheaper arm of `elimination_bounds` and `max_iterations` GREW -- an offer declaring more repetitions legal than are legal, which is precisely the soundness CR 732.2a's "may be legally taken" requires. Fail-closed for pin coverage, fail-open for the bound. So the acceptance decision is now one authority. `entry_announces` reports the announcement with a typed `TargetAnnouncement::{Chosen, NotProposerChoice}`; `entry_publishes_pin_slots` applies the CR 732.2a publication decision on top, and `bounded_cycle_charged_targets_for_window` reads the same announcement for CR 704.5a charging. One authority means the two readers agree on which entries are in the cycle. It does NOT by itself make them agree on each slot's legal PLAYER SET, and that distinction is the whole of the next section. A slot announced more than once in one window has a frame per announcement; publication skips a `NotProposerChoice` frame and charging does not, so the two can retain different frames of the same slot. `victim_slot` may therefore legitimately name a slot with no matching `schema.points` entry; that is the new invariant, documented on the field, and a future reader pairing the two lists must not treat it as a mismatch. The bound row asserts EQUALITY across a matched published/withheld pair, which pins the bound against moving in either direction -- not looser, and not tighter either, which would silently shrink offers that are legal today. It includes the case where the victim's period NETS A LIFE GAIN, because that is where the axis did not merely loosen but disarmed entirely at the cycle cap. Deduping the charged list first-wins over the same order as publication looked like it produced the same slots, and its doc said so. It does not. Publication skips a `NotProposerChoice` frame outright; charging keeps whichever frame it saw first. When the narrower frame sorts first, the schema offers a seat the bound never charges, and `max_iterations` GROWS -- the same fail-open direction the section above exists to close, one level further in: not "which slots", but "which seats within a slot". Charging now UNIONS the victim list on a slot collision. That is monotone, and the doc proves it rather than asserting it: the union only adds members to `declarable_victims`, an added seat's magnitude moves from `observed_life_loss` to `observed_life_loss.max(0) + S` with `S >= 0` by construction, and the narrowing step is monotone non-increasing in that divisor -- so the bound can only shrink. A monotone fix cannot introduce the failure it repairs. Reachability is stated as narrow and NOT closed, in both the function doc and the row. Elimination -- the realistic mechanism -- narrows the legal set monotonically, which puts the widest frame first and lands first-wins fail-CLOSED. The fail-open direction needs the legal player set to GROW mid-window, i.e. a player's untargetability ENDING mid-window; a corpus census finds 14 cards that can grant a player untargetability mid-loop, all self-protective and predominantly "until end of turn", which does not expire mid-turn -- so the grantor must also leave. No production trajectory reaching it was built by anyone, and the docs say that instead of implying either verdict. `forced_unique_targeting` answers only "is there exactly one legal assignment". Two other routes reach `AutoAssigned` or another seat's prompt and were still published as the proposer's decision point: a slot whose `chooser` is another player (CR 601.2c "of an opponent's choice"), and `TargetSelectionMode::Random` (CR 115.1), which raises no prompt at all and whose pin the RNG would contradict at drive time. Both are unanswerable by the proposer -- the exact undeclarable condition this commit exists to remove -- and the second was relieving gate (3) while unanswerable. The variant is therefore named `NotProposerChoice`, not `Forced`: for the chooser route the choice IS made, just not by the proposer, and `Forced` would have been a name-level lie. The condition tests `!target_selection_mode.is_chosen()` rather than `is_random()` so a future variant is withheld BY DEFAULT, matching this function's stated contract that the schema may only ever under-publish. This publication behaviour PREDATES this commit and is not sold as a defect it introduced. What is new is that `TargetAnnouncement` became the named authority for "is announcing this a game choice the proposer makes" while answering one of three axes -- and a type that claims an authority it does not exercise is worse than no type, because the next reader stops checking. One limit, stated because the section above could be read as covering it and does not. The forced arm loses no gate (3) cover, because gate (3)'s second disjunct asks `forced_unique_targeting` -- the same question -- so relief survives the withhold. **That co-authority argument does NOT extend to the two new routes.** Neither a foreign `chooser` nor a non-`Chosen` selection mode has a second disjunct asking after it. What that costs is narrower than "the entry is refused", and the difference is worth stating precisely because the imprecise version is the easier sentence to write. `entry_announces` still REPORTS the announcement on both new routes; it does not bail. The drop happens one layer up, where `entry_publishes_pin_slots` filters the target to `Chosen` and returns `None` only when the entry has no CR 603.5 `may` either. So a chooser-bearing MANDATORY entry publishes nothing and falls out of the offer, while a chooser-bearing OPTIONAL one -- the Disciple-of-the-Vault class -- still publishes its `may` gate and is still offerable. Both directions are fail-closed, since an unpublished point cannot be demanded by `predictability_gate` and an unminted offer cannot overstate a bound. It remains a behaviour change rather than a preserved invariant, and no row here measures how many real offers the mandatory case removes. `DecisionSlot::target` carries sub-index 0, so a multi-slot announcement would collapse onto one key. With distinct targets that stores `Conflicted` and is fail-closed; with the same object answering two instances of "target" -- which CR 601.2c expressly permits -- it would store a one-pin `Uniform`, a truncated answer indistinguishable from a complete one. The writer therefore reads the slot count off the prompt it is answering and refuses anything above one, rather than journalling a value it cannot express. The count is read from `state.waiting_for` rather than passed by the caller so a third reducer arm cannot drift from the other two. No `debug_assert` guards it: a multi-slot trigger announcement is legal and reachable -- a combat-damage trigger for 2 surfaces two optional slots -- so asserting would panic a debug build on a correct game. The key widens from `DecisionSource` to `DecisionSlot`, adding the `u8` sub-index the publisher already mints. This is NOT a bug fix and is not sold as one: every published point on all three measured boards carries a distinct source, so the collapsed key cannot collide on any board that exists today, and every collapse failure would have been fail-closed. It is taken because the consumer looks up `point.slot`, so keying by `DecisionSource` would force a lossy projection of the engine's own published decision identity. The end-to-end collision remains unexercised, and that non-claim is stated inside the guarding test's body, not only in review notes. SUPERSEDES three claims in the preceding commit's message, which described the journal as "keyed by `(DecisionSource, PlayerId)`" holding `Uniform { take }`: the key is now `(DecisionSlot, PlayerId)`; the value is `Uniform(LoopAnswerValue)` and `LoopAnswer` drops `Copy`; and the journal is no longer may-only, so its ring-clear census is renamed to `..._the_loop_answer_journal`. That message also said the builder ships "in the following commit" -- it is now two commits later, since this one contains no builder. The seat component, the `#[serde(skip, default)]` treatment, the eight ring-clear sites and the latch semantics all stand as written. Verification is provenance-aware, because the obvious positive control is not. A consumer that ignores the journal and pins a constant `Player(P1)` satisfies the shipped fixture, so the target rows are driven at a second seat: a writer that hard-codes the seat passes the first row and fails the second. Each row names the mutation that reds it, and each mutation is recorded with its occurrence count and before/after digests, because a mutation that silently fails to apply runs the test unmutated and reports success. The forced-target row does not settle for a two-player board, because "is this a two-player game" is a wrong implementation that a two-player fixture cannot distinguish from the right one. It also drives a three-seat board with one seat eliminated: still forced, because forcedness follows the legal set (CR 800.4, CR 102.1) and not the seat count. The multi-slot row is keyed to the slot axis rather than the announced-target count, and a probe replacing one with the other reds it. The two rows added for the per-slot union and the three-axis withhold are separately attributable, which is the property that makes them worth having. Three revert probes were applied by exact-text replace under a hard occurrence assert -- so a mutation that failed to apply could not report green -- and each reds exactly one row at exactly the assertion its doc names: reverting the union reds the union row; deleting the `chooser` disjunct reds the withhold row at its chooser arm; deleting the selection-mode disjunct reds the SAME row at its Random arm. The union row's fixture guard asserts the two victim sets actually disagree, so it cannot pass by the sets being accidentally equal. The withhold row's chooser arm is driven at BOTH three-player and two-player boards: the two-player arm has exactly one legal assignment and still reports "not the proposer's choice", which is the claim isolated from legal-set SIZE, since a size-based implementation would pass the three-player arm. One residual is stated rather than implied: `chooser != proposer` is currently equivalent to `chooser.is_some()`, because `collect_target_slots` already drops a chooser equal to the controller. The inequality is written against the seat the consumer reads, and the row's own body records that the difference is NOT discriminated by any fixture here rather than letting it look tested. One row is weaker than planned, and says so in its own body. The `SelectTargets` arm was to be covered at the wire tier, but all five tracked 4p dumps were driven for 60 beats and none reaches it -- every `TriggerTargetSelection` window they raise enumerates `ChooseTarget` and no `SelectTargets`. That row is therefore a structural census proving the arm is wired to the single writer, and the runtime gap is named rather than implied away. The CR 603.5 prompt census fired on the pin coordinate. It is re-adjudicated, not renumbered: the net shift is +448 to `:12425`, adjudicated in four logged steps rather than one leap, the producer line is sha256-identical to the digest the census log already records and unique file-wide at that digest, it remains inside `begin_pending_trigger_target_selection` which moved by the same +448, and the other four entries are byte-identical and unmoved. Withholding a published point removes a decision point, not a prompt producer, and reporting an announcement is not assigning `state.waiting_for`, so the census population is untouched -- the total (38) and partition (5/8/25) asserts both fire green, and the panic was on the third assert alone, which is what distinguishes a coordinate shift from a population change. That coordinate has now moved four times while the line's CONTENT never changed: its digest has identified this producer since `a6d1a0e62`. Every move was resolved by content first, with arithmetic agreeing afterwards as a check. A pin re-derived four times to the same content is evidence it tracks the right line -- and evidence that pinning a line NUMBER in the most-edited function of the most-edited file buys a maintenance cost the following commit is what actually pays off, by replacing coordinates with symbol references. The offer-writer census moves its TEST half 16 => 17, production unchanged at 22 with an identical per-file multiset. The new site is named in that file's log as its own doc requires: a `WaitingFor::LoopShortcut` destructure reading an offer the test itself minted, to assert the combination the decoupling makes reachable -- `schema.points` empty while `victim_slot` still names the forced victim. `GameState` gains no field; the value rides heap-side in the existing box, and the compile-time stack budget assert passes unchanged. Assisted-by: ClaudeCode:claude-opus-5 --- .../engine/src/analysis/decision_template.rs | 76 +- crates/engine/src/analysis/resource.rs | 938 +++++++++++++++++- crates/engine/src/game/engine.rs | 929 +++++++++++++++-- crates/engine/src/types/game_state.rs | 343 ++++++- .../fantastic_four_bounded_loop.rs | 296 +++++- .../engine/tests/integration/loop_shortcut.rs | 113 +++ .../loop_shortcut_offer_writer_census.rs | 13 +- .../tests/integration/natural_balance.rs | 35 +- 8 files changed, 2571 insertions(+), 172 deletions(-) diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index 5466341c18..f9baf80cf5 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -138,6 +138,39 @@ pub struct DecisionSlot { pub index: u8, } +impl DecisionSlot { + /// CR 601.2c (reached for a triggered ability via CR 603.3d) + CR 115.2: the + /// ANNOUNCEMENT target slot a BOUNDED-CYCLE ENTRY publishes. + /// + /// SCOPE OF THE AUTHORITY, stated narrowly because the number 0 is not globally + /// reserved: these two constructors are the single authority for the SUB-INDEX SHARED + /// BY `game::engine::entry_publishes_pin_slots` AND `GameState::loop_answer_journal` — + /// the publisher and the journal writers must agree, the way + /// `game::engine::object_decision_source` already makes them agree on the source half. + /// The `record_loop_pin` recast-template producer runs its OWN sub-index namespace over + /// the same source (0 for its `Targets`/`ConvokeTaps` pin, 1 for its `ManaColor` pin) + /// and deliberately does NOT route through here — its indices answer a different + /// question and coincide numerically only by accident. + /// + /// `pub`, not `pub(crate)`: `crates/engine/tests/integration/` is a SEPARATE CRATE, and + /// a `pub(crate)` constructor is unnameable there, so the integration rows would + /// hand-roll the very literal this constructor exists to delete (the shape + /// `object_decision_source`'s `pub(crate)` already forces on `may_source_key` in + /// `fantastic_four_bounded_loop.rs` and on `decision_source` in `natural_balance.rs`). + /// Every field of this `pub` struct in this `pub` module is already `pub`, so this adds + /// no reachability the type does not already have — it only removes the literal. + pub fn target(source: DecisionSource) -> Self { + Self { source, index: 0 } + } + + /// CR 603.5: the "may" gate on the SAME source — a second choice of one ability + /// instance, which is exactly what the sub-index exists to disambiguate. Same scoped + /// authority and same visibility rationale as [`DecisionSlot::target`]. + pub fn may(source: DecisionSource) -> Self { + Self { source, index: 1 } + } +} + /// CR 603.5: whether a "may" pin takes the optional action or declines it. Typed (not `bool`) /// so both outcomes are self-documenting at every construction and match site. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -153,9 +186,11 @@ pub enum UnlessPaymentOption { Decline, } -/// The observed answer to ONE published CR 603.5 "may" source, from ONE seat, across the -/// current loop-detection window. Journalled under the key `(DecisionSource, PlayerId)` -/// (`GameState::record_loop_answer`), so a seat can only ever answer for itself. +/// The observed answer to ONE published decision slot, from ONE seat, across the current +/// loop-detection window. Journalled under the key `(DecisionSlot, PlayerId)` +/// (`GameState::record_loop_answer`), so a seat can only ever answer for itself and the +/// sub-index keeps the two slots one source can publish (CR 601.2c target, CR 603.5 "may") +/// in two entries rather than collapsing them into one latched conflict. /// /// CR 732.2a says a shortcut proposal describes "a sequence of game choices, for all /// players, that may be legally taken based on the current game state and the predictable @@ -173,16 +208,43 @@ pub enum UnlessPaymentOption { /// only make reactively is one they cannot pin" disposition [`predictability_gate`]'s /// CR 732.2a firewall doc already records. Failing to offer is the fail-closed direction: /// strictly fewer offers, never a wrong pin. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +// `Copy` is DROPPED here (and nowhere else): `LoopAnswerValue::Targets` carries a `Vec`. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum LoopAnswer { - /// Every observed iteration answered this (source, seat) pair identically. - Uniform { take: MayChoiceOption }, - /// Two observed iterations of the same (source, seat) pair disagreed. Latched: + /// Every observed iteration answered this (slot, seat) pair identically. + Uniform(LoopAnswerValue), + /// Two observed iterations of the same (slot, seat) pair disagreed. Latched: /// never returns to `Uniform`. See the type doc — the refusal this produces is this /// engine's conservative policy, NOT a CR 732.2a mandate. Conflicted, } +/// The VALUE of one observed answer. Variants are distinct CR choice KINDS — the same axis +/// [`PinnedDecision`] and [`DecisionPointKind`] already partition, and under the same +/// CR 732.2a umbrella ("a sequence of game choices, for all players") that makes each of +/// those ONE type rather than one type per rule section. It is therefore a PARTIAL +/// observation-side projection of that kind space (2 of the 6 [`DecisionPointKind`] +/// variants), TOTALIZED at the consumer's wildcard-free `(DecisionPointKind, +/// LoopAnswerValue)` match — not an exhaustive peer of either. +/// +/// Parameterizing [`LoopAnswer::Uniform`] rather than adding a `UniformTargets` sibling is +/// deliberate: a `X`/`TargetX` sibling pair is CLAUDE.md's sibling-cluster smell, and it +/// would put the KIND axis on the same enum level as the LATCH axis (`Uniform` vs +/// `Conflicted`), which is the layer conflation CLAUDE.md's enum-design rule forbids. +/// +/// DELIBERATELY DERIVES NO `Serialize`/`Deserialize`, exactly as [`LoopAnswer`] does: the +/// `#[serde(skip)]` on `GameState::loop_answer_journal` is enforced AT COMPILE TIME by the +/// absence of that derive, and adding one here would silently re-open persistence of a +/// transient window. ([`TargetPin`] and [`MayChoiceOption`] do derive it; the bar lives on +/// the two enums above them, which is where it was put.) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoopAnswerValue { + /// CR 603.5: take the optional action, or decline it. + May(MayChoiceOption), + /// CR 608.2b + CR 601.2c: the announced targets for one slot, in announcement order. + Targets(Vec), +} + /// One pinned decision. Variants are distinct CR choice KINDS (ordering / targeting / /// modal / optional-"may" / "[A] unless [B]" break), not a parameterization axis. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 3197611125..ba31e1f003 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -673,9 +673,18 @@ pub struct PeriodicDelta { /// The whole-game resource change across one repetition, measured from the very /// frame pair that certified it. pub delta: ResourceVector, - /// CR 704.5a: per published choice slot, the life magnitude one repetition charges - /// to whichever player that slot's pin names. EMPTY for the untargeted class, where - /// the victims are already visible in `delta.life`. + /// CR 704.5a: per ANNOUNCED target slot, the life magnitude one repetition charges + /// to whichever player that slot's declaration names. EMPTY for the untargeted class, + /// where the victims are already visible in `delta.life`. + /// + /// ANNOUNCED, not PUBLISHED, and the distinction is load-bearing rather than pedantic: + /// CR 732.2a withholds a decision point for an announcement the PROPOSER does not make + /// (`game::engine::TargetAnnouncement::NotProposerChoice` — its own doc enumerates the + /// three routes: a single legal assignment, a CR 601.2c `target_chooser` seated on another + /// player, or a non-`Chosen` `TargetSelectionMode`), but CR 704.5a charges that victim all + /// the same. A slot present here with no matching `ShortcutDecisionSchema` point is + /// therefore CORRECT and expected, not a schema/certificate mismatch. Deriving this from + /// the published points instead let the withhold silently raise the bound. pub victim_slot: Vec<(DecisionSlot, i64)>, } @@ -955,13 +964,21 @@ impl ResourceVector { /// /// # Aggregation per DECLARABLE victim /// - /// `declarable_victims` is the union of the published `Targets` slots' legal targets — - /// EMPTY for the untargeted class. `slot_magnitude` is the per-period life loss the - /// certificate attributed to each published slot. A declaration may aim **every** slot + /// `declarable_victims` is the union of the ANNOUNCED target slots' legal player targets + /// — EMPTY for the untargeted class. `slot_magnitude` is the per-period life loss the + /// certificate attributed to each announced slot. A declaration may aim **every** slot /// at **one** opponent, so a declarable victim's life magnitude is the SUM over all /// slots; that is what makes an all-slots-on-one-seat declaration bounded by /// construction rather than by a cross-slot check in `validate_pins`. /// + /// ANNOUNCED, NOT PUBLISHED, and the caller + /// (`game::engine::bounded_cycle_charged_targets_for_window`) supplies it that way on + /// purpose. CR 732.2a withholds a decision point when the announcement is FORCED — the + /// player makes no choice — but CR 704.5a charges that victim regardless of who chose it. + /// Feeding this the PUBLISHED point set instead dropped a forced victim into the `else` + /// arm below and RAISED the bound; on a victim whose measured period nets a life GAIN it + /// disarmed the life axis at `MAX_SHORTCUT_CYCLES` outright. + /// /// PRECISELY WHAT IS IMPLEMENTED, and how it differs from the specified rule: this /// sums **every** positive `slot_magnitude` and charges that one total `S` to **every** /// member of `declarable_victims`. The specified rule is `S(p) = Σ over slots s with @@ -4336,7 +4353,27 @@ fn stack_entry_has_no_ordering_input(state: &GameState, entry: &StackEntry) -> b /// single legal assignment exists, limit=2) — the same authority the trigger /// dispatcher uses. Fail-closed on any build error, empty slots, or ≥2 legal /// assignments (`Ok(None)` / `Err`). -fn forced_unique_targeting( +/// +/// "The same authority the trigger dispatcher uses" is MEASURED, not asserted: +/// `triggers::prepare_trigger_targets` calls this very function and routes +/// `Ok(Some(targets))` to `PreparedTriggerTargets::AutoAssigned` (targets assigned +/// at dispatch, no prompt) and `Ok(None)` to `NeedsPlayerChoice` (the +/// `WaitingFor::TriggerTargetSelection` prompt). So `true` here is exactly "the +/// dispatcher will announce this target itself and the player is never asked". +/// +/// `pub(crate)` for ONE additional consumer, and it is the publish side of the same +/// question: [`crate::game::engine::entry_publishes_pin_slots`] must not publish a +/// CR 732.2a decision point for a choice no player makes. Exported rather than +/// re-derived there — two copies of this predicate could disagree about whether a +/// choice is forced, and the publisher and the relief disagreeing is precisely the +/// fail-open shape gate (3) exists to prevent. +/// +/// ⚠ THE BOARD IS THE VERDICT. Every caller must pass the frame the rest of its own +/// derivation uses — the announced pair's CARRYING FRAME for a retained sample, the +/// live board only for a live entry. Handing a retained pair the live board is +/// fail-OPEN: a target legal on the frame but gone from the live board collapses the +/// assignment to "forced" and relieves (or unpublishes) a choice that is not forced. +pub(crate) fn forced_unique_targeting( state: &GameState, ability: &crate::types::ability::ResolvedAbility, ) -> bool { @@ -7253,6 +7290,888 @@ mod tests { ); } + /// **Row F1.** CR 732.2a: a FORCED target is not a game choice, so the mint must NOT + /// publish a decision point for it. + /// + /// CR 732.2a describes a shortcut as "a sequence of game choices, for all players"; a + /// published `DecisionPoint` stands for one such choice. When exactly one legal assignment + /// exists, the announcing player makes none — `triggers::prepare_trigger_targets` routes + /// this predicate's `Ok(Some(..))` straight to `AutoAssigned`, so no + /// `WaitingFor::TriggerTargetSelection` is raised, so `record_trigger_target_answer` (whose + /// only two call sites are that prompt's reducer arms) never runs. A point published here + /// would therefore demand a `predictability_gate` answer that CANNOT ARRIVE, and since the + /// gate's `required` set is EVERY published point, one such point makes the whole offer + /// undeclarable — the precise failure the bounded-offer journal exists to remove. + /// + /// # Discrimination, and the confound it breaks + /// + /// (a) vs (b) differ in the number of living opponents, which is confounded with the number + /// of legal assignments — so (a′) repeats the forced verdict at (b)'s SEAT COUNT with one + /// opponent eliminated (CR 800.4 + CR 102.1: a departed seat is not choosable). An + /// implementation keyed on "is this a 2-player game" passes (a) and (b) and FAILS (a′). + /// + /// REVERT-PROBE, MEASURED (deleting the `forced_unique_targeting` withhold from + /// `entry_publishes_pin_slots`, with the mutation `cmp`-proved to have applied): the row + /// FLIPS TO FAILING at arm (a) — "CR 732.2a: no choice is made here, so nothing is + /// published". (a′) and (c) assert the SAME withhold on two further boards and are not + /// separately measured, because (a) panics first; each carries its own reach-guard instead. + /// (b)'s positive is pinned INDEPENDENTLY OF THIS ROW and on BOTH sides of the change, by + /// `bounded_cycle_pin_slots_requires_a_single_mandatory_announcement_slot`'s control on the + /// same 3p `drain_entry` fixture — so "the mint publishes nothing" cannot be what makes the + /// forced arms pass. + /// + /// # Reach-guards + /// + /// Each forced arm asserts the FULL announcement shape first (one mandatory slot, all-player + /// legal set), so the withhold is attributable to forced-ness rather than to one of the + /// upstream cardinality / optionality / all-`Player` conjuncts. Arm (d) asserts the RELIEF + /// survives: gate (3) was already passing on a forced board through + /// `stack_entry_has_no_ordering_input`, so unpublishing the point costs no cover. + #[test] + fn a_forced_target_is_not_a_published_decision_point() { + use crate::analysis::decision_template::DecisionPointKind; + use crate::game::ability_utils::build_target_slots; + use crate::game::engine::{bounded_cycle_pin_slots, entry_publishes_pin_slots}; + + let announcement = |state: &GameState| { + build_target_slots(state, state.stack[2].ability().unwrap()) + .map(|slots| { + slots + .iter() + .map(|s| (s.optional, s.legal_targets.clone())) + .collect::>() + }) + .ok() + }; + + // ── (a) FORCED: 2p, the single opponent is the only legal assignment ── + let (_p2, c2) = grown_window(2, |id| drain_entry(id, vec![])); + assert_eq!( + announcement(&c2), + Some(vec![(false, vec![TargetRef::Player(PlayerId(1))])]), + "reach-guard: ONE mandatory slot over PLAYERS — every conjunct upstream of the \ + forced-ness check accepts this entry, so the withhold below is attributable" + ); + assert!( + forced_unique_targeting(&c2, c2.stack[2].ability().unwrap()), + "reach-guard: one legal assignment ⇒ the dispatcher announces it without asking" + ); + assert!( + entry_publishes_pin_slots(&c2, &c2.stack[2], PlayerId(0)).is_none(), + "CR 732.2a: no choice is made here, so nothing is published — and a mandatory \ + drain has no CR 603.5 gate to publish either" + ); + assert!( + bounded_cycle_pin_slots(&c2, PlayerId(0)).is_empty(), + "and the point mint carries the withhold through" + ); + + // ── (a′) SAME SEAT COUNT as (b), one opponent eliminated ⇒ still forced ── + let (_pe, mut ce) = grown_window(3, |id| drain_entry(id, vec![])); + ce.players + .iter_mut() + .find(|p| p.id == PlayerId(2)) + .expect("fixture: the 3p board seats P2") + .is_eliminated = true; + assert_eq!( + ce.players.len(), + 3, + "reach-guard: the SEAT COUNT still matches (b) — only legality differs" + ); + assert_eq!( + announcement(&ce), + Some(vec![(false, vec![TargetRef::Player(PlayerId(1))])]), + "reach-guard: CR 800.4 + CR 102.1 — a departed seat is not one of the people in \ + the game, so the announcement authority enumerates ONE opponent" + ); + assert!( + entry_publishes_pin_slots(&ce, &ce.stack[2], PlayerId(0)).is_none(), + "the verdict follows the LEGAL SET, not the seat count" + ); + + // ── (b) MATCHED POSITIVE: 3p, two legal assignments ⇒ a real choice ⇒ published ── + let (_p3, c3) = grown_window(3, |id| drain_entry(id, vec![])); + assert!( + !forced_unique_targeting(&c3, c3.stack[2].ability().unwrap()), + "reach-guard: two opponents ⇒ `auto_select => Ok(None)` ⇒ the player IS asked" + ); + let published = bounded_cycle_pin_slots(&c3, PlayerId(0)); + assert_eq!( + published.len(), + 1, + "control: an unforced target choice is still published — without this every \ + assertion above is satisfied by a mint that publishes nothing" + ); + assert!( + matches!(published[0].kind, DecisionPointKind::Targets { .. }), + "control: and it is the CR 601.2c Targets point, not some other kind" + ); + + // ── (c) the CR 603.5 gate SURVIVES the withhold ── + // Withholding the forced target must not suppress the entry: a "may" on the same + // source is a real per-iteration choice with its own sub-index. + let (_pm, cm) = grown_window(2, optional_drain); + let pins = entry_publishes_pin_slots(&cm, &cm.stack[2], PlayerId(0)) + .expect("an optional entry still publishes its CR 603.5 gate"); + assert!( + pins.target.is_none(), + "the forced CR 601.2c target is withheld" + ); + assert!(pins.may.is_some(), "the CR 603.5 take/decline is not"); + assert!( + pins.legal_targets.is_empty(), + "no target slot carries no legal set" + ); + let may_points = bounded_cycle_pin_slots(&cm, PlayerId(0)); + assert_eq!( + may_points.len(), + 1, + "exactly the may point reaches the schema: {may_points:?}" + ); + assert!( + matches!(may_points[0].kind, DecisionPointKind::MayChoice), + "and it is the MayChoice point" + ); + + // ── (d) NO RELIEF IS LOST: gate (3) passes on a forced board without any pin ── + for (label, state) in [("(a) 2p", &c2), ("(a′) eliminated", &ce), ("(c) may", &cm)] { + assert!( + stack_entry_has_no_ordering_input(state, &state.stack[2]), + "{label}: the target axis of gate (3) is discharged by forced-ness itself, so \ + unpublishing the point cannot cost the cover a relief it used to get" + ); + } + } + + /// CR 704.5a — **WITHHOLDING A FORCED ANNOUNCEMENT FROM THE SCHEMA DOES NOT UNCHARGE ITS + /// VICTIM: the bound is the SAME whether or not the point is published.** + /// + /// The sibling row above asserts the CR 732.2a WITHHOLD (a forced announcement is not a + /// game choice, so no decision point is published). That withhold is right, and its blast + /// radius was not: `declarable_victims` and `PeriodicDelta::victim_slot` were BOTH derived + /// from the published point set, so withholding the point dropped the forced victim into + /// `elimination_bounds`' bare-`observed_life_loss` arm and the bound GREW — an offer + /// declaring more repetitions legal than CR 732.2a permits, on the very operator that + /// proves the proposal "may be legally taken based on the current game state". + /// + /// This row therefore asserts THE BOUND, not the publication. A row that only re-asserted + /// "the point is withheld" is exactly the row that already existed and that missed this. + /// + /// # The matched pair, and why the two arms are comparable + /// + /// Both arms run step (7)'s own two derivations verbatim, then the production + /// `elimination_bounds`. They differ in ONE axis — how many opponents the announcement + /// authority enumerates, which is what makes the announcement `Forced` (2p, one legal + /// assignment, point WITHHELD) or `Chosen` (3p, two legal assignments, point PUBLISHED). + /// The extra seat is parked at 40 life so its own headroom never binds, and the delta is + /// byte-identical across the arms, so the ONLY thing that can move the bound is whether + /// the withheld announcement is charged. Asserting EQUALITY pins both directions at once: + /// the pre-fix fail-OPEN (looser when withheld) and an over-correction (tighter when + /// withheld, which would silently shrink offers on boards that work today). + /// + /// * **(A) the ordinary forced drain** — P1 loses 1 per period. Charged: P1's magnitude is + /// `observed 1 + S 1 = 2` over headroom `7 - 1`, so **3**. Uncharged it is `1`, giving + /// **6**. + /// * **(B) the victim who NETS A LIFE GAIN** — P1 *gains* 1 per period while the proposer + /// loses 2. This is the shape where the defect is worst rather than merely loose: + /// uncharged, P1's magnitude is `-1`, `elimination_bounds`' `narrow` guard + /// (`magnitude > 0`) never fires and P1's life axis is DISARMED outright, leaving only + /// the proposer's `20 / 2 = 10`. Charged, the `.max(0)` clamp floors the gain at zero + /// and P1 is charged `0 + S 2 = 2` over headroom `7 - 1`, so **3**. Case (o) of + /// `elimination_bounds_conventions` guards that clamp in isolation; this row is what + /// proves a real production derivation still REACHES it on a forced board. + /// + /// # What a wrong implementation would still pass, and the guard for each + /// + /// * *charge every living seat* (ignore the legal set): both arms move together and stay + /// equal ⇒ the VICTIM-SET assertions below, not the equality, are what reject it. + /// * *republish the forced point* (revert the sibling row's withhold): the two derivations + /// coincide again and equality holds ⇒ the withhold reach-guard rejects it. + /// * *charge the victim but not the magnitude* (or vice versa): arm (A) yields 6, not 3 ⇒ + /// the exact-value assertions reject it. + /// + /// REVERT-PROBE, MEASURED (the mutation `cmp`-proved to have applied, and the file + /// restored byte-identically by SHA256 afterwards): RE-CONFLATE the two questions inside + /// the charging mint — add `.filter(|t| t.announcement == TargetAnnouncement::Chosen)` to + /// `game::engine::bounded_cycle_charged_targets_for_window`, which is precisely "charge + /// only what CR 732.2a publishes". The forced arm then charges NOTHING and the row FLIPS + /// TO FAILING at arm (A)'s first victim-set assertion: `[]` where `[PlayerId(1)]` is + /// required. Arm (B) is not separately measured because (A) panics first; it carries its + /// own exact-value assertion instead. + /// + /// ⚠ THE OTHER OBVIOUS REVERT DOES NOT REACH THIS ROW, and that is worth stating rather + /// than leaving to be re-derived: restoring step (7)'s published-point derivation inside + /// `try_offer_bounded_cycle_shortcut` leaves this row GREEN (measured), because this row + /// calls the charging mint directly. That revert is discriminated by the sibling + /// production-offer row `the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for`, + /// which flips on it. The two rows cover the two halves of the seam on purpose. + #[test] + fn a_withheld_forced_announcement_is_charged_like_a_published_one() { + use crate::analysis::decision_template::DecisionPointKind; + use crate::game::engine::{ + bounded_cycle_charged_targets_for_window, bounded_cycle_pin_slots, + }; + use std::collections::BTreeMap; + + /// Step (7)'s own two derivations, verbatim — the union of the CHARGED + /// announcements' legal player sets, and the per-slot magnitude keyed by + /// `worst_seat_life_loss`. One function, so neither arm can compute them a + /// different way. + fn step_seven( + state: &GameState, + delta: &ResourceVector, + ) -> (Vec, BTreeMap) { + let touch = + certified_period_touch(&[], state, PeriodCertification::ResourceSignatureOnly); + let charged = bounded_cycle_charged_targets_for_window(&touch, PlayerId(0)); + let mut victims: Vec = charged + .iter() + .flat_map(|(_, seats)| seats.iter().copied()) + .collect(); + victims.sort_unstable(); + victims.dedup(); + let magnitude = charged + .iter() + .map(|(slot, _)| (slot.clone(), delta.worst_seat_life_loss())) + .collect(); + (victims, magnitude) + } + + // One board per arm: `players` seats, P0 (the proposer) at 21, P1 (the victim) at + // 7, and every further seat parked at 40 so only P1's headroom can bind. + let board = |players: u8| { + let (_prior, mut current) = grown_window(players, |id| drain_entry(id, vec![])); + for p in current.players.iter_mut() { + p.life = match p.id { + PlayerId(0) => 21, + PlayerId(1) => 7, + _ => 40, + }; + } + current + }; + let life_delta = |seats: &[(PlayerId, i64)]| { + let mut v = ResourceVector::default(); + for (seat, n) in seats { + v.life.insert(*seat, *n); + } + v + }; + + let forced = board(2); + let chosen = board(3); + + // ── REACH-GUARDS: the two arms really are the withheld/published pair ──────────── + assert!( + bounded_cycle_pin_slots(&forced, PlayerId(0)).is_empty(), + "REACH-GUARD: the 2p arm's announcement must still be WITHHELD (CR 732.2a — one \ + legal assignment is no game choice). Without this the row would be satisfied by \ + re-publishing the forced point, which is the change the sibling row forbids" + ); + assert!( + bounded_cycle_pin_slots(&chosen, PlayerId(0)) + .iter() + .any(|p| matches!(p.kind, DecisionPointKind::Targets { .. })), + "REACH-GUARD: the 3p arm must PUBLISH its `Targets` point, else 'published' and \ + 'withheld' name the same board and the equality below is vacuous" + ); + + for (label, delta, expected, uncharged) in [ + ( + "(A) ordinary forced drain", + life_delta(&[(PlayerId(1), -1)]), + 3, + 6, + ), + ( + "(B) victim nets a life GAIN", + life_delta(&[(PlayerId(1), 1), (PlayerId(0), -2)]), + 3, + 10, + ), + ] { + let (forced_victims, forced_magnitude) = step_seven(&forced, &delta); + let (chosen_victims, chosen_magnitude) = step_seven(&chosen, &delta); + + // The victim SETS, asserted by content: an implementation that charged every + // living seat would keep the two bounds equal and pass the equality alone. + assert_eq!( + forced_victims, + vec![PlayerId(1)], + "{label}: CR 704.5a — the WITHHELD announcement still charges the one seat \ + its legal set names, and only that seat" + ); + assert_eq!( + chosen_victims, + vec![PlayerId(1), PlayerId(2)], + "{label}: and the published one charges both of its legal targets" + ); + assert_eq!( + (forced_magnitude.len(), chosen_magnitude.len()), + (1, 1), + "{label}: one SOURCE announces on both boards, so exactly one slot is \ + charged on each (PER SOURCE, NOT PER ENTRY)" + ); + + let forced_bound = + delta.elimination_bounds(&forced, &forced_victims, &forced_magnitude); + let chosen_bound = + delta.elimination_bounds(&chosen, &chosen_victims, &chosen_magnitude); + assert_eq!( + forced_bound, chosen_bound, + "{label}: CR 704.5a — the bound must not move because CR 732.2a declined to \ + publish the announcement as a game choice. The victim loses the life either \ + way; who chose the target is not an input to how much is lost" + ); + assert_eq!( + forced_bound, expected, + "{label}: and the shared value is the CHARGED one ({expected}), re-derived \ + by hand above — not the UNCHARGED {uncharged} the published-point \ + derivation produced" + ); + assert_ne!( + expected, uncharged, + "{label}: fixture guard — the two derivations must actually disagree on this \ + board, else the row cannot discriminate" + ); + } + } + + /// CR 704.5a + CR 732.2a — **a slot ANNOUNCED TWICE in one window is charged the UNION of + /// its legal sets, not the first frame's.** + /// + /// The two mints agree on WHICH entries the cycle accepts (both read `entry_announces`) + /// and they dedup the same slot the same way — but they keep DIFFERENT FRAMES of a repeat, + /// because publication skips a `NotProposerChoice` frame and charging does not. First-wins + /// charging therefore let a NARROW earlier frame's legal set stand for a slot the schema + /// publishes from a WIDER later one: the schema states the client may pin P2, + /// `declarable_victims` reads `[P1]`, `elimination_bounds` never charges P2, and + /// `max_iterations` GROWS. That is the fail-OPEN direction, on the operator whose whole job + /// is proving the proposed sequence "may be legally taken based on the current game state". + /// + /// # The board, and why it is a legal transition rather than a contrived one + /// + /// Three frames on ONE source (`CHURN_SRC`, P0's). The middle frame carries a P2-controlled + /// permanent whose `StaticMode::Hexproof` affects its controller — CR 702.11c, "you can't + /// be the target of spells or abilities your opponents control" — so the announcement + /// authority enumerates ONE opponent there and the announcement is forced. On the live + /// board that permanent has LEFT, so both opponents are legal and the announcement is the + /// proposer's choice. A permanent leaving the battlefield between two retained ring frames + /// is an ordinary event; nothing here rewinds an irreversible fact (contrast + /// `is_eliminated`, which is why this row does not use the elimination lever the sibling + /// rows use). + /// + /// **REACHABILITY: NARROW, AND NOT CLOSED — stated in both directions.** No production + /// trajectory that reaches this shape has been built, by the reviewer, the orchestrator or + /// this row. Elimination — the realistic mechanism, and the one every tracked dump shows — + /// narrows the legal set MONOTONICALLY, which puts the widest frame first and lands + /// first-wins fail-CLOSED. The fail-open direction needs a seat's untargetability to END + /// mid-window; a corpus census measured 14 cards granting a player untargetability + /// mid-loop, all self-protective and predominantly "until end of turn", which does not + /// expire mid-turn, so the path additionally needs the grantor to leave or a shorter + /// duration. This row builds the grantor-leaves half at the mint's own boundary. It is + /// NOT evidence that a full drive reaches it, and the shape is NOT "unreachable". + /// + /// # What a wrong implementation would still pass this row, and the guard for each + /// + /// * *charge every living seat* — passes the union assertion and FAILS the narrow-frame + /// reach-guard, which pins the first frame's legal set at exactly `[P1]`. + /// * *keep the LAST frame instead of unioning* — indistinguishable HERE (the later frame + /// is the wider one) and equally sound on this board, but it is not monotone in general; + /// the `charged.len() == 1` + slot-identity guards are what keep the row about the DEDUP + /// rather than about frame order, and the doc on + /// `game::engine::bounded_cycle_charged_targets_for_window` carries the monotonicity + /// argument the union rests on. + /// * *publish nothing at all* — the publication reach-guard requires the WIDE point to + /// reach the schema at the same `DecisionSlot`, so a mint that published nothing fails + /// before the claim. + /// * *drop the dedup entirely* — `charged.len() == 1` fails; two charged copies of one + /// slot would double `declared_life_magnitude` and silently halve the bound. + /// + /// REVERT-PROBE, and it is the shipped code's own previous form: replace the union arm + /// with `if charged.iter().any(|(slot, _)| *slot == target.slot) { continue; }`. The + /// charged victim list reads `[PlayerId(1)]` and the row FLIPS TO FAILING at the union + /// assertion; the bound assertion below then reads 6 where 3 is required. + #[test] + fn a_repeated_slots_victim_lists_are_unioned_not_first_wins() { + use crate::analysis::decision_template::DecisionPointKind; + use crate::game::ability_utils::build_target_slots; + use crate::game::engine::{ + bounded_cycle_charged_targets_for_window, bounded_cycle_pin_slots_for_window, + }; + use std::collections::BTreeMap; + + const GRANTOR: ObjectId = ObjectId(600); + + // P1 is parked out of reach so only P2's headroom can bind the life axis, and P2 is + // seeded at 7 so neither the charged nor the uncharged bound lands on the `1` floor. + let mut base = drain_state(3); + for p in base.players.iter_mut() { + p.life = match p.id { + PlayerId(0) => 21, + PlayerId(1) => 40, + _ => 7, + }; + } + + // Window head: nothing on the stack, so frame 1's entry counts as ANNOUNCED there. + let head = base.clone(); + + // Frame 1 — CR 702.11c: P2 controls a permanent granting its controller hexproof, so + // an opponent-controlled source cannot target them and the announcement is forced. + let mut narrow = base.clone(); + let mut grantor = GameObject::new( + GRANTOR, + CardId(77), + PlayerId(2), + "You Have Hexproof".to_string(), + Zone::Battlefield, + ); + grantor.static_definitions = + vec![ + StaticDefinition::new(StaticMode::Hexproof).affected(TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::You), + )), + ] + .into(); + narrow.objects.insert(GRANTOR, grantor); + narrow.battlefield.push_back(GRANTOR); + crate::game::layers::flush_layers(&mut narrow); + narrow.stack.push_back(drain_entry(10, vec![])); + + // The live board — the grantor has left, so both opponents are legal again. + let mut current = base.clone(); + current.stack.push_back(drain_entry(20, vec![])); + + let legal = |state: &GameState, entry: usize| { + build_target_slots(state, state.stack[entry].ability().unwrap()) + .map(|slots| { + slots + .iter() + .map(|s| (s.optional, s.legal_targets.clone())) + .collect::>() + }) + .ok() + }; + + // ── REACH-GUARDS: the window really is the narrow-then-wide repeat ────────────── + assert_eq!( + legal(&narrow, 0), + Some(vec![(false, vec![TargetRef::Player(PlayerId(1))])]), + "REACH-GUARD: CR 702.11c — the hexproof grantor must actually remove P2 from the \ + announcement authority's legal set on the FIRST frame, else this row is two \ + identical frames and the dedup is unobservable" + ); + assert_eq!( + legal(¤t, 0), + Some(vec![( + false, + vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)) + ] + )]), + "REACH-GUARD: and the live board must admit BOTH opponents, so the later frame is \ + the WIDER one" + ); + + let touch = certified_period_touch( + &[&head, &narrow], + ¤t, + PeriodCertification::ResourceSignatureOnly, + ); + assert_eq!( + touch + .announced + .iter() + .map(|(_, e)| e.id) + .collect::>(), + vec![ObjectId(10), ObjectId(20)], + "REACH-GUARD: exactly two announcements, NARROW FIRST — first-wins keeps the \ + narrow one, which is the whole shape under test" + ); + + // ── The publication half: ONE point, carrying the WIDE legal set ──────────────── + let points = bounded_cycle_pin_slots_for_window(&touch, PlayerId(0)); + assert_eq!( + points.len(), + 1, + "REACH-GUARD: the narrow frame's announcement is forced and withheld, the wide \ + one is the proposer's own choice and published — exactly one point: {points:?}" + ); + assert_eq!( + points[0].kind, + DecisionPointKind::Targets { + legal_targets: vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)) + ], + min_targets: 1, + max_targets: 1, + ordered: false, + }, + "REACH-GUARD: the SCHEMA states the client may pin P2. Everything below is about \ + the bound owing a charge for that stated pin" + ); + + // ── THE CLAIM: one slot, and its charge is the UNION ──────────────────────────── + let charged = bounded_cycle_charged_targets_for_window(&touch, PlayerId(0)); + assert_eq!( + charged.len(), + 1, + "PER SOURCE, NOT PER ENTRY: both entries carry one source, so one slot is \ + charged. Two copies would double `declared_life_magnitude` and halve the bound \ + instead of widening the victim set: {charged:?}" + ); + assert_eq!( + charged[0].0, points[0].slot, + "the charged slot IS the published slot — without this the union below could be \ + about a different decision point than the one the schema offers" + ); + assert_eq!( + charged[0].1, + vec![PlayerId(1), PlayerId(2)], + "CR 704.5a: a repeated slot charges the UNION of its announcements' legal player \ + sets. First-wins reads [P1] here, so the schema would offer a P2 pin that \ + `elimination_bounds` never charges and `max_iterations` would GROW" + ); + + // ── And the bound really moves, so the union is not a cosmetic set difference ─── + let mut delta = ResourceVector::default(); + delta.life.insert(PlayerId(2), -1); + let magnitude: BTreeMap = charged + .iter() + .map(|(slot, _)| (slot.clone(), delta.worst_seat_life_loss())) + .collect(); + assert_eq!( + delta.elimination_bounds(¤t, &charged[0].1, &magnitude), + 3, + "P2 is a declarable victim, so its life magnitude is `observed 1 + S 1 = 2` over \ + CR 704.5a headroom `7 - 1`; first-wins leaves P2 out of the victim set, charges \ + the bare observed 1 and returns 6" + ); + assert_eq!( + delta.elimination_bounds(¤t, &[PlayerId(1)], &magnitude), + 6, + "fixture guard — the two victim sets must actually disagree on this board, else \ + the assertion above cannot discriminate" + ); + } + + /// CR 601.2c + CR 115.1 + CR 732.2a — **an announcement the PROPOSER does not make is + /// withheld from the schema and charged all the same, on all THREE of CR 601.2c's axes.** + /// + /// `TargetAnnouncement` answers "is announcing this a game choice the proposer makes". + /// `forced_unique_targeting` answers only the assignment-COUNT half of that question, and + /// two `triggers::prepare_trigger_targets` routes raise no prompt for the proposer while + /// the count half reports "not forced": + /// + /// * **(a) CR 601.2c `target_chooser`** — "of an opponent's choice" (Volcanic Offering's + /// shape). `ability_utils::auto_select_targets_for_ability` early-returns `Ok(None)` + /// whenever ANY slot carries a chooser, so the count half is false even with exactly ONE + /// legal assignment — arm (a2) is that exact board. The prompt is raised for the + /// CHOOSER, `record_trigger_target_answer` journals under the seat that answered, and + /// every consumer reads `loop_answer(slot, proposer)` ⇒ an unanswerable published point, + /// which is the undeclarable-offer condition the bounded offer exists to remove. + /// * **(b) `TargetSelectionMode::Random`** — routed to `random_select_targets_for_ability` + /// and then `AutoAssigned`, so no prompt is ever raised, and the pin RELIEVES gate (3): + /// the offer would be minted because of a designation the RNG contradicts at drive time. + /// + /// **SCOPING HONESTY: the publication behaviour PREDATES the commit this row ships in.** + /// What is new is a named authority claiming to answer the whole question while reading one + /// of its three members. This row is not evidence of a defect this commit introduced. + /// + /// # What a wrong implementation would still pass, and the guard for each + /// + /// * *withhold everything* — arm (c) publishes on the SAME 3p board with neither axis set, + /// so a mint that published nothing fails there. + /// * *withhold by legal-set size* — arm (a1)/(b) have TWO legal opponents and are still + /// withheld; arm (c) has the same two and publishes. Size cannot separate them. + /// * *withhold, and also stop charging* — every arm asserts the CR 704.5a charge survives + /// with the full legal player set, which is the half `elimination_bounds` reads. + /// * *key the chooser on presence rather than on the SEAT* — not discriminated here and + /// deliberately so: `collect_target_slots` already drops a chooser equal to the + /// ability's controller, so on these fixtures `is_some()` and `is_some_and(!= proposer)` + /// coincide. The inequality guards `entry.controller != ability.controller` skew, which + /// no fixture in this crate builds. + /// + /// REVERT-PROBE (each measured separately, since the first failing arm panics): delete the + /// `slot.chooser` disjunct from `game::engine::entry_announces` ⇒ arms (a1)/(a2) FLIP TO + /// FAILING on "must be WITHHELD"; delete the `target_selection_mode` disjunct ⇒ arm (b) + /// flips instead. Neither deletion touches arm (c), which is what makes the two axes + /// separately attributable rather than jointly. + #[test] + fn an_announcement_the_proposer_does_not_make_is_withheld_but_still_charged() { + use crate::analysis::decision_template::DecisionPointKind; + use crate::game::ability_utils::build_target_slots; + use crate::game::engine::{ + bounded_cycle_charged_targets_for_window, bounded_cycle_pin_slots, + entry_publishes_pin_slots, + }; + use crate::types::ability::TargetSelectionMode; + + // The drain, with one of CR 601.2c's non-count announcement axes set. + let axis_drain = |id: u64, chooser: bool, random: bool| { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.targets = vec![TargetRef::Player(PlayerId(1))]; + if chooser { + // CR 601.2c: "of an opponent's choice" — `resolve_effect_player_ref` reads the + // already-announced opponent target, so the announcing seat is P1. + ability.target_chooser = Some(TargetFilter::Opponent); + } + if random { + ability.target_selection_mode = TargetSelectionMode::Random; + } + churn_entry(id, 0, ability, None) + }; + + let charged_victims = |state: &GameState| { + let touch = + certified_period_touch(&[], state, PeriodCertification::ResourceSignatureOnly); + bounded_cycle_charged_targets_for_window(&touch, PlayerId(0)) + }; + let announcement_slot = |state: &GameState| { + build_target_slots(state, state.stack[2].ability().unwrap()) + .map(|slots| { + slots + .iter() + .map(|s| (s.optional, s.chooser, s.legal_targets.clone())) + .collect::>() + }) + .ok() + }; + + // ── (c) CONTROL first: neither axis, two legal opponents ⇒ PUBLISHED ──────────── + let (_pc, control) = grown_window(3, |id| axis_drain(id, false, false)); + let control_points = bounded_cycle_pin_slots(&control, PlayerId(0)); + assert_eq!(control_points.len(), 1, "control: {control_points:?}"); + assert!( + matches!(control_points[0].kind, DecisionPointKind::Targets { .. }), + "control: an announcement the proposer DOES make is still published — without \ + this every withhold below is satisfied by a mint that publishes nothing" + ); + + // ── (a1) CR 601.2c chooser, 3p: two legal assignments, still not the proposer's ── + let (_p1, chooser_3p) = grown_window(3, |id| axis_drain(id, true, false)); + assert_eq!( + announcement_slot(&chooser_3p), + Some(vec![( + false, + Some(PlayerId(1)), + vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)) + ] + )]), + "REACH-GUARD (a1): ONE mandatory slot over PLAYERS whose ANNOUNCER is P1, not the \ + proposer — every conjunct upstream of the announcement check accepts this entry, \ + and the legal set is the SAME SIZE as the control's, so size cannot be the \ + discriminator" + ); + assert!( + entry_publishes_pin_slots(&chooser_3p, &chooser_3p.stack[2], PlayerId(0)).is_none(), + "CR 601.2c: P1 announces this target, so no point the PROPOSER could answer is \ + published — a published one is unanswerable at `loop_answer(slot, proposer)` and \ + one unanswerable point makes the WHOLE offer undeclarable" + ); + assert_eq!( + charged_victims(&chooser_3p) + .into_iter() + .map(|(_, seats)| seats) + .collect::>(), + vec![vec![PlayerId(1), PlayerId(2)]], + "CR 704.5a: withheld is not uncharged — whoever announces it, the named seat \ + loses the life" + ); + + // ── (a2) CR 601.2c chooser, 2p: EXACTLY ONE legal assignment, and the count half is + // blind to it — the precise claim, isolated ──────────────────────────────── + let (_p2, chooser_2p) = grown_window(2, |id| axis_drain(id, true, false)); + assert_eq!( + announcement_slot(&chooser_2p), + Some(vec![( + false, + Some(PlayerId(1)), + vec![TargetRef::Player(PlayerId(1))] + )]), + "REACH-GUARD (a2): exactly ONE legal assignment" + ); + assert!( + !forced_unique_targeting(&chooser_2p, chooser_2p.stack[2].ability().unwrap()), + "REACH-GUARD (a2): and the assignment-COUNT authority still reports NOT forced — \ + `auto_select_targets_for_ability` early-returns `Ok(None)` on any chooser. This \ + is why the count half alone minted `Chosen` for a one-assignment announcement" + ); + assert!( + entry_publishes_pin_slots(&chooser_2p, &chooser_2p.stack[2], PlayerId(0)).is_none(), + "so the withhold must come from the CHOOSER axis, not from forced-ness" + ); + assert_eq!( + charged_victims(&chooser_2p) + .into_iter() + .map(|(_, seats)| seats) + .collect::>(), + vec![vec![PlayerId(1)]], + "CR 704.5a: still charged, and only the one seat its legal set names" + ); + + // ── (b) CR 115.1 overridden: the GAME selects, so nobody is prompted ───────────── + let (_p3, random_3p) = grown_window(3, |id| axis_drain(id, false, true)); + assert_eq!( + announcement_slot(&random_3p), + Some(vec![( + false, + None, + vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)) + ] + )]), + "REACH-GUARD (b): no chooser is involved — this arm is the SELECTION-MODE axis \ + alone, on the control's own legal set" + ); + assert!( + !forced_unique_targeting(&random_3p, random_3p.stack[2].ability().unwrap()), + "REACH-GUARD (b): two legal assignments, so the count authority reports NOT \ + forced and would have published" + ); + assert!( + entry_publishes_pin_slots(&random_3p, &random_3p.stack[2], PlayerId(0)).is_none(), + "CR 115.1 is overridden — `prepare_trigger_targets` routes this to \ + `random_select_targets_for_ability` and `AutoAssigned`, raising no prompt at \ + all, so a pin would be a designation the RNG contradicts at drive time" + ); + assert_eq!( + charged_victims(&random_3p) + .into_iter() + .map(|(_, seats)| seats) + .collect::>(), + vec![vec![PlayerId(1), PlayerId(2)]], + "CR 704.5a: the RNG names one of these seats and it loses the life" + ); + } + + /// CR 704.5a + CR 732.2a — **the same fix at the PRODUCTION OFFER, on a board that + /// publishes NO decision point at all.** + /// + /// [`a_withheld_forced_announcement_is_charged_like_a_published_one`] drives step (7)'s + /// derivations directly; this one drives + /// `try_offer_bounded_cycle_shortcut_metered` — the producer the interactive bridge + /// calls — and asserts the value that actually ships to the client. + /// + /// THE COMBINATION IS UNREACHABLE BEFORE THE FIX: a published schema with ZERO decision + /// points, and a certificate whose `victim_slot` is NON-EMPTY. Both derivations used to + /// read the same list, so "no points" implied "nothing charged" by construction. + /// + /// # The board + /// + /// `ring_announcing_on_its_newest_sample` is a 2-seat ring whose newest retained sample + /// carries the drain entry, so the announcement's legal set is the single opponent and + /// the announcement is FORCED. The harness steps P1's life one point per retained frame, + /// so the certified period's measured delta is P1 `-1`. + /// + /// # The arithmetic, re-derived independently of `elimination_bounds` + /// + /// The certifying pair is the ring frame one period back against the live board, and the + /// harness steps P1 one life point per retained frame, so the MEASURED per-period delta is + /// P1 `-2` (asserted below rather than assumed). `worst_seat_life_loss` is therefore 2 and + /// one slot is charged, so `S = 2`; P1 is a declarable victim and is charged + /// `observed 2 + S 2 = 4` against CR 704.5a headroom `21 - 1 = 20`, giving **5**. + /// Uncharged — the published-point derivation, which sees no points here at all — P1's + /// magnitude is the bare observed `2` and the bound is **10**. + /// + /// P1 is seeded at 21 rather than the harness default so neither value lands on the `1` + /// floor of the legal range, where an over-charging bug would be indistinguishable from + /// the right answer. + /// + /// REVERT-PROBE: restore step (7)'s published-point derivation ⇒ `victim_slot` is empty + /// and `max_iterations` is 10 ⇒ both the non-empty assertion and the value assertion FLIP. + #[test] + fn the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for() { + use crate::game::engine::{ + try_offer_bounded_cycle_shortcut_metered, BoundedOfferRefusal, ProbeCap, + }; + use crate::types::game_state::WaitingFor; + + let state = ring_announcing_on_its_newest_sample( + |s| { + announcing_ring_source(s, CHURN_SRC); + // Seeded BEFORE any frame is snapshotted, so every retained sample and the + // live board share this headroom and only the harness's own per-frame step + // separates them. + s.players + .iter_mut() + .find(|p| p.id == PlayerId(1)) + .expect("the harness seats P1") + .life = 21; + }, + |frame| { + frame.stack.push_back(drain_entry(950, vec![])); + }, + ); + // REACH-GUARD: the fixture must really be the FORCED shape, or this row is about the + // ordinary published path the F4 dumps already cover. + let announced = announced_from_retained_sample(&state, 950); + assert!( + forced_unique_targeting(announced, announced.stack[0].ability().unwrap()), + "REACH-GUARD: one living opponent ⇒ one legal assignment ⇒ the dispatcher \ + announces the target itself and no player is ever asked" + ); + + let (outcome, meter) = + try_offer_bounded_cycle_shortcut_metered(&state, false, ProbeCap::Shipped); + let waiting = outcome.unwrap_or_else(|refusal: BoundedOfferRefusal| { + panic!( + "REACH-GUARD: the bounded offer must FIRE on this board, else every assertion \ + below is made about a refusal; got {refusal:?}, meter {meter:?}" + ) + }); + let WaitingFor::LoopShortcut { + certificate, + schema, + .. + } = &waiting + else { + panic!("the bounded producer returns a `LoopShortcut` offer; got {waiting:?}") + }; + let per_cycle = certificate + .per_cycle + .as_ref() + .expect("a bounded offer publishes the per-period signature its bound was divided by"); + + assert!( + schema.points.is_empty(), + "CR 732.2a: the ONE announcement on this board is FORCED, so the schema publishes \ + no decision point at all; got {:?}", + schema.points + ); + assert_eq!( + per_cycle.delta.life.get(&PlayerId(1)).copied(), + Some(-2), + "REACH-GUARD: the certified period must really drain the victim, else the bound \ + below is not about a CR 704.5a threshold; delta {:?}", + per_cycle.delta + ); + assert_eq!( + per_cycle + .victim_slot + .iter() + .map(|(_, m)| *m) + .collect::>(), + vec![2], + "CR 704.5a: the forced announcement is CHARGED even though CR 732.2a published no \ + point for it — the combination that was unreachable before, because both \ + derivations read the published list; got {:?}", + per_cycle.victim_slot + ); + assert_eq!( + schema.max_iterations, 5, + "CR 704.5a: headroom `21 - 1` over the charged magnitude `observed 2 + S 2`. The \ + published-point derivation charged nothing here and produced 10, declaring twice \ + as many repetitions legal as CR 732.2a permits" + ); + } + /// A slot an OFFER would publish for `CHURN_SRC`'s entries — built through the same /// authority the gates rebuild it with, so the rows prove the KEY matches rather than /// asserting a hand-written literal. `index: 0` is the CR 115.2 target choice, @@ -7434,7 +8353,10 @@ mod tests { "PINNED: the published CR 603.5 gate specifies that choice ⇒ cover" ); // The MayChoice point is load-bearing on its own: pinning only the target slot - // leaves the resolution choice unspecified. + // leaves the resolution choice unspecified. NOTE the target slot is pinned but NOT + // published on this board — the target is forced-unique (asserted above), so + // `a_forced_target_is_not_a_published_decision_point` is the row that owns that fact. + // Gate (3) still passes here on the ordering-input arm; only gate (6) rejects. assert!( !loop_states_cover_modulo_growth_scoped( &p_may, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index b25fce37cc..094249e6c6 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -2071,13 +2071,10 @@ fn certified_bounded_cycle_offer<'a>( verdicts: &mut crate::analysis::resource::PeriodVerdicts<'a>, cert_out: &mut Option, ) -> Result { - use crate::analysis::decision_template::{ - DecisionPoint, DecisionPointKind, DecisionSlot, IterationCount, - }; + use crate::analysis::decision_template::{DecisionPoint, DecisionSlot, IterationCount}; use crate::analysis::resource::{ certified_period_touch, PeriodCertification, PeriodTouch, PeriodicDelta, ResourceVector, }; - use crate::types::ability::TargetRef; let cur = ResourceVector::snapshot(state); // Written as an explicit newest-first walk rather than `find_map` because the candidate @@ -2290,20 +2287,38 @@ fn certified_bounded_cycle_offer<'a>( return Err(BoundedOfferRefusal::UnspecifiedChoiceWindow); } - // (7) THE BOUND. `declarable_victims` is the union of the published slots' legal targets - // — EMPTY for the untargeted class, where the victims are already in `delta.life`. + // (7) THE BOUND, derived from the ANNOUNCEMENT authority — never from `points`. + // + // ⚠ THE PUBLISHED POINT SET IS THE WRONG INPUT HERE, and reading it from there was a + // measured fail-OPEN. Publication answers CR 732.2a ("a sequence of game choices"), so + // step (6)'s mint WITHHOLDS the point for a FORCED announcement — correctly, since the + // announcing player makes no choice. The bound answers CR 704.5a ("if a player has 0 or + // less life, that player loses the game"), and a forced victim loses that life exactly as + // a chosen one does. Deriving the bound from `points` therefore made the CR 732.2a + // withhold drop the forced victim out of `declarable_victims`, charging it bare + // `observed_life_loss` instead of `observed_life_loss.max(0) + declared_life_magnitude` — + // so `max_iterations` GREW, and the offer stated more legal repetitions than are legal. + // Measured on an ordinary forced 2p targeted drain: 9 charged vs 19 uncharged; on a + // victim whose measured period NETS A LIFE GAIN the uncharged form leaves + // `elimination_bounds`' `narrow` guard (`magnitude > 0`) unfired and DISARMS the life + // axis at `MAX_SHORTCUT_CYCLES` entirely. + // + // `bounded_cycle_charged_targets_for_window` reads the SAME acceptance authority the + // point mint does (`entry_announces`), so the charged SLOT set is a superset of the + // published `Targets` slots by construction: on a board where every announcement is the + // proposer's own choice — every tracked dump today — this derivation is value-identical to + // the one it replaces. ⚠ THE SUPERSET IS OVER SLOTS ONLY. A repeated slot's per-slot LEGAL + // set is what `declarable_victims` below reads, and the two mints keep different frames of + // a repeat, so that set is made a superset separately, by the charging mint's UNION dedup + // (see its doc for the monotonicity proof). Claiming the per-slot legal set is a superset + // "by construction" from the shared acceptance authority alone is FALSE. + let charged_targets = bounded_cycle_charged_targets_for_window(&touch, proposer); + // `declarable_victims` is the union of those announcements' legal PLAYER sets — EMPTY for + // the untargeted class, where the victims are already in `delta.life`. let declarable_victims: Vec = { - let mut v: Vec = points + let mut v: Vec = charged_targets .iter() - .filter_map(|p| match &p.kind { - DecisionPointKind::Targets { legal_targets, .. } => Some(legal_targets), - _ => None, - }) - .flatten() - .filter_map(|t| match t { - TargetRef::Player(p) => Some(*p), - _ => None, - }) + .flat_map(|(_, victims)| victims.iter().copied()) .collect(); v.sort_unstable(); v.dedup(); @@ -2311,20 +2326,19 @@ fn certified_bounded_cycle_offer<'a>( }; // CR 704.5a: what ONE repetition charges to whichever seat a slot's pin names. The // max-vs-sum reasoning, the gain clamp and the fail-closed direction live on the - // function; `elimination_bounds` then sums the published slots per declarable victim. + // function; `elimination_bounds` then sums the charged slots per declarable victim. // Extracted rather than inlined so the fork has a callable seam. ⚠ THE "`victim_slot` IS // EMPTY ON EVERY TRAJECTORY THAT OFFERS TODAY" NOTE THAT STOOD HERE IS FALSIFIED, and is // replaced rather than softened: the answer-beat sampling site in `apply_action` announces // the entries a FORCED pre-priority window puts on the stack, and a CR 608.2b `Targets` - // declaration is exactly the shape that resolves across one. On the F4 boards `points` now - // carries Torch's `Targets` point, so this value is NOT dropped — it reaches + // declaration is exactly the shape that resolves across one. On the F4 boards the + // announcement carries Torch's target slot, so this value is NOT dropped — it reaches // `elimination_bounds` in production and `r1_the_bounded_offer_fires_on_the_real_f4_dump` // re-derives the published bound with a non-zero declared term. let worst_seat_life_loss: i64 = periodic.delta.worst_seat_life_loss(); - periodic.victim_slot = points + periodic.victim_slot = charged_targets .iter() - .filter(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) - .map(|p| (p.slot.clone(), worst_seat_life_loss)) + .map(|(slot, _)| (slot.clone(), worst_seat_life_loss)) .collect(); // `.cloned()`, not `.copied()`: `(DecisionSlot, i64)` is not `Copy`. let slot_magnitude: std::collections::BTreeMap = @@ -2531,9 +2545,11 @@ fn declares_opponent_player_target(ability: &crate::types::ability::ResolvedAbil /// What ONE accepted stack entry publishes: the slot keys, plus the legal set the /// ANNOUNCEMENT authority itself built for the target slot. pub(crate) struct EntryPinSlots { - /// CR 115.2 target choice — `index: 0`. `None` for shape (B), the may-only entry: - /// announcing it surfaces NO choice at all (`targets.is_empty()` and zero built slots), - /// so there is no CR 601.2c announcement choice for a pin to specify. + /// CR 115.2 target choice — `index: 0`. `None` in TWO shapes, and both are the absence of + /// a CR 601.2c *choice* rather than the absence of a target: shape (B), the may-only entry, + /// announces NO slot at all (`targets.is_empty()` and zero built slots); shape (A′) + /// announces one whose assignment is FORCED (`forced_unique_targeting`), which CR 732.2a + /// does not count as a game choice and which the dispatcher answers itself. pub(crate) target: Option, /// CR 603.5 "may" gate — `index: 1`, `Some` only if `ability.optional` — the mint /// additionally refuses on recipient, stored auto-choice and prompt-cardinality grounds @@ -2546,13 +2562,111 @@ pub(crate) struct EntryPinSlots { /// exactly one mandatory choice. Deriving it a second time from the head effect's /// filter would let the two disagree about WHICH choice is being published, which is /// the same class of divergence the cardinality conjunct closes about HOW MANY. - /// Empty for shape (B), which publishes no target slot to carry a legal set for. + /// Empty for shapes (B) and (A′), neither of which publishes a target slot to carry a + /// legal set for. + pub(crate) legal_targets: Vec, +} + +/// CR 601.2c (reached for a triggered ability via CR 603.3d): the ONE target an accepted +/// entry ANNOUNCES — the slot key, the legal set the announcement authority itself built, +/// and whether announcing it is a game CHOICE. +/// +/// THE TWO QUESTIONS THIS TYPE KEEPS APART, because conflating them was a measured +/// fail-OPEN. PUBLICATION answers CR 732.2a — *is this a game choice the player makes?* — +/// and shapes the schema. CHARGING answers CR 704.5a — *which seat is charged, and how +/// much?* — and shapes the bound. A forced announcement is not a choice, so it is withheld +/// from the schema; its victim still loses the life, so it is still charged. Deriving the +/// bound from the PUBLISHED point set made the CR 732.2a withhold silently drop the forced +/// victim into `elimination_bounds`' cheaper arm and RAISE `max_iterations`. +pub(crate) struct AnnouncedTarget { + /// CR 115.2 target choice — `index: 0`, the same key a published point carries, so a + /// charge and a publication of the same announcement can never land on different slots. + pub(crate) slot: crate::analysis::decision_template::DecisionSlot, + /// The legal set of the ONE announcement slot, taken VERBATIM from + /// `ability_utils::build_target_slots` — the same authority that decided there is + /// exactly one mandatory choice, and the same one `forced_unique_targeting` rebuilds + /// slots with. Never a second derivation from the head effect's filter. pub(crate) legal_targets: Vec, + pub(crate) announcement: TargetAnnouncement, } -/// CR 732.2a: the per-iteration choice slots ONE stack entry publishes for `proposer`, or +/// CR 732.2a: whether announcing an [`AnnouncedTarget`] is a *game choice the PROPOSER makes +/// at a prompt of their own*. +/// +/// Not a `bool`: the two arms name two different CR readings, and the whole defect this +/// type exists to prevent came from a caller re-deriving "was it a choice?" from a +/// downstream artifact instead of reading the answer. +/// +/// ⚠ THE QUESTION IS THREE-AXIS, and this type answered ONE of them while carrying the name of +/// all three. CR 601.2c routes an announcement by WHO announces (`target_chooser`) as well as +/// by HOW MANY assignments are legal (`forced_unique_targeting`), and CR 115.1 is overridden +/// outright when the game selects at random (`TargetSelectionMode`). Only the middle axis was +/// read. The publication BEHAVIOUR that gap produced predates the commit this type ships in — +/// what was new is a named authority claiming to answer "is announcing this a game choice the +/// proposer makes" while covering one of its three members. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TargetAnnouncement { + /// The PROPOSER announces this target, at a prompt that is really raised for them: + /// no other seat announces it (CR 601.2c `target_chooser`), the game does not select it + /// (CR 115.1 vs `TargetSelectionMode`), and `forced_unique_targeting` is false — so + /// `triggers::prepare_trigger_targets` routes it to `NeedsPlayerChoice`, a + /// `WaitingFor::TriggerTargetSelection` comes up under the proposer's own seat, and + /// `record_trigger_target_answer` can journal an answer AT THIS SLOT AND UNDER THIS KEY. + /// + /// ⚠ NOT "two or more legal assignments". That claim stood here and is FALSE: the conjunct + /// the code applies is the NEGATION of `forced_unique_targeting`, i.e. + /// `auto_select_targets_for_ability != Ok(Some(_))`. Two-or-more is its principal member, + /// but `Err` ("No legal target combinations available") negates it too, so this variant + /// carries no assignment COUNT — only the "nobody else and nothing else announced it, and + /// the dispatcher did not settle it" reading above. + Chosen, + /// The proposer makes no such announcement, so CR 732.2a publishes no decision point for + /// it. THREE DISJOINT ROUTES, each named at the code that takes it: + /// + /// * `forced_unique_targeting` — exactly one legal assignment, so the announcement is + /// determined rather than chosen and `triggers::prepare_trigger_targets` routes it to + /// `AutoAssigned` without asking anyone. + /// * CR 601.2c `target_chooser` ("of an opponent's choice") — a prompt IS raised, but for + /// ANOTHER seat. `ability_utils::auto_select_targets_for_ability` early-returns + /// `Ok(None)` whenever any slot carries a chooser, so `forced_unique_targeting` is false + /// even with ONE legal assignment. The writer journals under the ANNOUNCING seat while + /// the consumer reads `loop_answer(slot, proposer)` — an unanswerable published point, + /// which is the exact undeclarable-offer condition the bounded offer exists to remove. + /// * `TargetSelectionMode` other than `Chosen` — the game selects (CR 115.1 is overridden; + /// `triggers::prepare_trigger_targets` calls `random_select_targets_for_ability` and + /// routes to `AutoAssigned`), so no prompt is ever raised and a pin would be a + /// designation the RNG contradicts at drive time. + /// + /// CHARGED ALL THE SAME: CR 704.5a asks which seat loses how much life, and nobody having + /// made the choice changes neither who pays nor how much. Only the CR 732.2a publication + /// reader acts on this value. + NotProposerChoice, +} + +/// CR 601.2c + CR 603.5: everything ONE accepted stack entry ANNOUNCES for `proposer`, +/// BEFORE the CR 732.2a question of how much of it is a published game choice. +/// +/// THE SINGLE ACCEPTANCE AUTHORITY. Both [`entry_publishes_pin_slots`] (publication) and +/// [`bounded_cycle_charged_targets_for_window`] (CR 704.5a charging) are thin readers of +/// this one function, so the two can never disagree about WHICH entries are in the cycle — +/// only about which of their announcements is a published choice. Two independent +/// acceptance chains that could disagree is exactly the shape gate (3)'s single-authority +/// rule exists to forbid. +struct EntryAnnouncement { + target: Option, + may: Option, +} + +/// CR 732.2a: the per-iteration choice slots ONE stack entry PUBLISHES for `proposer`, or /// `None` when it publishes none. /// +/// A THIN READER of [`entry_announces`], which owns every acceptance conjunct documented +/// below; this function contributes exactly one thing on top of it — the CR 732.2a +/// publication decision (a `Forced` announcement is not a game choice, so no point is +/// published for it). The CR 704.5a charging reader +/// ([`bounded_cycle_charged_targets_for_window`]) reads the SAME announcement, so the two +/// cannot disagree about which entries are in the cycle. +/// /// SINGLE AUTHORITY, and that is the whole point of its existence: the MINT /// ([`bounded_cycle_pin_slots_for_window`]) maps it over the certified period's announced /// pairs, of which `state.stack` is the zero-window degenerate case, and the RELIEF @@ -2573,6 +2687,12 @@ pub(crate) struct EntryPinSlots { /// `targets.is_empty()` and zero built slots); and its source object still exists, so the /// slot can re-bind (CR 400.7 incarnation, fail-closed on absence). /// +/// Shape (A) carries one further conjunct that is a property of the BOARD rather than of the +/// ability: the announcement must actually be a CHOICE. A slot with exactly one legal +/// assignment is FORCED (shape (A′)) — `triggers::prepare_trigger_targets` announces it +/// without asking anyone — so it publishes its CR 603.5 gate alone, or nothing. See the +/// `forced_unique_targeting` call in the body for why publishing it is the undeclarable case. +/// /// SCOPE OF THE ANSWER: because the relief is a `continue` at gate (3), the relief /// predicate must be no coarser than EVERY fact `stack_entry_has_no_ordering_input` /// rejects on — not just the target one. Correspondence, in that function's own order: @@ -2585,6 +2705,43 @@ pub(crate) fn entry_publishes_pin_slots( entry: &StackEntry, proposer: PlayerId, ) -> Option { + let announced = entry_announces(state, entry, proposer)?; + // CR 732.2a: a shortcut describes "a sequence of game choices", so ONLY a `Chosen` + // announcement earns a decision point. `NotProposerChoice` is withheld — see + // [`TargetAnnouncement::NotProposerChoice`] and the shape (A′) block in + // [`entry_announces`]. + let published = announced + .target + .filter(|target| target.announcement == TargetAnnouncement::Chosen); + // An entry that publishes NOTHING publishes no slot set at all. This is the fail-closed + // reading shapes (B) and (A′) each carried inline as a `Some(may?)`, stated ONCE here + // instead of once per shape; the observable result is identical. + if published.is_none() && announced.may.is_none() { + return None; + } + let (target, legal_targets) = match published { + Some(target) => (Some(target.slot), target.legal_targets), + None => (None, vec![]), + }; + Some(EntryPinSlots { + target, + may: announced.may, + legal_targets, + }) +} + +/// CR 601.2c + CR 603.5 (reached for a triggered ability via CR 603.3d): what ONE stack +/// entry ANNOUNCES for `proposer`, or `None` when the entry is not one this cycle accepts. +/// +/// Every acceptance conjunct documented on [`entry_publishes_pin_slots`] lives here. What +/// does NOT live here is the CR 732.2a publication decision: this function reports whether +/// the announcement is `Chosen` or `Forced` and lets its two readers apply that fact to the +/// question each is answering — the schema (publication) or the bound (CR 704.5a charging). +fn entry_announces( + state: &GameState, + entry: &StackEntry, + proposer: PlayerId, +) -> Option { use crate::analysis::decision_template::DecisionSlot; if entry.controller != proposer { return None; @@ -2723,10 +2880,7 @@ pub(crate) fn entry_publishes_pin_slots( .as_ref() .is_none_or(|key| state.may_trigger_auto_choice(key).is_none()) }) - .map(|_| DecisionSlot { - source: source.clone(), - index: 1, - }); + .map(|_| DecisionSlot::may(source.clone())); let mut slots = super::ability_utils::build_target_slots(state, ability).ok()?; // SHAPE (B) — may-only. The announcement authority surfaced NO choice, so there is no // CR 601.2c target for a pin to specify and the entry publishes its CR 603.5 gate @@ -2734,16 +2888,14 @@ pub(crate) fn entry_publishes_pin_slots( // nothing" rather than "declared something the builder declined"; `optional` is // inherited from the `may` expression, which is `None` without it. A `may` the three // conjunct groups above suppressed leaves shape (B) with NO slot at all, so the whole - // entry publishes `None` — the fail-closed direction. + // entry publishes `None` — the fail-closed direction, now applied by the publication + // reader rather than restated here. Shape (B) also charges NOTHING under CR 704.5a: + // there is no announced target, so there is no seat a declaration could aim at. if slots.is_empty() { if !ability.targets.is_empty() { return None; } - return Some(EntryPinSlots { - target: None, - may: Some(may?), - legal_targets: vec![], - }); + return Some(EntryAnnouncement { target: None, may }); } if slots.len() != 1 { return None; @@ -2766,12 +2918,97 @@ pub(crate) fn entry_publishes_pin_slots( if !declares_opponent_player_target(ability) { return None; } - // Shape (A) — targeted. Index 1 is kept for the may slot in BOTH shapes, so slot - // identity is stable across them. - Some(EntryPinSlots { - target: Some(DecisionSlot { source, index: 0 }), + // SHAPE (A′) — NOT THE PROPOSER'S CHOICE, so there is no CHOICE OF THEIRS to publish. + // CR 732.2a describes a shortcut as "a sequence of game choices, for all players": a + // decision point stands for a game choice, and the proposer makes none of these three. + // CR 603.3d routes a trigger's announcement through CR 601.2c–d, and CR 601.2c has the + // player "announce their choice of an appropriate object or player for each target". + // + // WITHHOLD, never journal-an-auto-selection, and the difference is observable rather + // than stylistic: `triggers::prepare_trigger_targets` sends this exact predicate's + // `Ok(Some(..))` to `PreparedTriggerTargets::AutoAssigned`, so no + // `WaitingFor::TriggerTargetSelection` is ever raised, so `record_trigger_target_answer` + // — whose only two call sites are that prompt's reducer arms — never runs. A point + // published here would demand a `predictability_gate` answer no writer can produce, and + // one unanswerable point makes the WHOLE offer undeclarable (the gate's `required` set is + // every published point). Journalling the auto-selection instead would model a decision + // the player never made. + // + // THE SAME AUTHORITY AS THE RELIEF, exported rather than re-derived: gate (3)'s + // `stack_entry_has_no_ordering_input` asks `forced_unique_targeting` about this same + // fact, so withholding the point loses no relief — the entry passes gate (3) on the + // ordering-input arm instead of the pin arm. Evaluated on `state`, which for a window + // mint IS the pair's own carrying frame (`bounded_cycle_pin_slots_for_window` passes + // `frame`), the same board `build_target_slots` above enumerated the legal set from; that + // function's doc records why the live board would be fail-open here. + // + // Consistent with the sibling refusal one level up: a `ControllerRef::You` head is + // already refused as "a single forced seat, not a per-opponent choice". Forced-unique + // targeting is that same condition measured on the legal SET rather than on the filter. + // The `may` survives — a CR 603.5 take/decline is a real choice on the same source — and + // an entry with neither publishes nothing at all, exactly as shape (B) does. + // + // ⚠ `forced_unique_targeting` ANSWERS THE ASSIGNMENT-COUNT AXIS ONLY, and CR 601.2c has + // two more that decide the same question — WHO announces, and whether anybody does. The + // two cheap conjuncts run FIRST because each independently makes the count irrelevant: + // + // * CR 601.2c `target_chooser` ("of an opponent's choice", e.g. Volcanic Offering). The + // prompt is raised for the CHOOSER, and `record_trigger_target_answer` journals under + // the seat that answered it, while every consumer of a published point reads + // `loop_answer(slot, proposer)`. So the point is unanswerable at the proposer's key and + // one unanswerable point makes the WHOLE offer undeclarable — the same failure the + // forced arm above avoids. Note the count axis CANNOT see this: + // `auto_select_targets_for_ability` early-returns `Ok(None)` when ANY slot carries a + // chooser (`ability_utils.rs`, whose own comment names the `TargetSelectionMode::Random` + // guard as its mirror), so `forced_unique_targeting` is false here even when exactly ONE + // legal assignment exists. `slots.len() == 1` is already enforced above, so this reads + // the single announcement slot. The `!= proposer` half is not redundant with + // `collect_target_slots`' own `player != ability.controller` filter: it keys on the seat + // the CONSUMER reads, which is `entry.controller`, and those two coincide in production + // but are separate fields. Same shape as the sibling `may` mint's + // `.filter(|gate| gate.prompt_player == proposer)` — direction: strictly FEWER offers. + // * `TargetSelectionMode` other than `Chosen` — CR 115.1's "require their controller to + // choose" is overridden and the GAME selects. `triggers::prepare_trigger_targets` sends + // this to `random_select_targets_for_ability` and then to `AutoAssigned`, so no prompt + // is raised at all; worse than merely unanswerable, a pin here also RELIEVES gate (3), + // so the offer would be minted because of a designation the RNG contradicts at drive + // time. Written as `!is_chosen()` rather than `is_random()` deliberately: a future + // variant is withheld by DEFAULT, which is this function's documented fail-closed + // contract that the schema can only ever UNDER-publish. + // + // SCOPING HONESTY: this publication behaviour PREDATES the commit these types ship in. + // What is new is [`TargetAnnouncement`] claiming authority over "is announcing this a game + // choice the proposer makes" while reading one of the three axes. This is not a repair of + // a defect this commit introduced. + // + // ⚠ WITHHELD FROM THE SCHEMA IS NOT UNCHARGED, and the two used to be the same act. + // CR 704.5a asks which seat loses how much life, and a forced victim loses it exactly as + // a chosen one does — nobody having made the choice changes who pays, not how much. + // Reporting the shape here rather than dropping the announcement is what lets + // [`bounded_cycle_charged_targets_for_window`] charge it while + // [`entry_publishes_pin_slots`] still withholds it. Before, the shape was destroyed at + // this line and the CR 704.5a bound — derived from the surviving PUBLISHED points — read + // the withhold as "no victim", charging bare `observed_life_loss` instead of + // `observed_life_loss.max(0) + declared_life_magnitude`, so `max_iterations` GREW: the + // offer stated more legal repetitions than CR 732.2a permits. + let announcement = if slot.chooser.is_some_and(|chooser| chooser != proposer) + || !ability.target_selection_mode.is_chosen() + || crate::analysis::resource::forced_unique_targeting(state, ability) + { + TargetAnnouncement::NotProposerChoice + } else { + TargetAnnouncement::Chosen + }; + // Shape (A) / (A′) — targeted. Index 1 is kept for the may slot in BOTH shapes, so slot + // identity is stable across them. Both sub-indices come from `DecisionSlot`'s own + // constructors, which the CR 603.5 and CR 601.2c journal writers also use. + Some(EntryAnnouncement { + target: Some(AnnouncedTarget { + slot: DecisionSlot::target(source), + legal_targets: slot.legal_targets, + announcement, + }), may, - legal_targets: slot.legal_targets, }) } @@ -2916,6 +3153,106 @@ pub(crate) fn bounded_cycle_pin_slots_for_window( points } +/// CR 704.5a: what ONE CERTIFIED PERIOD CHARGES — the announcement slot of every accepted +/// entry, paired with the seats that announcement may name, whether or not CR 732.2a +/// publishes it as a decision point. +/// +/// DELIBERATELY NOT A FILTER OVER [`bounded_cycle_pin_slots_for_window`]'s OUTPUT, and that +/// is the entire reason this exists as its own reader. Publication answers CR 732.2a — "a +/// sequence of game choices, for all players" — so a FORCED announcement publishes nothing. +/// Charging answers CR 704.5a — "if a player has 0 or less life, that player loses the +/// game" — and the victim loses that life whether or not anybody chose it. Deriving the +/// bound from the published set therefore let the CR 732.2a withhold silently drop a forced +/// victim into `ResourceVector::elimination_bounds`' cheaper `observed_life_loss` arm, +/// RAISING `max_iterations`: the offer would state more legal repetitions than CR 732.2a +/// permits, on the very operator whose job is to prove the proposed sequence "may be legally +/// taken based on the current game state". +/// +/// SAME ACCEPTANCE AUTHORITY as the publication mint — both read [`entry_announces`] — so +/// the charged SLOT set is a superset of the published `Targets` slots by construction, never +/// an independently-derived one that could name an entry the schema does not. +/// +/// ⚠ THE SUPERSET IS OVER SLOTS, NOT OVER EACH SLOT'S LEGAL SET, and conflating the two is +/// what the dedup below exists to prevent. The two mints read the same announcements but keep +/// DIFFERENT ONES of a repeated slot: publication skips a `NotProposerChoice` frame entirely, +/// charging does not. So a first-wins charge could retain a narrow frame's legal set for a +/// slot the schema publishes from a WIDER later frame — the schema would offer a pin the bound +/// never charged, and `max_iterations` would GROW. +/// +/// PER SOURCE, NOT PER ENTRY, for the reason [`bounded_cycle_pin_slots`] documents at +/// length: one state-independent designation specifies every instance of that source's +/// announcement, so its slot is charged ONCE however many entries carry it. On a repeat the +/// victim lists are UNIONED rather than first-wins. +/// +/// # Why the union is MONOTONE — it can only tighten the bound, never loosen it +/// +/// The union changes exactly one input to +/// [`crate::analysis::resource::ResourceVector::elimination_bounds`]: +/// `declarable_victims` (its caller's flat union over these victim lists) can only GAIN +/// members. It cannot change `slot_magnitude`, which is keyed by SLOT and whose value is the +/// slot-independent `worst_seat_life_loss` — the union adds no slot. And for the one seat `p` +/// a union adds, that function's per-seat life magnitude moves from `observed_life_loss` to +/// `observed_life_loss.max(0) + S`, where `S = declared_life_magnitude >= 0` by construction +/// (its initializer filters `*m > 0` and sums; the empty sum is `0`). For `observed >= 0` that +/// is `observed + S >= observed`; for `observed < 0` it is `S >= 0 > observed`. So the +/// magnitude never decreases, and `narrow` — `bound.min(headroom.max(0) / magnitude)` over a +/// non-negative numerator, fired only when `magnitude > 0` — is monotone non-increasing in its +/// divisor. Hence the bound can only SHRINK. That is this repo's fail-closed direction. +/// +/// # Reachability of the shape this closes: NARROW, AND NOT CLOSED +/// +/// Stated honestly in both directions, because neither the reviewer nor the orchestrator built +/// the window. Divergent legal sets for ONE slot across a window need the legal PLAYER set to +/// GROW between frames. ELIMINATION — the realistic mechanism, and the one every tracked dump +/// exhibits — narrows it MONOTONICALLY (CR 800.4 + CR 102.1), which puts the widest frame +/// FIRST and lands first-wins fail-CLOSED. The fail-open direction needs a seat's +/// untargetability to END mid-window: a corpus census measured 14 cards granting a player +/// untargetability mid-loop, all self-protective and predominantly "until end of turn", which +/// does not expire mid-turn — so the path additionally needs the granting permanent to LEAVE, +/// or a shorter duration. `a_repeated_slots_victim_lists_are_unioned_not_first_wins` builds +/// exactly that board (CR 702.11c player hexproof whose grantor leaves between frames). It is +/// NOT a claim that a full production trajectory reaches it, and it is NOT "unreachable". +pub(crate) fn bounded_cycle_charged_targets_for_window( + touch: &crate::analysis::resource::PeriodTouch<'_>, + proposer: PlayerId, +) -> Vec<( + crate::analysis::decision_template::DecisionSlot, + Vec, +)> { + use crate::analysis::decision_template::DecisionSlot; + let mut charged: Vec<(DecisionSlot, Vec)> = Vec::new(); + for (frame, entry) in &touch.announced { + let Some(target) = entry_announces(frame, entry, proposer).and_then(|a| a.target) else { + continue; + }; + // CR 115.2: an object target is not a seat any CR 704 loss threshold applies to, so + // only players are collected — the same projection the bound always applied to the + // published set, moved to the authority that owns the legal set. + let victims: Vec = target + .legal_targets + .iter() + .filter_map(|t| match t { + TargetRef::Player(p) => Some(*p), + _ => None, + }) + .collect(); + // UNION, NOT FIRST-WINS. `position` (not `iter_mut().find`) so the immutable probe's + // borrow ends before the `None` arm pushes. + match charged.iter().position(|(slot, _)| *slot == target.slot) { + Some(i) => { + let seats = &mut charged[i].1; + for victim in victims { + if !seats.contains(&victim) { + seats.push(victim); + } + } + } + None => charged.push((target.slot, victims)), + } + } + charged +} + /// CR 732.2a: assemble a loop-shortcut offer's READ-side schema from its already-reified /// decision `points`, its proposed repeat mode, and its CR 704 count bound. /// @@ -4110,6 +4447,93 @@ pub(crate) fn object_decision_source( }) } +/// CR 608.2b + CR 601.2c (reached for a triggered ability via CR 603.3d) + CR 732.2a: +/// journal ONE seat's announced target choice for the current loop-detection window. THE +/// SINGLE WRITE AUTHORITY for the target axis — both `WaitingFor::TriggerTargetSelection` +/// reducer arms route through here, never inline, so the two cannot drift. +/// +/// FAIL-CLOSED ON A DEAD IDENTITY, and this DIVERGES DELIBERATELY from the proliferate +/// `record_loop_pin` site below, which `filter_map`s an unresolvable object away. There a +/// short pin vector still drives; here it would be journalled as a UNIFORM answer and then +/// fail `validate_pins` as an illegal pin value at declare time — a WRONG PIN rather than +/// no offer. `collect::>>()` makes any unresolvable member abandon the whole +/// write. +/// +/// FAIL-CLOSED ON A MULTI-SLOT ANNOUNCEMENT, and the key is why. `DecisionSlot::target` +/// hard-codes `index: 0` (its own doc: the sub-index disambiguates the two choices of ONE +/// ability instance — CR 601.2c target vs. CR 603.5 may — and nothing finer), so every slot of +/// a multi-slot announcement lands on ONE key. `LoopAnswerValue::Targets`' contract is "the +/// announced targets for one slot, in announcement order", so what gets stored is wrong in two +/// distinguishable ways: two slots taking DISTINCT targets latch `Conflicted` (fail-closed, +/// harmless), while two slots taking the SAME target — which CR 601.2c expressly permits, "if +/// the spell uses the word 'target' in multiple places, the same object or player can be chosen +/// once for each instance" — store a `Uniform` one-pin vector that LOOKS like a valid answer to +/// a two-choice announcement. Refusing the whole write is the only reading that cannot hand a +/// widened publisher a wrong pin. Deriving a real per-slot sub-index is the right long-term +/// answer and is deliberately NOT attempted here: `DecisionSlot`'s index namespace is shared +/// with the publisher and with `record_loop_pin`'s own numbering, so widening it is a design +/// change, not a guard. +/// +/// The slot count is read from the PROMPT IN HAND rather than passed by the caller. Both reducer +/// arms run BEFORE the handler replaces `waiting_for` (that is why the seat and source are only +/// readable there), so `state.waiting_for` here IS the `TriggerTargetSelection` the announcement +/// answers — the same value the arm matched on, since `apply_action`'s reducer matches a CLONE +/// and nothing writes the field in between. Reading it makes the guard un-driftable by +/// construction: a third arm cannot pass a stale or invented count, and a caller holding no +/// prompt at all has no announcement to journal and is refused. (A `debug_assert!` on the count +/// is deliberately NOT used: a multi-slot trigger announcement is legal and reachable in +/// production — `triggers.rs` measures a combat-damage trigger surfacing two target slots — so +/// asserting would panic a debug build on a correct game.) +/// +/// Gating is inherited, not restated: `record_loop_answer` carries the +/// `samples() && !in_simulation_probe()` gate, so this adds no second gate. +fn record_trigger_target_answer( + state: &mut GameState, + source_id: Option, + player: PlayerId, + targets: &[crate::types::ability::TargetRef], +) { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + }; + use crate::types::ability::TargetRef; + let announced_slots = match &state.waiting_for { + WaitingFor::TriggerTargetSelection { target_slots, .. } => target_slots.len(), + // No announcement in hand ⇒ nothing to journal. + _ => return, + }; + if announced_slots > 1 { + return; + } + let Some(source) = source_id.and_then(|id| object_decision_source(state, id)) else { + return; + }; + let Some(pins) = targets + .iter() + .map(|t| match t { + // CR 400.7: bind to the CURRENT incarnation, so a re-entered permanent stops + // matching instead of being falsely replayed. + TargetRef::Object(id) => object_decision_source(state, *id).map(TargetPin::ByIdentity), + // CR 732.2a: a seat is state-independent by construction — it can never denote + // "the newest copy" — so no iteration can turn the pin into a conditional + // action. + TargetRef::Player(pl) => Some(TargetPin::Player(*pl)), + }) + .collect::>>() + else { + return; + }; + if pins.is_empty() { + // A declined / empty announcement is not an answer a pin can specify. + return; + } + state.record_loop_answer( + DecisionSlot::target(source), + player, + LoopAnswer::Uniform(LoopAnswerValue::Targets(pins)), + ); +} + /// FIX-1 (CR 608.2b): the concrete targets of the recorded `Targets` pin whose slot source /// re-binds LIVE to `source_id` this iteration (the beat's cost / trigger source, e.g. the Relic /// cost source for a tap-cost pin or the Kilo trigger source for a proliferate pin). Resolving the @@ -8672,16 +9096,20 @@ fn apply_action( // carries the `samples() && !in_simulation_probe()` gate. let (answering_player, may_source) = (*player, *source_id); if let Some(source) = object_decision_source(state, may_source) { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, MayChoiceOption, + }; + // CR 603.5 rides sub-index 1, via `DecisionSlot::may` — the SAME + // constructor `entry_publishes_pin_slots` publishes the gate with, so the + // sub-index is a literal on neither side of the journal. state.record_loop_answer( - source, + DecisionSlot::may(source), answering_player, - crate::analysis::decision_template::LoopAnswer::Uniform { - take: if accept { - crate::analysis::decision_template::MayChoiceOption::Take - } else { - crate::analysis::decision_template::MayChoiceOption::Decline - }, - }, + LoopAnswer::Uniform(LoopAnswerValue::May(if accept { + MayChoiceOption::Take + } else { + MayChoiceOption::Decline + })), ); } engine_payment_choices::handle_optional_effect_choice(state, accept, &mut events)? @@ -10663,20 +11091,40 @@ fn apply_action( ( WaitingFor::TriggerTargetSelection { player, + source_id, target_slots, target_constraints, .. }, GameAction::SelectTargets { targets }, - ) => engine_stack::handle_trigger_target_selection_select_targets( - state, - *player, - target_slots, - target_constraints, - targets, - &mut events, - )?, - (WaitingFor::TriggerTargetSelection { .. }, GameAction::ChooseTarget { target }) => { + ) => { + // CR 608.2b + CR 732.2a: journal the announcement BEFORE the handler runs — it + // replaces `waiting_for`, so the prompt's own seat and source are only readable + // here, and the key reads the source object's CR 400.7 incarnation, which + // resolution can invalidate. `apply_action_boundary_core` snapshots the whole + // state and restores it on every `Err` return, so a write made before a handler + // that then errors is rolled back with everything else. + record_trigger_target_answer(state, *source_id, *player, targets.as_slice()); + engine_stack::handle_trigger_target_selection_select_targets( + state, + *player, + target_slots, + target_constraints, + targets, + &mut events, + )? + } + ( + WaitingFor::TriggerTargetSelection { + player, source_id, .. + }, + GameAction::ChooseTarget { target }, + ) => { + // Same write authority and same before-the-handler reason as the `SelectTargets` + // arm above. `target: None` yields an empty slice, which the helper's + // `pins.is_empty()` guard refuses — the fail-closed reading of a no-target + // announcement. + record_trigger_target_answer(state, *source_id, *player, target.as_slice()); let waiting_for = state.waiting_for.clone(); engine_stack::handle_trigger_target_selection_choose_target( state, @@ -15087,6 +15535,251 @@ mod stage2_injector_tests { oid } + /// Stand up the `WaitingFor::TriggerTargetSelection` prompt an announcement answers, with + /// `slot_count` announcement slot(s). + /// + /// `record_trigger_target_answer` reads the slot count off the prompt IN HAND — that is + /// production's own instrument, because both reducer arms run before the handler replaces + /// `waiting_for` — so a row that drives the writer has to stand the prompt up the way + /// production does rather than call the writer against a bare board. + fn stand_up_target_prompt( + state: &mut GameState, + player: PlayerId, + source: ObjectId, + slot_count: usize, + ) { + let slot = crate::types::game_state::TargetSelectionSlot { + legal_targets: vec![], + optional: false, + chooser: None, + effect_kind: crate::types::ability::EffectKind::NoOp, + effect_detail: crate::types::game_state::TargetEffectDetail::None, + }; + state.waiting_for = WaitingFor::TriggerTargetSelection { + player, + trigger_controller: None, + trigger_event: None, + trigger_events: vec![], + target_slots: vec![slot; slot_count], + mode_labels: vec![], + target_constraints: vec![], + selection: Default::default(), + source_id: Some(source), + description: None, + }; + } + + /// **Row T5.** CR 608.2b: an announcement one of whose members no longer resolves to a + /// live identity abandons the WHOLE journal write, rather than journalling a short + /// vector. + /// + /// This DIVERGES DELIBERATELY from the proliferate `record_loop_pin` site, which + /// `filter_map`s an unresolvable object away: there a short pin vector still drives, + /// while here a short vector would be journalled as a UNIFORM answer and then fail + /// `validate_pins` at declare time — a WRONG PIN rather than no offer. + /// + /// # Discrimination + /// + /// Replace `record_trigger_target_answer`'s `collect::>>()` with + /// `filter_map(..).collect::>()` (the `record_loop_pin` shape) ⇒ the negative + /// arm's `loop_answers_recorded()` rises to 1 with a one-pin vector and that assertion + /// flips. The mutation reds on the ASSERT, not on a compile error. + /// + /// # Paired positive / reach-guards + /// + /// The negative arm alone is satisfied by ANY no-op writer, so the positive arm runs + /// FIRST on the same state and asserts BOTH pins are journalled under the CR 601.2c + /// slot. The empty-announcement arm is the third case the helper's own guard names. + #[test] + fn c2a_row_t5_an_unresolvable_target_abandons_the_whole_journal_write() { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + }; + use crate::types::ability::TargetRef; + + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + state.loop_detection = LoopDetectionMode::Interactive; + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: the board starts with an EMPTY journal" + ); + let src = place(&mut state, 920, crate::types::zones::Zone::Battlefield); + let live = place(&mut state, 921, crate::types::zones::Zone::Battlefield); + let dead = ObjectId(922); + // The single-slot announcement this row is about — the writer refuses without the + // prompt it answers (see `stand_up_target_prompt`). + stand_up_target_prompt(&mut state, P0, src, 1); + assert!( + !state.objects.contains_key(&dead), + "reach-guard: the unresolvable member must genuinely be absent from `objects`, \ + else this row's negative arm tests nothing" + ); + let slot = DecisionSlot::target( + object_decision_source(&state, src).expect("the source object is live"), + ); + + // ── PAIRED POSITIVE: every member resolves ⇒ BOTH pins are journalled ── + record_trigger_target_answer( + &mut state, + Some(src), + P0, + &[TargetRef::Object(live), TargetRef::Player(P1)], + ); + assert_eq!( + state.loop_answers_recorded(), + 1, + "the fully-resolvable announcement must be journalled — without this the \ + negative arm below is satisfied by any no-op writer" + ); + assert_eq!( + state.loop_answer(&slot, P0), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::ByIdentity( + object_decision_source(&state, live).expect("the live target resolves") + ), + TargetPin::Player(P1), + ]))), + "CR 601.2c: the pins are journalled in ANNOUNCEMENT ORDER, and CR 400.7 binds \ + the object member to its current incarnation" + ); + + // ── THE ROW'S OWN CLAIM: one dead member abandons the whole write ── + let before = state.loop_answers_recorded(); + record_trigger_target_answer( + &mut state, + Some(src), + P1, + &[TargetRef::Object(dead), TargetRef::Player(P1)], + ); + assert_eq!( + state.loop_answers_recorded(), + before, + "CR 608.2b: an unresolvable member abandons the WHOLE write. Under a \ + `filter_map` this rises by one and journals a one-pin vector, which \ + `validate_pins` would later reject as an illegal pin value — a wrong pin \ + instead of no offer" + ); + + // ── the empty announcement the `ChooseTarget` arm's `target: None` produces ── + record_trigger_target_answer(&mut state, Some(src), P1, &[]); + assert_eq!( + state.loop_answers_recorded(), + before, + "an empty announcement is not an answer a pin can specify, so nothing is \ + journalled" + ); + } + + /// **Row F2.** CR 601.2c (reached for a triggered ability via CR 603.3d) + CR 608.2b: a + /// MULTI-SLOT announcement is refused outright, because `DecisionSlot::target` hard-codes + /// `index: 0` and would collapse every slot of it onto ONE journal key. + /// + /// # The value that makes this a defect rather than a rounding error + /// + /// Two slots taking DISTINCT targets latch `Conflicted` — fail-closed, harmless. Two slots + /// taking the SAME target — which CR 601.2c expressly permits ("if the spell uses the word + /// `target` in multiple places, the same object or player can be chosen once for each + /// instance") — stored `Uniform(Targets([one pin]))`: a TRUNCATED answer that satisfies + /// `LoopAnswerValue::Targets`' own contract ("the announced targets for ONE slot") only by + /// accident, and that a widened publisher would spend as a valid pin. Arm (c) below is that + /// exact shape. + /// + /// # Discrimination — and the axis it is keyed to + /// + /// Arms (a) and (b) differ in EXACTLY ONE fact, the prompt's `target_slots.len()`: the same + /// source, the same seat, the same single announced target. So the row cannot pass by + /// accident on the announcement's own shape. + /// + /// A `targets.len() > 1` guard — the plausible wrong reading, keyed to how many targets were + /// announced rather than how many slots were asked — passes (a) and FAILS (b) and (c), + /// because the `ChooseTarget` walk announces ONE target per beat no matter how many slots + /// the prompt carries. That is why (b)/(c) announce a single target against a two-slot + /// prompt rather than two targets at once. + /// + /// REVERT-PROBE (measured, in the fix report): delete the `announced_slots > 1` early return + /// ⇒ (b) and (c) FLIP TO FAILING, (c) with the truncated one-pin `Uniform` value named + /// above. Delete the `WaitingFor::TriggerTargetSelection` read instead ⇒ that is a compile + /// error, since `announced_slots` has no other source. + /// + /// # Reach-guard + /// + /// Arm (a) runs FIRST and asserts a POSITIVE write, so none of the refusals below is + /// satisfied by a writer that journals nothing at all. + #[test] + fn c2a_row_f2_a_multi_slot_announcement_is_refused_rather_than_collapsed() { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + }; + use crate::types::ability::TargetRef; + + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + state.loop_detection = LoopDetectionMode::Interactive; + let src = place(&mut state, 930, crate::types::zones::Zone::Battlefield); + let slot = DecisionSlot::target( + object_decision_source(&state, src).expect("the source object is live"), + ); + + // ── (a) POSITIVE CONTROL: one announcement slot ⇒ the answer is journalled ── + stand_up_target_prompt(&mut state, P0, src, 1); + record_trigger_target_answer(&mut state, Some(src), P0, &[TargetRef::Player(P1)]); + assert_eq!( + state.loop_answer(&slot, P0), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::Player(P1) + ]))), + "a single-slot announcement is exactly what this journal key describes" + ); + let after_positive = state.loop_answers_recorded(); + assert_eq!( + after_positive, 1, + "reach-guard: exactly one key exists so far" + ); + + // ── (b) THE CLAIM: the SAME announcement under a TWO-slot prompt is refused ── + stand_up_target_prompt(&mut state, P1, src, 2); + record_trigger_target_answer(&mut state, Some(src), P1, &[TargetRef::Player(P2)]); + assert_eq!( + state.loop_answer(&slot, P1), + None, + "CR 601.2c: two announcement slots are two choices, and `DecisionSlot::target`'s \ + `index: 0` can key only one of them — refuse rather than collapse" + ); + assert_eq!( + state.loop_answers_recorded(), + after_positive, + "and no key is created at all: the whole write is abandoned" + ); + + // ── (c) THE DANGEROUS SHAPE: two slots, the SAME target, answered slot by slot ── + // Pre-fix this stored `Uniform(Targets([Player(P2)]))` — a one-pin answer to a + // two-choice announcement, indistinguishable from a legitimate single-slot answer. + for _ in 0..2 { + record_trigger_target_answer(&mut state, Some(src), P2, &[TargetRef::Player(P2)]); + } + assert_eq!( + state.loop_answer(&slot, P2), + None, + "CR 601.2c permits the same target for each instance of `target`, so repeating it \ + must not read as a UNIFORM answer to the whole announcement" + ); + assert_eq!( + state.loop_answers_recorded(), + after_positive, + "(c) creates no key either" + ); + + // ── (d) no prompt in hand ⇒ no announcement to journal ── + state.waiting_for = WaitingFor::Priority { player: P0 }; + record_trigger_target_answer(&mut state, Some(src), P1, &[TargetRef::Player(P2)]); + assert_eq!( + state.loop_answers_recorded(), + after_positive, + "the writer is the `TriggerTargetSelection` reducer's authority; with no such \ + prompt there is no announced choice for it to record" + ); + } + /// CR 114.2 + CR 608.2b: a pinned SLOT whose source is a command-zone emblem must match /// the prompt that emblem raised; a graveyard or exile source must NOT. /// @@ -16519,21 +17212,109 @@ mod stage2_injector_tests { // `begin_pending_trigger_target_selection`. // // TO BE UNAMBIGUOUS FOR THE NEXT READER: the `+1` in `apply_action`'s - // `DecideOptionalEffect` arm is a READER, NOT A SIXTH PRODUCER. It destructures the - // cloned `state.waiting_for` scrutinee to journal the answer and never assigns - // `state.waiting_for`; the producer count in this vec is still five and this branch - // mints no new prompt. Total moves 37 => 38 and the partition 5/7/25 => 5/8/25 for - // the READER half only — adjudicated in this row's doc. - // ⚠ RE-REBASE onto upstream `7127326673`: `:12038 ⇒ :12043`, located by content - // digest, offset from `begin_pending_trigger_target_selection` unchanged at 134. - // ⚠ RE-REBASE onto upstream `635c51ec4` (#7382, pre-entry opponent controller): - // `:12043 ⇒ :12047`, +4 entirely above this producer. MEASURED in the rebased file, - // not computed: the enclosing-fn offset is the control and is STILL 134, which is - // what re-establishes producer identity — the same mint text appears at several - // coordinates in this crate, so the offset discriminates where the text cannot. - // This rebase surfaced as a CONFLICT in this very literal, which is the drift class - // FU-4 (content-hash coordinate anchor) exists to end; logged there, not re-argued here. - "game/engine.rs:12047".to_string(), + // `DecideOptionalEffect` arm is a READER, NOT A SIXTH PRODUCER. It destructures the cloned `state.waiting_for` + // scrutinee to journal the answer and never assigns `state.waiting_for`; the + // producer count in this vec is still five and this branch mints no new prompt. + // + // ⚠ RE-ADJUDICATED BY C2a (the CR 608.2b target axis on the same journal), NOT + // RELAXED. `:11977 ⇒ :12052`, **+75**, and ONLY this entry moved — the three + // `effects/mod.rs` pins and `scoped_library_search.rs:452` are in files C2a does + // not touch and did not move at all, which is the set-preservation evidence. The + // total stays **38** and the partition **5/8/25**: both of those asserts ran and + // fired GREEN on the run that caught this, so no producer or reader was gained. + // The `+75` is fully accounted for by C2a's own hunks ABOVE this line, measured + // with `git diff -U0 70fcd851a -- game/engine.rs`: `-3` (`entry_publishes_pin_slots`'s + // may-slot literal collapsing into `DecisionSlot::may`), `+1` (its target-slot + // comment), `+53` (`record_trigger_target_answer` and its doc), `+6`/`-2` (the + // `DecideOptionalEffect` arm re-expressed over `DecisionSlot::may` + + // `LoopAnswerValue::May`), `+1` (`source_id` bound in the `SelectTargets` arm) and + // `+19` (both `TriggerTargetSelection` arms' journal calls and their comments) — + // summing to exactly `+75`, so predicted `11977 + 75 = 12052` equals the observed + // coordinate. EVERY OTHER HUNK IN THIS FILE IS BELOW THIS PRODUCER — row T5 and + // this comment block, both inside `mod stage2_injector_tests` — which is why the + // shift equals the sum above it exactly. (No whole-file total is quoted here on + // purpose: this comment is itself part of that total, so the number could not be + // stated without falsifying itself.) Identity + // re-established rather than assumed: the line is sha256-identical + // (`8a544e87…5cc7d63` — the SAME digest this doc already recorded above) and is + // still inside `begin_pending_trigger_target_selection` (`:11843 ⇒ :11918`, the + // same `+75`). The diff instrument discriminates: the NEW tree at the OLD + // coordinate `:11977` holds a bare `source_id,` struct-field line, which mints + // nothing. C2a adds NO line matching the needle in a producing position. + // + // ⚠ C2a FIX ROUND (round 2/3, closing an independent review's F1/F2): `:12052 ⇒ :12132`, + // **+80**. RE-ADJUDICATED BY THE ORCHESTRATOR, NOT BY THE IMPLEMENTER — the executor was + // instructed to REPORT the shift and leave the literal alone, precisely so the number could + // not be nudged until the row passed. It complied; this line is the orchestrator's. + // + // Located BY CONTENT FIRST, arithmetic afterwards as a CHECK, per the doctrine at the head of + // this log. The line whose sha256 (WITH trailing newline) is + // `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63` sits at `:12132`; that + // digest matches exactly ONE line under a whole-file scan, so the coordinate is unambiguous. + // It is still inside `begin_pending_trigger_target_selection`, which opens at `:11998` with no + // intervening `fn`. The checks, computed AFTER locating the line and never used as its source: + // `12052 + 80 = 12132` for the producer and `11918 + 80 = 11998` for the function's opening + // line — the SAME `+80`, which is what a set of hunks lying wholly above one producer requires. + // + // The `+80` is accounted for by six hunks above this producer: `+2` (`EntryPinSlots.target` + // doc), `+1` (`legal_targets` doc), `+6` (fn doc), `+37` (the forced-target withhold), `+26` + // (writer doc) and `+8` (the writer's multi-slot guard). The remaining hunks are inside + // `mod stage2_injector_tests` and therefore below it. + // + // SET PRESERVATION: unchanged, and this is the conjunct that makes the move a SHIFT rather + // than a census drift. The other four entries are byte-identical AND unmoved + // (`effects/mod.rs:6252/6329/9522`, `scoped_library_search.rs:452`) — this round touches + // neither file. The total (**38**) and partition (**5/8/25**) asserts both ran FIRST and fired + // GREEN; the panic was on the third assert alone. Withholding a published `Targets` point + // removes a DECISION POINT, not a prompt producer, so no line matching the needle is added or + // removed by this round. + // + // ⚠ C2a FIX ROUND 3 (the cap round, closing the CR 704.5a bound regression the round-2 review + // found): `:12132 ⇒ :12302`, **+170**. Orchestrator's adjudication; the executor reported the + // shift and left the literal alone, as instructed. + // + // PURELY POSITIONAL, and that is measured rather than asserted: every hunk this round adds + // sits above `engine.rs:3133` (the announcement/charging split — `entry_announces`, + // `AnnouncedTarget`/`TargetAnnouncement`/`EntryAnnouncement`, and + // `bounded_cycle_charged_targets_for_window`), and there is NO hunk between there and this + // producer. The other four entries are byte-identical AND unmoved. + // + // Located BY CONTENT FIRST, arithmetic afterwards as a CHECK. The line whose sha256 (WITH + // trailing newline) is `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63` + // sits at `:12425`, and that digest matches exactly ONE line under a whole-file scan. It is + // still inside `begin_pending_trigger_target_selection`, which opens at `:12291` with no + // intervening `fn`. Checks computed AFTER locating it: `12302 + 123 = 12425` for the producer + // and `12168 + 123 = 12291` for the function's opening line — the SAME `+123`. + // + // FOURTH re-derivation of this one coordinate (`:12052 → :12132 → :12302 → :12425`), and the + // reason it keeps moving is that it is a LINE NUMBER in the most-edited function's file. Every + // move has been resolved BY CONTENT FIRST — the digest above has been this producer's identity + // since `a6d1a0e62` and has never itself changed — with arithmetic used only as a check that + // agrees afterwards. A coordinate re-derived four times without the content ever moving is + // evidence the pin is tracking the right line, not evidence the pin is fragile. + // + // SET PRESERVATION: this round adds a withhold CONDITION, not a prompt producer. + // `entry_announces` reports an announcement; it does not assign `state.waiting_for`, so no + // line matching the needle is added or removed (grep-counted 0 on both the `+` and `-` sets). + // Total (38) and partition (5/8/25) both fire GREEN first; the panic was on the third assert + // alone, which is what makes this a coordinate shift rather than a population change. + // + // ⚠ REBASE #3 (onto upstream/main): `:12487 ⇒ :12486`. The only shift is the + // **-1** upstream #7303 round 3 introduced ABOVE this producer (the + // `ReturnAsAuraTarget` resume arm's two raw attach calls collapsing into one call + // to the entering-Aura attachment authority, `-8 +7`). It was already folded into + // this entry at the C1 replay earlier in this same rebase; this commit's replay + // re-states it on top of the accumulated record rather than replacing that record, + // because the record is the evidence and the shift is one line of it. + // + // FIFTH re-derivation, same method: located BY CONTENT FIRST. The line whose + // sha256 is `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63` + // still matches exactly ONE line under a whole-file scan, and it is still inside + // `begin_pending_trigger_target_selection` with no intervening `fn`. Arithmetic + // afterwards as a CHECK only: `12487 - 1`. + // ⚠ REBASE #3: `:12486 ⇒ :12491`, located by content digest, offset from + // `begin_pending_trigger_target_selection` unchanged at 134. + "game/engine.rs:12491".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index fe42e8b46e..fe5fe171d9 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -14529,11 +14529,28 @@ declare_game_state! { /// dedup on semantically-identical positions is unaffected. #[serde(skip, default)] pub loop_detect_ring: std::collections::VecDeque>, - /// CR 603.5 + CR 732.2a: the answers given to published "may" sources during the - /// window `loop_detect_ring` is sampling, so the CR 732.2a declaration can pin the - /// choice each iteration actually made instead of guessing one. + /// CR 603.5 + CR 608.2b + CR 732.2a: the answers given to published decision SLOTS + /// during the window `loop_detect_ring` is sampling, so the CR 732.2a declaration can + /// pin the choice each iteration actually made instead of guessing one. Both published + /// axes ride ONE journal: the CR 603.5 "may" gate and the CR 601.2c target + /// announcement, distinguished by the value's own kind ([`LoopAnswerValue`]) and by the + /// slot's sub-index — a parallel target journal would double the eight ring-clear + /// sites and widen their census for no capability this field lacks. /// - /// KEYED BY THE PAIR `(source, seat)`, not by the source alone. CR 603.5 routes a + /// KEYED BY THE PAIR `(slot, seat)`, not by the source alone. The SUB-INDEX half is + /// what keeps the two slots one source can publish apart: `entry_publishes_pin_slots` + /// binds `source` ONCE and builds both `DecisionSlot::target(source)` (CR 601.2c) and + /// `DecisionSlot::may(source)` (CR 603.5) from it, and the shipped unit test + /// `bounded_cycle_pin_slots_publishes_the_may_gate_of_an_optional_trigger` asserts an + /// optional targeted trigger publishes both. Under a source-only key those two writes + /// would land in ONE entry with different values and latch `Conflicted`. NON-CLAIM, + /// measured: no board in this lane exercises that collision — every published point on + /// the three tracked F4 dumps carries a distinct source — so the sub-index is adopted + /// because it aligns the journal's identity with the engine's own published one + /// (`DecisionPoint.slot`, which is what the consumer looks up), never because a + /// measured board needs it. + /// + /// The SEAT half: CR 603.5 routes a /// "may" to whichever seat the effect names, and `game::effects` really does prompt /// several seats for ONE source inside one window (the scoped-search acceptance /// cascade). A seat can therefore only ever answer for itself: no seat's answer can @@ -14556,8 +14573,9 @@ declare_game_state! { /// semantically-identical positions), and cleared at every one of the ring's clear /// sites on the same receiver. `#[serde(skip)]` is also load-bearing rather than /// merely tidy: a `BTreeMap` with a tuple key has no JSON object form and - /// [`LoopAnswer`] deliberately derives no `Serialize`, so a future attempt to - /// persist this fails at compile time instead of silently emitting a stale window. + /// [`LoopAnswer`] AND [`LoopAnswerValue`] both deliberately derive no `Serialize`, so a + /// future attempt to persist this fails at compile time instead of silently emitting a + /// stale window. /// /// `Option>` for `game_state_size.rs`'s stated reason — "box it if it is a /// large rarely-populated one" — costing 8 B inline like the `life_safety_probe` @@ -14568,7 +14586,7 @@ declare_game_state! { pub(crate) loop_answer_journal: Option< Box< std::collections::BTreeMap< - (crate::analysis::decision_template::DecisionSource, PlayerId), + (crate::analysis::decision_template::DecisionSlot, PlayerId), crate::analysis::decision_template::LoopAnswer, >, >, @@ -21176,18 +21194,47 @@ impl GameState { } } - /// CR 603.5: record ONE seat's answer to ONE "may" source for the current - /// loop-detection window. A second, DIFFERENT answer from THE SAME SEAT for THE SAME - /// SOURCE latches [`LoopAnswer::Conflicted`] (see that type — an engine-capability - /// refusal, not a CR mandate). A different seat occupies a DIFFERENT KEY and can - /// neither conflict with, nor be read in place of, this seat's answer. + /// CR 603.5 + CR 608.2b: record ONE seat's answer to ONE published decision SLOT for + /// the current loop-detection window. A second, DIFFERENT answer from THE SAME SEAT for + /// THE SAME SLOT latches [`LoopAnswer::Conflicted`] (see that type — an + /// engine-capability refusal, not a CR mandate). A different seat, or the same source's + /// OTHER slot, occupies a DIFFERENT KEY and can neither conflict with, nor be read in + /// place of, this entry. + /// + /// The latch arm is axis-agnostic by construction: it compares whole + /// [`LoopAnswer`] values, so widening the value to carry CR 601.2c target vectors added + /// no second conflict rule. Equality for `Targets` is derived `Vec` + /// equality — order-sensitive, which is STRICTER than set equality and therefore + /// fail-closed (strictly fewer offers, never a wrong pin). Order-sensitivity cannot bite + /// on the BOUNDED-CYCLE schema, whose producer + /// (`game::engine::bounded_cycle_pin_slots_for_window`) hard-codes `min_targets: 1, + /// max_targets: 1, ordered: false` — a one-pin answer has no order to disagree about. + /// That is the SCOPE of the claim, not a whole-value-space one: a published `Targets` + /// point has a SECOND production producer, `game::engine::pinned_decisions_to_points`, + /// which emits `ordered: true` with `min/max = targets.len()` from a carried pin list; + /// and the writer (`game::engine::record_trigger_target_answer`) can journal a MULTI-PIN + /// vector for one slot. That stays FAIL-CLOSED on the stricter-than-set-equality reading + /// above rather than unreachable. + /// + /// ⚠ THE MECHANISM NAMED HERE WAS WRONG, and is corrected rather than dropped because the + /// CONCLUSION above survives it. The claim used to be that the writer "journals whatever + /// the announcement carried, which for one `multi_target` slot is several pins" — true on + /// ONE of its two reducer arms only. `GameAction::SelectTargets` hands the whole announced + /// vector over at once, so a multi-pin `Targets` value really is written in a single call. + /// `GameAction::ChooseTarget` does not: `engine_stack`'s + /// `handle_trigger_target_selection_choose_target` re-raises the SAME + /// `WaitingFor::TriggerTargetSelection` with the same one-element `target_slots` and only + /// `selection` advanced, and the writer reads its slot count off the prompt in hand — so a + /// two-pick single slot arrives as `Targets([A])` and then `Targets([B])` on ONE key, and + /// the latch above answers `Conflicted` rather than storing an ordered pair. Both routes + /// are fail-closed; only the second one is a conflict rather than an order comparison. /// /// Gated exactly like `game::engine::record_loop_pin` /// (`samples() && !in_simulation_probe()`), so the #4603-Off build never records and /// the detection/materialize drive replays without re-recording. pub(crate) fn record_loop_answer( &mut self, - source: crate::analysis::decision_template::DecisionSource, + slot: crate::analysis::decision_template::DecisionSlot, player: PlayerId, answer: crate::analysis::decision_template::LoopAnswer, ) { @@ -21199,7 +21246,7 @@ impl GameState { match self .loop_answer_journal .get_or_insert_default() - .entry((source, player)) + .entry((slot, player)) { Entry::Vacant(v) => { v.insert(answer); @@ -21212,25 +21259,26 @@ impl GameState { } } - /// The observed answer for one published may-source AS ANSWERED BY `player`. `None` = - /// that seat never answered this source in this window ⇒ a declaration must refuse, - /// exactly as [`LoopAnswer::Conflicted`] does. + /// The observed answer for one published SLOT AS ANSWERED BY `player`. `None` = that + /// seat never answered this slot in this window ⇒ a declaration must refuse, exactly as + /// [`LoopAnswer::Conflicted`] does. pub fn loop_answer( &self, - source: &crate::analysis::decision_template::DecisionSource, + slot: &crate::analysis::decision_template::DecisionSlot, player: PlayerId, ) -> Option { // `BTreeMap` keys by the owned tuple and no `Borrow` shape spans a tuple, so the // key is built. This is the seam's own idiom — `entry_publishes_pin_slots` builds - // its slot with `source: source.clone()`. One clone per published may point at - // declaration-build time, never per iteration. + // its slot with `source: source.clone()`. One clone per published point at + // declaration-build time, never per iteration. `.cloned()` rather than `.copied()` + // because `LoopAnswer` gave up `Copy` when its value grew a `Vec`. self.loop_answer_journal .as_ref()? - .get(&(source.clone(), player)) - .copied() + .get(&(slot.clone(), player)) + .cloned() } - /// How many distinct (source, seat) pairs this window has answered. `None` and an + /// How many distinct (slot, seat) pairs this window has answered. `None` and an /// empty map are indistinguishable here BY DESIGN — no caller may branch on the /// `Option`. pub fn loop_answers_recorded(&self) -> usize { @@ -30135,4 +30183,253 @@ mod tests { "an observed life move on a full-length snapshot still clears the ring" ); } + + // ───────────────────────────────────────────────────────────────────────────────── + // C2a — the loop-answer journal's STORAGE CONTRACT on the widened `(slot, seat)` key. + // + // ⚠ TIER, stated rather than implied: these three rows are IN-CRATE STORAGE-CONTRACT + // rows, NOT wire rows. They assert what the map does with the keys and values it is + // handed; that the PRODUCTION reducer arms hand it the right ones is asserted at the + // wire tier by `tests/integration/fantastic_four_bounded_loop.rs` (the CR 603.5 arm + // and the CR 608.2b `ChooseTarget` arm) and `tests/integration/loop_shortcut.rs` (the + // CR 608.2b `SelectTargets` arm). + // ───────────────────────────────────────────────────────────────────────────────── + + /// A `GameState` whose detector SAMPLES, which is `record_loop_answer`'s own gate. A + /// state built without this records nothing and every row below would pass vacuously + /// on an empty map — which is why each row asserts a CARDINALITY first. + fn journal_state() -> GameState { + let mut state = GameState::new_two_player(42); + state.loop_detection = LoopDetectionMode::Interactive; + state + } + + fn journal_source(id: u64) -> crate::analysis::decision_template::DecisionSource { + YieldTarget::ThisObject { + source_id: ObjectId(id), + incarnation: Some(1), + trigger_description: None, + } + } + + /// **Row T2 — the SUB-INDEX half of the key.** CR 601.2c and CR 603.5 are two choices + /// of ONE ability instance, and `entry_publishes_pin_slots` publishes both from a + /// single bound `source`. They must occupy TWO journal entries. + /// + /// # Discrimination + /// + /// Collapse the key to `(slot.source, player)` in `record_loop_answer`'s `entry(..)` + /// and `loop_answer`'s `get(..)` (keep both signatures) ⇒ the two writes land in ONE + /// entry, the second differs from the first, and the `Entry::Occupied` arm latches + /// `Conflicted`: the cardinality assertion reads 1 and both value lookups read + /// `Conflicted`. + /// + /// # Reach-guard + /// + /// The cardinality is asserted FIRST and is value-independent, so an unsampled + /// detector — an empty map — fails this row before any content assertion can pass on + /// nothing. + /// + /// # NON-CLAIM, measured and shipped in the row rather than in a report + /// + /// NO REAL BOARD IN THIS LANE EXERCISES THIS COLLISION: every published point on all + /// three tracked F4 dumps carries a DISTINCT source, so collapsing the sub-index + /// leaves those declarations intact. The card class is real (Scryfall: ≥8 cards + /// phrased "you may have target opponent …", e.g. Disciple of the Vault) and the + /// two-slots-from-one-source publisher shape is asserted in-tree by + /// `game::engine`'s `bounded_cycle_pin_slots_publishes_the_may_gate_of_an_optional_trigger`, + /// but the end-to-end board is UNMEASURED. This row is the storage invariant only. + #[test] + fn c2a_row_t2_one_source_two_sub_indices_occupy_two_journal_entries() { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, MayChoiceOption, TargetPin, + }; + + let mut state = journal_state(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: a fresh board starts with an EMPTY journal, so every entry below \ + is one this row wrote" + ); + let source = journal_source(910); + let seat = PlayerId(0); + + state.record_loop_answer( + DecisionSlot::may(source.clone()), + seat, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + state.record_loop_answer( + DecisionSlot::target(source.clone()), + seat, + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player(PlayerId( + 1, + ))])), + ); + + assert_eq!( + state.loop_answers_recorded(), + 2, + "CR 601.2c and CR 603.5 are two choices of ONE ability instance: the sub-index \ + must keep them in two entries. A source-only key holds 1" + ); + assert_eq!( + state.loop_answer(&DecisionSlot::may(source.clone()), seat), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "the CR 603.5 gate's own answer survives the CR 601.2c write on the same source" + ); + assert_eq!( + state.loop_answer(&DecisionSlot::target(source), seat), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::Player(PlayerId(1)) + ]))), + "and the CR 601.2c announcement's own answer survives the CR 603.5 write" + ); + } + + /// **Row T3 — the LATCH, on the target axis.** CR 732.2a: two observed iterations of + /// one (slot, seat) pair that DISAGREE latch [`LoopAnswer::Conflicted`], and the latch + /// is idempotent and seat-local. + /// + /// # Discrimination + /// + /// Narrow the `Entry::Occupied` arm to the may axis — + /// `if *o.get() != answer && !matches!(answer, LoopAnswer::Uniform(LoopAnswerValue::Targets(_)))` + /// ⇒ this row reds while C1's existing may-conflict row + /// (`c1_row2b_one_seat_answering_one_source_two_ways_latches_conflicted`) stays GREEN. + /// The mutation is therefore discriminating rather than shared. + /// + /// # Paired positive / reach-guards + /// + /// The first write is read back as `Uniform` BEFORE the differing one lands, so a + /// writer that recorded nothing cannot satisfy this row; and the sibling seat's entry + /// is asserted still `Uniform` afterwards, so a latch that fired globally fails too. + /// + /// # NON-CLAIM, measured and shipped in the row + /// + /// THE TARGET LATCH HAS NO MEASURED WIRE-TIER REACHABILITY: driving the three tracked + /// F4 dumps under an ALTERNATING-target policy through production `apply()` reaches NO + /// OFFER AT ALL (a varying player target moves a different seat's resources, so the + /// period's signature never repeats and certification refuses upstream), while + /// constant P1, constant P2 and constant P3 all certify. It is the VARIATION, not the + /// seat, that blocks certification. This latch is defence in depth. + #[test] + fn c2a_row_t3_a_differing_target_answer_latches_conflicted_idempotently_and_seat_locally() { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + }; + + let mut state = journal_state(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: a fresh board starts with an EMPTY journal" + ); + let slot = DecisionSlot::target(journal_source(911)); + let (seat, other_seat) = (PlayerId(0), PlayerId(1)); + let aimed_at_1 = LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player( + PlayerId(1), + )])); + let aimed_at_0 = LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player( + PlayerId(0), + )])); + + state.record_loop_answer(slot.clone(), seat, aimed_at_1.clone()); + state.record_loop_answer(slot.clone(), other_seat, aimed_at_1.clone()); + assert_eq!( + state.loop_answer(&slot, seat), + Some(aimed_at_1.clone()), + "paired positive: the FIRST answer is journalled as Uniform before any \ + disagreement, so a writer that stored nothing cannot reach the latch" + ); + + // The disagreement. + state.record_loop_answer(slot.clone(), seat, aimed_at_0); + assert_eq!( + state.loop_answer(&slot, seat), + Some(LoopAnswer::Conflicted), + "CR 732.2a: this engine refuses on a differing answer — an ENGINE-CAPABILITY \ + limit, not a rule the CR states" + ); + assert_eq!( + state.loop_answer(&slot, other_seat), + Some(aimed_at_1.clone()), + "seat-local: the other seat's entry is a DIFFERENT key and is untouched by the \ + latch" + ); + + // Idempotence: the latch fires on DISAGREEMENT, not on repetition, and never + // returns to `Uniform`. + state.record_loop_answer(slot.clone(), seat, aimed_at_1); + assert_eq!( + state.loop_answer(&slot, seat), + Some(LoopAnswer::Conflicted), + "the latch never returns to Uniform, even when a later answer matches the first" + ); + } + + /// **Row T4 — the SEAT half of the key, on the target axis.** A seat can only ever + /// answer for itself: two seats announcing DIFFERENT targets for one slot occupy two + /// entries and neither can be read in place of the other. + /// + /// # Discrimination + /// + /// Drop `player` from the key in `record_loop_answer`/`loop_answer` ⇒ ONE entry, the + /// second write disagrees with the first, and the latch makes both lookups + /// `Conflicted`: the cardinality reads 1 and both value assertions flip. + /// + /// # Reach-guard + /// + /// The two seats are asserted DISTINCT and their two answers asserted DIFFERENT before + /// the lookups — two identical answers would occupy two entries under either key shape + /// and prove nothing. + #[test] + fn c2a_row_t4_two_seats_answering_one_target_slot_occupy_two_independent_entries() { + use crate::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + }; + + let mut state = journal_state(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: a fresh board starts with an EMPTY journal" + ); + let slot = DecisionSlot::target(journal_source(912)); + let (seat_a, seat_b) = (PlayerId(0), PlayerId(1)); + assert_ne!( + seat_a, seat_b, + "reach-guard: the two answering seats must differ, else there is no seat axis" + ); + let answer_a = LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player( + PlayerId(1), + )])); + let answer_b = LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player( + PlayerId(0), + )])); + assert_ne!( + answer_a, answer_b, + "reach-guard: the two answers must DIFFER, else a collapsed key would also hold \ + two consistent entries and this row would not discriminate" + ); + + state.record_loop_answer(slot.clone(), seat_a, answer_a.clone()); + state.record_loop_answer(slot.clone(), seat_b, answer_b.clone()); + + assert_eq!( + state.loop_answers_recorded(), + 2, + "the seat is part of the key: two seats answering one slot hold TWO entries. A \ + seat-less key holds 1" + ); + assert_eq!( + state.loop_answer(&slot, seat_a), + Some(answer_a), + "each seat reads back its OWN announcement, uncorrupted by the other's" + ); + assert_eq!(state.loop_answer(&slot, seat_b), Some(answer_b)); + } } diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index d387efa5f2..705660f242 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -218,16 +218,25 @@ fn resolve_by_name(state: &GameState, name: &str) -> ObjectId { /// One beat of the F4 drive policy, every beat crossing the public `apply()` boundary. /// /// At `Priority` ALWAYS pass: the mandatory chain resolves and re-triggers, and that IS the -/// loop — casting here wanders off it. At Torch's CR 608.2b target choice aim **P1** (a +/// loop — casting here wanders off it. At Torch's CR 608.2b target choice aim `seat` (a /// CONSTANT seat, so the cycle is board-stable and the detector can certify it); at either /// CR 603.5 "may" prompt TAKE (declining Sue's token breaks the chain to Reed). /// +/// The aimed seat is a PARAMETER, not a constant, so a row can prove the journal FOLLOWS the +/// announcement instead of coinciding with one hard-coded seat. MEASURED: constant P1, +/// constant P2 and constant P3 all certify and reach the offer; it is the VARIATION between +/// iterations, not the seat, that blocks certification. +/// /// ⚠ This is deliberately NOT `loop_shortcut.rs`'s shared `dump_drive_one_beat`: that helper's /// victim preference matches `GameAction::SelectTargets`, and this dump raises /// `GameAction::ChooseTarget`, so its pin is inert here and its "first legal non-terminal /// action" fallback answers Sue's "may" with whichever `DecideOptionalEffect` is enumerated /// first. MEASURED: under that policy this dump reaches no offering beat at all. fn f4_drive_one_beat(state: &mut GameState) -> Result<(), String> { + f4_drive_one_beat_at(state, P1) +} + +fn f4_drive_one_beat_at(state: &mut GameState, seat: PlayerId) -> Result<(), String> { let who = state .waiting_for .acting_player() @@ -244,7 +253,7 @@ fn f4_drive_one_beat(state: &mut GameState) -> Result<(), String> { .find(|a| { matches!( a, - GameAction::ChooseTarget { target: Some(TargetRef::Player(p)) } if *p == P1 + GameAction::ChooseTarget { target: Some(TargetRef::Player(p)) } if *p == seat ) }) .or_else(|| { @@ -269,11 +278,15 @@ fn f4_drive_one_beat(state: &mut GameState) -> Result<(), String> { /// beat index. The beat is SEARCHED, never hardcoded — a hardcoded index is a fixture that /// drifts silently when the drive policy moves. fn drive_f4_to_offer(state: &mut GameState, cap: u32) -> Option { + drive_f4_to_offer_at(state, cap, P1) +} + +fn drive_f4_to_offer_at(state: &mut GameState, cap: u32, seat: PlayerId) -> Option { for beat in 0..cap { if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { return Some(beat); } - f4_drive_one_beat(state).ok()?; + f4_drive_one_beat_at(state, seat).ok()?; } None } @@ -1378,20 +1391,25 @@ fn r27_a1_the_f4_dumps_recorded_sample_keeps_a_live_half_normalization_would_hav // at a minted-or-refused declaration. // ───────────────────────────────────────────────────────────────────────────────────────── -/// The key the journal uses, built the way `game::engine::object_decision_source` builds it -/// (CR 400.7: `ThisObject` bound to the object's CURRENT incarnation, `trigger_description` -/// held `None`). Reconstructed here rather than called because the engine's helper is -/// `pub(crate)`; every row that uses it asserts the reconstruction is faithful by requiring -/// the production write site to have stored something under it. +/// The CR 603.5 "may" SLOT the journal keys on. The source half is built the way +/// `game::engine::object_decision_source` builds it (CR 400.7: `ThisObject` bound to the +/// object's CURRENT incarnation, `trigger_description` held `None`) and is reconstructed +/// here rather than called because the engine's helper is `pub(crate)`; every row that uses +/// it asserts the reconstruction is faithful by requiring the production write site to have +/// stored something under it. The SUB-INDEX half is not reconstructed at all — it comes from +/// the engine's own `DecisionSlot::may`, the same constructor the publisher and the +/// `DecideOptionalEffect` writer use, so this key cannot drift from theirs. fn may_source_key( state: &GameState, source_id: ObjectId, -) -> engine::types::game_state::YieldTarget { - engine::types::game_state::YieldTarget::ThisObject { - source_id, - incarnation: Some(state.objects[&source_id].incarnation), - trigger_description: None, - } +) -> engine::analysis::decision_template::DecisionSlot { + engine::analysis::decision_template::DecisionSlot::may( + engine::types::game_state::YieldTarget::ThisObject { + source_id, + incarnation: Some(state.objects[&source_id].incarnation), + trigger_description: None, + }, + ) } /// How the drive answers CR 603.5 "may" prompts. Typed rather than a pair of `bool`s: the @@ -1409,7 +1427,7 @@ enum MayPolicy { /// One answered "may" prompt, as the drive saw it. struct MayBeat { - key: engine::types::game_state::YieldTarget, + key: engine::analysis::decision_template::DecisionSlot, seat: PlayerId, take: bool, /// The journal entry for this (source, seat) pair BEFORE this beat was answered — the @@ -1489,7 +1507,7 @@ fn drive_f4_may_beats(state: &mut GameState, cap: u32, policy: MayPolicy) -> Vec /// below is empty and the row would pass on a board it never tested. #[test] fn c1_row1_the_may_journal_is_populated_at_the_f4_offer_under_the_proposers_own_key() { - use engine::analysis::decision_template::{LoopAnswer, MayChoiceOption}; + use engine::analysis::decision_template::{LoopAnswer, LoopAnswerValue, MayChoiceOption}; let mut state = load_f4(); assert_eq!( @@ -1506,14 +1524,17 @@ fn c1_row1_the_may_journal_is_populated_at_the_f4_offer_under_the_proposers_own_ ); let (proposer, _certificate, schema) = offer_parts(&state); - let may_sources: Vec<_> = schema + // The WHOLE published slot, sub-index included — the journal is keyed on it, so + // projecting it down to `slot.source` here would test a coarser identity than the one + // production writes and reads. + let may_slots: Vec<_> = schema .points .iter() .filter(|p| matches!(p.kind, DecisionPointKind::MayChoice)) - .map(|p| p.slot.source.clone()) + .map(|p| p.slot.clone()) .collect(); assert!( - !may_sources.is_empty(), + !may_slots.is_empty(), "reach-guard: the offer must publish at least one MayChoice point (r1b measures \ three points on this board), else the per-point assertions below are vacuous" ); @@ -1521,19 +1542,205 @@ fn c1_row1_the_may_journal_is_populated_at_the_f4_offer_under_the_proposers_own_ state.loop_answers_recorded() > 0, "CR 603.5: the offer beat must carry the answers the drive gave" ); - for source in &may_sources { + for slot in &may_slots { assert_eq!( - state.loop_answer(source, proposer), - Some(LoopAnswer::Uniform { - take: MayChoiceOption::Take - }), - "every published may point's source must be journalled under the PROPOSER's own \ - key; source {source:?}, proposer {proposer:?}, journal holds {} entries", + state.loop_answer(slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "every published may point's slot must be journalled under the PROPOSER's own \ + key; slot {slot:?}, proposer {proposer:?}, journal holds {} entries", state.loop_answers_recorded() ); } } +/// **Row T1 — WIRE / JOURNAL TIER.** CR 608.2b + CR 601.2c (reached via CR 603.3d) + +/// CR 732.2a: at the real F4 bounded offer, the published `Targets` point's SLOT carries the +/// announcement the proposer actually made, under the proposer's own key. +/// +/// Every beat crosses the public `apply()` boundary; the slot is bound from `schema.points` +/// and the pinned seat from the drive policy's own aim, so a re-dump that renumbers objects +/// flows through without edit. +/// +/// # Discrimination +/// +/// Delete the `record_trigger_target_answer(..)` call from `apply_action`'s +/// `(TriggerTargetSelection, ChooseTarget)` arm ⇒ the `Targets` slot is never journalled and +/// the value assertion reads `None`. The helper and its `SelectTargets` caller survive, so +/// the mutation COMPILES and reds on the assert. The `SelectTargets` arm is covered at a +/// DIFFERENT TIER by `loop_shortcut.rs`'s +/// `c2a_row_t1b_both_trigger_target_selection_arms_route_through_the_single_writer`, which is +/// a SOURCE CENSUS: it asserts that both reducer arms are WIRED to the single writer, and +/// structurally cannot observe an announced seat (no fixture in this repo reaches the +/// `SelectTargets` arm — that row's own doc records the per-dump measurement and the backlog +/// item). The two deletions are ASYMMETRIC, and the asymmetry is the usable part: deleting the +/// `SelectTargets` call reds ONLY the census, while deleting the `ChooseTarget` call reds BOTH — +/// so a red census names the arm, and this row disambiguates which one moved. The census cannot +/// be blind to either arm: it asserts `unwired.is_empty()` across both. +/// +/// # Sibling (T1-sib), asserted in this same body +/// +/// After that mutation the two `MayChoice` points still read `Uniform(May(Take))`, so the +/// deletion is TARGET-SPECIFIC and cannot be confused with a journal that stopped working. +/// +/// # Reach-guards, all asserted BEFORE the claim +/// +/// * the restored dump starts with an EMPTY journal, so every entry is one this drive wrote; +/// * the drive really reaches the CR 732.2a offer beat (searched, never hardcoded); +/// * the offer really publishes a `Targets` point — without this the loop below is empty; +/// * the drive's aimed seat is NOT the proposer's own seat, so a writer that journalled the +/// proposer instead of the announcement could not pass. +/// +/// # What this row does NOT claim +/// +/// It is a WRITER row. C2a ships no declaration consumer, so nothing here asserts that a +/// declaration is built from these entries. +#[test] +fn c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot() { + use engine::analysis::decision_template::{ + LoopAnswer, LoopAnswerValue, MayChoiceOption, TargetPin, + }; + + let mut state = load_f4(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: the restored dump starts with an EMPTY journal" + ); + + let beat = drive_f4_to_offer(&mut state, 400) + .expect("reach-guard: the F4 drive must reach the CR 732.2a bounded offer"); + let (proposer, _certificate, schema) = offer_parts(&state); + + let target_slots: Vec<_> = schema + .points + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .map(|p| p.slot.clone()) + .collect(); + assert!( + !target_slots.is_empty(), + "reach-guard: the offer must publish at least one CR 601.2c Targets point at beat \ + {beat}, else the per-point assertion below is vacuous" + ); + // `P1` is the seat `f4_drive_one_beat` aims Torch's "target opponent" at. It must not be + // the proposer, or a writer that journalled the PROMPT'S OWN SEAT rather than the + // ANNOUNCED target would satisfy this row. + assert_ne!( + P1, proposer, + "reach-guard: the drive's aimed seat must differ from the proposer's own seat" + ); + + for slot in &target_slots { + assert_eq!( + state.loop_answer(slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::Player(P1) + ]))), + "CR 608.2b: the published Targets slot must hold the announcement the drive made \ + (a constant CR 115.2 player target), under the PROPOSER's own key; slot \ + {slot:?}, proposer {proposer:?}, journal holds {} entries", + state.loop_answers_recorded() + ); + } + + // ── T1-sib: the CR 603.5 axis is untouched by the target axis's write ── + let may_slots: Vec<_> = schema + .points + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::MayChoice)) + .map(|p| p.slot.clone()) + .collect(); + assert!( + !may_slots.is_empty(), + "reach-guard: this board publishes MayChoice points too, else the sibling assertion \ + below is vacuous" + ); + for slot in &may_slots { + assert_eq!( + state.loop_answer(slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "T1-sib: deleting the target write must leave C1's CR 603.5 axis green — the two \ + axes share one journal but not one entry" + ); + } +} + +/// **Row T1-P — WIRE / PROVENANCE.** The journalled pin FOLLOWS THE ANNOUNCEMENT, not a +/// constant: driving the SAME dump with the SAME policy but a different aimed seat produces a +/// different journal value at the same published slot. +/// +/// # Why this row exists at all — the vacuity it closes +/// +/// [`c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot`] drives +/// the shipped policy, which aims at P1. A writer that IGNORED the announcement and stored +/// the constant `TargetPin::Player(P1)` would satisfy it exactly. Only a second seat +/// discriminates that, and it must be a REAL drive: the seat is announced through production +/// `apply()` at Torch's CR 601.2c choice, never injected. +/// +/// # Discrimination +/// +/// In `record_trigger_target_answer`, replace the mapped `targets` with +/// `vec![TargetPin::Player(PlayerId(1))]` ⇒ this row reds on the value while T1 stays GREEN. +/// That asymmetry is the point: T1 alone cannot see this mutation. +/// +/// # Reach-guards +/// +/// The P2 drive must reach the offer (MEASURED: constant P1, P2 and P3 all certify — it is +/// the variation between iterations, not the seat, that blocks certification), the offer must +/// publish a `Targets` point, and the aimed seat must differ from T1's. +#[test] +fn c2a_row_t1p_the_journalled_pin_follows_the_announced_seat_not_a_constant() { + use engine::analysis::decision_template::{LoopAnswer, LoopAnswerValue, TargetPin}; + + const AIMED: PlayerId = PlayerId(2); + assert_ne!( + AIMED, P1, + "reach-guard: this row's aimed seat must differ from the shipped policy's, else it \ + re-runs T1 and discriminates nothing" + ); + + let mut state = load_f4(); + assert_eq!( + state.loop_answers_recorded(), + 0, + "reach-guard: the restored dump starts with an EMPTY journal" + ); + let beat = drive_f4_to_offer_at(&mut state, 400, AIMED).expect( + "reach-guard: a CONSTANT non-P1 target still certifies — it is the VARIATION between \ + iterations, not the seat, that blocks the CR 732.2a offer", + ); + let (proposer, _certificate, schema) = offer_parts(&state); + assert_ne!( + AIMED, proposer, + "reach-guard: the aimed seat must not be the proposer's own" + ); + + let target_slots: Vec<_> = schema + .points + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .map(|p| p.slot.clone()) + .collect(); + assert!( + !target_slots.is_empty(), + "reach-guard: the offer at beat {beat} must publish a CR 601.2c Targets point" + ); + for slot in &target_slots { + assert_eq!( + state.loop_answer(slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::Player(AIMED) + ]))), + "PROVENANCE: the journal must hold the seat this drive ANNOUNCED ({AIMED:?}), not \ + the seat the shipped policy happens to aim at; slot {slot:?}" + ); + } +} + /// **Row 2b — JOURNAL TIER.** CR 603.5: ONE seat answering ONE source two different ways /// inside one detection window latches [`LoopAnswer::Conflicted`]. /// @@ -1560,7 +1767,7 @@ fn c1_row1_the_may_journal_is_populated_at_the_f4_offer_under_the_proposers_own_ /// satisfy this row. #[test] fn c1_row2b_one_seat_answering_one_source_two_ways_latches_conflicted() { - use engine::analysis::decision_template::{LoopAnswer, MayChoiceOption}; + use engine::analysis::decision_template::{LoopAnswer, LoopAnswerValue, MayChoiceOption}; let mut state = load_f4(); let beats = drive_f4_may_beats(&mut state, 400, MayPolicy::DeclineOnRepeat); @@ -1585,10 +1792,10 @@ fn c1_row2b_one_seat_answering_one_source_two_ways_latches_conflicted() { ); assert_eq!( last.before, - Some(LoopAnswer::Uniform { - take: MayChoiceOption::Take - }), - "paired positive: the FIRST answer was journalled as Uniform{{Take}} before the \ + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "paired positive: the FIRST answer was journalled as Uniform(May(Take)) before the \ differing one landed" ); assert_eq!( @@ -1610,7 +1817,7 @@ fn c1_row2b_one_seat_answering_one_source_two_ways_latches_conflicted() { /// `o.insert(LoopAnswer::Conflicted)` ⇒ this row reds while row 2b stays green. #[test] fn c1_row2b_sibling_an_identical_second_answer_stays_uniform() { - use engine::analysis::decision_template::{LoopAnswer, MayChoiceOption}; + use engine::analysis::decision_template::{LoopAnswer, LoopAnswerValue, MayChoiceOption}; let mut state = load_f4(); let beats = drive_f4_may_beats(&mut state, 400, MayPolicy::TakeUntilRepeat); @@ -1619,17 +1826,17 @@ fn c1_row2b_sibling_an_identical_second_answer_stays_uniform() { .expect("the drive must have answered at least one `may` prompt"); assert_eq!( last.before, - Some(LoopAnswer::Uniform { - take: MayChoiceOption::Take - }), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), "reach-guard: the last beat must be a REPEAT of an already-journalled pair, else this \ row asserts idempotence over a single write" ); assert_eq!( state.loop_answer(&last.key, last.seat), - Some(LoopAnswer::Uniform { - take: MayChoiceOption::Take - }), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), "an identical second answer must not latch Conflicted" ); } @@ -1645,7 +1852,7 @@ fn c1_row2b_sibling_an_identical_second_answer_stays_uniform() { /// /// Site 5 (`apply_action`'s pre-action clear, a `state` receiver) is driven directly. /// Sites 1–4 and 8 are covered structurally instead, by -/// [`c1_every_ring_clear_site_also_clears_the_may_journal`] — stated here so the coverage of +/// [`c1_every_ring_clear_site_also_clears_the_loop_answer_journal`] — stated here so the coverage of /// this row is not read as more than it is. /// /// # Discrimination @@ -1704,8 +1911,10 @@ fn c1_row7b_the_may_journal_follows_the_ring_on_the_same_receiver() { /// payload, and a decode of a populated board restores an empty journal. /// /// Discrimination: drop `skip` from the field's serde attribute ⇒ the key appears in the -/// encoded value ⇒ the first assertion flips (and `LoopAnswer` derives no `Serialize`, so -/// that edit does not even compile — which is the point of the note on the field). +/// encoded value ⇒ the first assertion flips (and NEITHER `LoopAnswer` NOR `LoopAnswerValue` +/// derives `Serialize`, so that edit does not even compile — which is the point of the note +/// on the field; the compile-time bar had to be re-checked when the value type grew a second +/// axis, and this row is the runtime half of it). #[test] fn c1_row7c_the_may_journal_does_not_cross_save_load() { let mut state = load_f4(); @@ -1743,7 +1952,7 @@ fn c1_row7c_the_may_journal_does_not_cross_save_load() { /// Discrimination: delete any one `loop_answer_journal = None;` that follows a ring clear ⇒ /// the pairing count drops and this row reds naming the file and line. #[test] -fn c1_every_ring_clear_site_also_clears_the_may_journal() { +fn c1_every_ring_clear_site_also_clears_the_loop_answer_journal() { use std::path::Path; let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); @@ -1769,7 +1978,8 @@ fn c1_every_ring_clear_site_also_clears_the_may_journal() { } assert!( unpaired.is_empty(), - "every ring-clear site must also clear the CR 603.5 may-answer journal; unpaired: \ + "every ring-clear site must also clear the CR 603.5 + CR 608.2b loop-answer journal; \ + unpaired: \ {unpaired:?}" ); assert_eq!( diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 2816e9349f..3c2b251bfb 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -5992,6 +5992,19 @@ fn dump_beat_actor(state: &GameState) -> Option<(PlayerId, Vec)> { /// Returns the beat's `GameEvent`s so a caller can key on what the beat actually DID /// (`CombatDamageDealtToPlayer` / `DamageDealt` for the CR 510.2 rows) instead of /// inferring it from phase and life deltas. Callers that only need liveness ignore it. +/// +/// ⚠ THE `pin` PREFERENCE IS INERT ON EVERY TRACKED DUMP, and that is MEASURED, not +/// inferred from this function's body. Driving all five tracked 4p dumps for 60 beats each: +/// only `dellian_emblem_conqueror_4p` reaches a `WaitingFor::TriggerTargetSelection` window +/// at all (seven of them, at beats 0/9/18/27/36/45/54, all on `ObjectId(541)`), and every +/// one of those windows enumerates **`GameAction::ChooseTarget` ×3 and +/// `GameAction::SelectTargets` ×0** — so the `SelectTargets` preference above never fires +/// and the fallback answers with a `ChooseTarget`. `dina`, `tenacity`, +/// `witherbloom_sprout_lumaret` and `witherbloom_sprout_lumaret_simple` reach NO +/// `TriggerTargetSelection` window in 60 beats. This is the same trap +/// `fantastic_four_bounded_loop.rs` already records for the F4 dump, and it means NO TRACKED +/// FIXTURE CAN EXERCISE THE `SelectTargets` REDUCER ARM at the wire tier — see +/// [`c2a_row_t1b_both_trigger_target_selection_arms_route_through_the_single_writer`]. fn dump_drive_one_beat( state: &mut GameState, pin: Option, @@ -12997,3 +13010,103 @@ fn a_clearing_beat_rebuilds_the_ring_inside_the_same_beat() { it can never name `minted`" ); } + +/// **Row T1b — STRUCTURAL, and the tier is FORCED.** CR 608.2b + CR 601.2c (reached via +/// CR 603.3d): BOTH `WaitingFor::TriggerTargetSelection` reducer arms route their +/// announcement through the single write authority `record_trigger_target_answer`. +/// +/// # ⚠ WHY THIS IS A SOURCE CENSUS AND NOT A WIRE ROW — measured, not conceded +/// +/// The `ChooseTarget` arm is covered end-to-end at the wire tier by +/// `fantastic_four_bounded_loop.rs`'s +/// `c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot` and its +/// P2 provenance sibling. **The `SelectTargets` arm has NO tracked fixture that reaches it.** +/// Driving all five tracked 4p dumps in this file for 60 beats each through production +/// `apply()`: only `dellian_emblem_conqueror_4p` reaches a `TriggerTargetSelection` window, +/// it reaches seven of them, and every one enumerates `ChooseTarget` ×3 / `SelectTargets` ×0; +/// the other four reach none. See [`dump_drive_one_beat`]'s doc for the per-dump numbers. +/// A wire row for that arm is therefore not writable from this repo's fixtures today — +/// recorded as a BACKLOG item (needs a dump whose trigger declares a multi-slot or +/// object-target announcement), never as a silently-absent row. +/// +/// This census covers exactly what it can: that the arm is WIRED. The writer's BEHAVIOUR is +/// proven separately and at a tier that can carry it — `game::engine`'s +/// `c2a_row_t5_an_unresolvable_target_abandons_the_whole_journal_write` drives the helper +/// itself, and both arms call that one helper, which is the point of it being one helper. +/// It is the same instrument class, and the same reasoning, as +/// `fantastic_four_bounded_loop.rs`'s ring-clear census. +/// +/// # Discrimination +/// +/// Delete the `record_trigger_target_answer(..)` call from EITHER arm ⇒ that arm lands in +/// `unwired` and this row reds NAMING the arm and its line. The mutation compiles (the other +/// caller survives), so it reds on the assert, not on a compile error. Without this row, r2's +/// own finding stands: deleting the `SelectTargets` call reds nothing in the suite. +/// +/// # Reach-guard +/// +/// The arm COUNT is asserted first and is independent of the call: a pattern reflow that hid +/// an arm from this scanner would read 1 or 0 and fail here rather than passing on a census +/// that found nothing to check. +#[test] +fn c2a_row_t1b_both_trigger_target_selection_arms_route_through_the_single_writer() { + use std::path::Path; + + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("game/engine.rs"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let lines: Vec<&str> = text.lines().collect(); + + let (mut wired, mut unwired) = (Vec::new(), Vec::new()); + for (i, line) in lines.iter().enumerate() { + let action = if line.contains("GameAction::SelectTargets {") { + "SelectTargets" + } else if line.contains("GameAction::ChooseTarget {") { + "ChooseTarget" + } else { + continue; + }; + // A reducer arm's `WaitingFor` half sits a few lines above its `GameAction` half in + // the tuple pattern; anything further away is a different construct. + if !lines[i.saturating_sub(10)..i] + .join("\n") + .contains("WaitingFor::TriggerTargetSelection {") + { + continue; + } + if lines[i..(i + 14).min(lines.len())] + .join("\n") + .contains("record_trigger_target_answer(") + { + wired.push(action); + } else { + unwired.push(format!("game/engine.rs:{} ({action})", i + 1)); + } + } + + assert_eq!( + wired.len() + unwired.len(), + 2, + "reach-guard: `apply_action` has exactly TWO `WaitingFor::TriggerTargetSelection` \ + reducer arms (`SelectTargets` and `ChooseTarget`). A different count means an arm \ + was added, removed, or reflowed out of this scanner's reach — re-derive this census, \ + do not re-number it. wired={wired:?} unwired={unwired:?}" + ); + assert!( + unwired.is_empty(), + "CR 608.2b: every `TriggerTargetSelection` reducer arm must journal its announcement \ + through `record_trigger_target_answer`, the single write authority. Unwired: \ + {unwired:?}" + ); + assert_eq!( + { + let mut w = wired.clone(); + w.sort_unstable(); + w + }, + vec!["ChooseTarget", "SelectTargets"], + "both arms by NAME, not just by count: a census that found the same arm twice would \ + satisfy a bare count while leaving the other one unmeasured" + ); +} diff --git a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs index f2255f7c8e..09596bcda8 100644 --- a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs @@ -230,7 +230,7 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid assert_eq!( (production.len(), in_test.len()), - (22, 16), + (22, 17), "CR 732.2a OFFER-WRITER SURFACE CHANGED (not re-measured — this number is an \ INVARIANCE pin over the whole 5d U-series).\n\ The three CERTIFICATION-PATH writers are `reconcile_terminal_result` (object-growth \ @@ -252,8 +252,15 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid real `per_cycle` so the proposer-elimination arm can be driven, and `certificate_of`, \ a read accessor for the same rows. PRODUCTION STAYED AT 22 across that change, which \ is the half this pin exists to protect: the new policy arm READS the certificate and \ - writes no offer); if it moves again, name the new site here too rather than only \ - moving the number.\n\ + writes no offer). FOURTH ADJUDICATION, 16 => 17: item-4 C2a's cap-round row \ + `the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for` in \ + `engine/src/analysis/resource.rs`, whose `WaitingFor::LoopShortcut` DESTRUCTURE reads the \ + offer it minted to assert the combination that decoupling CR 732.2a publication from CR \ + 704.5a charging makes reachable: `schema.points` EMPTY while `victim_slot` still names the \ + forced victim. A READ, not a writer. PRODUCTION STAYED AT 22 with an IDENTICAL per-file \ + multiset, and that conjunct is what makes this the benign case rather than a surface \ + change; if it moves again, name the new site here too rather than only moving the \ + number.\n\ measured per-file production multiset: {multiset:?}\n\ production: {production:?}\n\ test: {in_test:?}" diff --git a/crates/engine/tests/integration/natural_balance.rs b/crates/engine/tests/integration/natural_balance.rs index 9532d3f316..8a18eba9b7 100644 --- a/crates/engine/tests/integration/natural_balance.rs +++ b/crates/engine/tests/integration/natural_balance.rs @@ -508,8 +508,8 @@ fn natural_balance_collects_two_local_x_searches_before_one_shuffle_each() { /// /// # Discrimination /// -/// Collapse the journal key to the bare `DecisionSource` (keep both signatures; build the -/// key from `source` alone in `record_loop_answer`/`loop_answer`) and the two writes land +/// Collapse the journal key to the bare `DecisionSlot` (keep both signatures; build the +/// key from `slot` alone in `record_loop_answer`/`loop_answer`) and the two writes land /// in ONE entry: `loop_answers_recorded()` is 1, not 2, and the second write's differing /// value latches `Conflicted`, so the per-seat lookups no longer hold either. The /// cardinality assertion is value-independent and is asserted FIRST, so an empty journal — @@ -517,7 +517,9 @@ fn natural_balance_collects_two_local_x_searches_before_one_shuffle_each() { /// content assertion can pass vacuously. #[test] fn natural_balance_two_scoped_seats_journal_one_may_source_under_two_independent_keys() { - use engine::analysis::decision_template::{LoopAnswer, MayChoiceOption}; + use engine::analysis::decision_template::{ + DecisionSlot, LoopAnswer, LoopAnswerValue, MayChoiceOption, + }; use engine::types::game_state::{LoopDetectionMode, YieldTarget}; let mut scenario = GameScenario::new_n_player(3, 42); @@ -563,11 +565,16 @@ fn natural_balance_two_scoped_seats_journal_one_may_source_under_two_independent // identical and every journal assertion below would pass on an empty map. runner.state_mut().loop_detection = LoopDetectionMode::Interactive; - // ── the shared source, captured at the prompt beats rather than reconstructed ── - let decision_source = |state: &GameState, id: ObjectId| YieldTarget::ThisObject { - source_id: id, - incarnation: Some(state.objects[&id].incarnation), - trigger_description: None, + // ── the shared slot, captured at the prompt beats rather than reconstructed ── + // The CR 400.7 source half is still hand-rolled (`object_decision_source` is + // `pub(crate)`), but the CR 603.5 sub-index now routes through the engine's own + // `DecisionSlot::may`, so this key cannot drift from the publisher's. + let decision_source = |state: &GameState, id: ObjectId| { + DecisionSlot::may(YieldTarget::ThisObject { + source_id: id, + incarnation: Some(state.objects[&id].incarnation), + trigger_description: None, + }) }; let outcome = runner.cast(natural_balance).resolve(); @@ -645,16 +652,16 @@ fn natural_balance_two_scoped_seats_journal_one_may_source_under_two_independent ); assert_eq!( runner.state().loop_answer(&first_key, first_seat), - Some(LoopAnswer::Uniform { - take: MayChoiceOption::Take - }), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), "the accepting seat's own entry records Take" ); assert_eq!( runner.state().loop_answer(&second_key, second_seat), - Some(LoopAnswer::Uniform { - take: MayChoiceOption::Decline - }), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Decline + ))), "the declining seat's own entry records Decline, uncorrupted by the other seat's \ differing answer — under a source-only key this second write would instead latch \ Conflicted over the first" From 2993caf6d7a4b4a6e316210afa84afc6655ed0de Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 10 Aug 2026 23:17:10 -0500 Subject: [PATCH 08/44] feat(engine): publish the bounded shortcut's own declaration on the offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bounded loop-shortcut offer now carries the declaration the engine derived from the proposer's own journalled answers, instead of leaving every consumer to re-derive it. `build_bounded_declaration` reads the CR 608.2b loop-answer journal C2a shipped and emits a `DecisionTemplate` pinned to what the proposer actually answered; the AI declare candidate carries that template rather than fabricating one. CR 732.2a permits shortening by declaring a repeated sequence, and CR 732.1 makes a shortcut equivalent to explicitly identifying each game choice — so the declaration must reproduce the proposer's answers, not a plausible substitute. Redaction (CR 115.1/115.2): the projection drops the whole declaration for a non-proposer viewer when any pin names a hidden object. Written wildcard-free over all of `TargetPin` and `PinnedDecision` rather than only the `ByIdentity` case the plan named, because `#[serde(default)]` makes this field a restore ingress and a future identity-carrying pin must not default to leaking. Measurements that differ from the plan, recorded because the measurement wins: * Offer-writer census moves (22,17) -> (22,21), not the budgeted (22,20). Production is unchanged with a byte-identical per-file multiset. The plan predicted D6-n adds a mint but no read; its reach-guard destructure at `candidates.rs` is a fourth anchor-bearing line, and it is load-bearing — without it the negative row passes on an unbounded or empty-points offer, i.e. on the wrong conjunct. The site is named at the pin rather than the row contorted to hit the prediction. * The plan's D7 revert-probe was INERT and is replaced. Removing `#[serde(default)]` does not fail the round-trip: serde routes a missing field through `missing_field`, whose deserializer answers `deserialize_option` with `visit_none`, so an `Option` field is already missing-tolerant. The attribute stays (explicit intent, and load-bearing if the type ever stops being `Option`), but the row now mutates `#[serde(skip)]` and a `default = ".."` returning `Some(..)`, both of which red. * `size_of::()` is 12800 before and after, slack 256 against the 13056 ceiling, positive-controlled. No boxing; `game_state_size.rs` untouched. The CR 603.5 prompt census pin moved +127 and was re-derived content-first: the new line is sha256-identical to the old and still inside `begin_pending_trigger_target_selection`, with the diff hunks summing to +127 as an after-check. `cargo ai-gate` is NOT discharged here and is owed at this tip: `card-data.json` is absent from the worktree and generating it writes outside the frozen scope. Assisted-by: ClaudeCode:claude-opus-4.8 --- crates/engine/src/ai_support/candidates.rs | 156 +++++- crates/engine/src/analysis/resource.rs | 2 + crates/engine/src/game/engine.rs | 470 +++++++++++++++++- crates/engine/src/game/visibility.rs | 206 +++++++- crates/engine/src/types/game_state.rs | 28 ++ .../fantastic_four_bounded_loop.rs | 380 +++++++++++--- .../tests/integration/interaction_contract.rs | 4 + .../engine/tests/integration/loop_shortcut.rs | 177 ++++++- .../integration/loop_shortcut_mana_engine.rs | 1 + .../loop_shortcut_offer_writer_census.rs | 23 +- crates/phase-ai/src/policies/loop_shortcut.rs | 7 + crates/phase-ai/src/projection.rs | 1 + crates/phase-ai/src/search.rs | 1 + 13 files changed, 1345 insertions(+), 111 deletions(-) diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 7af74e9c8f..ee7ef04ccf 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -3295,7 +3295,10 @@ pub fn candidate_actions_broad_with_probe( // proposer declares or returns to ordinary priority. // (Scored by `phase_ai::policies::loop_shortcut::LoopShortcutPolicy`.) WaitingFor::LoopShortcut { - proposer, schema, .. + proposer, + schema, + declaration, + .. } => { // CR 732.2a: `UntilLethal` names no count, so it is legal ONLY against an offer // that narrowed no bound. `handle_declare_shortcut` rejects it outright against @@ -3327,15 +3330,23 @@ pub fn candidate_actions_broad_with_probe( // the AI's only non-declining option at such a node is an answer the engine // refuses. `ShortcutDecisionSchema::is_bounded()` is the engine's single // authority for "this producer narrowed the bound"; do NOT re-spell it as a - // comparison against `MAX_SHORTCUT_CYCLES`. Gated on empty `points` because this - // candidate carries `template: None`, which a published pin set fail-closes on. - if schema.points.is_empty() && schema.is_bounded() { + // comparison against `MAX_SHORTCUT_CYCLES`. + // + // The two admissible pin states, and nothing else: an EMPTY published point set + // (nothing to pin, so `template: None` is the complete answer), or a published set + // the offer ALREADY carries a declaration for. That `template` is the ENGINE'S OWN + // published declaration — the very value `handle_declare_shortcut` will validate — + // so there is exactly one pin authority at this node and the AI never constructs + // one. An offer with published points and NO declaration (a seat that never + // answered, or a `Conflicted` latch) still fail-closes: `declaration` is `None`, + // the conjunct below is false, and `DeclineShortcut` remains the only candidate. + if schema.is_bounded() && (schema.points.is_empty() || declaration.is_some()) { v.push(candidate( GameAction::DeclareShortcut { count: crate::analysis::decision_template::IterationCount::Fixed( schema.max_iterations, ), - template: None, + template: declaration.clone(), }, TacticalClass::Utility, Some(*proposer), @@ -5426,6 +5437,7 @@ mod tests { per_cycle: None, }, schema: crate::analysis::decision_template::ShortcutDecisionSchema::default(), + declaration: None, }; let candidates = candidate_actions(&state); @@ -7951,4 +7963,138 @@ mod tests { must be offered as a Mana-class ActivateAbility candidate" ); } + + // ── item-4 C2b row D6-n — the declare candidate is keyed to the offer's OWN declaration ── + + const D6N_PROPOSER: PlayerId = PlayerId(1); + + /// A BOUNDED offer publishing ONE point, carrying `declaration`. Called twice — once `None`, + /// once `Some` — so the two states differ in exactly one field and the mint spells the + /// `WaitingFor::LoopShortcut` anchor exactly once (a counted site in + /// `tests/integration/loop_shortcut_offer_writer_census.rs`). + /// + /// `ShortcutDecisionSchema::default()` carries `MAX_SHORTCUT_CYCLES`, i.e. `is_bounded()` is + /// FALSE — so `max_iterations` is set explicitly below the cap or the row would measure the + /// wrong conjunct. + fn d6n_offer( + declaration: Option, + ) -> GameState { + use crate::analysis::decision_template::{ + DecisionPoint, DecisionPointKind, DecisionSlot, IterationCount, ShortcutDecisionSchema, + }; + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: D6N_PROPOSER, + predicted_winner: None, + certificate: crate::analysis::loop_check::LoopCertificate { + unbounded: vec![], + win_kind: crate::analysis::loop_check::WinKind::Advantage, + mandatory: false, + residual_board_delta: crate::analysis::resource::BoardDelta::default(), + per_cycle: None, + }, + schema: ShortcutDecisionSchema { + iteration_count: IterationCount::Fixed(5), + max_iterations: 5, + points: vec![DecisionPoint { + slot: DecisionSlot::target(d6n_source()), + kind: DecisionPointKind::Targets { + legal_targets: vec![TargetRef::Player(PlayerId(0))], + min_targets: 1, + max_targets: 1, + ordered: false, + }, + }], + convoke_tappable_count: 0, + }, + declaration, + }; + state + } + + fn d6n_source() -> crate::types::game_state::YieldTarget { + crate::types::game_state::YieldTarget::ThisObject { + source_id: ObjectId(555), + incarnation: Some(1), + trigger_description: None, + } + } + + /// **Row D6-n — a bounded offer with published points and NO declaration enumerates only + /// `DeclineShortcut`.** + /// + /// CR 732.2a. The declare candidate carries the ENGINE's own published declaration, so an + /// offer that has none (a seat that never answered, or a `LoopAnswer::Conflicted` latch) must + /// fail closed rather than hand the search layer a fabricated pin set — which + /// `handle_declare_shortcut` would then refuse, i.e. an action that looks legal and is not. + /// + /// # Non-vacuity + /// + /// The positive arm is the SAME state one field apart: with `declaration: Some(..)` the + /// candidate appears AND carries that exact template. Without it, a generator that had + /// stopped emitting the candidate for any reason — including not running — would pass the + /// negative arm. + /// + /// REVERT-PROBE: replace `declaration.clone()` in the gate's `template:` with a fabricated + /// `Some(..)` and drop the `declaration.is_some()` conjunct ⇒ the negative arm flips. + /// + /// *What wrong implementation would still pass this row?* One that emits the candidate with + /// the RIGHT gate but a template it built itself — which the positive arm's equality against + /// the offer's own declaration refuses. + #[test] + fn d6n_a_points_carrying_offer_without_a_declaration_enumerates_only_decline() { + use crate::analysis::decision_template::{ + DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, IterationCount, + PinnedDecision, ReplayMode, TargetPin, + }; + + // ── the negative arm ── + let bare = d6n_offer(None); + let WaitingFor::LoopShortcut { schema, .. } = &bare.waiting_for else { + unreachable!("the fixture parks on the offer") + }; + assert!( + schema.is_bounded(), + "reach-guard: the `Fixed` candidate is gated on `is_bounded()` too, so an unbounded \ + offer would withhold it for the wrong reason" + ); + assert!( + !schema.points.is_empty(), + "reach-guard: a NON-empty published pin set is the conjunct this row is about — with \ + `points` empty the candidate is emitted regardless of the declaration" + ); + assert_eq!( + crate::ai_support::legal_actions(&bare), + vec![GameAction::DeclineShortcut], + "CR 732.2a: with points published and no declaration to state, declining is the only \ + honest candidate" + ); + + // ── the paired positive: the same state one field apart ── + let declaration = DecisionTemplate { + owner: D6N_PROPOSER, + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot::target(d6n_source()), + targets: vec![TargetPin::Player(PlayerId(0))], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(5), + }, + key: DecisionGroupKey::from_sources(&[d6n_source()], DecisionKind::LoopChoice), + }; + let declared = d6n_offer(Some(declaration.clone())); + assert!( + crate::ai_support::legal_actions(&declared).contains(&GameAction::DeclareShortcut { + count: IterationCount::Fixed(5), + template: Some(declaration), + }), + "POSITIVE CONTROL: with the offer carrying a declaration the candidate returns, and \ + its `template` is the offer's OWN value — the AI never constructs one. got {:?}", + crate::ai_support::legal_actions(&declared) + ); + assert!( + crate::ai_support::legal_actions(&declared).contains(&GameAction::DeclineShortcut), + "the decline stays legal on both arms, which is what keeps the pair one axis apart" + ); + } } diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index ba31e1f003..7092c2793a 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -13721,6 +13721,7 @@ mod tests { predicted_winner: None, certificate: cert.clone(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; let offer_json = serde_json::to_string(&offer).expect("the LoopShortcut payload carrying it must too"); @@ -13739,6 +13740,7 @@ mod tests { ..cert }, schema: ShortcutDecisionSchema::default(), + declaration: None, }; let shipped_json = serde_json::to_string(&shipped).expect("serializes"); assert!( diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 094249e6c6..6f92dd5a5a 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -1451,6 +1451,10 @@ fn reconcile_terminal_result(state: &mut GameState, result: &mut ActionResult) { predicted_winner: None, certificate, schema, + // CR 732.2a: the object-growth path re-derives its pins at materialize time + // from the carried recast template, so this offer states no engine-side + // declaration of its own. + declaration: None, }; result.waiting_for = state.waiting_for.clone(); } @@ -1527,6 +1531,9 @@ fn interactive_loop_bridge(state: &mut GameState, result: &mut ActionResult) { predicted_winner: Some(winner), certificate, schema, + // CR 732.2a: Path A publishes no decision points at all (the pin list above is + // empty), so there is nothing for a declaration to pin. + declaration: None, }; result.waiting_for = state.waiting_for.clone(); } @@ -2379,11 +2386,126 @@ fn certified_bounded_cycle_offer<'a>( IterationCount::Fixed(max_iterations), max_iterations, ); + // (10) The DECLARATION the engine can already specify for this offer, read out of the + // answer journal the same window populated. Built AFTER the schema because `points` is + // moved into `build_shortcut_schema`, and taking `&schema` keeps one point list rather + // than two. + let declaration = build_bounded_declaration(state, proposer, &schema); Ok(WaitingFor::LoopShortcut { proposer, predicted_winner: None, certificate, schema, + declaration, + }) +} + +/// CR 732.2a: the declaration THIS offer can already state, derived from what the proposer +/// actually answered at each published point — never from a constant and never from the +/// declaring client. +/// +/// CR 732.2a describes a shortcut proposal as "a sequence of game choices, for all players, +/// that may be legally taken based on the current game state and the predictable results of +/// the sequence of choices". Every published point of `schema` is one such choice; the +/// `(DecisionSlot, PlayerId)` journal holds the answer the proposer gave it during the +/// detection window (CR 601.2c announcements via `record_trigger_target_answer`, CR 603.5 +/// "may" answers via the `DecideOptionalEffect` arm). This function is the single authority +/// for turning that observation into a [`DecisionTemplate`], so the AI candidate generator and +/// the per-viewer projection read ONE value instead of each deriving their own. +/// +/// **Not a duplicate authority.** `game::interaction::materialize_loop_shortcut_response` +/// builds a conformant `DecisionTemplate` of the same shape (same `owner` / `decisions` / +/// `ReplayMode::Scheduled` / `DecisionGroupKey::from_sources` / `(!points.is_empty())` guard), +/// but from the CLIENT'S OWN submitted pins — a human's picks. This one is built from the +/// ENGINE'S OWN observed answers. Two inputs, one shape; a reviewer reading only the shape +/// would otherwise see duplication. +/// +/// FAIL-CLOSED on every uncertainty, because a wrong pin is worse than no offer: +/// +/// * an empty point set publishes no declaration at all — a declaration against an empty +/// schema would be the one shape `handle_declare_shortcut` validates neither +/// `predictability_gate` nor `validate_pins` against (both live inside its +/// `if !offer.schema.points.is_empty()` block); +/// * `None` (that seat never answered this slot) and [`LoopAnswer::Conflicted`] (it answered +/// two ways — see that type: an engine-capability refusal, NOT a CR 732.2a mandate) are the +/// SAME disposition here, because neither names a single answer to pin; +/// * the `(kind, value)` match is WILDCARD-FREE, so a future `DecisionPointKind` or +/// `LoopAnswerValue` variant gets a compile-time visit here instead of a silent pin. The +/// two kind/value MISMATCH groups return `None` rather than `unreachable!` because what +/// makes them unreachable is a key-shape agreement between publisher and writer, not a type +/// guarantee. +fn build_bounded_declaration( + state: &GameState, + proposer: PlayerId, + schema: &crate::analysis::decision_template::ShortcutDecisionSchema, +) -> Option { + use crate::analysis::decision_template::{ + DecisionGroupKey, DecisionKind, DecisionPointKind, DecisionTemplate, LoopAnswer, + LoopAnswerValue, PinnedDecision, ReplayMode, + }; + // (1) D4's grounds: an empty schema publishes no declaration. + if schema.points.is_empty() { + return None; + } + let mut decisions = Vec::with_capacity(schema.points.len()); + for point in &schema.points { + // (2) The journal read, under the PROPOSER's own key — the same key + // `record_trigger_target_answer` and the `DecideOptionalEffect` arm write under. + let LoopAnswer::Uniform(value) = state.loop_answer(&point.slot, proposer)? else { + return None; + }; + // (3) The wildcard-free (kind, value) match. + decisions.push(match (&point.kind, value) { + // CR 603.5: the "may" gate, answered Take or Decline. + (DecisionPointKind::MayChoice, LoopAnswerValue::May(take)) => { + PinnedDecision::MayChoice { + slot: point.slot.clone(), + take, + } + } + // CR 601.2c + CR 608.2b: the announced targets for this slot, in announcement + // order, re-checked for legality at every resolution. + (DecisionPointKind::Targets { .. }, LoopAnswerValue::Targets(targets)) => { + PinnedDecision::Targets { + slot: point.slot.clone(), + targets, + } + } + // Kind/value MISMATCH — the publisher and the journal writer disagree about what + // this slot is. Fail closed. + (DecisionPointKind::MayChoice, LoopAnswerValue::Targets(_)) + | (DecisionPointKind::Targets { .. }, LoopAnswerValue::May(_)) => return None, + // CR 700.2 modal / CR 732.6 "[A] unless [B]" / CR 601.2h + CR 702.51a convoke / + // CR 608.2d + CR 605.3b mana color: kinds this offer's publisher + // (`bounded_cycle_pin_slots_for_window`, which mints only `Targets` and + // `MayChoice`) cannot produce today. `LoopAnswerValue` carries no answer shape for + // any of them, so there is nothing to pin even when the slot IS journalled. + ( + DecisionPointKind::Mode { .. } + | DecisionPointKind::UnlessBreak + | DecisionPointKind::ConvokeTaps { .. } + | DecisionPointKind::ManaColor { .. }, + LoopAnswerValue::May(_) | LoopAnswerValue::Targets(_), + ) => return None, + }); + } + // (4) The template. `replay.count` carries the offer's own SUGGESTION; the driving count + // comes off `GameAction::DeclareShortcut` and nothing reads this copy (see + // `build_recast_template`'s note and `analysis::decision_template::resolve`'s doc). + Some(DecisionTemplate { + owner: proposer, + decisions, + replay: ReplayMode::Scheduled { + count: schema.iteration_count.clone(), + }, + key: DecisionGroupKey::from_sources( + &schema + .points + .iter() + .map(|point| point.slot.source.clone()) + .collect::>(), + DecisionKind::LoopChoice, + ), }) } @@ -9173,6 +9295,11 @@ fn apply_action( predicted_winner, certificate, schema, + // NOT threaded, deliberately: resolving a `template: None` declaration against + // the offer's own `declaration` is a change to the DECLARE handler's proposal + // shape, with its own hostile-fixture obligations (foreign period, restore + // ingress). `_` rather than a bind so nothing here implies otherwise. + declaration: _, }, GameAction::DeclareShortcut { count, template }, ) => { @@ -15183,6 +15310,308 @@ mod shortcut_schema_tests { } } +/// item-4 C2b — `build_bounded_declaration`, the consumer that turns the window's observed +/// answers into the offer's own CR 732.2a declaration. +/// +/// TIER NOTE, stated because it is FORCED rather than chosen: rows D1 / D1-P / D1-P-sib drive +/// the real F4 dump through production `apply()` and live in +/// `crates/engine/tests/integration/fantastic_four_bounded_loop.rs`. The three rows HERE are the +/// ones no tracked board can reach — a `Decline`d CR 603.5 answer at a certifying offer, and a +/// point kind the bounded publisher cannot mint — so each states its own unreachability rather +/// than implying a wire row was available and skipped. +#[cfg(test)] +mod bounded_declaration_tests { + use super::{build_bounded_declaration, build_shortcut_schema}; + use crate::analysis::decision_template::{ + DecisionPoint, DecisionPointKind, DecisionSlot, IterationCount, LoopAnswer, + LoopAnswerValue, MayChoiceOption, PinnedDecision, ShortcutDecisionSchema, TargetPin, + }; + use crate::types::ability::TargetRef; + use crate::types::game_state::{GameState, LoopDetectionMode, YieldTarget}; + use crate::types::identifiers::ObjectId; + use crate::types::mana::ManaColor; + use crate::types::player::PlayerId; + + const PROPOSER: PlayerId = PlayerId(0); + const AIMED: PlayerId = PlayerId(1); + + /// A CR 400.7-stable source identity, built the way `object_decision_source` builds one. + fn source(id: u64) -> YieldTarget { + YieldTarget::ThisObject { + source_id: ObjectId(id), + incarnation: Some(1), + trigger_description: None, + } + } + + /// A board whose journal ACCEPTS writes: `record_loop_answer` is gated on + /// `loop_detection.samples()`, so a default board would silently record nothing and every + /// row below would measure the "seat never answered" path instead of its own subject. + fn recording_state() -> GameState { + let mut state = GameState::new_two_player(7); + state.loop_detection = LoopDetectionMode::Interactive; + state + } + + fn targets_kind() -> DecisionPointKind { + DecisionPointKind::Targets { + legal_targets: vec![TargetRef::Player(AIMED)], + min_targets: 1, + max_targets: 1, + ordered: false, + } + } + + /// The two-kind schema the bounded publisher actually mints: one CR 603.5 `may` gate and one + /// CR 601.2c target slot, on two distinct sources. + fn may_and_target_schema() -> ShortcutDecisionSchema { + build_shortcut_schema( + vec![ + DecisionPoint { + slot: DecisionSlot::may(source(100)), + kind: DecisionPointKind::MayChoice, + }, + DecisionPoint { + slot: DecisionSlot::target(source(200)), + kind: targets_kind(), + }, + ], + IterationCount::Fixed(4), + 4, + ) + } + + /// **Row D1-P-may — the `MayChoice` pin FOLLOWS THE JOURNAL, not a constant.** + /// + /// CR 603.5: an optional trigger's answer is `Take` or `Decline`, and the declaration must + /// state the one the proposer actually gave. A consumer that hard-codes + /// `MayChoiceOption::Take` is indistinguishable from this one on every tracked board, which + /// is exactly the vacuity this row closes. + /// + /// # Why this tier is FORCED, and not a shortcut + /// + /// A wire-tier may-provenance drive is measured UNREACHABLE: answering every CR 603.5 prompt + /// `Decline` on the tracked F4 board reaches NO offer at all (declining Sue's token breaks + /// the chain to Reed, so the loop never certifies — the drive policy's own doc records it). + /// The residual is filed rather than hidden: it needs a bounded board on which the proposer + /// DECLINES and the loop still certifies, and the lane's real-fixtures rule bars + /// synthesizing one. + /// + /// # Non-vacuity + /// + /// The `Take` case is asserted in the SAME test from the SAME fixture one field apart, so a + /// consumer that returned `None` — or that dropped the may pin entirely — fails the positive + /// arm rather than passing the negative one by omission. + /// + /// REVERT-PROBE: hard-code `take: MayChoiceOption::Take` in the `(MayChoice, May)` arm ⇒ the + /// `Decline` arm's assertion flips (`Take != Decline`) while the `Take` arm stays green. + /// That asymmetry is the row. + /// + /// *What wrong implementation would still pass this row?* One that reads the journal for the + /// may axis but pins a CONSTANT target — D1-P and D1-P-sib cover that axis on the real dump. + #[test] + fn d1p_may_the_may_pin_follows_the_journal_not_a_constant() { + let schema = may_and_target_schema(); + let [may_point, target_point] = &schema.points[..] else { + panic!("the fixture publishes exactly two points"); + }; + + for answered in [MayChoiceOption::Decline, MayChoiceOption::Take] { + let mut state = recording_state(); + state.record_loop_answer( + may_point.slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(answered)), + ); + state.record_loop_answer( + target_point.slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player(AIMED)])), + ); + + // Reach-guard: the journal really holds the answer, under the PROPOSER's own key. + // Without this a gated-off `record_loop_answer` would make every arm below measure + // the "never answered" refusal instead. + assert_eq!( + state.loop_answer(&may_point.slot, PROPOSER), + Some(LoopAnswer::Uniform(LoopAnswerValue::May(answered))), + "reach-guard: the CR 603.5 answer must be journalled before the consumer runs" + ); + + let declaration = build_bounded_declaration(&state, PROPOSER, &schema) + .expect("both published points are answered, so the declaration is complete"); + assert_eq!( + declaration.decisions[0], + PinnedDecision::MayChoice { + slot: may_point.slot.clone(), + take: answered, + }, + "CR 603.5: the pinned option must be the one the proposer ANSWERED ({answered:?}), \ + not a constant" + ); + assert_eq!( + declaration.owner, PROPOSER, + "the declaration is the proposer's own, which is what the declare-time owner \ + firewall compares against" + ); + } + } + + /// **Row D3 — the consumer is TOTAL and FAIL-CLOSED over the four `DecisionPointKind`s the + /// bounded producer cannot mint.** + /// + /// CR 700.2 (`Mode`), CR 732.6 (`UnlessBreak`), CR 601.2h + CR 702.51a (`ConvokeTaps`) and + /// CR 608.2d + CR 605.3b (`ManaColor`) are real choice kinds with no observation-side answer + /// shape in `LoopAnswerValue`, so there is nothing to pin for them and the declaration must + /// refuse rather than guess. + /// + /// # ⚠ THE JOURNAL ENTRY ON THE FOUR-KIND POINT IS LOAD-BEARING, NOT DECORATION + /// + /// `build_bounded_declaration`'s body order is (1) empty check, (2) `state.loop_answer(..)?`, + /// (3) the `(kind, value)` match. An UNJOURNALLED four-kind point exits at step (2)'s `?` — + /// before control ever reaches the arm this row is about — and the reddening mutation returns + /// `None` there too, so real and mutant AGREE and nothing can red. Each case therefore + /// journals its own point and ASSERTS the entry is present before the consumer runs. + /// + /// # Unreachable today, and the row says so + /// + /// `bounded_cycle_pin_slots_for_window` constructs only `Targets` and `MayChoice` points. The + /// other four have one producer, `pinned_decisions_to_points`, which serves the two mints that + /// publish `declaration: None`. The row exists so a publisher relaxation gets a red test + /// instead of a silent pin. + /// + /// REVERT-PROBE: replace the four-kind arm with `_ => continue` ⇒ each case builds a + /// `Some(template)` with the four-kind point silently dropped ⇒ every `is_none()` flips while + /// the control stays green. + /// + /// *What wrong implementation would still pass this row?* One that returns `None` for + /// EVERYTHING — which the control arm (the same fixture with only mintable kinds) refuses. + #[test] + fn d3_the_consumer_fail_closes_on_every_kind_the_bounded_publisher_cannot_mint() { + let unmintable = [ + DecisionPointKind::Mode { + available_modes: vec![0, 1], + min_modes: 1, + max_modes: 1, + allow_repeats: false, + }, + DecisionPointKind::UnlessBreak, + DecisionPointKind::ConvokeTaps { + tappable: vec![ObjectId(31)], + }, + DecisionPointKind::ManaColor { + color: ManaColor::Blue, + }, + ]; + + // ── CONTROL, first: the same shape with only MINTABLE kinds yields `Some` ── + let control_schema = may_and_target_schema(); + let mut control = recording_state(); + control.record_loop_answer( + control_schema.points[0].slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + control.record_loop_answer( + control_schema.points[1].slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Player(AIMED)])), + ); + assert!( + build_bounded_declaration(&control, PROPOSER, &control_schema).is_some(), + "CONTROL: a fully-answered two-kind schema DOES publish a declaration — without this \ + a consumer that refused everything would pass all four cases below" + ); + + for kind in unmintable { + let odd_slot = DecisionSlot::target(source(300)); + let schema = build_shortcut_schema( + vec![ + DecisionPoint { + slot: DecisionSlot::may(source(100)), + kind: DecisionPointKind::MayChoice, + }, + DecisionPoint { + slot: odd_slot.clone(), + kind: kind.clone(), + }, + ], + IterationCount::Fixed(4), + 4, + ); + let mut state = recording_state(); + // The OTHER point is answered, so the refusal cannot be attributed to it. + state.record_loop_answer( + schema.points[0].slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + // `LoopAnswerValue` has exactly two variants and `May` pairs with NONE of the four + // kinds under test, so this entry is answerable and still unpinnable — which is the + // fail-closed disposition the row measures. + state.record_loop_answer( + odd_slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + assert_eq!( + state.loop_answer(&odd_slot, PROPOSER), + Some(LoopAnswer::Uniform(LoopAnswerValue::May( + MayChoiceOption::Take + ))), + "⚠ VACUITY GUARD for kind {kind:?}: an UNJOURNALLED point exits at the \ + `loop_answer(..)?` one step EARLIER, where the reddening mutation also returns \ + `None` — real and mutant would agree and this row could never fire" + ); + + assert!( + build_bounded_declaration(&state, PROPOSER, &schema).is_none(), + "CR 732.2a: {kind:?} has no `LoopAnswerValue` shape, so the declaration must \ + refuse rather than pin a guess or silently drop the point" + ); + } + } + + /// **Row D4 — an empty schema publishes NO declaration.** + /// + /// LOAD-BEARING, not tidiness: `predictability_gate` and `validate_pins` both live inside + /// `handle_declare_shortcut`'s `if !offer.schema.points.is_empty()` block, so a declaration + /// minted against an empty schema would travel the one declare path that runs NEITHER gate. + /// The invariant is also staged at fixture level by + /// `tests/integration/loop_shortcut.rs::r28_empty_schema_offer`, which passes + /// `declaration: None` for this reason. + /// + /// REVERT-PROBE: delete the `schema.points.is_empty()` early return ⇒ the loop body never + /// runs, step (4) builds a template with ZERO decisions ⇒ `is_none()` flips. + /// + /// *What wrong implementation would still pass this row?* One that also refuses a + /// fully-answered NON-empty schema — which D1-P-may's positive arm and D3's control refuse. + #[test] + fn d4_an_empty_schema_publishes_no_declaration() { + let empty = build_shortcut_schema(Vec::new(), IterationCount::Fixed(4), 4); + assert!( + empty.points.is_empty(), + "reach-guard: this fixture is the empty-schema case" + ); + let mut state = recording_state(); + // Journalled anyway: the refusal must be keyed on the EMPTY POINT SET, not on an empty + // journal, and a populated journal is the only way to tell those two apart. + state.record_loop_answer( + DecisionSlot::may(source(100)), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + assert!( + state.loop_answers_recorded() > 0, + "reach-guard: the journal is NON-empty, so the refusal below is the point set's" + ); + assert!( + build_bounded_declaration(&state, PROPOSER, &empty).is_none(), + "CR 732.2a: an offer that publishes no choice states no declaration" + ); + } +} + /// PR-7 Combo-UI Stage 2: the mid-drive pin injector (item 4) + the drive-period seam (item 6). #[cfg(test)] mod stage2_injector_tests { @@ -17299,22 +17728,34 @@ mod stage2_injector_tests { // Total (38) and partition (5/8/25) both fire GREEN first; the panic was on the third assert // alone, which is what makes this a coordinate shift rather than a population change. // - // ⚠ REBASE #3 (onto upstream/main): `:12487 ⇒ :12486`. The only shift is the - // **-1** upstream #7303 round 3 introduced ABOVE this producer (the - // `ReturnAsAuraTarget` resume arm's two raw attach calls collapsing into one call - // to the entering-Aura attachment authority, `-8 +7`). It was already folded into - // this entry at the C1 replay earlier in this same rebase; this commit's replay - // re-states it on top of the accumulated record rather than replacing that record, - // because the record is the evidence and the shift is one line of it. + // item-4 C2b (`WaitingFor::LoopShortcut.declaration`), base `1bc45bb8c`: `:12425 ⇒ :12552`, + // `+127`, and ONLY this entry moved — the other four live in `effects/` and + // `scoped_library_search.rs`, which this commit does not touch. LOCAL, not upstream, so the + // CI-vs-local diagnosis in the header does not apply. // - // FIFTH re-derivation, same method: located BY CONTENT FIRST. The line whose - // sha256 is `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63` - // still matches exactly ONE line under a whole-file scan, and it is still inside - // `begin_pending_trigger_target_selection` with no intervening `fn`. Arithmetic - // afterwards as a CHECK only: `12487 - 1`. - // ⚠ REBASE #3: `:12486 ⇒ :12491`, located by content digest, offset from + // LOCATED BY CONTENT FIRST, as this log requires: the line at `:12552` is sha256-identical + // (`8a544e878d3e77fb80391b95…`, the digest this producer has carried since `a6d1a0e62`) to + // `1bc45bb8c:game/engine.rs:12425`, and it is still inside + // `begin_pending_trigger_target_selection`, which moved by the same `+127` (opens + // `:12291 ⇒ :12418`). Arithmetic afterwards as a CHECK: `git diff -U0` on this file has five + // hunks above the producer — `+4` and `+3` (the two `declaration: None` mints with their + // reasons, in `reconcile_terminal_result` and `interactive_loop_bridge`), `+5` (the mint + // wiring in `certified_bounded_cycle_offer`), `+110` (`build_bounded_declaration` and its + // doc), and `+5` (`apply_action`'s `declaration: _` discharge and its deferral note) — which + // sum to exactly `+127`. The file's remaining two hunks (`mod bounded_declaration_tests`, + // `+302`, and one `#[cfg(test)]` field, `+1`) sit BELOW it. + // + // SET PRESERVATION: this commit adds a FIELD to `WaitingFor::LoopShortcut` and one + // declaration consumer; neither assigns `state.waiting_for` to an + // `OptionalEffectChoice`, so no line matching the needle is added or removed. The total (38) + // and the partition (5/8/25) both fired GREEN on the run that caught this; the panic was on + // this third assert alone, which is what makes it a coordinate shift rather than a + // population change. + // ⚠ REBASE #3: `:12614 ⇒ :12613`, located by content digest, offset from + // `begin_pending_trigger_target_selection` unchanged at 134. + // ⚠ REBASE #3: `:12613 ⇒ :12618`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - "game/engine.rs:12491".to_string(), + "game/engine.rs:12618".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ @@ -17935,6 +18376,7 @@ mod stage2_injector_tests { per_cycle: None, }, schema: ShortcutDecisionSchema::default(), + declaration: None, }; } diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 0f293a0bf6..2ec7002f0a 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -748,11 +748,13 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState predicted_winner, ref certificate, ref schema, + ref declaration, } = state.waiting_for { if !can_view_private_for_player(proposer) { use crate::analysis::decision_template::{ - DecisionPoint, DecisionPointKind, ShortcutDecisionSchema, + DecisionPoint, DecisionPointKind, DecisionSource, PinnedDecision, + ShortcutDecisionSchema, TargetPin, TargetSchedule, }; use crate::types::ability::TargetRef; // A target object is hidden from this viewer iff it sits in a private zone whose @@ -829,10 +831,54 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState _ => None, }) .sum(); + // CR 732.2b + CR 115.2: the responder's right is to name a place where they will + // make "a choice that's different than what's been proposed", so the proposal they + // see must be the whole proposal or none of it. A partially-redacted pin set is a + // LIE about what was proposed — it would show a shortened sequence the proposer + // never suggested — so this is ALL-OR-NOTHING: one pin naming an object this viewer + // may not see drops the entire declaration. A `TargetPin::Player` names a seat, + // which CR 115.2 makes a public legal target, and carries no hidden identity. + // + // Reuses `target_hidden` above rather than re-deriving the composite: the + // declaration's object identities and the schema's legal targets must be answerable + // by ONE hidden-info authority or the two could disagree about the same object. + let source_hidden = |source: &DecisionSource| match source { + crate::types::game_state::YieldTarget::ThisObject { source_id, .. } => { + target_hidden(*source_id) + } + // A card identity, not a live object: it names no zone occupant to hide. + crate::types::game_state::YieldTarget::AllCopies { .. } => false, + }; + let pin_hidden = |pin: &TargetPin| match pin { + TargetPin::ByIdentity(source) => source_hidden(source), + TargetPin::Player(_) => false, + TargetPin::Scheduled(schedule) => match schedule { + TargetSchedule::Constant(source) => source_hidden(source), + TargetSchedule::RoundRobin(sources) => sources.iter().any(&source_hidden), + TargetSchedule::Piecewise(steps) => { + steps.iter().any(|(_, source)| source_hidden(source)) + } + }, + }; + // Wildcard-free over `PinnedDecision`, so a future variant that carries an object + // identity gets a compile-time visit here instead of leaking silently. Every + // slot-only variant is already published unredacted as `point.slot`. + let declaration = declaration.clone().filter(|template| { + !template.decisions.iter().any(|pin| match pin { + PinnedDecision::Targets { targets, .. } => targets.iter().any(&pin_hidden), + PinnedDecision::Order { source, .. } => source_hidden(source), + PinnedDecision::Mode { .. } + | PinnedDecision::MayChoice { .. } + | PinnedDecision::UnlessBreak { .. } + | PinnedDecision::ConvokeTaps { .. } + | PinnedDecision::ManaColor { .. } => false, + }) + }); filtered.waiting_for = WaitingFor::LoopShortcut { proposer, predicted_winner, certificate: certificate.clone(), + declaration, schema: ShortcutDecisionSchema { iteration_count: schema.iteration_count.clone(), // CR 732.2a: the count bound is derived from PUBLIC board state (life, @@ -5835,4 +5881,162 @@ mod tests { assert_eq!(back.name, "Back Face"); assert_eq!(back.printed_ref, Some(back_ref)); } + + // ── item-4 C2b row D5-h — the offer's own `declaration` never crosses the viewer boundary + // carrying an object identity the viewer may not see ── + + const D5H_PROPOSER: PlayerId = PlayerId(0); + const D5H_VIEWER: PlayerId = PlayerId(1); + + /// One `LoopShortcut` offer whose declaration pins whatever `pins` builds from the HIDDEN + /// card's id. The card sits in the PROPOSER's hand, so the non-proposer viewer cannot + /// privately view its owner and `target_hidden` answers `true` for it. + /// + /// Called twice — once hidden, once all-`Player` — so the mint spells the + /// `WaitingFor::LoopShortcut` anchor exactly once (this is a counted site in + /// `tests/integration/loop_shortcut_offer_writer_census.rs`). + fn d5h_offer( + pins: impl FnOnce(ObjectId) -> Vec, + ) -> GameState { + use crate::analysis::decision_template::{ + DecisionGroupKey, DecisionKind, DecisionPoint, DecisionPointKind, DecisionSlot, + DecisionTemplate, IterationCount, PinnedDecision, ReplayMode, ShortcutDecisionSchema, + }; + let mut state = GameState::new_two_player(42); + let hidden = create_object( + &mut state, + CardId(4242), + D5H_PROPOSER, + "Secret Card".to_string(), + Zone::Hand, + ); + let slot = DecisionSlot::target(crate::types::game_state::YieldTarget::ThisObject { + source_id: ObjectId(777), + incarnation: Some(1), + trigger_description: None, + }); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: D5H_PROPOSER, + predicted_winner: None, + certificate: crate::analysis::loop_check::LoopCertificate { + unbounded: vec![], + win_kind: crate::analysis::loop_check::WinKind::LethalDamage, + mandatory: false, + residual_board_delta: crate::analysis::resource::BoardDelta::default(), + per_cycle: None, + }, + schema: ShortcutDecisionSchema { + iteration_count: IterationCount::Fixed(3), + max_iterations: 3, + points: vec![DecisionPoint { + slot: slot.clone(), + kind: DecisionPointKind::Targets { + legal_targets: vec![crate::types::ability::TargetRef::Player(D5H_VIEWER)], + min_targets: 1, + max_targets: 1, + ordered: false, + }, + }], + convoke_tappable_count: 0, + }, + declaration: Some(DecisionTemplate { + owner: D5H_PROPOSER, + decisions: vec![PinnedDecision::Targets { + slot: slot.clone(), + targets: pins(hidden), + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(3), + }, + key: DecisionGroupKey::from_sources(&[slot.source], DecisionKind::LoopChoice), + }), + }; + state + } + + /// The declaration AS PROJECTED for `viewer`. Both arms of D5-h read through here, so the + /// read also spells the census anchor exactly once. + fn d5h_projected_declaration( + state: &GameState, + viewer: PlayerId, + ) -> Option { + match filter_state_for_viewer(state, viewer).waiting_for { + WaitingFor::LoopShortcut { declaration, .. } => declaration, + other => panic!("the fixture parks on the CR 732.2a offer, got {other:?}"), + } + } + + /// **Row D5-h — a `ByIdentity` pin naming a hidden object drops the WHOLE declaration for a + /// non-proposer viewer.** + /// + /// CR 732.2b gives each other player the right to "shorten [the proposal] by naming a place + /// where they will make a game choice that's different than what's been proposed" — so what + /// they receive must be the whole proposal or none of it. A partially-redacted pin set would + /// show a sequence the proposer never suggested, which is why this is ALL-OR-NOTHING rather + /// than a per-pin filter. CR 115.2 makes a player a legal target in the open, so a + /// `TargetPin::Player` carries no hidden identity and travels unredacted. + /// + /// # This path is UNREACHABLE through today's publisher, and the row says so + /// + /// `record_trigger_target_answer` mints `ByIdentity` only for `TargetRef::Object`, and the + /// bounded publisher's own conjuncts (`TargetAnnouncement::Chosen`, and player-valued legal + /// sets on every tracked board — measured `any ByIdentity pin? false` on all five drives) + /// keep object pins out of a published slot today. The row exists so a publisher relaxation + /// cannot silently open the leak; it is not evidence that the leak is live. + /// + /// # Non-vacuity + /// + /// The all-`Player` arm is the paired positive from the SAME fixture one pin apart: a + /// redactor that dropped EVERY declaration would satisfy the hidden arm and fail this one. + /// The proposer's own projection is asserted too, so "drop it for everybody" fails twice. + /// + /// REVERT-PROBE: make the redaction's `TargetPin::ByIdentity(_)` arm answer `false` (pass + /// through unfiltered) ⇒ the hidden arm's `is_none()` flips while both positives stay green. + /// + /// *What wrong implementation would still pass this row?* One that redacts the declaration + /// but leaks the same identity through `schema.points` — that surface has its own row, + /// `loop_shortcut_schema_redacts_hidden_targets_for_non_controller`. + #[test] + fn d5h_a_hidden_object_pin_drops_the_whole_declaration_for_a_non_proposer() { + use crate::analysis::decision_template::TargetPin; + + // ── the hidden arm ── + let hidden_state = d5h_offer(|hidden| { + vec![ + TargetPin::ByIdentity(crate::types::game_state::YieldTarget::ThisObject { + source_id: hidden, + incarnation: Some(1), + trigger_description: None, + }), + TargetPin::Player(D5H_VIEWER), + ] + }); + // Reach-guards: the UNPROJECTED offer really carries a declaration (else `is_none()` + // below would be satisfied by a fixture that never had one), and the viewer really is a + // non-proposer (else the redaction block never runs at all). + assert!( + d5h_projected_declaration(&hidden_state, D5H_PROPOSER).is_some(), + "reach-guard + positive: the PROPOSER's own projection keeps the declaration, so the \ + drop below is keyed to the viewer boundary rather than to the fixture" + ); + assert_ne!(D5H_VIEWER, D5H_PROPOSER); + assert!( + d5h_projected_declaration(&hidden_state, D5H_VIEWER).is_none(), + "CR 732.2b: one pin naming an object this viewer may not see drops the ENTIRE \ + declaration — a partial pin set would state a proposal that was never made" + ); + + // ── the paired positive: every pin is a CR 115.2 seat ── + let public_state = d5h_offer(|_hidden| vec![TargetPin::Player(D5H_VIEWER)]); + assert_eq!( + d5h_projected_declaration(&public_state, D5H_VIEWER), + d5h_projected_declaration(&public_state, D5H_PROPOSER), + "an all-seat declaration is public and reaches the opponent UNCHANGED — without this \ + arm a redactor that dropped everything would pass the hidden arm above" + ); + assert!( + d5h_projected_declaration(&public_state, D5H_VIEWER).is_some(), + "and it is genuinely present, not two matching `None`s" + ); + } } diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index fe5fe171d9..add0e4c0f9 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -11087,6 +11087,33 @@ pub enum WaitingFor { /// forward-compatible deserialization of pre-schema snapshots. #[serde(default)] schema: crate::analysis::decision_template::ShortcutDecisionSchema, + /// CR 732.2a: the declaration the ENGINE itself can already specify from what this + /// window's proposer actually did — one pin per published point of `schema`, read out + /// of the `(DecisionSlot, PlayerId)` answer journal at offer construction, or `None` + /// when any published point has no single answer under the proposer's own key. + /// Built by `game::engine::build_bounded_declaration`; `None` on every other mint. + /// + /// LATCHED at offer time, not a live view: it is a snapshot of the answers the + /// detection window observed. `TargetPin::Player` is state-independent and + /// `TargetPin::ByIdentity` re-resolves live per iteration inside + /// `analysis::decision_template::resolve` (CR 608.2b), so latching the pin SET here + /// latches no per-iteration outcome. + /// + /// `#[serde(default)]` follows `schema`'s precedent on this same variant. Consequence, + /// chosen rather than discovered: a pre-declaration save decodes with `None`, which is + /// exactly today's refusal — fail-closed. ⚠ MEASURED: the attribute is BELT-AND-BRACES + /// on an `Option` field, not the mechanism — `serde_derive` routes a missing field + /// through `missing_field`, whose deserializer answers `deserialize_option` with + /// `visit_none`, so removing it changes no decode today. It is kept because it states + /// the intent at the field and becomes load-bearing the moment this stops being an + /// `Option`. Either way this field IS a client INGRESS — a restored save can carry a + /// hostile declaration, and the AI generator hands whatever it finds straight to + /// `GameAction::DeclareShortcut`. That is safe today only + /// because `handle_declare_shortcut` runs the owner firewall, `predictability_gate` + /// and `validate_pins` on EVERY declaration regardless of origin, and nothing here + /// adds a path around them. + #[serde(default)] + declaration: Option, }, /// CR 732.2b/c: the APNAP accept-or-shorten window. After the proposer declares the /// shortcut, each other living player is prompted in turn order (drain-one-advance @@ -22888,6 +22915,7 @@ mod forced_cascade_window_tests { predicted_winner: Some(PlayerId(0)), certificate: certificate(), schema: Default::default(), + declaration: None, }, ), ( diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index 705660f242..59a3eb12bb 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -313,7 +313,7 @@ fn offer_parts( /// point, `owner` and `count` supplied by the caller. /// /// This is the shape `handle_declare_shortcut` ACCEPTS (measured in -/// [`u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_shape_is_one_it_never_builds`]), +/// [`u6_the_generators_own_candidate_opens_the_window_and_the_accepted_shape_is_measured`]), /// so every row that needs either an accepted declaration or a one-axis hostile variant of one /// builds it here rather than re-deriving the mapping. Keyed off `schema.points` — never off a /// hard-coded slot — so a re-dump that renumbers objects, or a remedy that widens the announced @@ -1741,6 +1741,207 @@ fn c2a_row_t1p_the_journalled_pin_follows_the_announced_seat_not_a_constant() { } } +/// The declaration the live offer PUBLISHES. A separate accessor rather than a fourth element +/// on [`offer_parts`], so the ~20 existing callers of that helper are untouched. +fn offer_declaration( + state: &GameState, +) -> Option { + match &state.waiting_for { + WaitingFor::LoopShortcut { declaration, .. } => declaration.clone(), + other => panic!("expected the CR 732.2a bounded offer, got {other:?}"), + } +} + +/// **Row D1 — WIRE / CONFORMANCE.** The bounded offer publishes a `Some` declaration that +/// CONFORMS to the reference shape this suite already accepts, on all three tracked dumps. +/// +/// ⚠ **THIS IS A CONFORMANCE ORACLE, NEVER A PROVENANCE ONE.** [`f4_pin_template`] is a pure +/// function of `(schema, owner, count)` — it hard-codes `MayChoiceOption::Take` and +/// `TargetPin::Player(P1)` and never reads the journal — so a consumer that ignored the journal +/// entirely and emitted those same constants passes this row. That is exactly what +/// [`d1p_the_published_pin_follows_the_journal_not_a_constant`] and its P3 sibling are for. +/// +/// # The count trap, measured +/// +/// The reference must be built with `count = schema.max_iterations`, NOT the `1` every other +/// declare row in this file passes: `build_bounded_declaration` sets +/// `replay: Scheduled { count: schema.iteration_count }`, and `certified_bounded_cycle_offer` +/// builds the schema with `IterationCount::Fixed(max_iterations)`. Measured on all three boards: +/// `REAL == f4_pin_template(count = 1)` is FALSE and `REAL == f4_pin_template(count = max)` is +/// TRUE. +/// +/// # Reach-guards, asserted BEFORE the claim +/// +/// The journal holds at least one answer per published point (else `declaration.is_some()` +/// could only ever be the empty-schema path), the point count is the board's known one, and the +/// bound is the board's measured `max_iterations` — which is also the reference's count. +/// +/// REVERT-PROBE: make `build_bounded_declaration`'s `(Targets, Targets)` arm `return None` ⇒ +/// `is_some()` flips on all three boards. +#[test] +fn d1_the_bounded_offer_publishes_a_conformant_declaration_on_every_tracked_dump() { + use engine::analysis::decision_template::{predictability_gate, validate_pins}; + + for (label, mut state, expected_points, expected_max) in [ + ("F4", load_f4(), 3usize, 18u32), + ("MODE1", load_mode1(), 2, 17), + ("MODE2", load_mode2(), 3, 16), + ] { + let beat = drive_f4_to_offer(&mut state, 400) + .unwrap_or_else(|| panic!("[{label}] REACH-GUARD: the bounded offer must FIRE")); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + + assert!( + state.loop_answers_recorded() >= schema.points.len(), + "[{label}] REACH-GUARD: every published point must have an answer in the journal, \ + else a `Some` declaration below could not be about this schema at all. recorded={} \ + points={}", + state.loop_answers_recorded(), + schema.points.len() + ); + assert_eq!( + schema.points.len(), + expected_points, + "[{label}] REACH-GUARD: the published point count at beat {beat}" + ); + assert_eq!( + schema.max_iterations, expected_max, + "[{label}] REACH-GUARD: the CR 704.5a-derived bound — and the count the reference \ + below must be built with" + ); + + let declaration = offer_declaration(&state) + .unwrap_or_else(|| panic!("[{label}] the offer publishes a declaration")); + assert_eq!( + declaration, + f4_pin_template(&schema, proposer, schema.max_iterations), + "[{label}] CR 732.2a: the published declaration must CONFORM to the shape this \ + suite's accepted declarations take — one pin per published point, owner == \ + proposer, `replay.count` == the offer's own suggestion" + ); + + let required: Vec<_> = schema.points.iter().map(|p| p.slot.clone()).collect(); + assert!( + predictability_gate(&declaration, &required).is_ok(), + "[{label}] the published declaration covers every published slot — the coverage half \ + of the declare-time firewall" + ); + assert!( + validate_pins(&schema, &declaration, 1, &state).is_ok(), + "[{label}] and its pin VALUES are legal at iteration 1" + ); + assert!( + validate_pins(&schema, &declaration, schema.max_iterations, &state).is_ok(), + "[{label}] and at the full declared range — the count the AI's candidate carries" + ); + } +} + +/// **Row D1-P — WIRE / PROVENANCE.** The declaration's pinned target FOLLOWS THE JOURNAL, not a +/// constant: driving the SAME dump with the SAME policy but a different aimed seat publishes a +/// different pin at the same published slot. +/// +/// This is the CONSUMER-tier sibling of +/// [`c2a_row_t1p_the_journalled_pin_follows_the_announced_seat_not_a_constant`] (the WRITER-tier +/// row) and reuses its two helpers, so the drive is production `apply()` and the seat is +/// ANNOUNCED at Torch's CR 601.2c choice, never injected. +/// +/// # The asymmetry IS the row +/// +/// On the shipped P1 board, replacing the journalled targets with the constant +/// `vec![TargetPin::Player(PlayerId(1))]` is GREEN — that mutant is indistinguishable there. +/// At a second seat it is RED. Only a second seat discriminates a journal-blind consumer. +/// +/// # Reach-guards, asserted BEFORE the claim +/// +/// The offer fires at the aimed seat, the point set is the known one, the aimed seat is not the +/// proposer's own (or a writer that stored the PROMPT's seat would satisfy the row), and the +/// `Targets` point's journal entry already reads the aimed seat before the consumer is called. +/// +/// REVERT-PROBE: in `build_bounded_declaration`'s `(Targets, Targets)` arm, replace the +/// journalled `targets` with `vec![TargetPin::Player(PlayerId(1))]` ⇒ this row flips on the pin +/// VALUE while D1 stays green. +/// +/// *What wrong implementation would still pass this row?* One that reads the journal but ignores +/// `point.slot` — there is one `Targets` point here, so the slot axis is D1-P-may's and D3's. +#[test] +fn d1p_the_published_pin_follows_the_journal_not_a_constant() { + d1p_provenance_at_seat(PlayerId(2)); +} + +/// **Row D1-P-sib** — the same claim at a THIRD seat, so the provenance cannot be a coincidence +/// of one seat's numbering. +#[test] +fn d1p_sib_the_published_pin_provenance_is_not_specific_to_one_second_seat() { + d1p_provenance_at_seat(PlayerId(3)); +} + +fn d1p_provenance_at_seat(aimed: PlayerId) { + use engine::analysis::decision_template::{ + validate_pins, LoopAnswer, LoopAnswerValue, PinnedDecision, TargetPin, + }; + + assert_ne!( + aimed, P1, + "reach-guard: the aimed seat must differ from the shipped policy's, else this re-runs D1 \ + and discriminates nothing" + ); + let mut state = load_f4(); + let beat = drive_f4_to_offer_at(&mut state, 400, aimed).expect( + "reach-guard: a CONSTANT non-P1 target still certifies — it is the VARIATION between \ + iterations, not the seat, that blocks the CR 732.2a offer", + ); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + assert_ne!( + aimed, proposer, + "reach-guard: the aimed seat must not be the proposer's own" + ); + assert_eq!( + schema.points.len(), + 3, + "reach-guard: the published point set at beat {beat}" + ); + + let target_slot = schema + .points + .iter() + .find(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .map(|p| p.slot.clone()) + .expect("reach-guard: the offer publishes a CR 601.2c Targets point"); + // The WRITER's own output, asserted BEFORE the consumer runs: without this the row could not + // tell "the consumer ignored the journal" from "the journal never held the aimed seat". + assert_eq!( + state.loop_answer(&target_slot, proposer), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + TargetPin::Player(aimed) + ]))), + "reach-guard: the journal holds the ANNOUNCED seat {aimed:?} at the published slot" + ); + + let declaration = offer_declaration(&state).expect("the offer publishes a declaration"); + let pinned = declaration + .decisions + .iter() + .find_map(|pin| match pin { + PinnedDecision::Targets { slot, targets } if *slot == target_slot => Some(targets), + _ => None, + }) + .expect("the declaration pins the published Targets slot"); + assert_eq!( + *pinned, + vec![TargetPin::Player(aimed)], + "PROVENANCE: the declaration must pin the seat this drive ANNOUNCED ({aimed:?}), not the \ + seat the shipped policy happens to aim at" + ); + assert!( + validate_pins(&schema, &declaration, 1, &state).is_ok(), + "and the journal-derived pin is LEGAL against the offer's own schema — otherwise a \ + provenance-correct consumer could still be publishing an unusable declaration" + ); +} + /// **Row 2b — JOURNAL TIER.** CR 603.5: ONE seat answering ONE source two different ways /// inside one detection window latches [`LoopAnswer::Conflicted`]. /// @@ -2028,48 +2229,41 @@ fn optional_entries(state: &GameState) -> usize { // never emits — they do not assert the planned prediction. // ───────────────────────────────────────────────────────────────────────────────────────── -/// §5 U6 (i) — MEASURED: at the real F4 bounded offer the engine's AI candidate generator -/// emits exactly ONE action, `DeclineShortcut`. It offers no declaration at all. +/// **Row D6 — WIRE / POSITIVE.** At the real F4 bounded offer the AI candidate generator now +/// emits `DeclareShortcut { Fixed(max_iterations), Some(declaration) }` beside the decline, and +/// the `template` it carries IS THE OFFER'S OWN published declaration — not one the AI built. /// -/// Both declare candidates are excluded, each by a different conjunct, and this board trips -/// both at once: +/// ⚠ **THIS ROW'S PREVIOUS CLAIM WAS THE OPPOSITE, AND IT IS SUPERSEDED, NOT BROKEN.** As +/// `u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only` it asserted +/// `assert_eq!(actions, vec![GameAction::DeclineShortcut])` — that the generator could offer no +/// declaration at all, because its only `Fixed` candidate carried `template: None` and a +/// published pin set fail-closes on that. Publishing the offer's own declaration is exactly the +/// capability item-4 C2b adds, so the old assertion asserted the ABSENCE of this commit's +/// subject. The name had to change with it: "decline only" is now false on this board. /// -/// * `UntilLethal` is gated on `!schema.is_bounded()`. CR 732.2a: a count-free declaration -/// names no legal repetition number against an offer that narrowed the bound, and -/// `handle_declare_shortcut` refuses it — measured in -/// [`u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_shape_is_one_it_never_builds`]. -/// * `Fixed(max_iterations)` is gated on `schema.points.is_empty()` — it carries -/// `template: None`, and a published pin set fail-closes on that. F4 publishes THREE points -/// (`r1b`), so the gate is closed with room to spare; ONE would already have closed it. +/// # What is kept, and why /// -/// So the AI declines because it has nothing else it can legally say, not because it emitted a -/// declaration the engine then accepted-and-discarded. +/// Both reach-guards survive verbatim and have flipped from exclusion conjuncts to POSITIVE +/// ones: `is_bounded()` is the count gate, and a NON-empty `points` set is what makes the +/// declaration (rather than the empty-schema `None`) the reason the candidate appears. The +/// `predicted_winner == None` guard stays as a measured property of this board. /// -/// # Reach-guards (each excludes a way this could pass degenerately) +/// # Non-vacuity +/// +/// The template is asserted EQUAL to `offer_declaration(&state)`, never merely `Some(_)`: a +/// generator that fabricated its own conformant-looking template would satisfy `is_some()` and +/// fail this. `d6n_a_points_carrying_offer_without_a_declaration_enumerates_only_decline` +/// (in-crate, `ai_support/candidates.rs`) is the paired negative — with `declaration: None` the +/// candidate must NOT appear. /// -/// * the offer is BOUNDED (`is_bounded()`, bound narrowed below the ceiling) — that is the -/// `UntilLethal` gate's conjunct; on an unbounded offer that candidate would be PRESENT, so -/// without this guard the row could pass on a board where it was never at issue; -/// * `schema.points` is NON-empty — that is the `Fixed` gate's conjunct, and symmetrically the -/// row would otherwise pass on a board where `Fixed` was never at issue; -/// * `predicted_winner` is `None`. Recorded as a measured property of this board, NOT as -/// reachability for `phase_ai::policies::loop_shortcut::LoopShortcutPolicy`'s -/// `(None, UntilLethal) => reject` arm: since the bounded gate landed, this generator can no -/// longer put that pair in front of the policy from a bounded offer, and -/// `declare_until_lethal_with_no_predicted_winner_is_rejected` covers the arm directly. -/// -/// REVERT-PROBE — one per excluded candidate, because a single probe would leave the OTHER -/// exclusion holding the assertion up and report a false pass: -/// -/// * drop `!schema.is_bounded()` from the `UntilLethal` push in `ai_support/candidates.rs` -/// ⇒ `DeclareShortcut { UntilLethal, None }` reappears ⇒ this row FLIPS on the equality; -/// * drop `schema.points.is_empty() &&` from the `Fixed` push ⇒ `Fixed(max_iterations)` -/// appears ⇒ this row FLIPS on the equality. +/// REVERT-PROBE: drop the `|| declaration.is_some()` disjunct from the generator's gate ⇒ the +/// candidate disappears against this points-carrying offer ⇒ the equality flips. #[test] -fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { +fn d6_the_ai_declare_candidate_carries_the_offers_own_published_declaration() { let mut state = load_f4(); drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); let WaitingFor::LoopShortcut { predicted_winner, .. } = &state.waiting_for @@ -2079,31 +2273,39 @@ fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { assert!( schema.is_bounded() && schema.max_iterations < MAX_SHORTCUT_CYCLES_MIRROR, - "reach-guard: the generator's `Fixed` candidate is gated on `is_bounded()` too, so an \ - unbounded offer would exclude it for the wrong reason. bounded={} max_it={}", + "reach-guard: the generator's `Fixed` candidate is gated on `is_bounded()`, so an \ + unbounded offer would decide this row for the wrong reason. bounded={} max_it={}", schema.is_bounded(), schema.max_iterations ); assert!( !schema.points.is_empty(), - "reach-guard: a NON-empty published pin set is the conjunct this row is about" + "reach-guard: a NON-empty published pin set is the conjunct this row is about — with \ + `points` empty the candidate appears regardless of the declaration" ); assert_eq!( *predicted_winner, None, - "reach-guard + REACHABILITY for `phase_ai::policies::loop_shortcut`: the F4 offer \ - latches NO predicted winner, which is what routes its `(None, UntilLethal)` reject arm" + "reach-guard: the F4 offer latches NO predicted winner (a measured property of this \ + board, recorded so a future board swap is visible)" + ); + let declaration = offer_declaration(&state).expect( + "reach-guard: the offer PUBLISHES a declaration — that is the generator's new input", ); // ── the seam: `phase-ai/src/search.rs` `WaitingFor::LoopShortcut { .. } =>` calls this ── let actions = engine::ai_support::legal_actions(&state); assert_eq!( actions, - vec![GameAction::DeclineShortcut], - "MEASURED: exactly one candidate. No `UntilLethal` declaration (gated on \ - `!schema.is_bounded()`, and this offer narrowed its bound to {}), no `Fixed` \ - declaration (gated on `schema.points.is_empty()`, and this schema publishes {} \ - point(s)), and no declaration carrying a template at all — so the AI cannot pin the \ - point the offer DID publish", + vec![ + GameAction::DeclareShortcut { + count: IterationCount::Fixed(schema.max_iterations), + template: Some(declaration.clone()), + }, + GameAction::DeclineShortcut, + ], + "CR 732.2a: exactly two candidates. No `UntilLethal` declaration (gated on \ + `!schema.is_bounded()`, and this offer narrowed its bound to {}), and the `Fixed` \ + declaration carries the ENGINE'S OWN pin set for the {} published point(s)", schema.max_iterations, schema.points.len() ); @@ -2111,24 +2313,15 @@ fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { // Stated separately from the equality above so a future generator change that adds an // unrelated candidate reports the interesting fact rather than a diff of two long vectors. assert!( - !actions.iter().any(|a| matches!( - a, - GameAction::DeclareShortcut { - count: IterationCount::Fixed(_), - .. - } - )), - "no `Fixed` candidate is generated against a points-carrying offer" - ); - assert!( - !actions.iter().any(|a| matches!( + actions.iter().any(|a| matches!( a, GameAction::DeclareShortcut { - template: Some(_), - .. - } + count: IterationCount::Fixed(n), + template: Some(t), + } if *n == schema.max_iterations && *t == declaration )), - "the generator never builds a pin template — that is the capability §5 U6 asks about" + "the candidate's template is the offer's own declaration, VALUE-EQUAL — a fabricated \ + template of the same shape would fail here and pass an `is_some()` check" ); assert_eq!( proposer, P0, @@ -2136,11 +2329,25 @@ fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { ); } -/// §5 U6 (ii) — the branch that fires, and the MEASURED reason it fires. +/// §5 U6 (ii) — the generator's OWN candidate now opens the CR 732.2b window, and the four +/// one-axis declare drives that say why. +/// +/// ⚠ **THIS ROW'S PREVIOUS CLAIM WAS THAT THE CAPABILITY WAS ABSENT, AND IT IS SUPERSEDED, NOT +/// BROKEN.** As `u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_ +/// shape_is_one_it_never_builds` its candidate loop asserted that EVERY AI candidate lands on +/// `WaitingFor::Priority` — *"a `RespondToShortcut` here would mean the AI CAN open the +/// CR 732.2b window, which is the capability this row measures absent"*. That capability is +/// exactly what item-4 C2b adds, so the loop now asserts the complementary fact, still +/// RE-DERIVED from the generator rather than hand-named: the declare candidate opens the window +/// and the decline hands priority back. /// -/// Every action the AI can take at this offer hands priority straight back; the accepted shape -/// is one the generator never emits. Four declarations are driven through `apply()` on the SAME -/// real offer board, differing one axis at a time: +/// **The four one-axis drives below are UNCHANGED and remain the engine-side guards the +/// generator's gate depends on** — in particular `Fixed(max) + None ⇒ Priority`, which is a +/// LIVE fail-closed guard (resolving a `template: None` declaration against the offer's own +/// declaration is a declare-handler change deliberately out of this commit's partition). +/// +/// Four declarations are driven through `apply()` on the SAME real offer board, differing one +/// axis at a time: /// /// | declaration | measured | /// |---|---| @@ -2159,7 +2366,7 @@ fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { /// Reed's `may` was unpublished). Splitting the two keeps this row a DECLARE-time matrix. /// /// The `UntilLethal` rows are what justifies the generator's `!schema.is_bounded()` gate -/// ([`u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only`]): the engine refuses that count +/// ([`d6_the_ai_declare_candidate_carries_the_offers_own_published_declaration`]): the engine refuses that count /// against a narrowed bound on a real board, so emitting it was offering the search layer an /// action that is accepted-then-discarded. These rows keep measuring the ENGINE guard directly, /// which is the fact the generator gate depends on and must not be allowed to rot. @@ -2178,8 +2385,7 @@ fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { /// The row asserts both arms for exactly that reason: a single-guard probe would report the /// AI's candidate as still-refused and hide the change. #[test] -fn u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_shape_is_one_it_never_builds( -) { +fn u6_the_generators_own_candidate_opens_the_window_and_the_accepted_shape_is_measured() { let mut state = load_f4(); drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); let (proposer, _certificate, schema) = offer_parts(&state); @@ -2194,27 +2400,43 @@ fn u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_ state.last_loop_action_sequence.len() ); - // Every AI candidate, driven through the public boundary. Since the bounded gate landed this - // set is `[DeclineShortcut]` alone, so on its own the loop is a WEAK statement — it is the - // four one-axis drives below that carry this row. Kept because it is the only assertion here - // that re-derives the candidate set from the generator rather than naming shapes by hand: a - // future generator change that reintroduces a declaration at this node has to survive it. + // Every AI candidate, driven through the public boundary and dispatched on its own SHAPE, + // so the expectation is re-derived from the generator rather than named by hand: a future + // generator change at this node has to survive it. let candidates = engine::ai_support::legal_actions(&state); assert!( !candidates.is_empty(), "positive control: an EMPTY candidate set would satisfy the loop below vacuously" ); + let mut opened_the_window = 0usize; for action in candidates { let mut probe = state.clone(); apply(&mut probe, proposer, action.clone()).expect("dispatched — refusal is a HANDBACK"); - assert!( - matches!(probe.waiting_for, WaitingFor::Priority { .. }), - "CR 800.4a: the AI candidate {action:?} hands priority back. A \ - `RespondToShortcut` here would mean the AI CAN open the CR 732.2b window, which \ - is the capability this row measures absent. got {:?}", - probe.waiting_for - ); + match &action { + GameAction::DeclareShortcut { .. } => { + opened_the_window += 1; + assert!( + matches!(probe.waiting_for, WaitingFor::RespondToShortcut { .. }), + "CR 732.2b: the generator's own declare candidate {action:?} must OPEN the \ + accept-or-shorten window — it carries the engine's published declaration, \ + which is the shape the accepted-control arm below proves the engine takes. \ + A `Priority` here means the AI is enumerating an action the engine refuses. \ + got {:?}", + probe.waiting_for + ); + } + _ => assert!( + matches!(probe.waiting_for, WaitingFor::Priority { .. }), + "CR 800.4a: the decline candidate {action:?} hands priority back, got {:?}", + probe.waiting_for + ), + } } + assert_eq!( + opened_the_window, 1, + "reach-guard for the loop above: EXACTLY ONE candidate is a declaration, so neither arm \ + of the match is vacuous" + ); let outcome = |count: IterationCount, template: Option<_>| { let mut probe = state.clone(); diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 199e773e0c..1d3e0f8412 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -2365,6 +2365,7 @@ fn loop_shortcut_zero_max_iterations_is_rejected_not_clamped() { max_iterations, ..Default::default() }, + declaration: None, }; bind(&mut state, "loop-zero-bound"); state @@ -2426,6 +2427,7 @@ fn loop_shortcut_narrowed_max_iterations_bounds_the_picker() { max_iterations: 3, ..Default::default() }, + declaration: None, }; bind(&mut state, "loop-narrowed-bound"); @@ -2474,6 +2476,7 @@ fn loop_shortcut_number_schema_accepts_a_fixed_count_above_one() { // No narrowed CR 732.2a bound — `Default` carries the global cap. ..Default::default() }, + declaration: None, }; bind(&mut state, "loop-count"); let view = priority_view(&state); @@ -2570,6 +2573,7 @@ fn loop_shortcut_schema_and_materializer_cover_every_decision_point_kind() { ], convoke_tappable_count: 1, }, + declaration: None, }; bind(runner.state_mut(), "loop-point-kinds"); diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 3c2b251bfb..8a70adec3c 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -1079,12 +1079,14 @@ fn loop_shortcut_acting_player_reads_proposer() { predicted_winner: Some(P0), certificate: cert.clone(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; let wf_b = WaitingFor::LoopShortcut { proposer: P2, predicted_winner: None, certificate: cert.clone(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; assert_eq!(wf_a.acting_player(), Some(P1)); assert_eq!(wf_b.acting_player(), Some(P2)); @@ -1118,6 +1120,7 @@ fn loop_shortcut_acting_player_reads_proposer() { predicted_winner: Some(P0), certificate: cert.clone(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; apply(&mut delegated, P0, GameAction::DeclineShortcut) .expect("the turn controller may submit the priority holder's decline"); @@ -1691,6 +1694,7 @@ fn injected_3p_one_faller_no_crown() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -1832,6 +1836,7 @@ fn declare_illegal_pin_falls_back_legal_ingests() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema: schema.clone(), + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -1852,6 +1857,7 @@ fn declare_illegal_pin_falls_back_legal_ingests() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema, + declaration: None, }; runner2 .act(GameAction::DeclareShortcut { @@ -1908,6 +1914,7 @@ fn injected_3p_unequal_life_pin_all_no_crown() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -4297,6 +4304,7 @@ fn loop_shortcut_schema_redacts_hidden_targets_for_non_controller() { predicted_winner: Some(P0), certificate: cert, schema, + declaration: None, }; let targets_of = |wf: &WaitingFor| -> Vec { @@ -6513,6 +6521,7 @@ fn template_none_against_a_pin_consuming_schema_falls_back_to_manual_play() { predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema: schema.clone(), + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -8356,6 +8365,7 @@ fn bounded_offer_parts( predicted_winner: None, certificate, schema, + declaration: _, } => (*proposer, certificate, schema), other => panic!("expected a bounded LoopShortcut offer, got {other:?}"), } @@ -11301,6 +11311,7 @@ fn g1_declare_verdict( predicted_winner: Some(P0), certificate: synthetic_lethal_cert(), schema, + declaration: None, }; runner .act(GameAction::DeclareShortcut { @@ -11988,6 +11999,7 @@ fn r28_empty_schema_offer(runner: &mut GameRunner) { predicted_winner, certificate, schema, + declaration: _, } = runner.state().waiting_for.clone() else { panic!("staged from the live offer, never from thin air"); @@ -12000,6 +12012,11 @@ fn r28_empty_schema_offer(runner: &mut GameRunner) { points: vec![], ..schema }, + // `None` is a RULING, not a default. Passing the live field through would stage the R5 + // board's measured `Some` declaration into a control whose whole purpose is to be + // declaration-free — and it would contradict the invariant that an empty schema + // publishes no declaration, at the very fixture that stages an empty schema. + declaration: None, }; } @@ -12015,6 +12032,7 @@ fn r28_nonempty_schema_offer(runner: &mut GameRunner, slot: DecisionSlot) { predicted_winner, certificate, schema, + declaration: _, } = runner.state().waiting_for.clone() else { panic!("staged from the live offer, never from thin air"); @@ -12039,6 +12057,10 @@ fn r28_nonempty_schema_offer(runner: &mut GameRunner, slot: DecisionSlot) { }], ..schema }, + // Same ruling as [`r28_empty_schema_offer`]: both helpers exist to stage `schema.points` + // and NOTHING else, so the two must keep differing in exactly one field. Staging a live + // `Some` here would add a second axis to a pair whose whole value is being one apart. + declaration: None, }; } @@ -12361,22 +12383,41 @@ fn r28_c_a_restored_proposal_with_a_foreign_template_owner_is_refused_at_consump /// candidate was emitted), and the answer-beat sampling site now announces the Bond's trigger /// entry, so it publishes one `Targets` point. This row is the pin for that transition. /// -/// * **arm (a), the live board:** one published point ⇒ the candidate set is `DeclineShortcut` -/// alone, and specifically carries NO `Fixed` declaration. +/// * **arm (a), the live board:** one published point, and the offer carries the engine's own +/// declaration for it ⇒ the `Fixed` candidate is emitted CARRYING THAT DECLARATION. /// * **arm (b), the POSITIVE CONTROL, same board one field apart:** stage the schema's `points` -/// empty ([`r28_empty_schema_offer`]) ⇒ the `Fixed` candidate RETURNS. Without this arm, -/// arm (a) would be satisfied by a generator that had stopped emitting `Fixed` for any -/// reason at all — including not running. +/// empty ([`r28_empty_schema_offer`]) ⇒ the `Fixed` candidate is emitted with `template: None`. +/// Without this arm, arm (a) would be satisfied by a generator that emitted `Fixed` +/// unconditionally. +/// +/// ⚠ **ARM (a)'S PREVIOUS CLAIM WAS THE OPPOSITE, AND IT IS SUPERSEDED, NOT BROKEN.** As +/// `ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin` it asserted +/// `assert_eq!(live, vec![GameAction::DeclineShortcut])` — that a published pin set WITHDREW the +/// declare candidate, because the only declaration the generator could emit carried +/// `template: None` and would be accepted-then-discarded. item-4 C2b gives the generator the +/// offer's own declaration to carry, so the withdrawal is exactly the behaviour this commit +/// replaces, and the name had to stop saying "withdraws". +/// +/// **ARM (b) IS BYTE-IDENTICAL AND THAT IS EARNED, NOT LUCK.** [`r28_empty_schema_offer`] is a +/// rest-less destructure plus a rebuild literal, so C2b had to CHOOSE a value for `declaration` +/// there; it passes `None`. Threading the live field through would stage this board's measured +/// `Some` declaration into a control that exists to be declaration-free, and arm (b)'s +/// `template: None` match would fail. See that helper's own comment. /// /// Both arms read the ENGINE's candidate set through `legal_actions`, the same seam /// `phase-ai`'s search calls, so this is not a re-implementation of the gate agreeing with /// itself. The row is deliberately NOT `#[ignore]`d: the two pre-existing `phase-ai` bounded /// rows are, and an ignored row reports `ok` while executing nothing. #[test] -fn ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin() { +fn ai1_the_bounded_declare_candidate_carries_the_offers_own_pin_when_one_is_published() { // ── arm (a): the LIVE offer, which now publishes one point ── let (mut runner, _slot, _bond, _hexproof, _lives) = r5_reach_offer(); - let WaitingFor::LoopShortcut { schema, .. } = runner.state().waiting_for.clone() else { + let WaitingFor::LoopShortcut { + schema, + declaration, + .. + } = runner.state().waiting_for.clone() + else { panic!("r5_reach_offer returns at the offer"); }; assert!( @@ -12390,13 +12431,23 @@ fn ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin() "REACH-GUARD: the published pin set is the conjunct this row is about; got {:?}", schema.points ); + let declaration = declaration.expect( + "REACH-GUARD: this board's proposer answered its one published point, so the offer \ + publishes a declaration — without one arm (a) would measure the fail-closed path \ + `d6n_a_points_carrying_offer_without_a_declaration_enumerates_only_decline` covers", + ); let live = engine::ai_support::legal_actions(runner.state()); assert_eq!( live, - vec![GameAction::DeclineShortcut], - "AI1(a): against a points-carrying bounded offer the ONLY legal candidate is the \ - decline — a `template: None` declaration is accepted and then discarded by \ - `handle_declare_shortcut`, which is worse than no candidate at all" + vec![ + GameAction::DeclareShortcut { + count: IterationCount::Fixed(schema.max_iterations), + template: Some(declaration), + }, + GameAction::DeclineShortcut, + ], + "AI1(a): against a points-carrying bounded offer that HAS a declaration, the generator \ + emits it — carrying the ENGINE's own pin set, never one the AI built" ); // ── arm (b): the POSITIVE CONTROL — the same board with an EMPTY point set ── @@ -12421,6 +12472,110 @@ fn ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin() ); } +/// **Row D7 — a PRE-DECLARATION save decodes with `declaration: None`, i.e. today's refusal.** +/// +/// CR 732.2a. `WaitingFor::LoopShortcut.declaration` carries `#[serde(default)]`, following +/// `schema`'s precedent on the same variant. The consequence is CHOSEN, not discovered: a +/// snapshot written before this field existed decodes with `None`, the AI's declare candidate +/// stays withheld (`declaration.is_some()` is false) and the human path is unchanged — the same +/// behaviour that shipped before the field. Fail-closed by construction. +/// +/// # Non-vacuity +/// +/// The positive control is the round-trip WITH the key present: a decoder that always produced +/// `None` — or a `declaration` that never serialized at all — fails it. And the key's removal is +/// asserted to have actually removed something, so a typo in the field name cannot make the +/// "old save" arm pass by decoding an unmodified payload. +/// +/// # ⚠ REVERT-PROBE, MEASURED — and the OBVIOUS probe is INERT, which is why it is named here +/// +/// Deleting `#[serde(default)]` from the field does **NOT** red this row: measured, the stripped +/// payload still decodes and this test still passes. `serde_derive` routes a missing field +/// through `serde::__private::de::missing_field`, whose deserializer answers `deserialize_option` +/// with `visit_none` — so an `Option` field is already missing-tolerant, and the attribute is +/// belt-and-braces here (it follows `schema`'s precedent on the same variant and states the +/// intent explicitly; it becomes load-bearing the moment the field stops being an `Option`). +/// +/// The two probes that DO red this row, one per arm, both RUN: +/// +/// * `#[serde(skip)]` in place of `#[serde(default)]` ⇒ the declaration never reaches the wire +/// ⇒ the POSITIVE CONTROL round-trip fails (a `Some(..)` decodes back as `None`); +/// * `#[serde(default = "…")]` pointing at a function returning `Some(..)` ⇒ the stripped +/// payload decodes with a fabricated declaration ⇒ the old-save arm's `matches!` fails. +#[test] +fn d7_a_pre_declaration_save_decodes_with_no_declaration() { + let slot = DecisionSlot::target(YieldTarget::ThisObject { + source_id: ObjectId(881), + incarnation: Some(1), + trigger_description: None, + }); + let offer = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: None, + certificate: synthetic_lethal_cert(), + schema: ShortcutDecisionSchema { + iteration_count: IterationCount::Fixed(3), + max_iterations: 3, + points: vec![DecisionPoint { + slot: slot.clone(), + kind: DecisionPointKind::Targets { + legal_targets: vec![TargetRef::Player(P1)], + min_targets: 1, + max_targets: 1, + ordered: false, + }, + }], + convoke_tappable_count: 0, + }, + declaration: Some(DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::Player(P1)], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(3), + }, + key: DecisionGroupKey::from_sources(&[slot.source], DecisionKind::LoopChoice), + }), + }; + + let mut json = serde_json::to_value(&offer).expect("the offer serializes"); + // POSITIVE CONTROL: with the key present the declaration survives the wire intact. + assert_eq!( + serde_json::from_value::(json.clone()).expect("round-trips"), + offer, + "a live offer's declaration must survive serialization — otherwise the `None` below \ + would prove nothing about the DEFAULT" + ); + + // The pre-C2b payload: the same offer with no `declaration` key at all. + let removed = json["data"] + .as_object_mut() + .expect("the adjacently-tagged payload is an object") + .remove("declaration"); + assert!( + removed.is_some(), + "reach-guard: the key must have been present to remove, else the 'old save' arm below \ + decodes an unmodified payload and asserts nothing" + ); + let decoded: WaitingFor = serde_json::from_value(json).expect( + "an OLD save must still decode (CR 732.2a offers \ + predate this field)", + ); + assert!( + matches!( + decoded, + WaitingFor::LoopShortcut { + declaration: None, + .. + } + ), + "the forward-compatible default is `None`, which is today's refusal — fail-closed. got \ + {decoded:?}" + ); +} + // ───── PR #7005 maintainer item: the answer-beat sampler records the SYNCHRONIZED window ───── /// CR 732.2a. `game::engine::apply_action`'s forced-window ANSWER sampler (the site gated on diff --git a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs index b973a188ea..429792f221 100644 --- a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs +++ b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs @@ -684,6 +684,7 @@ fn loop_action_sequence_conditional_load_migration() { per_cycle: None, }, schema: ShortcutDecisionSchema::default(), + declaration: None, }; at_offer.last_loop_action_sequence = vec![pinned_step()]; let json = serde_json::to_string(&at_offer).expect("serialize offer save"); diff --git a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs index 09596bcda8..70a491e442 100644 --- a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs @@ -230,7 +230,7 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid assert_eq!( (production.len(), in_test.len()), - (22, 17), + (22, 21), "CR 732.2a OFFER-WRITER SURFACE CHANGED (not re-measured — this number is an \ INVARIANCE pin over the whole 5d U-series).\n\ The three CERTIFICATION-PATH writers are `reconcile_terminal_result` (object-growth \ @@ -261,6 +261,27 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid multiset, and that conjunct is what makes this the benign case rather than a surface \ change; if it moves again, name the new site here too rather than only moving the \ number.\n\ + FIFTH ADJUDICATION, 17 => 21: item-4 C2b publishes the bounded offer's own CR 732.2a \ + `declaration` on `WaitingFor::LoopShortcut`, and its two in-crate rows spell the anchor \ + FOUR times between them. Named individually, because this census counts LINES (its \ + `classify()` is `line.contains(needle)`, deliberately replacing a construction-shaped \ + detector), so a mint and a read of the same fixture are two counted sites: (1) \ + `engine/src/game/visibility.rs` row D5-h's MINT, `d5h_offer` — one local helper called \ + twice, staging a declaration whose pins are all-seat vs. one naming a hidden object; \ + (2) `engine/src/game/visibility.rs` row D5-h's READ, `d5h_projected_declaration` — one \ + local helper destructuring `filter_state_for_viewer(..).waiting_for`, called three \ + times (hidden/viewer, hidden/proposer, public); (3) \ + `engine/src/ai_support/candidates.rs` row D6-n's MINT, `d6n_offer` — one local helper \ + called twice, `declaration: None` vs `Some(..)` and nothing else; (4) \ + `engine/src/ai_support/candidates.rs` row D6-n's READ — the reach-guard destructure \ + that asserts the staged offer really `is_bounded()` and really publishes a point \ + BEFORE the negative claim, without which that row would pass on the wrong conjunct. \ + Site (4) was NOT predicted (the plan budgeted 20 on the reading that D6-n's assertions \ + touch only `legal_actions`); the measurement wins and the site is named rather than the \ + row contorted to hit the budget. ALL FOUR ARE `#[cfg(test)]` FIXTURES: none writes an \ + offer the period machinery can certify. PRODUCTION STAYED AT 22 with an IDENTICAL \ + per-file multiset — C2b adds the field INSIDE existing literals and patterns and \ + introduces no new production anchor line.\n\ measured per-file production multiset: {multiset:?}\n\ production: {production:?}\n\ test: {in_test:?}" diff --git a/crates/phase-ai/src/policies/loop_shortcut.rs b/crates/phase-ai/src/policies/loop_shortcut.rs index 6038175d21..1d4b84b6ea 100644 --- a/crates/phase-ai/src/policies/loop_shortcut.rs +++ b/crates/phase-ai/src/policies/loop_shortcut.rs @@ -138,6 +138,10 @@ impl TacticalPolicy for LoopShortcutPolicy { predicted_winner, schema, certificate, + // Scoring reads the offer's BOUND and its certificate, never its pins: the + // engine-published declaration is what the candidate already carries, so re-reading + // it here would score the same value twice. + declaration: _, } = &ctx.state.waiting_for else { return na(); @@ -439,6 +443,7 @@ mod tests { predicted_winner, certificate: cert(), schema: ShortcutDecisionSchema::default(), + declaration: None, }; state } @@ -629,6 +634,7 @@ mod tests { max_iterations, ..Default::default() }, + declaration: None, }; state } @@ -649,6 +655,7 @@ mod tests { max_iterations, ..Default::default() }, + declaration: None, }; state } diff --git a/crates/phase-ai/src/projection.rs b/crates/phase-ai/src/projection.rs index a4d25687b0..f9fb4983cd 100644 --- a/crates/phase-ai/src/projection.rs +++ b/crates/phase-ai/src/projection.rs @@ -1218,6 +1218,7 @@ mod tests { per_cycle: None, }, schema: engine::analysis::decision_template::ShortcutDecisionSchema::default(), + declaration: None, }; let (_actor, action, is_policy_choice, _successor) = diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index ff0b940332..7de95de3ab 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -5256,6 +5256,7 @@ mod tests { per_cycle: None, }, schema: engine::analysis::decision_template::ShortcutDecisionSchema::default(), + declaration: None, }; assert_eq!( From f6e67d1aa31bdb920bb690922ca2fc2dba3515cb Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 11 Aug 2026 05:04:50 -0500 Subject: [PATCH 09/44] fix(engine): validate the published declaration through one authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes three findings from an independent review of the preceding commit. The publisher emitted a declaration without validating it. `build_bounded_declaration` ran neither `predictability_gate` nor `validate_pins`, while both of its siblings ran both and failed closed, and the AI gate keyed on `declaration.is_some()` as though it meant "the declare handler will accept this". Nothing enforced that. The two gates are now composed once, in `declaration_conforms`, which derives the required-slot list a single time and runs both halves. All three sites route through it: the publisher, the declare handler, and the interaction decoder. This closes a LATENT divergence. Reachability was not established and is not claimed: the publisher and the answer writer agree today only because one hard-codes a single slot while the other bails above one, which is an accident of two predicates rather than an invariant. The two derivations were measured before being collapsed, and they were NOT identical. The required-slot list, schema argument and gate conjunction matched; the validated range did not (the handler computes it, the decoder passes 1). The range is therefore a parameter of the shared authority rather than a value picked from one caller — the collapse covers only the axes measured equal. The difference is verdict-neutral where it occurs: the range is read only by the `Scheduled` pin arm, and the decoder emits only `Player` and `ByIdentity` pins, so its verdict is unchanged at any range >= 1. The publisher validates at the schema's own ceiling, the widest count a declarer may name, so publishing implies acceptance at every shorter count. The offer-writer census conjunct is rewritten, not relaxed. It pinned `validate_pins` at three production sites and required each call site to pair it with the coverage gate — a per-call-site convention that could not see a site running NEITHER gate, which is how this defect stayed invisible. It now pins the stronger property: pin value-legality has exactly one production consumer, that consumer also runs the coverage half, and every publishing or accepting site routes through it. The superseded shape is recorded at the assert rather than overwritten. Two CR annotations were wrong and are corrected. `CR 800.4a` is player elimination, not priority; it was cited twice in this file for a priority handback. One occurrence this series rewrote now cites CR 732.2a, the rule the decline handler itself cites, with the reasoning stated. The other was left by the fix executor as out of its scope and is corrected here as an orchestrator decision: shipping a known-wrong rule number next to its corrected twin would manufacture exactly the false confidence the annotation rule exists to prevent. `CR 115.2` no longer carries a publicity claim it does not make; the hidden-information half is stated as an engine property, and no CR number was invented for it. The CR 603.5 prompt census pin moved +30 and was re-derived content-first: the new line is sha256-identical to the old, still inside the same function, with hunk arithmetic used only as an after-check and the set totals preserved. Assisted-by: ClaudeCode:claude-opus-4.8 --- .../engine/src/analysis/decision_template.rs | 47 ++++ crates/engine/src/game/engine.rs | 223 ++++++++++++++++-- crates/engine/src/game/interaction.rs | 19 +- crates/engine/src/game/visibility.rs | 28 ++- .../fantastic_four_bounded_loop.rs | 8 +- .../loop_shortcut_offer_writer_census.rs | 111 ++++++--- 6 files changed, 365 insertions(+), 71 deletions(-) diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index f9baf80cf5..d53825493c 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -1034,6 +1034,53 @@ pub fn validate_pins( Ok(()) } +/// CR 732.2a: THE SINGLE AUTHORITY for *"is this declaration a legal answer to this offer's +/// schema?"* — [`predictability_gate`]'s COVERAGE half and [`validate_pins`]' VALUE half, run +/// together against a `required` slot list derived HERE from `schema.points` rather than by +/// each caller. +/// +/// Three sites ask that question — `game::engine::handle_declare_shortcut` (the declare +/// firewall), `game::interaction::materialize_loop_shortcut_response` (the human ingress) and +/// `game::engine::build_bounded_declaration` (the engine's own publisher) — and a declaration +/// PUBLISHED under one predicate but ACCEPTED under another is the divergence this exists to +/// make unrepresentable: `declaration.is_some()` is read by `ai_support::candidates` as "the +/// declare handler will take this", and only a shared predicate makes that true. +/// +/// # `validated_range` STAYS A PARAMETER, and that is a measurement, not a hedge +/// +/// The two pre-existing call sites did NOT pass the same range, so folding one in would adopt +/// one site's semantics for the other: +/// +/// * the declare firewall passes `game::engine::shortcut_validated_range(&count, template)` — +/// the range the ACCEPTED COUNT will drive; +/// * the interaction decoder passes `1`, correct by construction there because it emits only +/// `TargetPin::Player` and `TargetPin::ByIdentity` pins, and `resolve_target` resolves both +/// WITHOUT reading `iteration` (only `TargetPin::Scheduled` consults it). Its verdict is +/// therefore identical at any range ≥ 1. +/// +/// Ranges are nested rather than contradictory — `0..n` re-checks are a superset of `0..m` for +/// `m <= n`, so a wider range is strictly stricter — which is why a PUBLISHER must validate at +/// the widest range it could be declared with: passing there implies passing at every count a +/// declarer may name. +/// +/// # Returns `bool`, deliberately +/// +/// All three callers discard the failure KIND (they already spelled `.is_err() || .is_err()`) +/// and their dispositions have nothing in common: manual-play handback via +/// `reject_shortcut_declaration`, `InteractionReasonCode::ConstraintUnsatisfied`, and "publish +/// no declaration". A union error type would have no reader. [`predictability_gate`] and +/// [`validate_pins`] stay public and typed for the rows that assert on the specific violation. +pub fn declaration_conforms( + schema: &ShortcutDecisionSchema, + template: &DecisionTemplate, + validated_range: IterationIndex, + state: &GameState, +) -> bool { + let required: Vec = schema.points.iter().map(|p| p.slot.clone()).collect(); + predictability_gate(template, &required).is_ok() + && validate_pins(schema, template, validated_range, state).is_ok() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 6f92dd5a5a..18493275ac 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -2420,6 +2420,28 @@ fn certified_bounded_cycle_offer<'a>( /// ENGINE'S OWN observed answers. Two inputs, one shape; a reviewer reading only the shape /// would otherwise see duplication. /// +/// # PUBLISHED IS VALIDATED — `is_some()` means "the declare handler will take this" +/// +/// `ai_support::candidates` gates its `DeclareShortcut` candidate on `declaration.is_some()` +/// and hands this very template to `handle_declare_shortcut`, which validates it. So the +/// publisher must not be able to emit anything that handler would refuse: step (5) runs the +/// SHARED [`crate::analysis::decision_template::declaration_conforms`] — the same coverage + +/// value-legality predicate that handler and the human ingress run — rather than a third +/// derivation of `required` alongside theirs. +/// +/// The range is `shortcut_validated_range(&schema.iteration_count, ..)`, i.e. this offer's own +/// ceiling, because it is the WIDEST count any declarer may name against this schema +/// (`is_bounded()` publishers set `iteration_count == Fixed(max_iterations)` and the handler +/// rejects anything above the cap). `validate_pins` re-checks `0..range`, so passing at the +/// ceiling implies passing at every shorter `Fixed(n)` the handler could be given. +/// +/// LATENT, NOT LIVE, and the distinction is not decoration: no tracked board reaches a +/// declaration this refuses — row D1 measures both gates passing at the full range on all +/// three dumps — because the publisher copies `legal_targets` from the same announcement the +/// journal answer came from, and `record_trigger_target_answer` bails above one announced +/// slot while this schema hard-codes `min/max: 1`. That agreement is an accident of two +/// functions with two predicates; step (5) is what makes it an invariant. +/// /// FAIL-CLOSED on every uncertainty, because a wrong pin is worse than no offer: /// /// * an empty point set publishes no declaration at all — a declaration against an empty @@ -2492,7 +2514,7 @@ fn build_bounded_declaration( // (4) The template. `replay.count` carries the offer's own SUGGESTION; the driving count // comes off `GameAction::DeclareShortcut` and nothing reads this copy (see // `build_recast_template`'s note and `analysis::decision_template::resolve`'s doc). - Some(DecisionTemplate { + let template = DecisionTemplate { owner: proposer, decisions, replay: ReplayMode::Scheduled { @@ -2506,7 +2528,17 @@ fn build_bounded_declaration( .collect::>(), DecisionKind::LoopChoice, ), - }) + }; + // (5) VALIDATE BEFORE PUBLISHING — the same authority `handle_declare_shortcut` accepts + // under. See this function's "Published is validated" doc section for why the range is the + // schema's OWN count and why this is not a third derivation. + crate::analysis::decision_template::declaration_conforms( + schema, + &template, + shortcut_validated_range(&schema.iteration_count, Some(&template)), + state, + ) + .then_some(template) } /// CR 704.5a / CR 704.5c: a determinate lethal drain (0-or-less life / 10-poison) repeats @@ -5907,8 +5939,6 @@ fn handle_declare_shortcut( if !offer.schema.points.is_empty() { match &template { Some(t) => { - let required: Vec = - offer.schema.points.iter().map(|p| p.slot.clone()).collect(); // CR 732.2a: validate over the range the ACCEPTED COUNT will drive, not // over the schedule's own period. `shortcut_drive_period` answers a // different question (how many cycles one measurement must aggregate), and @@ -5916,15 +5946,15 @@ fn handle_declare_shortcut( // set at an index the count reaches, and REFUSED conforming declarations // whose count is shorter than the schedule. let validated_range = shortcut_validated_range(&count, Some(t)); - if crate::analysis::decision_template::predictability_gate(t, &required).is_err() - || crate::analysis::decision_template::validate_pins( - offer.schema, - t, - validated_range, - state, - ) - .is_err() - { + // Coverage + value legality via the shared authority, so the predicate this + // handler ACCEPTS under is the same one `build_bounded_declaration` PUBLISHES + // under and the human ingress EMITS under. The range is this site's own. + if !crate::analysis::decision_template::declaration_conforms( + offer.schema, + t, + validated_range, + state, + ) { reject_shortcut_declaration(state, &mut result); return Ok(result); } @@ -15610,6 +15640,145 @@ mod bounded_declaration_tests { "CR 732.2a: an offer that publishes no choice states no declaration" ); } + + /// **Row D8 — the PUBLISHER cannot publish what the HANDLER would refuse.** + /// + /// `ai_support::candidates` reads `declaration.is_some()` as *"`handle_declare_shortcut` + /// will accept this"* and hands the published template straight to `DeclareShortcut`. That + /// reading was unenforced: the publisher ran neither firewall half, and the two sides agreed + /// only because the publisher copies `legal_targets` from the same announcement the journal + /// answer came from. This row pins the implication itself. + /// + /// # The two halves are measured on DIFFERENT instruments, so this is not circular + /// + /// The "handler refuses it" half is measured by calling `validate_pins` DIRECTLY on the + /// template the pre-fix publisher would have emitted — the handler's own value-legality + /// firewall, at the range that handler validates a `Fixed(max_iterations)` declaration over. + /// Only then is the publisher asked. A row that asserted `is_none()` alone would pass on a + /// publisher that refuses for any unrelated reason. + /// + /// # Reach-guards, asserted BEFORE the claim + /// + /// The journal really holds the hostile answer (else the publisher exits one step earlier at + /// `loop_answer(..)?` and the refusal is not this one), and `predictability_gate` PASSES on + /// that template (both published slots are pinned) — so the refusal is attributable to the + /// VALUE half, not to coverage. + /// + /// # LATENT, not live — the row says so rather than implying a bug was shipped + /// + /// No tracked board reaches this: `record_trigger_target_answer` journals the seat it + /// ANNOUNCED, and the publisher's `legal_targets` come from that same announcement, so the + /// disagreement staged here is fixture-made. Reachability is NOT claimed. + /// + /// REVERT-PROBE: delete step (5)'s `declaration_conforms(..)` call (return `Some(template)`) + /// ⇒ the hostile arm's `is_none()` flips while the control stays green. + /// + /// *What wrong implementation would still pass this row?* One that validates COVERAGE only — + /// `predictability_gate` alone passes here, which is why the reach-guard asserts it. And one + /// that refuses everything, which the control arm refuses. + #[test] + fn d8_the_publisher_refuses_a_declaration_the_declare_handler_would_reject() { + use crate::analysis::decision_template::{ + declaration_conforms, predictability_gate, validate_pins, DecisionGroupKey, + DecisionKind, DecisionTemplate, ReplayMode, + }; + + let schema = may_and_target_schema(); + let [may_point, target_point] = &schema.points[..] else { + panic!("the fixture publishes exactly two points"); + }; + // CR 608.2b: the published legal set names AIMED only, so a pin naming PROPOSER is + // outside the offer's own legal set — an illegal pin VALUE at a legally exposed slot. + assert!( + !matches!(&target_point.kind, DecisionPointKind::Targets { legal_targets, .. } + if legal_targets.contains(&TargetRef::Player(PROPOSER))), + "reach-guard: PROPOSER must NOT be a published legal target, or the hostile pin \ + below is a conforming one and this row measures nothing" + ); + + for (label, pinned, expect_published) in [ + ("hostile", TargetPin::Player(PROPOSER), false), + ("control", TargetPin::Player(AIMED), true), + ] { + let mut state = recording_state(); + state.record_loop_answer( + may_point.slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::May(MayChoiceOption::Take)), + ); + state.record_loop_answer( + target_point.slot.clone(), + PROPOSER, + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![pinned.clone()])), + ); + assert_eq!( + state.loop_answer(&target_point.slot, PROPOSER), + Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ + pinned.clone() + ]))), + "[{label}] reach-guard: the answer must be journalled, or the publisher exits at \ + `loop_answer(..)?` and the verdict below is the 'never answered' one" + ); + + // The template the UNVALIDATED publisher would have emitted, spelled out here so the + // handler-side half below is measured on it rather than on whatever the publisher + // now returns. + let as_published = DecisionTemplate { + owner: PROPOSER, + decisions: vec![ + PinnedDecision::MayChoice { + slot: may_point.slot.clone(), + take: MayChoiceOption::Take, + }, + PinnedDecision::Targets { + slot: target_point.slot.clone(), + targets: vec![pinned.clone()], + }, + ], + replay: ReplayMode::Scheduled { + count: schema.iteration_count.clone(), + }, + key: DecisionGroupKey::from_sources( + &schema + .points + .iter() + .map(|point| point.slot.source.clone()) + .collect::>(), + DecisionKind::LoopChoice, + ), + }; + let required: Vec<_> = schema.points.iter().map(|p| p.slot.clone()).collect(); + assert!( + predictability_gate(&as_published, &required).is_ok(), + "[{label}] reach-guard: COVERAGE passes on this template — every published slot \ + is pinned — so the handler's verdict below is the VALUE half's" + ); + + // ── HALF 1, on the handler's own instrument: would `handle_declare_shortcut` take + // it, at the range it validates the AI's `Fixed(max_iterations)` candidate over? + let handler_accepts = + validate_pins(&schema, &as_published, schema.max_iterations, &state).is_ok(); + assert_eq!( + handler_accepts, expect_published, + "[{label}] the declare-time pin firewall's verdict on the published shape" + ); + assert_eq!( + declaration_conforms(&schema, &as_published, schema.max_iterations, &state), + handler_accepts, + "[{label}] and the shared authority agrees with its own value half — it is the \ + conjunction of the two gates, not a third predicate" + ); + + // ── HALF 2: the PUBLISHER's verdict must be the same one ── + assert_eq!( + build_bounded_declaration(&state, PROPOSER, &schema).is_some(), + handler_accepts, + "[{label}] CR 732.2a: `declaration.is_some()` is read as 'the declare handler \ + will accept this'. A template the handler refuses must NOT be published, and a \ + template it accepts must be" + ); + } + } } /// PR-7 Combo-UI Stage 2: the mid-drive pin injector (item 4) + the drive-period seam (item 6). @@ -17751,11 +17920,33 @@ mod stage2_injector_tests { // and the partition (5/8/25) both fired GREEN on the run that caught this; the panic was on // this third assert alone, which is what makes it a coordinate shift rather than a // population change. - // ⚠ REBASE #3: `:12614 ⇒ :12613`, located by content digest, offset from + // + // item-4 C2b FIX ROUND (F1: `declaration_conforms`, the shared declare-legality + // authority), base `908720e6f`: `:12552 ⇒ :12582`, `+30`, and ONLY this entry moved — + // the other four live in `effects/` and `scoped_library_search.rs`, untouched here. + // LOCAL, not upstream, so the CI-vs-local diagnosis in the header does not apply. + // + // LOCATED BY CONTENT FIRST, as this log requires: the line at `:12582` is + // sha256-identical (`8a544e878d3e77fb80391b95…`, the digest this producer has carried + // since `a6d1a0e62`) to `908720e6f:game/engine.rs:12552`, and it is still inside + // `begin_pending_trigger_target_selection`, which moved by the same `+30` (opens + // `:12418 ⇒ :12448`). Arithmetic afterwards as a CHECK: `git diff -U0` on this file has + // five hunks above the producer — `+22` (`build_bounded_declaration`'s "PUBLISHED IS + // VALIDATED" doc section), `0` (the `Some(..)` tail rebound to `let template = ..`), + // `+10` (step (5)'s `declaration_conforms` call), `-2` (the `required` derivation + // DELETED from `handle_declare_shortcut`, now derived once inside the authority) and + // `0` (that site's condition rewritten in place) — summing to exactly `+30`. The + // file's remaining hunk (row D8 in `mod bounded_declaration_tests`, `+139`) is BELOW. + // + // SET PRESERVATION: this round adds one validation call and one `#[cfg(test)]` row; + // neither assigns `state.waiting_for` an `OptionalEffectChoice`, so no line matching + // the needle is added or removed. The total (38) and the partition (5/8/25) both fired + // GREEN on the run that caught this; the panic was on this third assert alone. + // ⚠ REBASE #3: `:12644 ⇒ :12643`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - // ⚠ REBASE #3: `:12613 ⇒ :12618`, located by content digest, offset from + // ⚠ REBASE #3: `:12643 ⇒ :12648`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - "game/engine.rs:12618".to_string(), + "game/engine.rs:12648".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 30ebeadbc1..9f2e8243df 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -13,8 +13,8 @@ use crate::ai_support::{ FilterPipeline, TacticalClass, }; use crate::analysis::decision_template::{ - predictability_gate, validate_pins, DecisionGroupKey, DecisionKind, DecisionTemplate, - IterationCount, PinnedDecision, ReplayMode, TargetPin, + declaration_conforms, DecisionGroupKey, DecisionKind, DecisionTemplate, IterationCount, + PinnedDecision, ReplayMode, TargetPin, }; use crate::types::ability::{ AggregateFunction, ChoiceType, ChooseFromZoneConstraint, Comparator, CounterCostSelection, @@ -8919,11 +8919,6 @@ fn materialize_loop_shortcut_response( key: DecisionGroupKey::from_sources(&sources, DecisionKind::LoopChoice), }); if let Some(template) = &template { - let required = authoritative_schema - .points - .iter() - .map(|point| point.slot.clone()) - .collect::>(); // TRAP REMOVAL, NOT A BUG FIX — recorded so the next reader does not "correct" this // literal into `shortcut_validated_range(..)` and then wonder what changed. This // decoder emits only `Player` and `ByIdentity` pins, both of which resolve @@ -8934,11 +8929,13 @@ fn materialize_loop_shortcut_response( // `Fixed(0)` either: the count-spec projection's `Fixed` arm hard-codes `min: 1` // beside its `debug_assert!(schema.max_iterations >= 1, ..)` and its clamp. // ⚠ Navigation trap: `shortcut_drive_period`'s doc enumerates its own consumers, and - // this site consumes `validate_pins` WITHOUT consuming that helper, so it is + // this site consumes the pin firewall WITHOUT consuming that helper, so it is // invisible from there. - if predictability_gate(template, &required).is_err() - || validate_pins(authoritative_schema, template, 1, authoritative_state).is_err() - { + // + // The `required` slot list is no longer derived here: `declaration_conforms` derives + // it from the SAME `authoritative_schema` this site already passed, so the coverage + // half and the value half can no longer drift apart per call site. + if !declaration_conforms(authoritative_schema, template, 1, authoritative_state) { return Err(InteractionReasonCode::ConstraintUnsatisfied); } } diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 2ec7002f0a..b2bebaa948 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -831,13 +831,20 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState _ => None, }) .sum(); - // CR 732.2b + CR 115.2: the responder's right is to name a place where they will - // make "a choice that's different than what's been proposed", so the proposal they - // see must be the whole proposal or none of it. A partially-redacted pin set is a - // LIE about what was proposed — it would show a shortened sequence the proposer - // never suggested — so this is ALL-OR-NOTHING: one pin naming an object this viewer - // may not see drops the entire declaration. A `TargetPin::Player` names a seat, - // which CR 115.2 makes a public legal target, and carries no hidden identity. + // CR 732.2b: the responder's right is to name a place where they will make "a + // choice that's different than what's been proposed", so the proposal they see must + // be the whole proposal or none of it. A partially-redacted pin set is a LIE about + // what was proposed — it would show a shortened sequence the proposer never + // suggested — so this is ALL-OR-NOTHING: one pin naming an object this viewer may + // not see drops the entire declaration. + // + // A `TargetPin::Player` needs no redaction, and that is an ENGINE property rather + // than a CR one — no rule makes seat identity public. This projection hides card + // identities and hidden-zone contents; the seat list itself is never per-viewer + // filtered (`filtered.players[..]` is redacted in place, never removed), so a + // `PlayerId` names something every viewer already has. CR 115.2 is cited for the + // narrower thing it actually says: a spell or ability may target a player when it + // specifies so, which is what makes a seat a legal pin value at all. // // Reuses `target_hidden` above rather than re-deriving the composite: the // declaration's object identities and the schema's legal targets must be answerable @@ -5973,8 +5980,9 @@ mod tests { /// where they will make a game choice that's different than what's been proposed" — so what /// they receive must be the whole proposal or none of it. A partially-redacted pin set would /// show a sequence the proposer never suggested, which is why this is ALL-OR-NOTHING rather - /// than a per-pin filter. CR 115.2 makes a player a legal target in the open, so a - /// `TargetPin::Player` carries no hidden identity and travels unredacted. + /// than a per-pin filter. A `TargetPin::Player` travels unredacted because seat identity is + /// public IN THIS ENGINE — no CR rule states that, and the redaction comment says so; CR + /// 115.2 only establishes that a seat can be a targeted (hence pinnable) value. /// /// # This path is UNREACHABLE through today's publisher, and the row says so /// @@ -6026,7 +6034,7 @@ mod tests { declaration — a partial pin set would state a proposal that was never made" ); - // ── the paired positive: every pin is a CR 115.2 seat ── + // ── the paired positive: every pin is a seat, which carries no hidden identity ── let public_state = d5h_offer(|_hidden| vec![TargetPin::Player(D5H_VIEWER)]); assert_eq!( d5h_projected_declaration(&public_state, D5H_VIEWER), diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index 59a3eb12bb..cda81ac9dc 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -2427,7 +2427,11 @@ fn u6_the_generators_own_candidate_opens_the_window_and_the_accepted_shape_is_me } _ => assert!( matches!(probe.waiting_for, WaitingFor::Priority { .. }), - "CR 800.4a: the decline candidate {action:?} hands priority back, got {:?}", + // CR 732.2a: a shortcut is a SUGGESTION made by the player who already has + // priority, so refusing it takes no game action and that player still has + // priority — `handle_decline_shortcut` re-seats `WaitingFor::Priority` and + // cites the same rule. (Not CR 800.4a, which is player-elimination.) + "CR 732.2a: the decline candidate {action:?} hands priority back, got {:?}", probe.waiting_for ), } @@ -2537,7 +2541,7 @@ fn u6_the_declare_owner_firewall_holds_on_the_real_f4_offer() { vec![("RespondToShortcut", 0), ("Priority", 0)], "CR 732.2a + CR 603.5: the declaration owned by the engine-issued proposer opens the \ APNAP window; the byte-identical declaration owned by {hostile:?} is refused into the \ - CR 800.4a manual handback. `handle_declare_shortcut` pushes no events on either path, \ + manual handback. `handle_declare_shortcut` pushes no events on either path, \ so the event counts are exact rather than wildcards" ); } diff --git a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs index 70a491e442..ba8d30a32f 100644 --- a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs @@ -203,13 +203,17 @@ fn census(needle: &str) -> Vec { /// machinery certify. The PRODUCTION half is unchanged at 22 and so is the /// per-file multiset below, which is the half §10 ruling condition (2) is about. /// -/// R8 CONJUNCT 2, same test — every production `validate_pins(` site is a -/// declare-time gate paired with `predictability_gate`. +/// R8 CONJUNCT 2, same test — pin VALUE-legality has exactly ONE production +/// consumer (`analysis::decision_template::declaration_conforms`), that consumer +/// also runs `predictability_gate`'s COVERAGE half, and every production site +/// that publishes or accepts a declaration routes through it. Superseded shape, +/// recorded at the assert: a per-call-site `validate_pins`/`predictability_gate` +/// pairing rule, which could not see a site that ran neither. /// /// ON FAILURE, the named consequence (§10 ruling condition (2)): a new -/// production site in a certification-path file, or a declare site without its -/// `validate_pins` pairing, means the period machinery may have created a path -/// that CERTIFIES WITHOUT DECLARING OR DRIVING. That converts +/// production site in a certification-path file, or a declare site that does not +/// route through the shared authority, means the period machinery may have +/// created a path that CERTIFIES WITHOUT DECLARING OR DRIVING. That converts /// answer-legality-at-certification from a doc note into owed work, and the /// U-series stops until it is carried. Adjudication is a human step; this is not /// a test to relax. A new *read* site is the benign case and the message says so. @@ -304,45 +308,88 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid certification-path file, so the per-file multiset is pinned too" ); - // ── CONJUNCT 2: every production `validate_pins(` site is a declare-time gate ── - // UNQUALIFIED anchor, deliberately: the fully-qualified + // ── CONJUNCT 2: pin VALUE-legality has exactly ONE production consumer, and it + // is the one that also runs the COVERAGE half ── + // + // ⚠ THE SHAPE OF THIS CONJUNCT CHANGED, AND THE OLD ONE IS RECORDED RATHER + // THAN OVERWRITTEN. It used to pin `validate_pins(` at 3 production sites (1 + // definition + 2 declare-time call sites) and assert each CALL SITE had a + // `predictability_gate(` hit within two lines. That pairing rule was a + // per-call-site *convention*: it could only catch a site that forgot the + // coverage half, never a site that ran BOTH gates against a differently-derived + // `required` list — and it could not see `build_bounded_declaration`, which + // PUBLISHED a declaration while running NEITHER gate (the C2b review's finding + // F1). The two gates are now composed once, in + // `analysis::decision_template::declaration_conforms`, and the invariant this + // conjunct pins is the stronger one: production validates pin values in exactly + // ONE place, that place runs both halves, and every declare-time site routes + // through it. A relapse — a second `validate_pins(` consumer, or a declare site + // that stops routing through the authority — fails the counts below. + // + // UNQUALIFIED anchors, deliberately: the fully-qualified // `crate::analysis::decision_template::validate_pins(` form matches only the // `engine.rs` site and under-counts by one — this plan's own finding 5, // applied symmetrically. let pins = census("validate_pins("); let pins_production: Vec<&Hit> = pins.iter().filter(|h| !h.in_test).collect(); + let pins_files: Vec<&str> = pins_production.iter().map(|h| h.file.as_str()).collect(); assert_eq!( - pins_production.len(), - 3, - "expected 1 definition (`analysis/decision_template.rs`) + 2 declare-time call sites \ - (`game/engine.rs::handle_declare_shortcut`, \ - `game/interaction.rs::materialize_loop_shortcut_response`); got {pins_production:?}" + pins_files, + vec![ + "engine/src/analysis/decision_template.rs", + "engine/src/analysis/decision_template.rs" + ], + "expected `validate_pins(` to appear in production exactly twice, BOTH in \ + `analysis/decision_template.rs`: its own definition and its single consumer, \ + `declaration_conforms`. A hit in any other file is a declare-time site that \ + re-derives the pin firewall instead of routing through the shared authority — the \ + divergence C2b's F1 closed, where a PUBLISHER emitted a declaration under a weaker \ + predicate than the HANDLER accepts under. got {pins_production:?}" ); - let definition = pins_production - .iter() - .filter(|h| h.file == "engine/src/analysis/decision_template.rs") - .count(); - assert_eq!(definition, 1, "exactly one definition: {pins_production:?}"); - // Each CALL SITE is paired with `predictability_gate` — the coverage half of - // the same declare-time gate. Pairing is asserted WITHIN the enclosing - // statement, i.e. a `predictability_gate` hit within two lines of the call. + // The one consumer runs the COVERAGE half too, asserted the way the old + // per-call-site rule did: a `predictability_gate` hit within two lines. let gates = census("predictability_gate("); - for site in pins_production + let consumer = pins_production .iter() - .filter(|h| h.file != "engine/src/analysis/decision_template.rs") - { - let paired = gates + .max_by_key(|h| h.line) + .expect("the assert above proves two hits"); + assert!( + gates .iter() - .any(|g| g.file == site.file && g.line.abs_diff(site.line) <= 2); - assert!( - paired, - "CR 732.2a: a declare site that validates pin VALUES without also running \ - `predictability_gate`'s COVERAGE check can accept a proposal that leaves a \ - published choice unpinned — the certifies-without-declaring shape §10 condition \ - (2) names. Unpaired site: {site:?}; gates: {gates:?}" - ); + .any(|g| g.file == consumer.file && g.line.abs_diff(consumer.line) <= 2), + "CR 732.2a: validating pin VALUES without also running `predictability_gate`'s \ + COVERAGE check can accept a proposal that leaves a published choice unpinned — the \ + certifies-without-declaring shape §10 condition (2) names. Unpaired: {consumer:?}; \ + gates: {gates:?}" + ); + + // Every production site that asks "is this declaration legal?" — the declare + // handler, the human ingress, and the bounded PUBLISHER — routes through the + // authority. The publisher is the site F1 added: it is what makes + // `declaration.is_some()`, the predicate `ai_support::candidates` gates its + // `DeclareShortcut` candidate on, mean "the handler will accept this". + let authority = census("declaration_conforms("); + let authority_production: Vec<&Hit> = authority.iter().filter(|h| !h.in_test).collect(); + let mut authority_per_file: BTreeMap<&str, usize> = BTreeMap::new(); + for h in &authority_production { + *authority_per_file.entry(h.file.as_str()).or_default() += 1; } + assert_eq!( + authority_per_file.into_iter().collect::>(), + vec![ + ("engine/src/analysis/decision_template.rs", 1), + ("engine/src/game/engine.rs", 2), + ("engine/src/game/interaction.rs", 1), + ], + "expected 1 definition + 3 call sites: \ + `game/engine.rs::handle_declare_shortcut` (accept), \ + `game/engine.rs::build_bounded_declaration` (publish), \ + `game/interaction.rs::materialize_loop_shortcut_response` (human emit). A MISSING \ + call site is a path that publishes or accepts a declaration under its own predicate; \ + a NEW one is benign but must be named here rather than absorbed. \ + got {authority_production:?}" + ); } /// R8 ANTI-VACUITY ARM 2 — THE FOREIGN-FORM PLANT. From bb3f79450e16e8daaf178af63c105f694999d949 Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 10 Aug 2026 07:19:49 -0500 Subject: [PATCH 10/44] docs(engine): cite loop-shortcut seams by symbol instead of rotting line numbers Eighteen comment claims across three files asserted facts that were stale or simply wrong. The class is one defect, not eighteen: a comment that cites a line number is a claim with an expiry date, and nothing re-checks it. The two assigned sites both cited `engine.rs:3006-3011` for `apply_action`'s deliberate-action ring clear. That coordinate had already rotted twice (`:3006-3011` -> `:6948` -> `:7147` today), so writing today's number would only schedule a third rot. Both now name the symbol. The same sweep found ten more coordinates in the same state, two of which had decayed far enough to point at bare tokens: one cited line is `EngineError,` and another is `false`. `docs/MagicCompRules.txt` line numbers are deleted outright rather than corrected. That file is gitignored and fetched by `./scripts/fetch-comp-rules.sh`, so its line numbers depend on which rules revision a given reader downloaded -- not merely rot-prone but unreproducible between readers. The CR number is the stable identifier and is what CLAUDE.md says to grep. Two citations that measured correct today were removed on the same rule, so no doc block is left inconsistent about its own citation policy. One correction is substantive rather than cosmetic. Three comments claimed an 8 KB inbound WebSocket frame cap. The only inbound cap is `phase-server`'s `MAX_WS_MESSAGE_BYTES` at 64 KB, and a server test builds a payload, asserts it exceeds 8 KB, and asserts it is accepted -- so the tree already contained a test refuting its own comments, wrong by a factor of eight. Neither rationale's conclusion changes; both premises did. A fourth comment overstated the ring-clear gate as covering "every deliberate action" when the gate has a second conjunct, `!answering_forced_window`; the Seam-1 claim survives, because `WaitingFor::LoopShortcut` is not a forced cascade window, and the text now states the real two-conjunct rule. This commit is NOT purely comments. Its own hunks shifted a pinned producer coordinate by +8 and turned the CR 603.5 prompt census red -- the mechanized form of the exact defect this commit exists to remove. The pin is updated to `:11985` only because the producer was proved to have MOVED rather than a sixth producer having appeared: the line is byte-identical by sha256 to `70fcd851a:engine.rs:11977`, that hash is the one the census log already carries for `:11515`, `:11549` and `:11977`, the four hunks above it account for exactly +8, and the enclosing `begin_pending_trigger_target_selection` moved by the same +8. The drift record carries that derivation so the next reader re-checks it instead of trusting it. The planned consolidation half is a no-op, and for a stronger reason than "already done": `reject_shortcut_declaration` has six callers, all inside `handle_declare_shortcut`, and the five other `living_priority_seat` handbacks each clear extra state (`loop_answer_journal` / `last_loop_action_sequence`) that `reject_shortcut_declaration` deliberately does not touch, so consolidating them would be wrong rather than merely unnecessary. No edit was manufactured. Test counts are unchanged (18830 lib, 4807 integration), which is the evidence that no assertion was weakened. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/engine.rs | 107 ++++++++++++------ crates/engine/src/types/game_state.rs | 10 +- .../engine/tests/integration/loop_shortcut.rs | 4 +- 3 files changed, 82 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 18493275ac..1d63a0d0e3 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -2662,7 +2662,7 @@ fn pinned_decisions_to_points( /// CR 115.2 + CR 732.2a: does the ability's HEAD effect declare the "target opponent" PLAYER /// filter — a `Typed` filter with no type constraints, no object properties, and /// `controller: Opponent`, the shape `game::targeting::find_legal_targets` collapses to -/// players-only (`crates/engine/src/game/targeting.rs:192-193`)? +/// players-only? /// /// SHAPE ACCEPTANCE ONLY, and the `bool` return is what enforces it: the published legal /// set must come from the announcement authority (`ability_utils::build_target_slots`), never @@ -3817,8 +3817,9 @@ fn shortcut_drive_period( .unwrap_or(1) // CR 732.2a SAFETY LIMIT: the drive period is STRUCTURALLY unbounded in the engine — // its length is the client template schedule's own length. On the WS transport the - // 8 KB inbound-frame cap (phase-server/src/main.rs:409/1420) already bounds a hostile - // schedule to a few hundred entries (~1-2 s stall, not a million-cycle remote DoS), + // inbound-frame cap (`phase-server`'s `MAX_WS_MESSAGE_BYTES`, 64 KB, applied at its + // `ws.max_message_size`) already bounds a hostile schedule to a finite entry count + // (a bounded stall, not a million-cycle remote DoS), // but in-process callers (WASM/Tauri/local) bypass that cap, so clamp here AT THE // SOURCE for every caller. Real schedules rotate over a handful of object sources // (period ≪ cap), so this is invisible to every legitimate loop; a clamped-shorter @@ -4181,10 +4182,10 @@ fn inject_pinned_answer( /// the prompt carrying `source_id`? /// /// [`crate::analysis::decision_template::resolve_source`] is deliberately BATTLEFIELD-ONLY, -/// and that filter IS the CR 608.2b (`docs/MagicCompRules.txt:2789`) legality re-check for +/// and that filter IS the CR 608.2b legality re-check for /// `ByIdentity` **target** pins — a pinned target that left the battlefield must stop /// matching. It must not be widened. But a SLOT's source only identifies WHICH ability -/// instance prompts, and CR 114.2 (`:828`) puts a planeswalker EMBLEM — "both owned and +/// instance prompts, and CR 114.2 puts a planeswalker EMBLEM — "both owned and /// controlled by that player" — in the **command zone**, where it stays for the whole game /// and raises its triggers from. So the command-zone disjunct lives HERE, at the caller, /// scoped to object identity + the pinned CR 400.7 incarnation. @@ -5816,11 +5817,13 @@ struct LoopShortcutOffer<'a> { schema: &'a crate::analysis::decision_template::ShortcutDecisionSchema, } -/// CR 732.2a (MagicCompRules.txt:6372) + CR 800.4a (MagicCompRules.txt:6408): reject a +/// CR 732.2a + CR 800.4a: reject a /// shortcut declaration and hand priority back to the next living seat — the manual-play /// handback every reject path in `handle_declare_shortcut` lands on. Single -/// authority: a sixth reject path added later cannot forget to sync -/// `result.waiting_for`. +/// authority: a SEVENTH reject path added later cannot forget to sync +/// `result.waiting_for` — six exist today (the sixth is the `template.owner` firewall). +/// Cited by CR number, not by `MagicCompRules.txt` line: that file is gitignored and +/// re-fetched, so its line coordinates rot on every rules release. fn reject_shortcut_declaration(state: &mut GameState, result: &mut ActionResult) { priority::reset_priority(state); state.waiting_for = WaitingFor::Priority { @@ -5876,7 +5879,8 @@ fn handle_declare_shortcut( // the single authority — BEFORE the proposal is built — into the same fail-closed // manual-play handback the pin validation above uses. This is THE catastrophic remote // vector: `Fixed(u32)` scalar-encodes up to ~4.3e9 cycles in ~10 bytes, sailing through - // the 8 KB WS frame cap → one GameState clone + drive per cycle. Both confirmation paths + // the WS frame cap (`phase-server`'s `MAX_WS_MESSAGE_BYTES`, 64 KB) → one GameState clone + + // drive per cycle. Both confirmation paths // (solitaire-immediate below, APNAP Accept) consume this one proposal, and both drive // helpers (materialize_fixed_shortcut / materialize_object_growth_shortcut) read `n` from // it, so this one check bounds every Fixed drive on every transport. The drive helpers do @@ -6029,9 +6033,13 @@ fn handle_declare_shortcut( /// Re-offer suppression, by seam: /// - Interactive bridge (Seam 1, `find_live_loop_winner` reads `loop_detect_ring`, gated by /// `!stack.is_empty()`): suppressed by the GENERAL deliberate-action invariant, not by this -/// handler. `apply_action` (engine.rs:3006-3011) invalidates `loop_detect_ring` for every -/// deliberate (non-`PassPriority`/`OrderTriggers`) action; `DeclineShortcut` is a deliberate -/// break, so the ring is already empty before this handler runs. Seam-1 suppression is the +/// handler. `apply_action`'s deliberate-action ring clear invalidates `loop_detect_ring` for +/// every action that is neither `PassPriority`/`OrderTriggers` nor the answer to a +/// `WaitingFor::is_forced_cascade_window` window; `LoopShortcut` is in neither exemption (it +/// is not a member of that window class — see the `forced_cascade_window_class` test), so +/// `DeclineShortcut` is a deliberate break and the ring is already empty before this handler +/// runs. Cited by SYMBOL, not by line: this reference named a hard coordinate that rotted +/// twice, so a fresh number would only schedule a third rot. Seam-1 suppression is the /// shared invariant every cast/activate/play-land relies on — the handler does NOT re-clear /// the ring (re-clearing would special-case `DeclineShortcut` to distrust an engine-wide /// invariant). The interactive e2e's "no re-offer" assertion guards this end-to-end: a future @@ -6068,8 +6076,8 @@ fn handle_decline_shortcut( waiting_for: state.waiting_for.clone(), log_entries: vec![], }; - // Seam 1 (loop_detect_ring) is already invalidated by apply_action's deliberate-action - // ring-clear (engine.rs:3006-3011) — see doc. Only Seam 2 is the handler's gap, and only + // Seam 1 (loop_detect_ring) is already invalidated by `apply_action`'s deliberate-action + // ring clear — see doc. Only Seam 2 is the handler's gap, and only // for the decliner's OWN period (CR 732.2a): if state.loop_period_controller() == Some(proposer) { state.last_loop_action_sequence.clear(); @@ -7031,7 +7039,7 @@ fn finish_completed_or_interrupted_until_stack_empty_sessions(state: &mut GameSt // against an absurd/hostile count — NOT a rules constraint. It bounds both a `Fixed(n)` // cycle count (handle_declare_shortcut) and a template drive period (shortcut_drive_period). // Motivating vector: a `u32` count scalar-encodes up to ~4.3e9 cycles in ~10 JSON bytes, so -// it sails through the 8 KB inbound WS frame cap (phase-server/src/main.rs:409/1420) yet +// it sails through the inbound WS frame cap (`phase-server`'s `MAX_WS_MESSAGE_BYTES`, 64 KB) yet // would force ~4.3e9 GameState clones — a byte cap cannot see it, only this count cap can. // 1_000 is generous vs any honest Fixed count (~10x KCI-style loops); worst-case bounded // cost is 1_000 cycles x <=10_000 beats = 1e7. @@ -12182,14 +12190,14 @@ fn apply_action( // // BLAST RADIUS. Nothing this leaves in `state` survives to a consumer unrecomputed: // `finish_action_boundary` runs the SAME `sync_waiting_for` over `result.waiting_for` - // (`:1171`) and copies the outcome back into the result (`:1189`), and the reorder + // and copies the outcome back into the result, and the reorder // never changes `ActionResult.waiting_for` itself. That is an argument about // RE-DERIVATION, not reachability, because `apply_action_boundary` is not the only // route: `inject_pinned_answer`'s three dispatches and `drive_loop_action_iteration`'s // ten reach `apply_action` directly, and // `apply_interaction_pre_reconciliation_for_life_safety` returns `raw.result` without - // ever calling `finish_action_boundary` (`apply_action_boundary_core`'s own comment at - // `:1119` records it). All three drive a CLONE — `drive_one_shortcut_cycle`'s `work`, + // ever calling `finish_action_boundary` (`apply_action_boundary_core`'s own comment + // records it). All three drive a CLONE — `drive_one_shortcut_cycle`'s `work`, // the drive's `clone`, `preview_candidate_life_safety`'s `preview` — never the settled // board. MEASURED pre-reorder by an instrumented `debug_assert_eq!` census over the // full lib + integration corpus (per-site counts in PR #7005's history; one unit = @@ -16553,7 +16561,7 @@ mod stage2_injector_tests { /// row is what fails when ONE conjunct is dropped. /// /// Why each matters: `find_legal_targets` collapses a `Typed` filter to PLAYERS ONLY - /// when both `type_filters` and `properties` are empty (`targeting.rs:192-193`, issue + /// when both `type_filters` and `properties` are empty (issue /// #2004). A type- or property-bearing filter therefore falls through to OBJECT /// enumeration — publishing it would put a point whose legal set is object refs into /// player-pin machinery. `controller: You` does collapse to players, but to exactly ONE @@ -17884,18 +17892,20 @@ mod stage2_injector_tests { // intervening `fn`. Checks computed AFTER locating it: `12302 + 123 = 12425` for the producer // and `12168 + 123 = 12291` for the function's opening line — the SAME `+123`. // - // FOURTH re-derivation of this one coordinate (`:12052 → :12132 → :12302 → :12425`), and the - // reason it keeps moving is that it is a LINE NUMBER in the most-edited function's file. Every - // move has been resolved BY CONTENT FIRST — the digest above has been this producer's identity - // since `a6d1a0e62` and has never itself changed — with arithmetic used only as a check that - // agrees afterwards. A coordinate re-derived four times without the content ever moving is - // evidence the pin is tracking the right line, not evidence the pin is fragile. + // FOURTH re-derivation of this one coordinate (`:12052 → :12132 → :12302 → :12425`), + // and the reason it keeps moving is that it is a LINE NUMBER in the most-edited function + // of the most-edited file. Every move has been resolved BY CONTENT FIRST — the digest + // above has been this producer's identity since `a6d1a0e62` and has never itself changed + // — with arithmetic used only as a check that agrees afterwards. A coordinate re-derived + // four times to the same content is evidence the pin tracks the right line, not evidence + // the pin is fragile. // - // SET PRESERVATION: this round adds a withhold CONDITION, not a prompt producer. - // `entry_announces` reports an announcement; it does not assign `state.waiting_for`, so no - // line matching the needle is added or removed (grep-counted 0 on both the `+` and `-` sets). - // Total (38) and partition (5/8/25) both fire GREEN first; the panic was on the third assert - // alone, which is what makes this a coordinate shift rather than a population change. + // SET PRESERVATION (C2a round 4): that round adds a withhold CONDITION, not a prompt + // producer. `entry_announces` reports an announcement; it does not assign + // `state.waiting_for`, so no line matching the needle is added or removed (grep-counted 0 + // on both the `+` and `-` sets). Total (38) and partition (5/8/25) both fire GREEN first; + // the panic was on the third assert alone, which is what makes it a coordinate shift + // rather than a population change. // // item-4 C2b (`WaitingFor::LoopShortcut.declaration`), base `1bc45bb8c`: `:12425 ⇒ :12552`, // `+127`, and ONLY this entry moved — the other four live in `effects/` and @@ -17942,11 +17952,40 @@ mod stage2_injector_tests { // neither assigns `state.waiting_for` an `OptionalEffectChoice`, so no line matching // the needle is added or removed. The total (38) and the partition (5/8/25) both fired // GREEN on the run that caught this; the panic was on this third assert alone. - // ⚠ REBASE #3: `:12644 ⇒ :12643`, located by content digest, offset from + // + // ⚠ C3 (the stale-coordinate comment sweep), REBASED ONTO THE C2b FIX ROUND — the + // coordinate below is a PLACEHOLDER and is deliberately invalid until measured. C3's own + // hunks above this producer are unchanged and have always summed to `+8`: `+1` in + // `shortcut_drive_period` and `+1` in `handle_declare_shortcut` (both replacing a + // measured-wrong "8 KB" WS frame cap with `phase-server`'s `MAX_WS_MESSAGE_BYTES`, + // 64 KB), `+2` on `reject_shortcut_declaration`'s doc (rotted `MagicCompRules.txt` line + // numbers dropped in favour of the CR numbers, which are the stable identifiers), and + // `+4` on `handle_decline_shortcut`'s doc (the twice-rotted `engine.rs:3006-3011` + // ring-clear coordinate replaced by a SYMBOL reference). `engine.rs`'s entire delta in + // C3 is COMMENT HUNKS and nothing else, so a comment round cannot mint a prompt. + // + // THIS ENTRY'S BASE HAS NOW BEEN RE-DERIVED SIX TIMES, and recording that is the point. + // C3 was authored against `70fcd851a` (`:11977 ⇒ :11985`); successive rebases moved its + // base to C2a's `:12052`, then `:12132`, then `:12302`, then C2a round 4's `:12425`, + // then C2b's `:12552`, and now the C2b fix round's `:12582`. Every stored number was + // correct only for the parent it was written against, and every time the CONTENT was + // unchanged. **A coordinate is a fact about a tree, not a property of this commit** — + // which is exactly why C3 replaces line coordinates with SYMBOL references everywhere + // else, and why this row's own pin is the one place that cannot take its own advice. + // + // Resolved BY CONTENT FIRST, arithmetic afterwards as a CHECK: the line whose sha256 + // (WITH trailing newline) is `8a544e878d3e77fb80391b95af8f74059540d5ce4ad6fb83559f364df5cc7d63`, + // which must match exactly ONE line under a whole-file scan and must still sit inside + // `begin_pending_trigger_target_selection` with no intervening `fn`. + // + // SET PRESERVATION (C3): unchanged. The other four entries live in `game/effects/mod.rs` + // and `game/effects/scoped_library_search.rs`, neither of which C3 touches, and a comment + // round adds no line matching the needle — total still 38, partition still 5/8/25. + // ⚠ REBASE #3: `:12652 ⇒ :12651`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - // ⚠ REBASE #3: `:12643 ⇒ :12648`, located by content digest, offset from + // ⚠ REBASE #3: `:12651 ⇒ :12656`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - "game/engine.rs:12648".to_string(), + "game/engine.rs:12656".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ @@ -18999,7 +19038,7 @@ mod kilo_interruptibility_tests { /// combo-interruptibility-acceptance-criterion). A declined `Counters`/`Life` axis leaves its /// ∞ capability marker in `unbounded_resources` intentionally (CR 732.2b never forces a /// shortcut). This test guards the MEASURED retirement path (a) documented at the boundary - /// seam: the empty-stack offer hook `try_offer_object_growth_shortcut` (engine.rs:472) is NOT + /// seam: the empty-stack offer hook `try_offer_object_growth_shortcut` is NOT /// gated by existing ∞ marks, so a later genuine re-detection RE-OFFERS the loop and can /// re-collapse the declined axis once the observer is gone. /// diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index add0e4c0f9..36175930e9 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -3614,7 +3614,7 @@ pub enum PersistentAxisMaterialization { /// (the `TokensCreated` axis). Carries NO per-cycle count because the per-cycle /// fodder count k is STRUCTURALLY ≡ 1: this stash is only registered when /// `materialize_object_growth_shortcut`'s `derived_fodder_class` - /// (engine.rs:1991-2005) found EXACTLY one new battlefield object per period + /// found EXACTLY one new battlefield object per period /// (a two+-object period returns `None` ⇒ no `Tokens` stash), so the boundary /// mint of `count: amount` == k·amount is EXACT. (Contrast `Counters`/`Life`, /// which carry a measured `per_cycle_delta` to handle k>1.) @@ -21206,7 +21206,7 @@ impl GameState { /// shorter slice, so a short snapshot would silently skip tail seats and RETAIN the /// ring — the one direction the "clearing can only SHRINK the prior set" guarantee /// forbids. One comparison keeps that guarantee structural instead of contractual. - /// (CR 119.3, `MagicCompRules.txt:1065`, is the rule the life comparison implements.) + /// (CR 119.3 is the rule the life comparison implements.) pub(crate) fn invalidate_loop_ring_on_unobserved_life_move(&mut self, lives_before: &[i32]) { if self.players.len() != lives_before.len() || self @@ -22132,9 +22132,11 @@ fn _gamestate_partition_is_total(s: &GameState) { // legitimate loop; a heterogeneous/reordered period is correctly caught and rejected). last_loop_action_sequence: _, // - `resolution_source_relatch` (CR 400.7j self-move re-latch): EXCLUDED-REQUIRED (measured - // by ordering trace, not doc-trust). The clear at stack.rs:194 fires at the START of the + // by ordering trace, not doc-trust). The `stack.rs` clears (`resolution_source_relatch = + // None`, one at each resolution-start site) fire at the START of the // NEXT resolution, while `record_loop_detect_sample` fires at the Priority window AFTER - // this resolution's self-move SET it (zones.rs:610) — so at the sample beat it HOLDS this + // this resolution's self-move SET it (`zones::record_resolution_source_relatch`) — so at + // the sample beat it HOLDS this // iteration's `current_incarnation`, which bumps every iteration. COMPARING it would make // every self-moving loop compare UNEQUAL (a false-negative — it would make the 4d // Sprout-Swarm buyback loop undetectable). It is an incarnation/timestamp identity, and diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 8a70adec3c..6070b073c7 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -2131,7 +2131,9 @@ fn b3_materialize_stop_short() { /// PR-7 DoS cap (CR 732.2a SAFETY LIMIT): a `Fixed` count over `MAX_SHORTCUT_CYCLES` is /// handed back to manual play with NO drive. This is the engine-side count cap that stops the /// catastrophic 4-byte remote vector — `Fixed(u32)` scalar-encodes ~4.3e9 cycles in ~10 bytes, -/// sailing through the 8 KB WS frame cap. The count is HARDCODED as `Fixed(u32::MAX)`; the cap +/// sailing through the WS frame cap (`phase-server`'s `MAX_WS_MESSAGE_BYTES`, 64 KB). The count +/// is HARDCODED as +/// `Fixed(u32::MAX)`; the cap /// const is private to the engine crate and invisible across this integration-test boundary. /// /// VACUITY TRAP (PR-7): a handback lands on `WaitingFor::Priority`, and so does the cap-ABSENT From f292805d9a51da47da20f3d6ac2f2b43f6acf026 Mon Sep 17 00:00:00 2001 From: lgray Date: Mon, 10 Aug 2026 11:21:28 -0500 Subject: [PATCH 11/44] feat(engine): publish what a loop-shortcut's declared count actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 732.2a lets a player declare "a loop that repeats a specified number of times" only against "the predictable results of the sequence of choices". The offer already carried the number; it carried nothing about the results. A player picking a count saw a bare integer, and the only way to show its consequence was `count × per-cycle` arithmetic in the display layer — which the frontend-is-not-a-logic-layer rule forbids outright. So the engine multiplies. `InteractionResponseSpec::Shortcut` gains `preview: Option`, where the preview is `{ count, entries }` and each entry is a signed, already-finished magnitude tagged with a display family and, for the per-seat families, the seat it lands on. `count` travels WITH the entries rather than beside them, and that pairing is load-bearing: every magnitude is stated for exactly that count and no other, so a renderer cannot attach these numbers to a different one. ## Why not the previewer that already exists `interaction::preview_interaction` answers a different question — "is this response submittable" — by cloning the state and applying to the clone. It cannot answer this one even in principle: the count may reach `MAX_SHORTCUT_CYCLES`, and the entire point of a CR 732.2a shortcut is that the sequence is *not* played out. The preview here is arithmetic over the certificate's measured `per_cycle.delta` and nothing else — it applies no action, resolves nothing, and touches no `GameState`. A test asserts that routing (see below), because "it happens not to clone today" is not a property the next editor can see. ## `axis_components` — the fold `unbounded_components` was hiding `ResourceVector::unbounded_components` reports only what a cycle *accrues* (`n > 0`, plus a `LibraryDelta` exemption for mill). A preview built on it publishes a lethal drain loop as producing NOTHING, because the victim's life term is negative and therefore invisible. The sign is the whole difference, so the unfiltered fold is now its own method and `unbounded_components` is a filter over it: self.axis_components().into_iter() .filter(|&(axis, n)| n > 0 || matches!(axis, ResourceAxis::LibraryDelta(_))) That is exactly the previous predicate, with the CR 401 mill exemption promoted from a comment on one loop arm to a visible term. No caller of `unbounded_components` changes behavior. Named `axis_components` because `components` is already taken by a different fold over the same fields — that one yields the CONSUMED/GAINED classification with no axis identity, for `is_net_progress`. ## What the engine decides, and what it refuses to decide * **Grouping** stays with `derived_views::family_of`. `preview_family` is a pure, exhaustive, wildcard-free rename of `UnboundedFamily` into its wire code — it makes no grouping decision and so cannot drift from the authority. A new family fails to compile until it chooses a code. * **The seat** is *not* keyed from the proposer. A drain's magnitude belongs to the player losing the life, which is precisely the key `ResourceVector`'s per-player maps already use. CR 119.3 / CR 120.1 / CR 401 / CR 704.5c name the four axes that have a seat; every other axis is a whole-game quantity with `player: None`. * **The aggregation** is per family, not per axis. `ResourceVector` distinguishes mana by color and counters by `(kind, bearer class)`; summing those into one labelled magnitude is exactly the work the display layer is forbidden to do for itself. * **Cancellation** drops the family. A cycle that gains and spends the same mana states nothing, and is omitted rather than shown as `0`. ## When there is no preview `None` unless BOTH a measured per-period signature and a finite count exist: * `per_cycle` is published only by `certified_bounded_cycle_offer`. Every other mint carries `None`, and so does every save written before the field existed. * `UntilLethal` names no number to multiply by — it is the determinate-drain mode, where the count is the drain's own arithmetic rather than a choice. These coincide by construction, not by luck: the bounded producer is the same one that narrows `max_iterations` and mints `Fixed(max_iterations)`. A preview therefore exists on exactly the offers whose count is worth picking. ## Tests — three rows, five mutations, all red * **C4a** asserts the finished magnitude at TWO distinct counts, and the second count is the row's whole point: one count is satisfiable by an implementation that ignores `count` and publishes the raw per-cycle delta, or by one pinned to a constant. Revert-probes run: dropping the `count` factor fails both arms; hardcoding the factor to `3` PASSES the `n = 3` arm and fails `n = 5`. * **C4b** asserts absence on each missing precondition separately, including a period that nets to zero on every family. * **C4c** asserts the preview never routes through the clone-apply previewer. No value assertion can catch that regression, so this row reads the source — which makes it the row most able to be vacuous. It carries a positive control (`preview_interaction` must EXIST in the file, else the asserted absence is the absence of the whole search) and a reach-guard that the extracted span is the real function body (it must contain the multiplication that is the function's entire job). The fixture is built so three distinct wrong implementations surface as value mismatches — two mana colors (a per-axis publisher emits two `Mana` rows), a life LOSS on a non-proposer seat (invisible to `unbounded_components`, and misattributed by a proposer-keyed subject map), and a whole-game axis with no seat. Each is stated as a reach-guard that asserts the fixture property BEFORE any preview is read, naming the wrong implementation it makes observable — otherwise the row could pass while the preview was built on the wrong fold. Every mutation ran under an apply-proof harness: a mutation that silently fails to apply runs the test unmutated and reports PASS, which is indistinguishable from "this row does not discriminate". ## Bounds Magnitudes clamp to `i32`. One period's delta is a difference of two game-state readings, so no delta approaches ~2.1M; `i32` is exact in the JS number the binding generates, which `i64` is not. Named in-code with its ceiling. ## Open for C5 (not closed here) The preview states the count the offer SUGGESTS, which for a bounded offer is its ceiling (`Fixed(max_iterations)`). The picker C5 wires up may select below it. The client cannot recompute — that is the arithmetic this commit exists to move into the engine — so C5 must either display the preview only for the stated count, or the engine must answer per selected count. That decision is C5's, is recorded as such, and is not resolved by this commit. Assisted-by: ClaudeCode:claude-opus-5 --- .../adapter/generated/interaction/index.ts | 8 +- crates/engine/src/analysis/resource.rs | 60 ++-- crates/engine/src/bin/interaction_bindings.rs | 6 +- crates/engine/src/game/interaction.rs | 146 ++++++++- crates/engine/src/types/interaction.rs | 74 +++++ .../tests/integration/interaction_contract.rs | 277 +++++++++++++++++- 6 files changed, 549 insertions(+), 22 deletions(-) diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index 6a616cf2fa..9a2a393660 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -83,6 +83,12 @@ export type InteractionDamageAssignmentMode = "normal" | "asThoughUnblocked"; export type InteractionShortcutCountSpec = { "type": "fixed", "data": { min: number, max: number, suggested: number, } } | { "type": "untilLethal" }; +export type InteractionShortcutPreviewFamily = "mana" | "life" | "damage" | "mill" | "counters" | "tokens" | "cards" | "casts" | "combats" | "turns" | "triggers"; + +export type InteractionShortcutPreviewEntry = { family: InteractionShortcutPreviewFamily, player: number | null, amount: number, }; + +export type InteractionShortcutPreview = { count: number, entries: Array, }; + export type InteractionShortcutPointKind = "targets" | "convokeTaps" | "mode" | "mayChoice" | "unlessBreak" | "manaColor"; export type InteractionShortcutPoint = { group: number, kind: InteractionShortcutPointKind, min: number, max: number, unique: boolean, ordered: boolean, readOnly: boolean, candidateIds: Array, }; @@ -93,7 +99,7 @@ export type InteractionShortcutDecision = { "type": "decline" } | { "type": "acc export type InteractionShortcutReply = { "type": "accept" } | { "type": "shorten", "data": { atIteration: number, } }; -export type InteractionResponseSpec = { "type": "select", "data": { constraint: SelectionConstraint, confirm: ConfirmSemantics, } } | { "type": "assignAmounts", "data": { minTotal: number, maxTotal: number, exactTotal: number | null, } } | { "type": "assignDamage", "data": { total: number, modes: Array, confirm: ConfirmSemantics, } } | { "type": "sequence", "data": { min: number, max: number, unique: boolean, includeAll: boolean, engineValidated: boolean, escape: InteractionChoiceId | null, confirm: ConfirmSemantics, } } | { "type": "groupedSequence", "data": { groups: Array, unique: boolean, confirm: ConfirmSemantics, } } | { "type": "manaGroups", "data": { groups: Array, maxBatch: number, escape: InteractionChoiceId | null, confirm: ConfirmSemantics, } } | { "type": "text", "data": { allowArbitrary: boolean, maxLen: number, confirm: ConfirmSemantics, } } | { "type": "deckPartition", "data": { minMainTotal: number, maxMainTotal: number, confirm: ConfirmSemantics, } } | { "type": "relations", "data": { edges: Array, min: number, max: number, sourceConstraint: InteractionRelationSourceConstraint, allowGroups: boolean, confirm: ConfirmSemantics, } } | { "type": "number", "data": { min: number, max: number, confirm: ConfirmSemantics, } } | { "type": "shortcut", "data": { count: InteractionShortcutCountSpec, points: Array, allowDecline: boolean, confirm: ConfirmSemantics, } } | { "type": "shortcutReply", "data": { minIteration: number, maxIteration: number, confirm: ConfirmSemantics, } }; +export type InteractionResponseSpec = { "type": "select", "data": { constraint: SelectionConstraint, confirm: ConfirmSemantics, } } | { "type": "assignAmounts", "data": { minTotal: number, maxTotal: number, exactTotal: number | null, } } | { "type": "assignDamage", "data": { total: number, modes: Array, confirm: ConfirmSemantics, } } | { "type": "sequence", "data": { min: number, max: number, unique: boolean, includeAll: boolean, engineValidated: boolean, escape: InteractionChoiceId | null, confirm: ConfirmSemantics, } } | { "type": "groupedSequence", "data": { groups: Array, unique: boolean, confirm: ConfirmSemantics, } } | { "type": "manaGroups", "data": { groups: Array, maxBatch: number, escape: InteractionChoiceId | null, confirm: ConfirmSemantics, } } | { "type": "text", "data": { allowArbitrary: boolean, maxLen: number, confirm: ConfirmSemantics, } } | { "type": "deckPartition", "data": { minMainTotal: number, maxMainTotal: number, confirm: ConfirmSemantics, } } | { "type": "relations", "data": { edges: Array, min: number, max: number, sourceConstraint: InteractionRelationSourceConstraint, allowGroups: boolean, confirm: ConfirmSemantics, } } | { "type": "number", "data": { min: number, max: number, confirm: ConfirmSemantics, } } | { "type": "shortcut", "data": { count: InteractionShortcutCountSpec, points: Array, allowDecline: boolean, preview: InteractionShortcutPreview | null, confirm: ConfirmSemantics, } } | { "type": "shortcutReply", "data": { minIteration: number, maxIteration: number, confirm: ConfirmSemantics, } }; export type InteractionOpportunityResponse = { "type": "exactChoices", "data": { choices: Array, } } | { "type": "schema", "data": { spec: InteractionResponseSpec, candidates: Array, } }; diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 7092c2793a..65bd74cb83 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -877,49 +877,57 @@ impl ResourceVector { any_increase || mills } - /// The component axes that strictly increased over this delta — the - /// candidate **unbounded** resources a `WinKind` classifier (PR-2) reads to - /// name the loop's win condition. A mill axis surfaces here as a negative - /// `library_delta`, so it is reported separately via its sign. - /// - /// Returns each increasing axis as a [`ResourceAxis`] tag with its signed - /// magnitude. - pub fn unbounded_components(&self) -> Vec<(ResourceAxis, i64)> { + /// EVERY axis this delta moved, in either direction, as a [`ResourceAxis`] tag with its + /// signed magnitude — the unfiltered fold [`Self::unbounded_components`] narrows. + /// + /// Named `axis_components` because [`Self::components`] is taken by a different fold over + /// the same fields: that one yields the [`Component`] CONSUMED/GAINED classification with + /// no axis identity, for [`Self::is_net_progress`]. + /// + /// The distinction from that method is the SIGN, and it is the whole reason this exists: + /// `unbounded_components` reports only what a loop *accrues*, so a drain loop's defining + /// term — the victim's NEGATIVE `life` — is invisible through it. A consumer that has to + /// state what a repetition COSTS, rather than what it gains, cannot be built on that + /// method. The one such consumer today is `game::interaction`'s CR 732.2a shortcut + /// preview, which states the finished magnitude of a declared repeat count and would + /// otherwise show a lethal drain as producing nothing. + /// + /// Order is fixed (mana, life, damage, library, poison, counters, triggers, then the + /// scalar axes) and every map is a `BTreeMap`, so the result is deterministic. + pub fn axis_components(&self) -> Vec<(ResourceAxis, i64)> { let mut out = Vec::new(); for (i, &n) in self.mana.iter().enumerate() { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Mana(MANA_INDEX[i]), n)); } } for (pid, &n) in &self.life { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Life(*pid), n)); } } for (pid, &n) in &self.damage_dealt { - if n > 0 { + if n != 0 { out.push((ResourceAxis::DamageDealt(*pid), n)); } } - // CR 401: a mill loop is unbounded *downward* on library size. for (pid, &n) in &self.library_delta { if n != 0 { out.push((ResourceAxis::LibraryDelta(*pid), n)); } } - // CR 704.5c: rising poison on a victim is an unbounded loss axis. for (pid, &n) in &self.poison { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Poison(*pid), n)); } } for (&key, &n) in &self.counters { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Counter(key.0, key.1), n)); } } for (&kind, &n) in &self.generic_triggers { - if n > 0 { + if n != 0 { out.push((ResourceAxis::Trigger(kind), n)); } } @@ -935,13 +943,31 @@ impl ResourceVector { (ResourceAxis::LtbTriggers, self.ltb_triggers), (ResourceAxis::SacTriggers, self.sac_triggers), ] { - if n > 0 { + if n != 0 { out.push((axis, n)); } } out } + /// The component axes that strictly increased over this delta — the + /// candidate **unbounded** resources a `WinKind` classifier (PR-2) reads to + /// name the loop's win condition. A mill axis surfaces here as a negative + /// `library_delta`, so it is reported separately via its sign. + /// + /// Returns each increasing axis as a [`ResourceAxis`] tag with its signed + /// magnitude. + /// + /// CR 401: the `LibraryDelta` exemption is what keeps a mill loop — unbounded + /// *downward* on library size — in the result while every other axis is required to + /// have risen. + pub fn unbounded_components(&self) -> Vec<(ResourceAxis, i64)> { + self.axis_components() + .into_iter() + .filter(|&(axis, n)| n > 0 || matches!(axis, ResourceAxis::LibraryDelta(_))) + .collect() + } + /// CR 732.2a + CR 704.5a / CR 704.5c / CR 104.3c + CR 121.4: the largest number of /// times this per-period delta may legally be repeated in one shortcut proposal. /// diff --git a/crates/engine/src/bin/interaction_bindings.rs b/crates/engine/src/bin/interaction_bindings.rs index 2ade424c1c..97f3d208c7 100644 --- a/crates/engine/src/bin/interaction_bindings.rs +++ b/crates/engine/src/bin/interaction_bindings.rs @@ -14,7 +14,8 @@ use engine::types::interaction::{ InteractionRelationConstraint, InteractionRelationSourceConstraint, InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionShortcutCountSpec, InteractionShortcutDecision, InteractionShortcutPin, InteractionShortcutPoint, - InteractionShortcutPointKind, InteractionShortcutReply, InteractionShortcutResponseCode, + InteractionShortcutPointKind, InteractionShortcutPreview, InteractionShortcutPreviewEntry, + InteractionShortcutPreviewFamily, InteractionShortcutReply, InteractionShortcutResponseCode, InteractionSlotKind, InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, InteractionWaitingForKind, InteractionZoneCode, SelectionConstraint, SimultaneousDecisionKind, ViewerInteraction, @@ -89,6 +90,9 @@ fn expected_bindings() -> String { InteractionRelationSourceConstraint, InteractionDamageAssignmentMode, InteractionShortcutCountSpec, + InteractionShortcutPreviewFamily, + InteractionShortcutPreviewEntry, + InteractionShortcutPreview, InteractionShortcutPointKind, InteractionShortcutPoint, InteractionShortcutPin, diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 9f2e8243df..8799f078f2 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -16,6 +16,7 @@ use crate::analysis::decision_template::{ declaration_conforms, DecisionGroupKey, DecisionKind, DecisionTemplate, IterationCount, PinnedDecision, ReplayMode, TargetPin, }; +use crate::analysis::resource::ResourceAxis; use crate::types::ability::{ AggregateFunction, ChoiceType, ChooseFromZoneConstraint, Comparator, CounterCostSelection, DoorLockOp, EffectKind, ObjectProperty, SearchSelectionConstraint, TapCreaturesAggregateStat, @@ -49,7 +50,8 @@ use crate::types::interaction::{ InteractionRelationConstraint, InteractionRelationSourceConstraint, InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, InteractionShortcutCountSpec, InteractionShortcutDecision, InteractionShortcutPoint, - InteractionShortcutPointKind, InteractionShortcutReply, InteractionShortcutResponseCode, + InteractionShortcutPointKind, InteractionShortcutPreview, InteractionShortcutPreviewEntry, + InteractionShortcutPreviewFamily, InteractionShortcutReply, InteractionShortcutResponseCode, InteractionSlotKind, InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, InteractionWaitingForKind, InteractionZoneCode, SelectionConstraint, SimultaneousDecisionKind, ViewerInteraction, MAX_INTERACTION_LIST_LEN, @@ -63,6 +65,7 @@ use crate::types::player::PlayerId; use crate::types::zones::Zone; use super::combat::AttackTarget; +use super::derived_views::{family_of, UnboundedFamily}; use super::dungeon::DungeonId; use super::engine::{ apply_interaction, apply_interaction_for_simulation, EngineError, MAX_SHORTCUT_CYCLES, @@ -1115,6 +1118,7 @@ struct LoopShortcutPointProjection { #[derive(Debug, Clone)] struct LoopShortcutProjection { count: InteractionShortcutCountSpec, + preview: Option, points: Vec, candidates: Vec, } @@ -2430,12 +2434,119 @@ fn number_projection(waiting_for: &WaitingFor) -> Option { } } +/// Rename `game::derived_views::UnboundedFamily` into its projection-layer code. +/// +/// A pure rename on purpose: `family_of` stays the SINGLE authority for which axis groups +/// into which family, so this function makes no grouping decision of its own and cannot +/// drift from it. Exhaustive with no wildcard — a new family must choose a code here. +/// +/// Mirrors `comparator_dto` above: the engine owns the fact, and the projection layer owns +/// the name it crosses the wire under. +fn preview_family(family: UnboundedFamily) -> InteractionShortcutPreviewFamily { + match family { + UnboundedFamily::Mana => InteractionShortcutPreviewFamily::Mana, + UnboundedFamily::Life => InteractionShortcutPreviewFamily::Life, + UnboundedFamily::Damage => InteractionShortcutPreviewFamily::Damage, + UnboundedFamily::Mill => InteractionShortcutPreviewFamily::Mill, + UnboundedFamily::Counters => InteractionShortcutPreviewFamily::Counters, + UnboundedFamily::Tokens => InteractionShortcutPreviewFamily::Tokens, + UnboundedFamily::Cards => InteractionShortcutPreviewFamily::Cards, + UnboundedFamily::Casts => InteractionShortcutPreviewFamily::Casts, + UnboundedFamily::Combats => InteractionShortcutPreviewFamily::Combats, + UnboundedFamily::Turns => InteractionShortcutPreviewFamily::Turns, + UnboundedFamily::Triggers => InteractionShortcutPreviewFamily::Triggers, + } +} + +/// CR 119.3 + CR 120.1 + CR 401 + CR 704.5c: the seat a resource axis lands ON, for the four +/// axes that name one. Every other axis is a whole-game quantity with no seat. +/// +/// This is a different question from "who controls the loop" and it is deliberately not +/// answered from the proposer: a drain's magnitude belongs to the player LOSING the life, +/// which is exactly the seat `ResourceVector`'s per-player maps are keyed by. +fn preview_subject(axis: ResourceAxis) -> Option { + match axis { + ResourceAxis::Life(player) + | ResourceAxis::DamageDealt(player) + | ResourceAxis::LibraryDelta(player) + | ResourceAxis::Poison(player) => Some(player), + ResourceAxis::Mana(_) + | ResourceAxis::Counter(_, _) + | ResourceAxis::Trigger(_) + | ResourceAxis::TokensCreated + | ResourceAxis::CardsDrawn + | ResourceAxis::Casts + | ResourceAxis::LandfallTriggers + | ResourceAxis::CombatPhases + | ResourceAxis::ExtraTurns + | ResourceAxis::DeathTriggers + | ResourceAxis::EtbTriggers + | ResourceAxis::LtbTriggers + | ResourceAxis::SacTriggers => None, + } +} + +/// CR 732.2a: the finished magnitude of repeating `count` cycles of a measured per-period +/// delta — "the predictable results of the sequence of choices", stated per display family +/// and per affected seat. +/// +/// **This is arithmetic over the certificate's `per_cycle.delta`, and nothing else.** It +/// applies no game action, resolves nothing, and touches no `GameState`: the multiplication +/// `n × δ` is the whole computation. In particular it is NOT +/// `interaction::preview_interaction`, which answers a different question (is this response +/// submittable) by cloning the state and applying to the clone. A clone-apply cannot answer +/// this one anyway — the count may be up to `MAX_SHORTCUT_CYCLES`, and the point of a CR +/// 732.2a shortcut is that the sequence is *not* played out. +/// +/// The fold is over families, not axes: `ResourceVector` distinguishes mana by color and +/// counters by `(kind, bearer class)`, and summing those into one labelled magnitude per seat +/// is the aggregation the display layer is forbidden to do for itself. Losses are included +/// (signed), which is why this reads `axis_components()` rather than `unbounded_components()` — +/// the latter reports only what a cycle accrues, so a lethal drain would preview as nothing. +/// +/// ponytail: magnitudes clamp to `i32`, so a per-cycle delta above ~2.1M would be reported +/// short. No such delta exists — one period's delta is a difference of two game-state +/// readings — and `i32` is exact in the JS number the binding generates, which `i64` is not. +fn shortcut_preview_entries( + delta: &crate::analysis::resource::ResourceVector, + count: u32, +) -> Vec { + let mut per_cycle_totals: BTreeMap<(InteractionShortcutPreviewFamily, Option), i64> = + BTreeMap::new(); + for (axis, magnitude) in delta.axis_components() { + let key = ( + preview_family(family_of(axis)), + preview_subject(axis).map(|player| player.0), + ); + let total = per_cycle_totals.entry(key).or_insert(0); + *total = total.saturating_add(magnitude); + } + per_cycle_totals + .into_iter() + .filter_map(|((family, player), per_cycle)| { + // Families that cancel to zero across their axes (a cycle that gains and spends + // the same mana) state nothing and are dropped rather than shown as `0`. + let amount = per_cycle.saturating_mul(i64::from(count)); + (amount != 0).then_some(InteractionShortcutPreviewEntry { + family, + player, + amount: amount.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32, + }) + }) + .collect() +} + fn loop_shortcut_projection( waiting_for: &WaitingFor, ) -> Result { use crate::analysis::decision_template::DecisionPointKind; - let WaitingFor::LoopShortcut { schema, .. } = waiting_for else { + let WaitingFor::LoopShortcut { + schema, + certificate, + .. + } = waiting_for + else { return Err(InteractionReasonCode::UnsupportedResponse); }; if schema.points.len() > MAX_INTERACTION_LIST_LEN { @@ -2514,6 +2625,35 @@ fn loop_shortcut_projection( InteractionShortcutCountSpec::UntilLethal } }; + // CR 732.2a: state what the offer's own count DOES, so the picker's number carries its + // consequence instead of standing alone. Two authorities have to agree before there is + // anything to state, and both are the offer's own: + // + // * `per_cycle` — published only by the producer that measured a per-period signature + // (`certified_bounded_cycle_offer`). Every other mint carries `None`, and so does + // every save written before the field existed. + // * a FINITE count — `UntilLethal` names no number to multiply by. It is the + // determinate-drain mode, where the count is the drain's own arithmetic, not a + // player's choice. + // + // Those two coincide by construction rather than by luck: the bounded producer is the + // one that both narrows `max_iterations` and mints `Fixed(max_iterations)`, so a preview + // exists exactly on the offers whose count is worth picking. + // + // `suggested` is the stated count, and the ONLY count these magnitudes describe — which + // is why it travels with them in `InteractionShortcutPreview.count` rather than being + // left for a renderer to assume. + let preview = match (&count, &certificate.per_cycle) { + (InteractionShortcutCountSpec::Fixed { suggested, .. }, Some(periodic)) => { + let entries = shortcut_preview_entries(&periodic.delta, *suggested); + (!entries.is_empty()).then_some(InteractionShortcutPreview { + count: *suggested, + entries, + }) + } + (InteractionShortcutCountSpec::Fixed { .. }, None) + | (InteractionShortcutCountSpec::UntilLethal, _) => None, + }; let mut candidates = Vec::new(); let mut points = Vec::with_capacity(schema.points.len()); for point in &schema.points { @@ -2667,6 +2807,7 @@ fn loop_shortcut_projection( } Ok(LoopShortcutProjection { count, + preview, points, candidates, }) @@ -7019,6 +7160,7 @@ fn opportunity_for_slot( count: projection.count, points, allow_decline: true, + preview: projection.preview.clone(), confirm: ConfirmSemantics::Explicit, }, candidates, diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index e7d2e44bd8..e4a18c8652 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -969,10 +969,18 @@ pub enum InteractionResponseSpec { max: u32, confirm: ConfirmSemantics, }, + /// CR 732.2a: the loop-shortcut declaration. `count` is the picker's window and + /// `preview` is what the count it states actually DOES, per axis — see + /// [`InteractionShortcutPreview`] for why the count travels with the magnitudes. + /// + /// The doc lives on the VARIANT rather than on `preview`: ts_rs emits field docs into + /// the generated bindings as JSDoc but drops variant docs, and a comment block in the + /// middle of a union keeps that file from being one declaration per line. Shortcut { count: InteractionShortcutCountSpec, points: Vec, allow_decline: bool, + preview: Option, confirm: ConfirmSemantics, }, ShortcutReply { @@ -999,6 +1007,72 @@ pub enum InteractionShortcutCountSpec { UntilLethal, } +/// The display family one shortcut-preview magnitude belongs to — the projection-layer code +/// for `game::derived_views::UnboundedFamily`, mapped by an exhaustive `match` in +/// `game::interaction`. +/// +/// A code rather than a mirror of `analysis::resource::ResourceAxis`, for this module's own +/// stated reason: `ResourceAxis` carries `PlayerId`, `ManaType`, `CounterClass`, +/// `ObjectClass` and `TriggerKind` payloads, and generating those would be the "second +/// generated copy of the existing engine wire graph" this file exists to avoid. The client +/// already labels these eleven families (glyph + i18n key per family), so a code is +/// everything a renderer needs. +/// +/// No CR governs a display grouping — the grouping authority is `derived_views::family_of`, +/// and this enum tracks it variant-for-variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub enum InteractionShortcutPreviewFamily { + Mana, + Life, + Damage, + Mill, + Counters, + Tokens, + Cards, + Casts, + Combats, + Turns, + Triggers, +} + +/// One axis of what a declared shortcut count finishes with: a signed magnitude, already +/// multiplied out by the engine. +/// +/// `amount` is the FINISHED total, not a per-cycle rate, and it is signed — a drain loop +/// states its victim's life as negative. `player` is the seat the magnitude lands on for the +/// per-seat families (life, damage, mill, and the poison term of counters) and `None` for the +/// whole-game ones (mana, tokens, cards, casts, combats, turns, triggers). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub struct InteractionShortcutPreviewEntry { + pub family: InteractionShortcutPreviewFamily, + pub player: Option, + pub amount: i32, +} + +/// CR 732.2a: the engine-computed consequence of repeating a certified loop a stated number +/// of times — "the predictable results of the sequence of choices", published as numbers. +/// +/// `count` is carried WITH the entries, and that pairing is the point: every magnitude here +/// is stated for exactly this count and for no other, so a renderer can never attach these +/// numbers to a different one. The engine multiplies; the display layer reads. +/// +/// Absent (`None` on the spec) when the offer states no per-period signature to multiply, or +/// states no finite count to multiply it by. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub struct InteractionShortcutPreview { + pub count: u32, + pub entries: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] #[serde( diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 1d3e0f8412..79fe5e376a 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -34,7 +34,8 @@ use engine::types::interaction::{ InteractionPreviewRequest, InteractionPreviewStatus, InteractionReasonCode, InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, InteractionShortcutCountSpec, InteractionShortcutDecision, InteractionShortcutPin, - InteractionShortcutPointKind, InteractionShortcutResponseCode, InteractionSubmission, + InteractionShortcutPointKind, InteractionShortcutPreview, InteractionShortcutPreviewEntry, + InteractionShortcutPreviewFamily, InteractionShortcutResponseCode, InteractionSubmission, PreviewRequestId, MAX_INTERACTION_LIST_LEN, }; use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; @@ -2502,6 +2503,280 @@ fn loop_shortcut_number_schema_accepts_a_fixed_count_above_one() { assert_eq!(preview.status, InteractionPreviewStatus::Confirmable); } +/// The per-period signature the C4 preview rows multiply out. Chosen so that three separate +/// ways of getting the preview wrong all show up as a value mismatch: +/// +/// * **two mana colors**, so a preview that published raw axes instead of folding them into +/// one engine-side family total would emit two `Mana` rows; +/// * **a life LOSS on a seat that is not the proposer**, which `unbounded_components` drops +/// entirely (it reports only what a cycle accrues) and which a proposer-keyed subject +/// mapping would attribute to the wrong player; +/// * **a whole-game axis** (`tokens_created`) with no seat, so the `Option` subject is +/// exercised on both sides. +fn preview_period_delta() -> engine::analysis::resource::ResourceVector { + let mut delta = engine::analysis::resource::ResourceVector::default(); + // `MANA_INDEX` is `[W, U, B, R, G, C]`. + delta.mana[0] = 1; + delta.mana[1] = 2; + delta.life.insert(P1, -2); + delta.tokens_created = 4; + delta +} + +/// A `LoopShortcut` offer stated exactly the way `certified_bounded_cycle_offer` states one: +/// `Fixed(max_iterations)` as the suggestion and the same number as the ceiling, with the +/// measured period on the certificate. +fn preview_offer( + iteration_count: IterationCount, + max_iterations: u32, + per_cycle: Option, +) -> GameState { + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: Some(P0), + certificate: engine::analysis::loop_check::LoopCertificate { + unbounded: Vec::new(), + win_kind: engine::analysis::loop_check::WinKind::Advantage, + mandatory: false, + residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: per_cycle.map(|delta| engine::analysis::resource::PeriodicDelta { + frames_per_period: 1, + delta, + victim_slot: Vec::new(), + }), + }, + schema: ShortcutDecisionSchema { + iteration_count, + max_iterations, + ..Default::default() + }, + }; + bind(&mut state, "loop-preview"); + state +} + +fn shortcut_preview_of(state: &GameState) -> Option { + let view = priority_view(state); + let InteractionOpportunityResponse::Schema { + spec: InteractionResponseSpec::Shortcut { preview, .. }, + .. + } = &view.opportunities[0].response + else { + panic!("loop shortcut uses a shortcut schema"); + }; + preview.clone() +} + +fn preview_entry( + family: InteractionShortcutPreviewFamily, + player: Option, + amount: i32, +) -> InteractionShortcutPreviewEntry { + InteractionShortcutPreviewEntry { + family, + player, + amount, + } +} + +/// C4a — CR 732.2a: the offer publishes what its stated count actually DOES, computed by the +/// engine as `n × δ` over the certificate's measured per-period delta. Without this the count +/// picker C5 wires up is a number with no displayed consequence, and the only other way to +/// show one is `× count` arithmetic in the display layer, which the layer rule forbids. +/// +/// **Asserted at TWO distinct counts, and that is the point of the row.** A single count is +/// satisfiable by an implementation that ignores `count` entirely and publishes the raw +/// per-cycle delta, or by one that hardcodes a constant. Only the pair pins the +/// multiplication. +/// +/// REVERT-PROBES, both RUN: +/// * drop the `count` factor (`per_cycle` instead of `per_cycle.saturating_mul(count)`) ⇒ +/// both arms fail on values; +/// * hardcode the factor to `3` ⇒ the `n = 3` arm still PASSES and the `n = 5` arm fails, +/// which is exactly the "one value is satisfiable by a constant" hole the second count +/// closes. +#[test] +fn loop_shortcut_preview_states_the_finished_magnitude_for_the_declared_count() { + use engine::analysis::resource::ResourceAxis; + + // ── REACH-GUARDS on the fixture, before any preview is read. Each one names the wrong + // implementation it makes observable; without them this row could pass while the + // preview was built on the wrong fold or aggregated in the wrong layer. + let delta = preview_period_delta(); + assert!( + !delta + .unbounded_components() + .iter() + .any(|(axis, _)| matches!(axis, ResourceAxis::Life(_))), + "reach-guard: the victim's life LOSS is INVISIBLE to `unbounded_components`, so a \ + preview rebuilt on that fold would silently publish a lethal drain as producing \ + nothing. The `Life` expectations below are what detect it" + ); + assert_eq!( + delta + .axis_components() + .iter() + .filter(|(axis, _)| matches!(axis, ResourceAxis::Mana(_))) + .count(), + 2, + "reach-guard: the period moves TWO mana axes, so the single `Mana` entry expected \ + below is proof the engine folded them — not proof that only one existed" + ); + assert_ne!( + P1.0, P0.0, + "reach-guard: the victim is not the proposer, so a subject mapping keyed off the \ + proposer resolves to the wrong seat" + ); + + let at = |n: u32| { + shortcut_preview_of(&preview_offer( + IterationCount::Fixed(n), + n, + Some(preview_period_delta()), + )) + .expect("a bounded offer with a measured period states a preview") + }; + + let three = at(3); + assert_eq!( + three.count, 3, + "the count travels WITH the magnitudes, so a renderer cannot attach them to another" + ); + assert_eq!( + three.entries, + vec![ + preview_entry(InteractionShortcutPreviewFamily::Mana, None, 9), + preview_entry(InteractionShortcutPreviewFamily::Life, Some(P1.0), -6), + preview_entry(InteractionShortcutPreviewFamily::Tokens, None, 12), + ], + "CR 732.2a: three repetitions of (+1W +2U, P1 -2 life, +4 tokens) finish at +9 mana, \ + P1 at -6 life, +12 tokens" + ); + + let five = at(5); + assert_eq!(five.count, 5); + assert_eq!( + five.entries, + vec![ + preview_entry(InteractionShortcutPreviewFamily::Mana, None, 15), + preview_entry(InteractionShortcutPreviewFamily::Life, Some(P1.0), -10), + preview_entry(InteractionShortcutPreviewFamily::Tokens, None, 20), + ], + "the SECOND count is what makes this row unsatisfiable by a constant: an \ + implementation pinned to 3 passes the arm above and fails here" + ); +} + +/// C4a, negative half — a preview is published only when the offer supplies BOTH authorities +/// it multiplies: a measured per-period signature and a finite count. Every arm is paired +/// with the positive control on the same builder, so none of them can pass because the whole +/// window failed to project. +#[test] +fn loop_shortcut_preview_is_absent_without_both_a_period_and_a_finite_count() { + // ── PAIRED POSITIVE, first. + assert!( + shortcut_preview_of(&preview_offer( + IterationCount::Fixed(4), + 4, + Some(preview_period_delta()), + )) + .is_some(), + "control: both authorities present must publish a preview, else every arm below \ + passes for an unrelated reason" + ); + + // ── No measured period: every mint except the bounded one carries `per_cycle: None`, + // as does every save written before that field existed. + assert_eq!( + shortcut_preview_of(&preview_offer(IterationCount::Fixed(4), 4, None)), + None, + "an offer that states no per-period signature has nothing to multiply" + ); + + // ── CR 704.5a: `UntilLethal` is the determinate-drain mode. It names no number, so + // there is no declared count to state a finished magnitude for — even though the + // period here IS measured, which is what keeps this arm distinct from the one above. + assert_eq!( + shortcut_preview_of(&preview_offer( + IterationCount::UntilLethal, + 4, + Some(preview_period_delta()), + )), + None, + "`UntilLethal` states no finite count to multiply the period by" + ); + + // ── A period whose every family nets to zero (one W gained and one W spent) states + // nothing, and is dropped rather than published as a row of zeroes. + let mut inert = engine::analysis::resource::ResourceVector::default(); + inert.mana[0] = 1; + inert.mana[5] = -1; + assert_eq!( + inert.axis_components().len(), + 2, + "reach-guard: the inert period really does move two axes, so the `None` below is the \ + family fold cancelling them — not an empty vector arriving empty" + ); + assert_eq!( + shortcut_preview_of(&preview_offer(IterationCount::Fixed(4), 4, Some(inert))), + None, + "a period that nets to nothing on every family publishes no preview at all" + ); +} + +/// C4a's hostile guard — the preview is ARITHMETIC, and must never become a clone-apply. +/// +/// `game::interaction::preview_interaction` answers a different question (is this response +/// submittable) by cloning the whole `GameState` and applying to the clone. It cannot answer +/// this one: a CR 732.2a shortcut's declared count may reach `MAX_SHORTCUT_CYCLES`, and the +/// entire point of the rule is that the sequence is NOT played out to find out what it does. +/// A future rewrite that reached for the previewer would be quietly quadratic and quietly +/// wrong, and no value assertion would catch it — so this row reads the source. +/// +/// REVERT-PROBE, RUN: add the line `// preview_interaction` inside the function body ⇒ this +/// row fails on the assert (it still compiles, so the probe discriminates on the assertion +/// rather than on the build). +#[test] +fn loop_shortcut_preview_never_routes_through_the_clone_apply_previewer() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/game/interaction.rs"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + + // ── POSITIVE CONTROL: the banned symbol IS in this file. Without this the "does not + // contain" assertions below would pass just as happily against an empty read, a + // renamed file, or a search that never matched anything. + assert!( + text.contains("pub fn preview_interaction("), + "positive control: `preview_interaction` must exist in this file, else the absence \ + asserted below is the absence of the whole search" + ); + + let marker = "\nfn shortcut_preview_entries("; + let start = text + .find(marker) + .expect("reach-guard: the preview function must be found by name, or this row is vacuous") + + 1; + let rest = &text[start..]; + let end = rest[1..] + .find("\nfn ") + .map_or(rest.len(), |offset| offset + 1); + let body = &rest[..end]; + + assert!( + body.contains("saturating_mul"), + "reach-guard: the extracted span must be the real body — the multiplication is the \ + function's entire job, so its absence means the span is wrong" + ); + for banned in ["preview_interaction", "state.clone()", "GameState"] { + assert!( + !body.contains(banned), + "CR 732.2a: the shortcut preview is `n × δ` over the certificate's measured \ + period. It must not reach `{banned}` — a clone-apply cannot state the result of \ + a sequence that is deliberately never played out" + ); + } +} + #[test] fn loop_shortcut_schema_and_materializer_cover_every_decision_point_kind() { let mut scenario = GameScenario::new(); From 393cd44f487183e067a08e47d1e16764a39233b6 Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 11 Aug 2026 05:14:11 -0500 Subject: [PATCH 12/44] fix(engine): adapt the C3/C4 stack to the published declaration field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consequences of rebasing this pair onto the loop-shortcut declaration work, neither of which git could surface as a conflict. `preview_offer` stages a `LoopShortcut` literal, and enum-variant literals admit no `..`, so the new `declaration` field made it a forced site (E0063). The value is chosen from the invariant rather than from what silences the compiler: this helper builds its schema with `..Default::default()`, so `points` is empty, and an empty schema never publishes a declaration — the same property the publisher's own row asserts. `None` is therefore what the engine would stage here. Filling a forced field with whatever compiles is how a fixture drifts into staging a state the producer cannot emit, after which a row passes against something impossible. The CR 603.5 prompt census pin moved again. The producer sat at :12582 on the new parent, and this pair's own comment hunks push it a further +8. It was re-derived content-first, as that log requires: the line whose sha256 is 8a544e87… matches exactly one line file-wide, still inside `begin_pending_trigger_target_selection` (which opens at :12456 with no intervening `fn`), and the +8 arithmetic agrees only as an after-check. The drift log now carries both lineages in order — the declaration commit's entry, its fix round's, and this rebase's — rather than one overwriting the other. Disclosure: the conflict resolution deliberately committed the pin as an invalid :99999 placeholder, because the true coordinate cannot be measured until every commit in the pair has been replayed. That placeholder is present in the preceding commit and is resolved here. It is deliberate: a coordinate that cannot yet be measured must fail structurally rather than read as plausible. Folding it backwards would require re-replaying the pair for no gain, and this repository squashes on merge, so the tip is what gates. Assisted-by: ClaudeCode:claude-opus-4.8 --- crates/engine/src/game/engine.rs | 4 ++-- crates/engine/tests/integration/interaction_contract.rs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 1d63a0d0e3..888580c707 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -17953,8 +17953,8 @@ mod stage2_injector_tests { // the needle is added or removed. The total (38) and the partition (5/8/25) both fired // GREEN on the run that caught this; the panic was on this third assert alone. // - // ⚠ C3 (the stale-coordinate comment sweep), REBASED ONTO THE C2b FIX ROUND — the - // coordinate below is a PLACEHOLDER and is deliberately invalid until measured. C3's own + // ⚠ C3 (the stale-coordinate comment sweep), REBASED ONTO THE C2b FIX ROUND: + // `:12582 ⇒ :12590`, `+8`, LOCAL — measured at this tip, not carried. C3's own // hunks above this producer are unchanged and have always summed to `+8`: `+1` in // `shortcut_drive_period` and `+1` in `handle_declare_shortcut` (both replacing a // measured-wrong "8 KB" WS frame cap with `phase-server`'s `MAX_WS_MESSAGE_BYTES`, diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 79fe5e376a..9e800cf8cd 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -2551,6 +2551,12 @@ fn preview_offer( max_iterations, ..Default::default() }, + // `points` is empty here (`..Default::default()`), and an empty schema never publishes a + // declaration — the same invariant row D4 asserts against `build_bounded_declaration`. So + // `None` is what the engine itself would stage, not merely what makes the literal compile. + // These rows exercise the PREVIEW projection, which reads the certificate and schema; a + // declaration here would stage a state the producer cannot emit. + declaration: None, }; bind(&mut state, "loop-preview"); state From f0762a6a3cafaeafa5e6bd01244b05ca46294498 Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 11 Aug 2026 06:47:10 -0500 Subject: [PATCH 13/44] fix(engine): close six review findings on the loop-shortcut preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row that could not fail. The preview's no-clone guard was attached to `shortcut_preview_entries`, which takes no `GameState` and whose only caller has none either — so the clone-apply regression it forbids could not originate anywhere in its span. The row now reads two spans, including the arm of `opportunity_for_slot` where the spec is actually built, and the span is proven to contain a constructible site both statically (the enclosing signature binds two `&GameState`) and by measurement (the reviewer's exact clone compiles there, and the row goes red on it). A positive control proves the search is real; it does not prove the span is right. One partition, one owner. `preview_subject` and `attribution_player` carried the same axis-to-seat split in two exhaustive matches, so a future payload-keyed axis would have forced two decisions but not the same one, and the offer could attribute a seat the HUD does not. `payload_seat` in `derived_views` is now the single authority and `attribution_player` resolves through it. The two were measured equal across all 17 axes and four controller positions before being collapsed, including seat-differs directions; the collapse was not assumed from their shape. `preview.entries` is now counted against the outbound payload budget, which the `Shortcut` arm previously skipped. Bounded small in practice, so this is consistency, not a live limit failure. The two family enums are pinned to the same wire string. They agreed only because every variant is one word: one serializes `lowercase`, the other `camelCase`, so the first two-word variant would have diverged silently and the client's family-keyed lookup would have missed. The new row derives its family list from the committed golden and compares both enums' strings, and its probe is a genuine two-word variant rather than a today-failing case. No wire string changed. The real-game row now asserts the published preview. Every prior preview row hand-built its state, so the producer could have stopped emitting previews in real games with all of them green. The 4p dump row now derives the viewer interaction and asserts the preview, its count, and the per-seat life entries. Caveat recorded in the row: an existing schema pin fires first under the same probe, so this block covers the published payload losing its preview, which that pin cannot see. CR 704.5c is restored. It annotated a poison arm that this series moved, and was dropped rather than relocated. It now sits on the surviving doc, re-verified against the rules text and reworded to describe where the code actually lives — a verbatim paste would have described a loop that no longer exists there. Assisted-by: ClaudeCode:claude-opus-4.8 --- crates/engine/src/analysis/resource.rs | 9 + crates/engine/src/game/derived_views.rs | 56 ++++-- crates/engine/src/game/interaction.rs | 182 +++++++++++++++--- .../tests/integration/interaction_contract.rs | 109 +++++++++-- .../engine/tests/integration/loop_shortcut.rs | 86 +++++++++ 5 files changed, 371 insertions(+), 71 deletions(-) diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 65bd74cb83..53b1b2abb7 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -961,6 +961,15 @@ impl ResourceVector { /// CR 401: the `LibraryDelta` exemption is what keeps a mill loop — unbounded /// *downward* on library size — in the result while every other axis is required to /// have risen. + /// + /// CR 704.5c: rising poison on a victim is an unbounded loss axis — and unlike mill it + /// needs no exemption, because poison RISES toward the ten-counter loss, so `Poison(p)` + /// is carried by the `n > 0` term itself. RELOCATED, not re-derived: this annotation sat + /// above the poison arm of this method's own loop until the `axis_components` split moved + /// that loop out, and it belongs beside the CR 401 term because the pair is what states + /// WHICH loss axes survive the filter and why. Re-verified against + /// `docs/MagicCompRules.txt`: "704.5c If a player has ten or more poison counters, that + /// player loses the game." pub fn unbounded_components(&self) -> Vec<(ResourceAxis, i64)> { self.axis_components() .into_iter() diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index a7b294e186..b0f6a42727 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -1839,33 +1839,41 @@ fn scheduled_display_axes( axes } -/// CR 732.2a: which player's HUD a pumped `axis` belongs to, given the loop's -/// `controller`. Exhaustive by design (no wildcard) — a new `ResourceAxis` -/// variant must make a deliberate attribution choice here, never silently inherit -/// a default. +/// The seat a resource axis LANDS ON, for the four axes that name one — and `None` for every +/// axis that is a whole-game quantity with no seat at all. /// -/// A payload-keyed axis names the player it acts on, so the badge follows the -/// payload, NOT permanent control: +/// **This is the SINGLE authority for that partition.** Both consumers derive from it rather +/// than restating it: [`attribution_player`] below (which resolves `None` to the loop's +/// controller for the HUD badge) and `game::interaction`'s CR 732.2a shortcut preview (which +/// publishes `None` as "no seat"). Two exhaustive matches over the same 15 arms would force a +/// future payload-keyed axis to make two decisions but not the SAME decision, and the offer +/// could then attribute a seat the HUD does not. +/// +/// Exhaustive by design (no wildcard) — a new `ResourceAxis` variant must make a deliberate +/// seat choice here, never silently inherit a default. +/// +/// A payload-keyed axis names the player it acts on, so the seat follows the payload, NOT +/// permanent control, and in particular is NOT keyed from the loop's proposer — a drain's +/// magnitude belongs to the player LOSING the life, which is exactly the key +/// `ResourceVector`'s per-player maps already use: /// - CR 119.3 + CR 704.5a: `Life(p)` — CR 119.3 makes `p` the player whose life total the /// effect adjusts, and CR 704.5a is why that matters (the afflicted player reaching 0 life /// loses). A drain drives an opponent's total down and lifegain raises the controller's own; -/// either way the badge belongs on `p`'s HUD. -/// - CR 120: `DamageDealt(p)` — damage accrues to the player it is dealt to, so an -/// opponent-burn loop shows `∞` on the victim's HUD. -/// - CR 704.5b: `LibraryDelta(p)` — a mill drives an opponent's library toward the -/// empty-draw loss and a self-mill the controller's own; the badge follows `p`. -/// +/// either way it belongs to `p`. +/// - CR 120.1: `DamageDealt(p)` — damage accrues to the player it is dealt to, so an +/// opponent-burn loop lands on the victim. +/// - CR 401 + CR 704.5b: `LibraryDelta(p)` — a mill drives an opponent's library toward the +/// empty-draw loss and a self-mill the controller's own; the seat follows `p`. /// - CR 704.5c: `Poison(p)` — a poison ∞ drives the afflicted player toward the -/// 10-poison loss, so the badge belongs on the VICTIM's HUD. +/// 10-poison loss, so it belongs to the VICTIM. /// -/// Every aggregate axis carries no victim PlayerId and is attributed to the loop's -/// `controller` (the player generating the unbounded resource). -fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { +/// Every aggregate axis carries no victim `PlayerId` and therefore no seat. +pub(crate) fn payload_seat(axis: ResourceAxis) -> Option { match axis { ResourceAxis::Life(p) | ResourceAxis::DamageDealt(p) | ResourceAxis::LibraryDelta(p) - | ResourceAxis::Poison(p) => p, + | ResourceAxis::Poison(p) => Some(p), ResourceAxis::Mana(_) | ResourceAxis::Counter(_, _) | ResourceAxis::Trigger(_) @@ -1878,10 +1886,22 @@ fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { | ResourceAxis::DeathTriggers | ResourceAxis::EtbTriggers | ResourceAxis::LtbTriggers - | ResourceAxis::SacTriggers => controller, + // A whole-game quantity: mana in a pool, tokens on a board, triggers on a stack. + // Nothing in the payload names a player, so there is no seat to report. + | ResourceAxis::SacTriggers => None, } } +/// CR 732.2a: which player's HUD a pumped `axis` belongs to, given the loop's `controller`. +/// +/// A thin resolution of [`payload_seat`], which is the authority for the partition and carries +/// the per-axis CR citations: a payload-keyed axis badges on the seat it names, and every +/// aggregate axis badges on the loop's `controller` (the player generating the unbounded +/// resource). Deliberately NOT a second exhaustive match — see `payload_seat`. +fn attribution_player(axis: ResourceAxis, controller: PlayerId) -> PlayerId { + payload_seat(axis).unwrap_or(controller) +} + /// CR 732.2a: whether the object-growth `∞` display set the accept registered for `axis` /// still has LIVE authority — i.e. at least one registered member is still on the /// battlefield (CR 110.1: a permanent stops being one as it moves to another zone). diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 8799f078f2..03ffd4977e 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -16,7 +16,6 @@ use crate::analysis::decision_template::{ declaration_conforms, DecisionGroupKey, DecisionKind, DecisionTemplate, IterationCount, PinnedDecision, ReplayMode, TargetPin, }; -use crate::analysis::resource::ResourceAxis; use crate::types::ability::{ AggregateFunction, ChoiceType, ChooseFromZoneConstraint, Comparator, CounterCostSelection, DoorLockOp, EffectKind, ObjectProperty, SearchSelectionConstraint, TapCreaturesAggregateStat, @@ -65,7 +64,7 @@ use crate::types::player::PlayerId; use crate::types::zones::Zone; use super::combat::AttackTarget; -use super::derived_views::{family_of, UnboundedFamily}; +use super::derived_views::{family_of, payload_seat, UnboundedFamily}; use super::dungeon::DungeonId; use super::engine::{ apply_interaction, apply_interaction_for_simulation, EngineError, MAX_SHORTCUT_CYCLES, @@ -2458,34 +2457,6 @@ fn preview_family(family: UnboundedFamily) -> InteractionShortcutPreviewFamily { } } -/// CR 119.3 + CR 120.1 + CR 401 + CR 704.5c: the seat a resource axis lands ON, for the four -/// axes that name one. Every other axis is a whole-game quantity with no seat. -/// -/// This is a different question from "who controls the loop" and it is deliberately not -/// answered from the proposer: a drain's magnitude belongs to the player LOSING the life, -/// which is exactly the seat `ResourceVector`'s per-player maps are keyed by. -fn preview_subject(axis: ResourceAxis) -> Option { - match axis { - ResourceAxis::Life(player) - | ResourceAxis::DamageDealt(player) - | ResourceAxis::LibraryDelta(player) - | ResourceAxis::Poison(player) => Some(player), - ResourceAxis::Mana(_) - | ResourceAxis::Counter(_, _) - | ResourceAxis::Trigger(_) - | ResourceAxis::TokensCreated - | ResourceAxis::CardsDrawn - | ResourceAxis::Casts - | ResourceAxis::LandfallTriggers - | ResourceAxis::CombatPhases - | ResourceAxis::ExtraTurns - | ResourceAxis::DeathTriggers - | ResourceAxis::EtbTriggers - | ResourceAxis::LtbTriggers - | ResourceAxis::SacTriggers => None, - } -} - /// CR 732.2a: the finished magnitude of repeating `count` cycles of a measured per-period /// delta — "the predictable results of the sequence of choices", stated per display family /// and per affected seat. @@ -2514,9 +2485,14 @@ fn shortcut_preview_entries( let mut per_cycle_totals: BTreeMap<(InteractionShortcutPreviewFamily, Option), i64> = BTreeMap::new(); for (axis, magnitude) in delta.axis_components() { + // Both halves of the key are `derived_views`' decisions, not this layer's: `family_of` + // owns the grouping and `payload_seat` owns the seat. The seat in particular is NOT + // keyed from the proposer — a drain's magnitude belongs to the player LOSING the life + // — and sharing the authority with `attribution_player` is what keeps the offer from + // attributing a seat the HUD badge does not. let key = ( preview_family(family_of(axis)), - preview_subject(axis).map(|player| player.0), + payload_seat(axis).map(|player| player.0), ); let total = per_cycle_totals.entry(key).or_insert(0); *total = total.saturating_add(magnitude); @@ -7973,7 +7949,9 @@ fn bound_outbound_spec( } } } - InteractionResponseSpec::Shortcut { points, .. } => { + InteractionResponseSpec::Shortcut { + points, preview, .. + } => { budget.list(points.len())?; for point in points { budget.list(point.candidate_ids.len())?; @@ -7981,6 +7959,12 @@ fn bound_outbound_spec( budget.string(candidate_id.as_str())?; } } + // CR 732.2a: the preview's entries are a published outbound list like every other + // list on this spec (at most one per display family per seat), so they are charged + // to the same ceiling rather than crossing uncounted. + if let Some(preview) = preview { + budget.list(preview.entries.len())?; + } } InteractionResponseSpec::Select { .. } | InteractionResponseSpec::AssignAmounts { .. } @@ -9595,3 +9579,137 @@ pub fn submit_interaction( )?; Ok(AppliedInteraction { action, result }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// F4 — the preview's entry list is budgeted like every other outbound list on the + /// shortcut spec. + /// + /// `bound_outbound_spec` counted `points` and each point's `candidate_ids` but not + /// `preview.entries`, so the one list added by the CR 732.2a preview crossed the boundary + /// uncounted. It is bounded small in practice (at most one entry per display family per + /// seat), so this is a CONSISTENCY row and not a live payload-exhaustion row — which is + /// why it drives the budget to its last free slot rather than building a giant preview. + /// + /// PAIRED CONTROL FIRST: the same spec at the same starting budget WITHOUT a preview must + /// fit. Without it, the failure below could come from the spec's other lists, or from a + /// budget that was already over before the preview was ever read. + /// + /// WHAT WRONG IMPLEMENTATION WOULD STILL PASS THIS ROW? One that budgets the preview's + /// entries but not a future second list added to the same spec — the row pins the field it + /// names, not "every field is budgeted". One that charged the entries to the STRING budget + /// instead would fail here, because the control proves the LIST budget is what moved. + /// + /// REVERT-PROBE, RUN: drop the `preview` budget call ⇒ the second assertion gets `Ok`. + #[test] + fn the_shortcut_preview_entry_list_is_counted_against_the_outbound_budget() { + let spec = |preview| InteractionResponseSpec::Shortcut { + count: InteractionShortcutCountSpec::Fixed { + min: 1, + max: 3, + suggested: 3, + }, + points: Vec::new(), + allow_decline: true, + preview, + confirm: ConfirmSemantics::Explicit, + }; + let preview = InteractionShortcutPreview { + count: 3, + entries: vec![ + InteractionShortcutPreviewEntry { + family: InteractionShortcutPreviewFamily::Life, + player: Some(1), + amount: -6, + }, + InteractionShortcutPreviewEntry { + family: InteractionShortcutPreviewFamily::Mana, + player: None, + amount: 9, + }, + ], + }; + let at_last_free_slot = || OutboundBudget { + entries: MAX_INTERACTION_LIST_LEN - 1, + string_bytes: 0, + }; + + let mut budget = at_last_free_slot(); + assert!( + bound_outbound_spec(&spec(None), &mut budget).is_ok(), + "control: with one slot free and no preview, this spec's own lists fit — so the \ + refusal below is the preview being counted, not the spec being oversized" + ); + + let mut budget = at_last_free_slot(); + assert_eq!( + bound_outbound_spec(&spec(Some(preview)), &mut budget), + Err(InteractionReasonCode::PayloadTooLarge), + "CR 732.2a: the preview's entries are published outbound, so they are charged to \ + the same ceiling as every other list on the spec" + ); + } + + /// F5 — the offer channel and the HUD channel must SPELL each display family identically. + /// + /// `InteractionShortcutPreviewFamily` is `rename_all = "camelCase"`; its grouping authority + /// `derived_views::UnboundedFamily` is `rename_all = "lowercase"`. All eleven variants are + /// single words today, so both spell `mana`, `life`, ... and the agreement reads as design + /// when it is coincidence. A future two-word family would cross as `extraTurns` on the + /// offer and `extraturns` on the HUD, and the client's family-keyed lookups + /// (`UNBOUNDED_FAMILY_GLYPH` and `UNBOUNDED_FAMILY_LABEL_KEY`, both + /// `Record` in `client/src/components/hud/HudBadges.tsx`) would + /// silently miss on the offer while still resolving on the HUD. + /// + /// `preview_family`'s exhaustive match pins the GROUPING, not the STRING — a new family + /// build-breaks it, a renamed WIRE STRING does not. This row pins the string. + /// + /// It takes its family list from `unbounded-family-tags.json`, the same golden the client's + /// `Record` is checked against, so one chain now runs + /// engine grouping ⇒ HUD string ⇒ offer string. That also makes the list forced rather than + /// hand-maintained: an 18th `ResourceAxis` reds `family_tag_table_matches_the_client_golden` + /// until the golden is regenerated, and a regenerated golden carries the new family here. + /// + /// THIS ROW PASSES TODAY BY CONSTRUCTION, AND THAT IS THE POINT — it is written to fail on + /// a two-word variant, which is the only way the divergence can ship. + /// + /// WHAT WRONG IMPLEMENTATION WOULD STILL PASS THIS ROW? One that mis-GROUPS an axis (that + /// is `family_tag_table_matches_the_client_golden`'s question, not this one), and one that + /// adds a family reachable from no `ResourceAxis` at all, which the golden cannot see and + /// no client lookup can receive. + /// + /// REVERT-PROBE, RUN: rename the `Turns` variant of BOTH enums to `ExtraTurns` and + /// regenerate the golden ⇒ `extraturns` vs `extraTurns` ⇒ this row FAILS. + #[test] + fn every_preview_family_spells_the_same_wire_string_as_its_unbounded_family() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../client/src/test/fixtures/unbounded-family-tags.json" + ); + let golden: BTreeMap = + serde_json::from_str(&std::fs::read_to_string(path).expect("committed family golden")) + .expect("the family golden parses as tag -> UnboundedFamily"); + let families: std::collections::BTreeSet = + golden.values().copied().collect(); + assert_eq!( + families.len(), + 11, + "reach-guard: every display family must be reachable from the golden, else this \ + row silently checks a subset of the wire surface" + ); + + for family in families { + let hud = serde_json::to_string(&family).expect("UnboundedFamily serializes"); + let offer = + serde_json::to_string(&preview_family(family)).expect("preview family serializes"); + assert_eq!( + hud, offer, + "the shortcut offer and the HUD badge must cross the wire under the SAME \ + string for this display family — the client keys both into one \ + `Record`, so a divergence is a silent lookup miss" + ); + } + } +} diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 9e800cf8cd..2ef06dc43b 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -2740,9 +2740,32 @@ fn loop_shortcut_preview_is_absent_without_both_a_period_and_a_finite_count() { /// A future rewrite that reached for the previewer would be quietly quadratic and quietly /// wrong, and no value assertion would catch it — so this row reads the source. /// -/// REVERT-PROBE, RUN: add the line `// preview_interaction` inside the function body ⇒ this -/// row fails on the assert (it still compiles, so the probe discriminates on the assertion -/// rather than on the build). +/// ⚠ TWO SPANS, AND THE SECOND ONE IS WHY THIS ROW CAN FAIL AT ALL (fix round 2, F1). +/// +/// The first revision read only `shortcut_preview_entries`, whose signature is +/// `(&ResourceVector, u32)` — no `GameState` is in scope anywhere in it, and neither is one in +/// its only caller `loop_shortcut_projection(&WaitingFor)`. The banned construct was therefore +/// not CONSTRUCTIBLE in the span, so the row could not fail no matter what regressed. MEASURED +/// by the reviewer: inserting `let mut probe_clone = authoritative_state.clone();` immediately +/// above the `loop_shortcut_projection` call left all three C4 rows green. +/// +/// The clone-apply can only originate where the spec is BUILT: `opportunity_for_slot`'s +/// `LoopShortcut` arm, which holds `authoritative_state` and `filtered_state`, both +/// `&GameState`. Both spans are read now, and the arm span proves its OWN constructibility — +/// the enclosing signature binds two `&GameState` parameters and the span uses one — so it +/// cannot silently degrade into another span where the ban is unwritable. A positive control +/// proves the SEARCH is real; only the constructibility guard proves the SPAN is right. +/// +/// WHAT WRONG IMPLEMENTATION WOULD STILL PASS THIS ROW? One that clones the state inside a +/// THIRD function called from the arm — the ban is textual, not a call-graph closure — and one +/// that computes the right numbers by some other expensive means. This is a routing guard; the +/// value rows above pin the arithmetic. +/// +/// REVERT-PROBES, BOTH RUN: +/// * add the line `// preview_interaction` inside `shortcut_preview_entries` ⇒ FAILS on the +/// assert (it still compiles, so the probe discriminates on the assertion, not the build); +/// * insert `let mut probe_clone = authoritative_state.clone();` immediately above the +/// `loop_shortcut_projection` call in the arm — the reviewer's exact probe ⇒ FAILS. #[test] fn loop_shortcut_preview_never_routes_through_the_clone_apply_previewer() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/game/interaction.rs"); @@ -2757,29 +2780,73 @@ fn loop_shortcut_preview_never_routes_through_the_clone_apply_previewer() { asserted below is the absence of the whole search" ); - let marker = "\nfn shortcut_preview_entries("; - let start = text - .find(marker) - .expect("reach-guard: the preview function must be found by name, or this row is vacuous") - + 1; - let rest = &text[start..]; - let end = rest[1..] - .find("\nfn ") - .map_or(rest.len(), |offset| offset + 1); - let body = &rest[..end]; + // The span from `marker` up to the next `terminator`, both anchored at a line start. + let extract = |scope: &str, marker: &str, terminator: &str| -> String { + let start = scope.find(marker).unwrap_or_else(|| { + panic!("reach-guard: `{marker}` must be found by name, or this row is vacuous") + }); + let rest = &scope[start + marker.len()..]; + let end = rest.find(terminator).unwrap_or(rest.len()); + format!("{marker}{}", &rest[..end]) + }; + // ── SPAN 1: the arithmetic itself. + let arithmetic = extract(&text, "\nfn shortcut_preview_entries(", "\nfn "); assert!( - body.contains("saturating_mul"), + arithmetic.contains("saturating_mul"), "reach-guard: the extracted span must be the real body — the multiplication is the \ function's entire job, so its absence means the span is wrong" ); - for banned in ["preview_interaction", "state.clone()", "GameState"] { - assert!( - !body.contains(banned), - "CR 732.2a: the shortcut preview is `n × δ` over the certificate's measured \ - period. It must not reach `{banned}` — a clone-apply cannot state the result of \ - a sequence that is deliberately never played out" - ); + + // ── SPAN 2: the attach site, where the spec carrying the preview is built. + let builder = "\nfn opportunity_for_slot("; + let builder_start = text.find(builder).expect( + "reach-guard: the spec builder must be found by name — it is the only scope holding a \ + `GameState` on the preview's path", + ); + let builder_scope = &text[builder_start..]; + let signature_end = builder_scope + .find(") -> ") + .expect("reach-guard: the builder's signature must be delimited"); + let signature = &builder_scope[..signature_end]; + // ── CONSTRUCTIBILITY: the ban below is only a guard where the banned thing can be + // WRITTEN. This span sits inside a function that binds two `&GameState` parameters, + // so `authoritative_state.clone()` — the reviewer's exact probe — compiles here. + assert!( + signature.contains("authoritative_state: &GameState") + && signature.contains("filtered_state: &GameState"), + "constructibility: the arm span guards nothing unless a `GameState` is IN SCOPE to be \ + cloned. `shortcut_preview_entries` takes `(&ResourceVector, u32)`, which is exactly \ + why reading only that function produced a row that could not fail" + ); + let attach = extract( + builder_scope, + "\n HumanResponseModel::LoopShortcut => {", + "\n HumanResponseModel::", + ); + assert!( + attach.contains("loop_shortcut_projection(") && attach.contains("projection.preview"), + "reach-guard: the extracted arm must be the one that projects the offer AND publishes \ + the preview onto the spec, else the ban is being applied to the wrong arm" + ); + assert!( + attach.contains("filtered_state"), + "constructibility, second half: the arm must actually USE one of those `&GameState` \ + bindings, so a clone is writable at the exact point the reviewer's probe inserted one" + ); + + for (span_name, body) in [ + ("shortcut_preview_entries", &arithmetic), + ("opportunity_for_slot's LoopShortcut arm", &attach), + ] { + for banned in ["preview_interaction", "state.clone()", "GameState"] { + assert!( + !body.contains(banned), + "CR 732.2a: the shortcut preview is `n × δ` over the certificate's measured \ + period. {span_name} must not reach `{banned}` — a clone-apply cannot state \ + the result of a sequence that is deliberately never played out" + ); + } } } diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 6070b073c7..dabf614293 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -8608,6 +8608,92 @@ fn dina_untargeted_drain_4p_offers_at_three_live_opponents() { must not be written by raising a bounded offer; got {:?}", state.unbounded_resources ); + + // ── F6: THE PREVIEW REACHES A REAL GAME ── + // + // Every other row that asserts anything about the CR 732.2a count preview hand-builds a + // `WaitingFor` and projects it. That leaves one hole none of them can see: the preview is + // published only when the offer pairs `per_cycle: Some` with a FINITE count, and the + // producer that pairs them is the bounded one. Route the bound through + // `shortcut_iteration_count` — which returns `UntilLethal` for `LethalDamage | PoisonLoss`, + // and this fixture's `win_kind` is exactly `LethalDamage` — and the preview vanishes from + // EVERY real game while all three hand-built rows stay green. This row closes that by + // reading the preview off the real 4p dump the engine itself raised the offer on. + // + // WHAT WRONG IMPLEMENTATION WOULD STILL PASS THIS ROW? One that publishes the preview only + // on this fixture's exact per-period shape (the hand-built rows cover the shape space), and + // one that previews the right numbers for a count the player did not pick — the reserved + // per-selected-count question, deliberately not answered here. + // + // REVERT-PROBE, RUN: mint this offer's count through `shortcut_iteration_count` (i.e. + // `UntilLethal` for a lethal drain) ⇒ `preview` is `None` ⇒ the expect below FAILS. + let suggested = i64::from(schema.max_iterations); + let life_deltas: Vec<(PlayerId, i64)> = per_cycle + .delta + .life + .iter() + .filter(|(_, delta)| **delta != 0) + .map(|(seat, delta)| (*seat, *delta)) + .collect(); + assert!( + life_deltas.len() >= 2, + "reach-guard: the preview's per-seat fold only discriminates when the period moves \ + MORE THAN ONE seat's life — a single-seat period is satisfiable by an implementation \ + that keys every entry to the proposer; measured {life_deltas:?}" + ); + + engine::game::interaction::bind_interaction_authority( + &mut state, + engine::types::interaction::InteractionSessionId("dina-preview".to_string()), + ) + .expect("the offer beat binds an interaction authority"); + let filtered = engine::game::visibility::filter_state_for_viewer(&state, proposer); + let view = engine::game::interaction::derive_viewer_interaction(&state, &filtered, proposer); + let engine::types::interaction::InteractionOpportunityResponse::Schema { + spec: engine::types::interaction::InteractionResponseSpec::Shortcut { preview, .. }, + .. + } = &view.opportunities[0].response + else { + panic!("the bounded offer publishes a shortcut schema to its proposer"); + }; + let preview = preview.as_ref().expect( + "CR 732.2a: the offer the engine raised on a REAL 4p drain must publish what its \ + declared count does. A `None` here means every preview has vanished from every real \ + game while the hand-built projection rows stayed green.", + ); + assert_eq!( + i64::from(preview.count), + suggested, + "the magnitudes are stated for the offer's own suggested count and no other" + ); + + let mut expected: Vec<(Option, i32)> = life_deltas + .iter() + .map(|(seat, delta)| (Some(seat.0), (delta * suggested) as i32)) + .collect(); + let mut published: Vec<(Option, i32)> = preview + .entries + .iter() + .filter(|entry| { + entry.family == engine::types::interaction::InteractionShortcutPreviewFamily::Life + }) + .map(|entry| (entry.player, entry.amount)) + .collect(); + expected.sort_unstable(); + published.sort_unstable(); + assert_eq!( + published, expected, + "CR 119.3: every seat the certified period moves life on is previewed at that seat, \ + multiplied out by the declared count — recomputed here from the offer-beat certificate" + ); + for (seat, _, loss) in losses.iter().filter(|(id, _, _)| *id != proposer) { + assert!( + published.contains(&(Some(seat.0), (-loss * suggested) as i32)), + "CR 704.5a: victim seat {seat:?} loses {loss} per cycle, so its previewed life \ + entry must be the NEGATIVE finished magnitude on that seat's own key — a \ + proposer-keyed subject map publishes it on the wrong HUD; got {published:?}" + ); + } } /// The dina 4p drain DRIVEN through `apply()` to the beat the engine itself raises the From 9174c29d04b3fe3a29c09bb9d0c31b561287de4a Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 11 Aug 2026 08:10:21 -0500 Subject: [PATCH 14/44] fix(engine): close four review follow-ups on the loop-shortcut preview The independent review of the preview fix round returned ACCEPT WITH FOLLOW-UPS. All four are narrow; three are prose, one is a test pin. - The single-authority doc miscounted its own partition. `payload_seat`'s doc said "the same 15 arms"; `ResourceAxis` has 17 variants and the match has 2 arms (4 seat-bearing + 13 aggregate). 15 was correct under no convention -- it came from a review finding's "4 seat + the other 11", which undercounted by two, and was copied verbatim. That comment is what a future variant-adder reads to judge completeness, so a wrong count there is a wrong completeness signal. - `CR 732.2a` was cited on outbound payload accounting it does not describe. It governs shortcut proposals; the line charges a list against a byte ceiling, and it was the only CR annotation in ~200 lines of budget plumbing. CLAUDE.md reserves annotations for code implementing a rule, so the correct action is removal rather than a better number. The prose stands unchanged. - The wire-string row's stated motivation claimed a renamed family would "silently miss" on the client. Measured: nothing on the client reads the preview's family, and the HUD's lookups are keyed by a separately declared union -- a future consumer crossing the two would break as a TypeScript type error. The row is correct and unchanged; only its justification overstated. - The clone-apply ban is textual, so its cheapest evasion -- widening `loop_shortcut_projection` to accept a `&GameState` -- contains none of the banned strings and lands in a span the row does not read. Pinning that signature closes the route by type instead of by text. Verified: with the parameter widened, the old textual ban's six checks all still pass and only the new pin reds, so this covers what the row structurally could not see. Assisted-by: ClaudeCode:claude-opus-4.8 --- crates/engine/src/game/derived_views.rs | 6 +-- crates/engine/src/game/interaction.rs | 14 ++++--- .../tests/integration/interaction_contract.rs | 37 ++++++++++++++++++- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/game/derived_views.rs b/crates/engine/src/game/derived_views.rs index b0f6a42727..196ca80426 100644 --- a/crates/engine/src/game/derived_views.rs +++ b/crates/engine/src/game/derived_views.rs @@ -1845,9 +1845,9 @@ fn scheduled_display_axes( /// **This is the SINGLE authority for that partition.** Both consumers derive from it rather /// than restating it: [`attribution_player`] below (which resolves `None` to the loop's /// controller for the HUD badge) and `game::interaction`'s CR 732.2a shortcut preview (which -/// publishes `None` as "no seat"). Two exhaustive matches over the same 15 arms would force a -/// future payload-keyed axis to make two decisions but not the SAME decision, and the offer -/// could then attribute a seat the HUD does not. +/// publishes `None` as "no seat"). Two exhaustive matches over the same 17 `ResourceAxis` +/// variants would force a future payload-keyed axis to make two decisions but not the SAME +/// decision, and the offer could then attribute a seat the HUD does not. /// /// Exhaustive by design (no wildcard) — a new `ResourceAxis` variant must make a deliberate /// seat choice here, never silently inherit a default. diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 03ffd4977e..ec659eba02 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -7959,7 +7959,7 @@ fn bound_outbound_spec( budget.string(candidate_id.as_str())?; } } - // CR 732.2a: the preview's entries are a published outbound list like every other + // The preview's entries are a published outbound list like every other // list on this spec (at most one per display family per seat), so they are charged // to the same ceiling rather than crossing uncounted. if let Some(preview) = preview { @@ -9658,10 +9658,14 @@ mod tests { /// `derived_views::UnboundedFamily` is `rename_all = "lowercase"`. All eleven variants are /// single words today, so both spell `mana`, `life`, ... and the agreement reads as design /// when it is coincidence. A future two-word family would cross as `extraTurns` on the - /// offer and `extraturns` on the HUD, and the client's family-keyed lookups - /// (`UNBOUNDED_FAMILY_GLYPH` and `UNBOUNDED_FAMILY_LABEL_KEY`, both - /// `Record` in `client/src/components/hud/HudBadges.tsx`) would - /// silently miss on the offer while still resolving on the HUD. + /// offer and `extraturns` on the HUD — one grouping published in two wire vocabularies, + /// and THIS row is what catches it. Nothing on the client does: no client code reads the + /// preview's `family` today (the generated `InteractionShortcutPreviewFamily` in + /// `client/src/adapter/generated/interaction/index.ts` has no consumer), and the HUD's + /// family-keyed lookups (`UNBOUNDED_FAMILY_GLYPH` / `UNBOUNDED_FAMILY_LABEL_KEY`, both + /// `Record` in `client/src/components/hud/HudBadges.tsx`) are keyed by + /// the SEPARATELY declared hand-written `UnboundedFamily` union — so a future consumer that + /// crossed the two would break as a TypeScript type error, not miss silently. /// /// `preview_family`'s exhaustive match pins the GROUPING, not the STRING — a new family /// build-breaks it, a renamed WIRE STRING does not. This row pins the string. diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 2ef06dc43b..f7be80a549 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -2761,11 +2761,22 @@ fn loop_shortcut_preview_is_absent_without_both_a_period_and_a_finite_count() { /// that computes the right numbers by some other expensive means. This is a routing guard; the /// value rows above pin the arithmetic. /// -/// REVERT-PROBES, BOTH RUN: +/// The likeliest instance of that first gap is closed by TYPE rather than by text (fix round 3, +/// G4): the cheapest way to reach a `GameState` from the preview is to widen +/// `loop_shortcut_projection` to accept one, which contains none of the banned strings and +/// lives in a span this row does not read. Its parameter list is pinned below, so the +/// projection can see the waiting-for state and nothing else — and neither can anything it +/// calls. What remains uncovered is a clone reached through some OTHER existing binding, which +/// no signature can rule out. +/// +/// REVERT-PROBES, ALL THREE RUN: /// * add the line `// preview_interaction` inside `shortcut_preview_entries` ⇒ FAILS on the /// assert (it still compiles, so the probe discriminates on the assertion, not the build); /// * insert `let mut probe_clone = authoritative_state.clone();` immediately above the -/// `loop_shortcut_projection` call in the arm — the reviewer's exact probe ⇒ FAILS. +/// `loop_shortcut_projection` call in the arm — the reviewer's exact probe ⇒ FAILS; +/// * widen `loop_shortcut_projection` to `(waiting_for: &WaitingFor, _state: &GameState)` — +/// the exact evasion the textual ban misses ⇒ FAILS on the signature pin (and on nothing +/// else, which is the point). #[test] fn loop_shortcut_preview_never_routes_through_the_clone_apply_previewer() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/game/interaction.rs"); @@ -2835,6 +2846,28 @@ fn loop_shortcut_preview_never_routes_through_the_clone_apply_previewer() { bindings, so a clone is writable at the exact point the reviewer's probe inserted one" ); + // ── TYPE-LEVEL PIN: the ban below is TEXTUAL, so its cheapest evasion is to widen + // `loop_shortcut_projection` to take a `&GameState` and clone it THERE — a third span + // this row does not read, and one that would contain none of the three banned strings. + // The projection's parameter list closes that route by TYPE rather than by text: with + // only a `&WaitingFor` in scope, no callee it reaches can be handed a `GameState` + // either, so "the preview computation cannot see game state" stops being a search + // result and becomes a fact about the signature. + let projection_signature = extract(&text, "\nfn loop_shortcut_projection(", ") -> "); + let projection_params = projection_signature + .strip_prefix("\nfn loop_shortcut_projection(") + .expect("`extract` re-emits its own marker, so the prefix is always present") + .split_whitespace() + .collect::>() + .join(" "); + assert_eq!( + projection_params.trim_end_matches(','), + "waiting_for: &WaitingFor", + "type-level pin: the shortcut preview is computed from the WAITING-FOR state alone. \ + Adding a parameter here — a `&GameState`, or anything reaching one — reopens the \ + clone-apply route through a span the textual ban below never reads" + ); + for (span_name, body) in [ ("shortcut_preview_entries", &arithmetic), ("opportunity_for_slot's LoopShortcut arm", &attach), From ffbd3dac9d4e816810f9dc9ea030f585bf959f49 Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 11 Aug 2026 09:54:15 -0500 Subject: [PATCH 15/44] fix(engine): redact a declared template on every carrier, not just the offer `filter_state_for_viewer` redacted `WaitingFor::LoopShortcut`'s declaration all-or-nothing when a pin named a hidden source, but `RespondToShortcut` was never mentioned in the file at all -- and `handle_declare_shortcut` moves the identical template verbatim onto `RespondToShortcut.proposal.template` one transition later, where it was public to every responder and spectator. The gap is pre-existing; it ships first because the ranking work is what makes that field routinely populated. CR 732.2b: a responder's right is to name a place where they will make a choice different from what was proposed, so what they see must be the WHOLE proposal or none of it -- a partially redacted pin set is a lie about what was proposed. That is why the predicate is all-or-nothing rather than a per-pin trim, and it is now stated once instead of twice. THREE carriers, not two. The planned signature keyed on `DecisionTemplate`, which cannot express the third: `LoopActionContext.pins` is a bare `Vec` with no template wrapper, and `GameState::last_loop_action_sequence` is `skip_serializing_if` -- serialized whenever non-empty, with zero mentions in the visibility layer. Keying the authority on `&[PinnedDecision]` covers all three from one function. Today that third sweep redacts nothing, because the field's three writers can only name battlefield permanents and seats; it is covered anyway, since "no current writer mints a hidden pin" is a runtime-shape argument with a shelf life, and the sweep costs five lines. A fourth carrier, `GameState::decision_templates`, is deliberately out: owner-retain removes it entirely rather than redacting it. The variant-discoverability gate was blind. CLAUDE.md makes an engine-inventory grep the mandatory check before proposing any engine variant, but the generator walked only `crates/engine/src/types`, so it answered ABSENT -- indistinguishable from "safe to add" -- for every enum under `analysis/`. Adding that directory takes the inventory from 534 enums to 562 and makes `TargetSchedule` visible; reverting the constant makes it absent again. This fixes the tool so the documented mandate is true, rather than narrowing the mandate to what the tool could see. Verification, with pass counts because a leg that executes nothing also exits zero: clippy (CI form) exit 0; lib 18850 passed / 0 failed; integration 4821 passed / 0 failed. The new rows are revert-probed -- deleting the `RespondToShortcut` arm fails the responder row, deleting the sweep fails the recorded-pin row, and flipping the shared authority's `any` to `all` fails both new rows together with the pre-existing `d5h` row, which is the equality proof that the extraction is behaviour-preserving. Assisted-by: ClaudeCode:claude-opus-4.8 --- crates/engine-inventory-gen/src/main.rs | 49 ++-- crates/engine/src/game/visibility.rs | 194 ++++++++++----- .../engine/tests/integration/loop_shortcut.rs | 222 ++++++++++++++++++ 3 files changed, 381 insertions(+), 84 deletions(-) diff --git a/crates/engine-inventory-gen/src/main.rs b/crates/engine-inventory-gen/src/main.rs index 5a63988519..22edd74305 100644 --- a/crates/engine-inventory-gen/src/main.rs +++ b/crates/engine-inventory-gen/src/main.rs @@ -74,12 +74,16 @@ struct ClusterSmell { cluster: SiblingCluster, } -const TARGET_DIR: &str = "crates/engine/src/types"; +/// Every directory whose `pub enum`s are engine surface a variant proposal must be able to +/// discover. `types/` alone is not that set: CLAUDE.md makes an inventory grep the mandatory +/// discoverability gate before proposing a variant, and `analysis/` holds public rules-bearing +/// enums (`TargetSchedule`, `PinnedDecision`, `ReplayMode`, …) that the gate structurally could +/// not see while this was a single directory. +const TARGET_DIRS: &[&str] = &["crates/engine/src/types", "crates/engine/src/analysis"]; const OUTPUT: &str = "data/engine-inventory.json"; fn main() -> Result<()> { let workspace_root = find_workspace_root()?; - let target = workspace_root.join(TARGET_DIR); let output = workspace_root.join(OUTPUT); let cr_re = Regex::new(r"CR \d{3}(?:\.\d+[a-z]?)?")?; @@ -87,28 +91,31 @@ fn main() -> Result<()> { let mut enums: BTreeMap = BTreeMap::new(); let mut sources: Vec = Vec::new(); - for entry in WalkDir::new(&target).into_iter().filter_map(|e| e.ok()) { - let path = entry.path(); - if path.extension().is_none_or(|ext| ext != "rs") { - continue; - } - let rel = path.strip_prefix(&workspace_root).unwrap_or(path); - sources.push(rel.display().to_string()); + for dir in TARGET_DIRS { + let target = workspace_root.join(dir); + for entry in WalkDir::new(&target).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "rs") { + continue; + } + let rel = path.strip_prefix(&workspace_root).unwrap_or(path); + sources.push(rel.display().to_string()); - let content = - fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let file = match syn::parse_file(&content) { - Ok(f) => f, - Err(_) => continue, // skip unparseable files (likely WIP) - }; + let content = + fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let file = match syn::parse_file(&content) { + Ok(f) => f, + Err(_) => continue, // skip unparseable files (likely WIP) + }; - for item in &file.items { - if let Item::Enum(e) = item { - if !is_pub(&e.vis) { - continue; + for item in &file.items { + if let Item::Enum(e) = item { + if !is_pub(&e.vis) { + continue; + } + let entry = build_enum_entry(e, &content, rel, &cr_re); + enums.insert(e.ident.to_string(), entry); } - let entry = build_enum_entry(e, &content, rel, &cr_re); - enums.insert(e.ident.to_string(), entry); } } } diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index b2bebaa948..1e818e7645 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -97,6 +97,82 @@ pub(crate) fn capture_library_search_card_view( } } +/// CR 732.2b: the responder's right is to name a place where they will make "a choice that's +/// different than what's been proposed", so the proposal they see must be the whole proposal or +/// none of it. A partially-redacted pin set is a LIE about what was proposed — it would show a +/// shortened sequence the proposer never suggested — so this is ALL-OR-NOTHING: one pin naming an +/// object this viewer may not see drops the entire pin vector. +/// +/// THE SINGLE AUTHORITY for that decision. It is keyed on `[PinnedDecision]` rather than on +/// `DecisionTemplate` because this engine has THREE viewer-visible carriers of that same vector, +/// and a per-carrier copy of the predicate is exactly what let them drift before: +/// +/// 1. `WaitingFor::LoopShortcut.declaration.decisions` — the proposer-facing offer. +/// 2. `WaitingFor::RespondToShortcut.proposal.template.decisions` — the responder-facing copy. +/// `game::engine::handle_declare_shortcut` moves the identical template verbatim onto +/// `ShortcutProposal.template` one state transition later, where every responder and spectator +/// reads it. +/// 3. `GameState::last_loop_action_sequence[].pins` — the recorded loop period. It is serialized +/// whenever non-empty (`skip_serializing_if = "Vec::is_empty"`, not `skip`) and has no other +/// redaction seam. Its three writers (the `game::engine::record_loop_pin` call sites: a +/// mana-ability tap cost, a mana-color choice, a proliferate target) can only name battlefield +/// permanents and seats today, so that call redacts nothing on any board the engine currently +/// mints — it is wired so a fourth writer cannot open the leak silently. +/// +/// `GameState::decision_templates` is the fourth carrier and deliberately does NOT route here: it +/// is redacted by owner-retain (`filtered.decision_templates.retain(|t| t.owner == viewer)`), so a +/// template the viewer does not own is REMOVED entirely and there is nothing left for this +/// predicate to answer about it. +/// +/// A `TargetPin::Player` needs no redaction, and that is an ENGINE property rather than a CR one — +/// no rule makes seat identity public. This projection hides card identities and hidden-zone +/// contents; the seat list itself is never per-viewer filtered (`filtered.players[..]` is redacted +/// in place, never removed), so a `PlayerId` names something every viewer already has. CR 115.2 is +/// cited for the narrower thing it actually says: a spell or ability may target a player when it +/// specifies so, which is what makes a seat a legal pin value at all. +/// +/// `target_hidden` is passed in rather than re-derived so that the declaration's object identities +/// and the offer schema's legal targets are answered by ONE hidden-info authority; two derivations +/// could disagree about the same object. +fn pins_name_hidden_source( + pins: &[crate::analysis::decision_template::PinnedDecision], + target_hidden: &dyn Fn(ObjectId) -> bool, +) -> bool { + use crate::analysis::decision_template::{ + DecisionSource, PinnedDecision, TargetPin, TargetSchedule, + }; + let source_hidden = |source: &DecisionSource| match source { + crate::types::game_state::YieldTarget::ThisObject { source_id, .. } => { + target_hidden(*source_id) + } + // A card identity, not a live object: it names no zone occupant to hide. + crate::types::game_state::YieldTarget::AllCopies { .. } => false, + }; + let pin_hidden = |pin: &TargetPin| match pin { + TargetPin::ByIdentity(source) => source_hidden(source), + TargetPin::Player(_) => false, + TargetPin::Scheduled(schedule) => match schedule { + TargetSchedule::Constant(source) => source_hidden(source), + TargetSchedule::RoundRobin(sources) => sources.iter().any(&source_hidden), + TargetSchedule::Piecewise(steps) => { + steps.iter().any(|(_, source)| source_hidden(source)) + } + }, + }; + // Wildcard-free over `PinnedDecision`, so a future variant that carries an object + // identity gets a compile-time visit here instead of leaking silently. Every + // slot-only variant is already published unredacted as `point.slot`. + pins.iter().any(|pin| match pin { + PinnedDecision::Targets { targets, .. } => targets.iter().any(&pin_hidden), + PinnedDecision::Order { source, .. } => source_hidden(source), + PinnedDecision::Mode { .. } + | PinnedDecision::MayChoice { .. } + | PinnedDecision::UnlessBreak { .. } + | PinnedDecision::ConvokeTaps { .. } + | PinnedDecision::ManaColor { .. } => false, + }) +} + /// Returns a filtered copy of the game state for the given viewer. /// Hides all opponents' hand contents and all library contents except where the /// viewer is explicitly allowed to see them. @@ -736,6 +812,22 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState } } + // A target object is hidden from this viewer iff it sits in a private zone whose + // owner the viewer can't privately view AND it isn't otherwise revealed/peeked. + // Hoisted above the CR 732.2a/b blocks below because all THREE pin carriers + // (`LoopShortcut.declaration`, `RespondToShortcut.proposal.template`, + // `last_loop_action_sequence[].pins`) must answer "may this viewer see that object?" the + // same way; a per-arm copy is what let the first two drift apart. + let target_hidden = |id: ObjectId| -> bool { + state.objects.get(&id).is_some_and(|obj| { + matches!(obj.zone, Zone::Hand | Zone::Library) + && !can_view_private_for_player(obj.owner) + && !is_visible_revealed_card(state, viewer, id) + && !state.viewer_knows_card_identity(viewer, id) + && !private_look_visible.contains(&id) + }) + }; + // CR 732.2a: redact hidden-info legal targets in a `LoopShortcut` OFFER for a viewer who is // NOT the schema's proposer. The schema is built for the offer's public declaration; this // is the SOLE seam that removes a hidden-zone (hand/library) legal target from a viewer who @@ -753,21 +845,9 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState { if !can_view_private_for_player(proposer) { use crate::analysis::decision_template::{ - DecisionPoint, DecisionPointKind, DecisionSource, PinnedDecision, - ShortcutDecisionSchema, TargetPin, TargetSchedule, + DecisionPoint, DecisionPointKind, ShortcutDecisionSchema, }; use crate::types::ability::TargetRef; - // A target object is hidden from this viewer iff it sits in a private zone whose - // owner the viewer can't privately view AND it isn't otherwise revealed/peeked. - let target_hidden = |id: ObjectId| -> bool { - state.objects.get(&id).is_some_and(|obj| { - matches!(obj.zone, Zone::Hand | Zone::Library) - && !can_view_private_for_player(obj.owner) - && !is_visible_revealed_card(state, viewer, id) - && !state.viewer_knows_card_identity(viewer, id) - && !private_look_visible.contains(&id) - }) - }; let points: Vec = schema .points .iter() @@ -831,56 +911,13 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState _ => None, }) .sum(); - // CR 732.2b: the responder's right is to name a place where they will make "a - // choice that's different than what's been proposed", so the proposal they see must - // be the whole proposal or none of it. A partially-redacted pin set is a LIE about - // what was proposed — it would show a shortened sequence the proposer never - // suggested — so this is ALL-OR-NOTHING: one pin naming an object this viewer may - // not see drops the entire declaration. - // - // A `TargetPin::Player` needs no redaction, and that is an ENGINE property rather - // than a CR one — no rule makes seat identity public. This projection hides card - // identities and hidden-zone contents; the seat list itself is never per-viewer - // filtered (`filtered.players[..]` is redacted in place, never removed), so a - // `PlayerId` names something every viewer already has. CR 115.2 is cited for the - // narrower thing it actually says: a spell or ability may target a player when it - // specifies so, which is what makes a seat a legal pin value at all. - // - // Reuses `target_hidden` above rather than re-deriving the composite: the - // declaration's object identities and the schema's legal targets must be answerable - // by ONE hidden-info authority or the two could disagree about the same object. - let source_hidden = |source: &DecisionSource| match source { - crate::types::game_state::YieldTarget::ThisObject { source_id, .. } => { - target_hidden(*source_id) - } - // A card identity, not a live object: it names no zone occupant to hide. - crate::types::game_state::YieldTarget::AllCopies { .. } => false, - }; - let pin_hidden = |pin: &TargetPin| match pin { - TargetPin::ByIdentity(source) => source_hidden(source), - TargetPin::Player(_) => false, - TargetPin::Scheduled(schedule) => match schedule { - TargetSchedule::Constant(source) => source_hidden(source), - TargetSchedule::RoundRobin(sources) => sources.iter().any(&source_hidden), - TargetSchedule::Piecewise(steps) => { - steps.iter().any(|(_, source)| source_hidden(source)) - } - }, - }; - // Wildcard-free over `PinnedDecision`, so a future variant that carries an object - // identity gets a compile-time visit here instead of leaking silently. Every - // slot-only variant is already published unredacted as `point.slot`. - let declaration = declaration.clone().filter(|template| { - !template.decisions.iter().any(|pin| match pin { - PinnedDecision::Targets { targets, .. } => targets.iter().any(&pin_hidden), - PinnedDecision::Order { source, .. } => source_hidden(source), - PinnedDecision::Mode { .. } - | PinnedDecision::MayChoice { .. } - | PinnedDecision::UnlessBreak { .. } - | PinnedDecision::ConvokeTaps { .. } - | PinnedDecision::ManaColor { .. } => false, - }) - }); + // CR 732.2b, ALL-OR-NOTHING: one pin naming an object this viewer may not see drops + // the entire declaration. The predicate itself is `pins_name_hidden_source` (this + // file), the single authority shared with the `RespondToShortcut` projection below, + // which receives this very template verbatim one state transition later. + let declaration = declaration + .clone() + .filter(|template| !pins_name_hidden_source(&template.decisions, &target_hidden)); filtered.waiting_for = WaitingFor::LoopShortcut { proposer, predicted_winner, @@ -900,6 +937,37 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState } } + // CR 732.2b: the RESPONDER-facing copy of the very declaration redacted above. + // `game::engine::handle_declare_shortcut` moves the proposer's template verbatim onto + // `ShortcutProposal.template` and installs it here, so without this arm every identity the + // `LoopShortcut` block drops is public to every responder and spectator one transition later. + // Same authority, same all-or-nothing: the template is dropped whole, never trimmed. + // + // Guarded on the PROPOSER's private access (`proposal.proposer`), not on the responder + // (`player`), because the offer's declaration is the proposer's hidden information and every + // seat but theirs — the current responder, the queued ones, and spectators — receives this + // same projection. + if let WaitingFor::RespondToShortcut { proposal, .. } = &mut filtered.waiting_for { + if !can_view_private_for_player(proposal.proposer) + && proposal + .template + .as_ref() + .is_some_and(|t| pins_name_hidden_source(&t.decisions, &target_hidden)) + { + proposal.template = None; + } + } + + // CR 732.2a: the THIRD carrier of the same pin vector — the recorded loop period, which + // serializes whenever non-empty and has no other redaction seam. All-or-nothing per recorded + // step, for the reason spelled on `pins_name_hidden_source`: a half-shown period states a + // sequence that was never played. + for step in &mut filtered.last_loop_action_sequence { + if pins_name_hidden_source(&step.pins, &target_hidden) { + step.pins.clear(); + } + } + if let WaitingFor::DigChoice { player, library_owner, diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index dabf614293..48bd452e11 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -4351,6 +4351,228 @@ fn loop_shortcut_schema_redacts_hidden_targets_for_non_controller() { ); } +/// R0-a ⭐ (SECURITY): the RESPONDER-facing copy of a declared template is redacted too. +/// +/// `handle_declare_shortcut` moves the proposer's `DecisionTemplate` VERBATIM onto +/// `ShortcutProposal.template` one state transition after the `LoopShortcut` offer whose +/// `declaration` is redacted by `d5h_a_hidden_object_pin_drops_the_whole_declaration_for_a_non_proposer` +/// (`engine/src/game/visibility.rs`). Before this row, `grep -c RespondToShortcut +/// crates/engine/src/game/visibility.rs` was 0: the identical pin vector was public to every +/// responder and spectator. +/// +/// CR 732.2b, ALL-OR-NOTHING: the responder's right is to shorten "by naming a place where they +/// will make a game choice that's different than what's been proposed", so a half-shown pin set +/// would state a proposal that was never made. The hostile fixture pins ONE hidden hand card and +/// ONE public seat, and the assertion is `template.is_none()` — an implementation that merely +/// trimmed the hidden pin would hand back `Some` with a one-pin vector and fail here. +/// +/// # Non-vacuity +/// +/// The board is FOUR seats so that the pinned card's owner is neither the proposer nor a +/// responder, which separates three properties a 2-seat fixture conflates. Paired positives, +/// because a redactor that simply dropped every template would satisfy the negative for free: +/// (1) the PROPOSER (P0) keeps it — the guard is keyed to the viewer boundary; (2) the OWNER of +/// the pinned hand card (P3) keeps it even though they are not the proposer — so the drop is +/// keyed to what the viewer may actually see, not to "everyone but the proposer"; (3) an all-seat +/// template reaches the responder unchanged and byte-equal to the proposer's. The negative is +/// asserted for the QUEUED responder P2 as well as the current one P1, which is what makes the +/// guard's keying on `proposal.proposer` (not on the prompted `player`) observable. +/// +/// REVERT-PROBE: delete the `WaitingFor::RespondToShortcut` arm in `filter_state_for_viewer` ⇒ +/// P1/P2 see the hidden-hand pin ⇒ the two `is_none()` assertions FAIL while all positives stay +/// green. Flipping the shared `pins_name_hidden_source`'s inner `any` to `all` fails this row AND +/// the pre-existing `LoopShortcut` row D5-h — which is the proof that the extraction left ONE +/// authority behind rather than two copies. +/// +/// *What wrong implementation would still pass this row?* One that redacts `proposal.template` +/// but leaks the same identity through the `LoopShortcut` offer it came from — that surface is +/// covered by D5-h and by `loop_shortcut_schema_redacts_hidden_targets_for_non_controller` above. +#[test] +fn respond_to_shortcut_template_redacts_a_hidden_pin_for_non_proposers() { + const P3: PlayerId = PlayerId(3); + + let mut scenario = GameScenario::new_n_player(4, 7); + scenario.at_phase(Phase::PreCombatMain); + // The pinned card sits in a hand belonging to NEITHER the proposer (P0) nor either + // responder (P1 current, P2 queued), so "cannot see it" and "is not the proposer" are + // distinguishable properties of a viewer on this one board. + let hidden_hand = scenario.add_bolt_to_hand(P3); + let runner = scenario.build(); + + let source = YieldTarget::ThisObject { + source_id: ObjectId(999), + incarnation: None, + trigger_description: None, + }; + let window = |pins: Vec| -> GameState { + let mut state = runner.state().clone(); + state.waiting_for = WaitingFor::RespondToShortcut { + player: P1, + remaining_players: vec![P2], + proposal: ShortcutProposal { + proposer: P0, + predicted_winner: Some(P0), + count: IterationCount::Fixed(3), + unbounded: vec![], + win_kind: WinKind::LethalDamage, + template: Some(DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: source.clone(), + index: 0, + }, + targets: pins, + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(3), + }, + key: DecisionGroupKey::from_sources( + std::slice::from_ref(&source), + DecisionKind::LoopChoice, + ), + }), + per_cycle: None, + }, + }; + state + }; + let projected = |state: &GameState, viewer: PlayerId| -> Option { + match engine::game::visibility::filter_state_for_viewer(state, viewer).waiting_for { + WaitingFor::RespondToShortcut { proposal, .. } => proposal.template, + other => panic!("the fixture parks on the CR 732.2b response window, got {other:?}"), + } + }; + + // ── the hostile arm: one hidden hand card + one public seat in the SAME pin vector ── + let hidden_state = window(vec![ + TargetPin::ByIdentity(YieldTarget::ThisObject { + source_id: hidden_hand, + incarnation: None, + trigger_description: None, + }), + TargetPin::Player(P1), + ]); + assert!( + projected(&hidden_state, P0).is_some(), + "reach-guard + positive: the PROPOSER's own projection keeps the template, so the drops \ + below are keyed to the viewer boundary rather than to the fixture" + ); + assert!( + projected(&hidden_state, P1).is_none(), + "CR 732.2b: the CURRENT responder must not receive a proposal whose pin names a card in \ + a hand they cannot see — and all-or-nothing means the public seat pin goes with it" + ); + assert!( + projected(&hidden_state, P2).is_none(), + "the QUEUED responder is projected the same way: the guard keys on `proposal.proposer`, \ + not on the prompted `player`" + ); + assert!( + projected(&hidden_state, P3).is_some(), + "positive: the OWNER of the pinned hand card keeps the template even though they are not \ + the proposer — the drop is keyed to what this viewer may actually see, so a redactor \ + that dropped the template for every non-proposer fails here" + ); + + // ── the paired positive: an all-seat template carries no hidden identity ── + let public_state = window(vec![TargetPin::Player(P1)]); + assert_eq!( + projected(&public_state, P1), + projected(&public_state, P0), + "an all-seat template reaches the responder UNCHANGED — without this arm a redactor that \ + dropped every template would pass the negatives above" + ); + assert!( + projected(&public_state, P1).is_some(), + "and it is genuinely present, not two matching `None`s" + ); +} + +/// F4 (review finding): the THIRD carrier of the same `Vec` — +/// `GameState::last_loop_action_sequence[].pins` — routes through the same authority. +/// +/// It is serialized whenever non-empty (`skip_serializing_if = "Vec::is_empty"`, not `skip`) and +/// had zero hits in `visibility.rs` before this change. Its three production writers (the +/// `record_loop_pin` call sites: a mana-ability tap cost, a mana-color choice, a proliferate +/// target) can only name battlefield permanents and seats, so no board the engine mints today +/// reaches the redaction — this row constructs the pin a fourth writer would produce, which is +/// the only way to hold the seam closed before that writer exists. +/// +/// # Non-vacuity +/// +/// The owner arm (P1 sees their own hand card) is the paired positive: a sweep that cleared every +/// recorded pin would satisfy the negative and fail it. The step itself is asserted to survive in +/// both arms, so "the whole sequence was dropped" cannot masquerade as a pass. +/// +/// REVERT-PROBE: delete the `for step in &mut filtered.last_loop_action_sequence` sweep ⇒ P2 keeps +/// the hidden-hand pin ⇒ the `is_empty()` assertion FAILS while both positives stay green. +/// +/// *What wrong implementation would still pass this row?* One that clears `pins` unconditionally +/// for every non-owner — the owner arm is what rejects it. +#[test] +fn recorded_loop_pins_are_redacted_for_a_viewer_who_cannot_see_the_pinned_object() { + use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + let mut scenario = GameScenario::new_n_player(3, 7); + scenario.at_phase(Phase::PreCombatMain); + let hidden_hand = scenario.add_bolt_to_hand(P1); // a hidden card in P1's hand + let runner = scenario.build(); + + let mut state = runner.state().clone(); + let card_id = state.objects[&hidden_hand].card_id; + state.last_loop_action_sequence = vec![LoopActionContext { + card_id, + controller: P1, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::NotUsed, + }, + convoke: None, + pins: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: YieldTarget::ThisObject { + source_id: ObjectId(999), + incarnation: None, + trigger_description: None, + }, + index: 0, + }, + targets: vec![ + TargetPin::ByIdentity(YieldTarget::ThisObject { + source_id: hidden_hand, + incarnation: None, + trigger_description: None, + }), + TargetPin::Player(P1), + ], + }], + }]; + + let pins_for = |viewer: PlayerId| -> Vec { + let seq = engine::game::visibility::filter_state_for_viewer(&state, viewer) + .last_loop_action_sequence; + assert_eq!( + seq.len(), + 1, + "the recorded step itself is never dropped — only its pin vector is redacted" + ); + seq[0].pins.clone() + }; + + assert_eq!( + pins_for(P1).len(), + 1, + "positive: the hand's OWNER keeps the recorded pin — `target_hidden` answers false for a \ + card this viewer may privately see" + ); + assert!( + pins_for(P2).is_empty(), + "a viewer who cannot see P1's hand receives no pin naming that card — all-or-nothing, so \ + the public seat pin in the same vector goes with it" + ); +} + /// T6 (serde): the schema rides the `WaitingFor::LoopShortcut` serialization as `data.schema` /// (tag/content) and round-trips equal — the FE contract that lets the frontend read the offer's /// decision schema off the wire without any engine-side special casing. From 6dd5c80cf993092e582ae3002e55db06dbadacf1 Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 11 Aug 2026 19:18:04 -0500 Subject: [PATCH 16/44] fix(engine): discriminate the pin-level redaction axis; walk the whole crate for the enum inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds four of the five follow-ups from the independent review of 3ccfbf91c (item4-run/t-r0-review/FINDINGS.md). The fifth is deliberately NOT folded; the measurement that stopped it is at the end. MED-1 — the PIN-LEVEL all-or-nothing axis of `pins_name_hidden_source` was discriminated by no row in the tree: all three existing fixtures build a one-element `decisions` vector, and on one element `any` and `all` are the same function, so the equality proof in 3ccfbf91c's message covers only the WITHIN-pin axis. New row `d5h2_a_public_pin_ahead_of_a_hidden_one_still_drops_the_whole_declaration` builds `[ManaColor, Targets{hidden}]` — the public pin FIRST, so `all(..)` and a `decisions[0]`-only check both fail it. That is the ordinary production shape: `record_loop_pin` appends a tap-cost `Targets` pin, a `ManaColor` pin and a proliferate `Targets` pin onto ONE `LoopActionContext.pins`, and `build_recast_template` clones that same vector into the offer's declaration. `d5h_offer` splits into `d5h_offer_decisions` — which keeps the fixture's single `WaitingFor::LoopShortcut {` literal, counted by the offer-writer census — plus a one-pin shorthand, so D5-h's own body is unchanged. Measured both directions: outer `pins.iter().any` -> `.all` gives `FAILED. 68 passed; 1 failed` with ONLY the new row failing; restored (sha256 verified, file touched, `Compiling phase-engine` in the log) gives `ok. 69 passed; 0 failed`. LOW-3 — the predicate's "every slot-only variant is already published unredacted as `point.slot`" comment holds only for carrier 1, which co-publishes the schema. It now states the per-carrier truth and marks the carrier-2/3 claim as a property of today's PRODUCERS (`build_recast_template`, the `record_loop_pin` sites, the offer schema's stack-entry sources) rather than of `DecisionSlot`, and names `source_hidden` as the call to add when a producer first slots a hidden-zone source. No leak measured, so this is comment-only. LOW-4 — `ShortcutProposal`'s doc still claimed "there is no hidden information to redact" on the very type this series redacts. It now excepts `template`, names `handle_declare_shortcut` as what moves it here verbatim and `pins_name_hidden_source` as the redaction authority, and states the CR 732.2b all-or-nothing rule. LOW-5 — `TARGET_DIRS` becomes `&["crates/engine/src"]`: shorter than the two-element list and complete (`types/` + `analysis/` left 85 of 647 top-level `pub enum`s invisible to a gate CLAUDE.md scopes to "any other engine enum"). Measured: 562 -> 646 enums, 4977 -> 5283 variants. The one cost is disclosed on the const rather than absorbed: 647 declarations yield 646 entries because `LayoutKind` is declared in both `types/card.rs` and `database/synthesis.rs` and the catalogue is keyed on the ident. MED-2 (unify the three carriers' gates) is NOT in this commit. Applied literally — the proposer guard dropped from both `WaitingFor` arms — it fails two green rows, and in both the loser is the PROPOSER's own view of their own information: `loop_shortcut_schema_redacts_hidden_targets_for_non_controller` ("controller keeps all legal targets", left 2 right 3, because that guard also wraps the SCHEMA redaction) and `respond_to_shortcut_template_redacts_a_hidden_pin_for_non_proposers` ("the PROPOSER's own projection keeps the template", whose fixture pins a card in a THIRD player's hand). The reported inconsistency is real — carrier 3 is guard-free while carriers 1/2 key on `can_view_private_for_player`, which CR 723.4 turn control makes strictly wider than "is the proposer" — but closing it needs a design change rather than a subtraction, so it is left open. Gate (worktree item4-wt-c34, dedicated CARGO_TARGET_DIR): cargo test -p phase-engine --lib ok. 18851 passed; 0 failed; 6 ignored (was 18850 passed; 6 ignored) cargo test -p phase-engine --test integration ok. 4821 passed; 0 failed; 2 ignored cargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings exit 0 Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine-inventory-gen/src/main.rs | 21 ++- crates/engine/src/analysis/loop_check.rs | 15 ++- crates/engine/src/game/visibility.rs | 159 +++++++++++++++++++++-- 3 files changed, 171 insertions(+), 24 deletions(-) diff --git a/crates/engine-inventory-gen/src/main.rs b/crates/engine-inventory-gen/src/main.rs index 22edd74305..6829aefee5 100644 --- a/crates/engine-inventory-gen/src/main.rs +++ b/crates/engine-inventory-gen/src/main.rs @@ -1,6 +1,6 @@ //! Engine surface inventory generator. //! -//! Walks `crates/engine/src/types/` via `syn`, enumerates every `pub enum` and +//! Walks `crates/engine/src/` via `syn`, enumerates every `pub enum` and //! its variants with file:line, doc comments, and CR annotations. Auto-detects //! sibling-cluster smells (variants sharing a name root that look like //! parameterization candidates per the workspace "Parameterize, don't proliferate" @@ -75,11 +75,20 @@ struct ClusterSmell { } /// Every directory whose `pub enum`s are engine surface a variant proposal must be able to -/// discover. `types/` alone is not that set: CLAUDE.md makes an inventory grep the mandatory -/// discoverability gate before proposing a variant, and `analysis/` holds public rules-bearing -/// enums (`TargetSchedule`, `PinnedDecision`, `ReplayMode`, …) that the gate structurally could -/// not see while this was a single directory. -const TARGET_DIRS: &[&str] = &["crates/engine/src/types", "crates/engine/src/analysis"]; +/// discover. CLAUDE.md makes an inventory grep the mandatory discoverability gate before +/// proposing a variant and scopes it to "any other engine enum", so the walk is the WHOLE +/// engine crate rather than a hand-kept subset: `types/` + `analysis/` left 85 of the 647 +/// top-level `pub enum`s under `crates/engine/src` structurally invisible to the gate +/// (`game/` 61, `ai_support/` 13, `parser/` 7, `database/` 4). One root is also shorter than +/// the list it replaces. +/// +/// MEASURED COST, disclosed rather than absorbed: the catalogue is a `BTreeMap` keyed on the +/// enum IDENT, and across the whole crate exactly one ident collides — `LayoutKind`, declared +/// in both `types/card.rs` and `database/synthesis.rs` — so 647 declarations yield 646 entries +/// and the later walk order wins. The gate this feeds is an existence/parameterization lookup +/// by name, which still answers for `LayoutKind`; a module-qualified key is the fix if a +/// second collision ever makes the per-variant listing ambiguous. +const TARGET_DIRS: &[&str] = &["crates/engine/src"]; const OUTPUT: &str = "data/engine-inventory.json"; fn main() -> Result<()> { diff --git a/crates/engine/src/analysis/loop_check.rs b/crates/engine/src/analysis/loop_check.rs index ae6d034ef7..8e90194157 100644 --- a/crates/engine/src/analysis/loop_check.rs +++ b/crates/engine/src/analysis/loop_check.rs @@ -158,10 +158,17 @@ impl LoopCertificate { } } -/// CR 732.2a: the public, log/display summary a `WaitingFor::RespondToShortcut` carries -/// to each responding opponent — "the player with priority suggests repeating this loop -/// N times". Every field is derived from public board state (the confirmed certificate + -/// the proposer's declared count), so there is no hidden information to redact. +/// CR 732.2a: the log/display summary a `WaitingFor::RespondToShortcut` carries to each +/// responding opponent — "the player with priority suggests repeating this loop N times". +/// +/// Every field EXCEPT [`ShortcutProposal::template`] is derived from public board state (the +/// confirmed certificate + the proposer's declared count). `template` is NOT: it is the +/// proposer's `DecisionTemplate` moved here verbatim by `game::engine::handle_declare_shortcut`, +/// and its pins can name objects in hidden zones. It is redacted per viewer in +/// `game::visibility::filter_state_for_viewer` through the shared `pins_name_hidden_source` +/// authority — all-or-nothing per CR 732.2b, the whole template is dropped and never trimmed. +/// The blanket "no hidden information to redact" this doc used to claim is exactly what let +/// this carrier drift from the `WaitingFor::LoopShortcut` offer it is copied from. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ShortcutProposal { /// CR 732.2a: the player with priority who proposed the shortcut. This is separate from diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 1e818e7645..34ad5138d9 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -160,8 +160,21 @@ fn pins_name_hidden_source( }, }; // Wildcard-free over `PinnedDecision`, so a future variant that carries an object - // identity gets a compile-time visit here instead of leaking silently. Every - // slot-only variant is already published unredacted as `point.slot`. + // identity gets a compile-time visit here instead of leaking silently. + // + // `slot` is deliberately not inspected, and the honest reason is PER-CARRIER rather than + // global. Carrier 1 co-publishes the identical `DecisionSlot` unredacted as + // `schema.points[].slot` (the `LoopShortcut` arm below), so redacting it here would hide + // nothing that arm hands over anyway. Carriers 2 and 3 publish NO schema, and for them the + // claim is narrower and measured rather than structural: every `DecisionSlot.source` this + // engine mints today is either `YieldTarget::AllCopies { card_id }` (a card identity, which + // occupies no zone) or a `ThisObject` that `game::engine::object_decision_source` built from + // a stack object or a battlefield permanent — never a hand/library occupant. That is a + // property of today's PRODUCERS (`build_recast_template` and the `record_loop_pin` call + // sites feed carriers 2/3; a proposer-declared template's slots come from the offer schema, + // whose sources are stack entries), not of `DecisionSlot` itself, whose constructors accept + // any object. A producer that slots a hidden-zone source would leak it through carriers 2/3, + // and `source_hidden` above is already the function to call on `slot.source` when one exists. pins.iter().any(|pin| match pin { PinnedDecision::Targets { targets, .. } => targets.iter().any(&pin_hidden), PinnedDecision::Order { source, .. } => source_hidden(source), @@ -5963,19 +5976,24 @@ mod tests { const D5H_PROPOSER: PlayerId = PlayerId(0); const D5H_VIEWER: PlayerId = PlayerId(1); - /// One `LoopShortcut` offer whose declaration pins whatever `pins` builds from the HIDDEN - /// card's id. The card sits in the PROPOSER's hand, so the non-proposer viewer cannot - /// privately view its owner and `target_hidden` answers `true` for it. + /// One `LoopShortcut` offer whose declaration carries whatever `decisions` builds from the + /// HIDDEN card's id and the fixture's slot. The card sits in the PROPOSER's hand, so the + /// non-proposer viewer cannot privately view its owner and `target_hidden` answers `true` + /// for it. /// - /// Called twice — once hidden, once all-`Player` — so the mint spells the + /// EVERY arm of D5-h and D5-h/2 mints through here, so the fixture spells the /// `WaitingFor::LoopShortcut` anchor exactly once (this is a counted site in - /// `tests/integration/loop_shortcut_offer_writer_census.rs`). - fn d5h_offer( - pins: impl FnOnce(ObjectId) -> Vec, + /// `tests/integration/loop_shortcut_offer_writer_census.rs`, which pins the per-file + /// multiset — a second literal in this file would fail that row). + fn d5h_offer_decisions( + decisions: impl FnOnce( + ObjectId, + &crate::analysis::decision_template::DecisionSlot, + ) -> Vec, ) -> GameState { use crate::analysis::decision_template::{ DecisionGroupKey, DecisionKind, DecisionPoint, DecisionPointKind, DecisionSlot, - DecisionTemplate, IterationCount, PinnedDecision, ReplayMode, ShortcutDecisionSchema, + DecisionTemplate, IterationCount, ReplayMode, ShortcutDecisionSchema, }; let mut state = GameState::new_two_player(42); let hidden = create_object( @@ -6016,10 +6034,7 @@ mod tests { }, declaration: Some(DecisionTemplate { owner: D5H_PROPOSER, - decisions: vec![PinnedDecision::Targets { - slot: slot.clone(), - targets: pins(hidden), - }], + decisions: decisions(hidden, &slot), replay: ReplayMode::Scheduled { count: IterationCount::Fixed(3), }, @@ -6029,6 +6044,19 @@ mod tests { state } + /// The ONE-pin shorthand D5-h uses: a single `PinnedDecision::Targets` carrying `pins`. + fn d5h_offer( + pins: impl FnOnce(ObjectId) -> Vec, + ) -> GameState { + use crate::analysis::decision_template::PinnedDecision; + d5h_offer_decisions(|hidden, slot| { + vec![PinnedDecision::Targets { + slot: slot.clone(), + targets: pins(hidden), + }] + }) + } + /// The declaration AS PROJECTED for `viewer`. Both arms of D5-h read through here, so the /// read also spells the census anchor exactly once. fn d5h_projected_declaration( @@ -6115,4 +6143,107 @@ mod tests { "and it is genuinely present, not two matching `None`s" ); } + + /// **Row D5-h/2 — the ACROSS-PIN axis: a declaration whose FIRST pin is public and whose + /// SECOND names a hidden object still drops WHOLE.** + /// + /// D5-h above and both integration rows build a ONE-element `decisions` vector, and on a + /// one-element vector `pins.iter().any(..)` and `pins.iter().all(..)` are the same function — + /// so the PIN-LEVEL quantifier of `pins_name_hidden_source` was discriminated by no row in the + /// tree (measured: flipping the OUTER `any` to `all` left lib and integration fully green). + /// CR 732.2b is all-or-nothing across the WHOLE pin set, not within one pin: a declaration + /// that survives because only *some* of its pins name hidden objects states a proposal that + /// was never made. + /// + /// # The multi-pin shape is the ORDINARY production shape, not an exotic one + /// + /// `game::engine::record_loop_pin` appends up to three pins onto ONE `LoopActionContext.pins` + /// in temporal order — a mana-ability tap-cost `Targets` pin (`index: 0`), a `ManaColor` pin + /// (`index: 1`), then a proliferate `Targets` pin — and `game::engine::build_recast_template` + /// clones that very vector (`decisions = ctx.pins.clone()`) into the offer's declaration + /// before pushing a `ConvokeTaps` pin. A public pin sitting ahead of a hidden one is therefore + /// exactly what those producers mint; this row builds `[ManaColor, Targets{hidden}]`, i.e. + /// pins 2 and 3 of that production sequence. + /// + /// # Non-vacuity / discrimination + /// + /// The PUBLIC pin is FIRST, so an implementation that stops at the first pin — `all(..)`, or a + /// `decisions.first()` peek — keeps the declaration and fails the negative below. Paired + /// positives: the proposer's own projection keeps it, and an all-public TWO-pin declaration + /// reaches the non-proposer unchanged, so a redactor that dropped every multi-pin declaration + /// fails here. The pin count and the first pin's variant are asserted on the projected + /// proposer copy, so a fixture that silently built one pin (or a hidden first pin) cannot + /// satisfy the negative for the wrong reason. + /// + /// REVERT-PROBE (measured both directions, `item4-run/t-r0-fold/REPORT.md`): outer + /// `pins.iter().any` -> `.all` in `pins_name_hidden_source` ⇒ this row FAILS while every other + /// row in `game::visibility::tests` stays green; restored ⇒ it passes. + #[test] + fn d5h2_a_public_pin_ahead_of_a_hidden_one_still_drops_the_whole_declaration() { + use crate::analysis::decision_template::{PinnedDecision, TargetPin}; + use crate::types::mana::ManaColor; + + // ── the hostile arm: pin 1 carries no identity, pin 2 names the hidden hand card ── + let hidden_state = d5h_offer_decisions(|hidden, slot| { + vec![ + PinnedDecision::ManaColor { + slot: slot.clone(), + color: ManaColor::Blue, + }, + PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::ByIdentity( + crate::types::game_state::YieldTarget::ThisObject { + source_id: hidden, + incarnation: Some(1), + trigger_description: None, + }, + )], + }, + ] + }); + let proposer_copy = d5h_projected_declaration(&hidden_state, D5H_PROPOSER) + .expect("reach-guard + positive: the PROPOSER's own projection keeps the declaration"); + assert_eq!( + proposer_copy.decisions.len(), + 2, + "reach-guard: the fixture really carries TWO pins — `any` and `all` are the same \ + function on a one-pin vector, which is why this row exists" + ); + assert!( + matches!(proposer_copy.decisions[0], PinnedDecision::ManaColor { .. }), + "reach-guard: the FIRST pin carries no hidden identity, so a check that stops at \ + `decisions[0]` must look further to answer correctly" + ); + assert!( + d5h_projected_declaration(&hidden_state, D5H_VIEWER).is_none(), + "CR 732.2b: ONE pin naming an object this viewer may not see drops the ENTIRE \ + declaration, however many public pins precede it" + ); + + // ── the paired positive: the SAME two-pin shape with no hidden identity travels whole ── + let public_state = d5h_offer_decisions(|_hidden, slot| { + vec![ + PinnedDecision::ManaColor { + slot: slot.clone(), + color: ManaColor::Blue, + }, + PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::Player(D5H_VIEWER)], + }, + ] + }); + assert_eq!( + d5h_projected_declaration(&public_state, D5H_VIEWER), + d5h_projected_declaration(&public_state, D5H_PROPOSER), + "a two-pin declaration with no hidden identity reaches the opponent UNCHANGED — \ + without this arm a redactor that dropped every multi-pin declaration would pass the \ + negative above" + ); + assert!( + d5h_projected_declaration(&public_state, D5H_VIEWER).is_some(), + "and it is genuinely present, not two matching `None`s" + ); + } } From 7eaf8a0d9baa75f63f64724750524e3d3cd2cc33 Mon Sep 17 00:00:00 2001 From: lgray Date: Tue, 11 Aug 2026 23:17:20 -0500 Subject: [PATCH 17/44] feat(engine): parameterize a target schedule's subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CR 732.2a proposal may pre-declare, in order, whom each episode's announcement names. `TargetSchedule`'s three payloads carry a `Ranking` (a checked, ordered, duplicate-free `Vec`) instead of a bare source, so a subject is either an `Object(DecisionSource)` — a CR 400.7 identity — or a TARGET-class `Seat(PlayerId)`. Behaviour-preserving: `evaluate_schedule` consumes `head()` only and never advances the ranking, so a one-element ranking is exactly the old constant. Entries 1..n are declared, not consumed, in this commit. The `Seat` arm asks a different question from `resolve_source`, which is battlefield-only and whose consumers document that its zone filter IS the CR 608.2b target re-check. `resolve_ability_instance` is factored out of `slot_source_prompted`'s existing battlefield ∪ CR 114.2 command-zone match (behaviour verbatim) and answers it; a `None` fails closed to `IllegalTarget`, which is the disposition the drive already takes for graveyard/exile/hand sources. Redaction descends into every subject of every ranking, so a hidden object source carried in a ranking's tail still drops the whole declaration for a non-proposer (CR 732.2b, all-or-nothing). Carrier 4 moves from owner-retain to the CR 723.4 private-access predicate that carriers 1 and 2 already apply. The wire guard bounds the nested ranking on all three schedule arms, including `Constant`, which newly carries a Vec. DOC-SWEEP (unnamed-obligation sweep, per changed property): needle edit-list still-true n/a sum TargetSchedule 23 13 1 37 resolve_source|slot_source_prompted 5 34 22 61 decision_templates 4 3 22 29 Falsified-reason docs moved in this commit: visibility.rs carrier-4 gate quote and the "never an opponent's saved orderings" rationale (both the sweep's positive controls, re-found not assumed), plus three new finds — `shortcut_drive_period`'s "rotates DecisionSource objects, not players", R1-l's revert-probe wording after the factoring, and the payload guard's two-level nesting comment. Known and deliberately not changed: `pinned_targets_for_source` and `pinned_mana_color_for_source` still spell this question with bare `resolve_source`. Migrating them would widen what the drive accepts — a behaviour change with no test row and no CR analysis here. Assisted-by: ClaudeCode:claude-opus-5 --- .../engine/src/analysis/decision_template.rs | 725 +++++++++++++++++- crates/engine/src/game/engine.rs | 107 +-- crates/engine/src/game/visibility.rs | 263 ++++++- .../engine/tests/integration/loop_shortcut.rs | 28 +- .../src/game_action_payload_guard.rs | 34 +- .../tests/game_action_payload_guard.rs | 125 ++- 6 files changed, 1186 insertions(+), 96 deletions(-) diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index d53825493c..7da8ddd102 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -413,6 +413,109 @@ pub enum DecisionPointKind { ManaColor { color: ManaColor }, } +/// CR 115.2 + CR 601.2c: WHO one announcement names, stored in re-bindable form. The +/// storable dual of [`ConcreteTarget`], which already draws exactly this two-way split at +/// the RESOLVED end of the same pipeline — so this adds no categorical boundary, it gives +/// the existing one a pre-resolution spelling. +/// +/// PROVENANCE, and it is the whole point of the type: a `Seat` here is a TARGET +/// (CR 601.2c), judged by `game::targeting::player_is_legal_target` — existence PLUS +/// CR 702.11c hexproof / CR 702.18a shroud / CR 702.16b protection. A merely CHOSEN player +/// (CR 115.10a — e.g. a CR 701.34a proliferate choice) is NOT this type; it stays +/// [`TargetPin::Player`] and keeps its existence-only authority. Two questions, two +/// spellings. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum AnnouncementSubject { + Object(DecisionSource), + Seat(PlayerId), +} + +/// Why a subject list is not a legal [`Ranking`]. Both clauses are refused at CONSTRUCTION, +/// which is what makes [`Ranking::head`] infallible — no `Option` leaks into the resolver, +/// and a wire-supplied list fails the LOAD rather than the drive (the same disposition +/// `reject_zero_bound_shortcut_offer` takes for a wire-sourced `max_iterations`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RankingError { + /// An empty ranking names nobody: there is no head to announce. + Empty, + /// CR 601.2c: an announcement names its choice per target. A repeated subject is not an + /// ordering — it is the same declaration twice, and it would make the tail unreachable. + DuplicateSubject, +} + +impl std::fmt::Display for RankingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => f.write_str("a ranking must name at least one announcement subject"), + Self::DuplicateSubject => { + f.write_str("a ranking must not name the same announcement subject twice") + } + } + } +} + +/// CR 732.1 + CR 732.2a: a DECLARED, ORDERED pre-commitment over announcement subjects for +/// ONE slot. A one-element ranking IS the old constant pin; that is the parameterization, +/// and it is why there is no `Ranked` sibling of [`TargetSchedule`]. +/// +/// CONSUMED AT AN EPISODE BOUNDARY, NEVER MID-DRIVE. Within one accepted drive only +/// [`Ranking::head`] is ever resolved (see `evaluate_schedule`): advancing to a later entry +/// because a game event removed the head would be the conditional action CR 732.2a bars, and +/// CR 732.2a also requires the sequence to END at a place where a player has priority — +/// which the drive-end handback already is. The tail is a pre-declaration for the NEXT +/// episode, validated by THAT episode's `validate_pins` against THAT episode's published +/// legal set. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "Vec")] +pub struct Ranking(Vec); + +impl Ranking { + /// The invariant is expressed ONCE, here — and this is the `TryFrom` the + /// `#[serde(try_from)]` shim runs, so a wire-supplied empty or duplicated list is + /// refused before any resolver sees it. + pub fn new(subjects: Vec) -> Result { + if subjects.is_empty() { + return Err(RankingError::Empty); + } + // Sort a view, never the payload: the declared ORDER is the whole point of the type. + // `Ord` is derived (it has to be — `DecisionTemplate` derives it for deterministic AI + // action ordering), so this is n·log n on a wire-length-bounded list rather than the + // quadratic scan a non-`Hash` payload would otherwise force. + let mut seen: Vec<&AnnouncementSubject> = subjects.iter().collect(); + seen.sort_unstable(); + if seen.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(RankingError::DuplicateSubject); + } + Ok(Self(subjects)) + } + + /// The constant case; every mechanical migration site uses it. Infallible — one element + /// can violate neither clause. + pub fn one(subject: AnnouncementSubject) -> Self { + Self(vec![subject]) + } + + /// The ONLY reader inside `evaluate_schedule` (CR 732.2a: a drive resolves the head and + /// never advances past it). Infallible by the non-empty invariant `new`/`one` enforce. + pub fn head(&self) -> &AnnouncementSubject { + &self.0[0] + } + + /// The whole list, for the callers that must see past the head without resolving it: + /// the hidden-source redaction walk (`game::visibility`) and the wire length bound. + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +impl TryFrom> for Ranking { + type Error = RankingError; + + fn try_from(subjects: Vec) -> Result { + Self::new(subjects) + } +} + /// A pinned target. `ByIdentity` re-resolves to a live legal ObjectId each iteration /// (CR 608.2b); `Scheduled` is an iteration-indexed pure function (CR 732.2a). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -458,13 +561,21 @@ pub enum IterationCount { /// enforced BY CONSTRUCTION: no variant carries any prior-outcome/event input, so a /// "react to what happened" target is unrepresentable (this is what collapses the /// predictability gate's "no conditional" clause into "total coverage"). +/// +/// AND THE PURITY INVARIANT IS NARROWER THAN "consults the live set", now that each step +/// carries a [`Ranking`] rather than a single subject: a variant consults the live legal set +/// to **re-bind the declared subject** (CR 400.7); it never uses the live set to +/// **substitute a different subject**. Selecting a different entry because a game event +/// removed the first is exactly the conditional action CR 732.2a bars — which is why a +/// `Ranking` is advanced only at an episode boundary, by a caller, never by +/// `evaluate_schedule`. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum TargetSchedule { - Constant(DecisionSource), - RoundRobin(Vec), + Constant(Ranking), + RoundRobin(Vec), /// Pre-declared switch-over: identity for [start, next-start). The switch point is /// FIXED IN ADVANCE (not triggered by an in-loop event), keeping it 732.2a-predictable. - Piecewise(Vec<(u32, DecisionSource)>), + Piecewise(Vec<(u32, Ranking)>), // NOTE (RULED Deferral 2): `IndexedClass { filter: TargetFilter, stride: i32 }` — an // iteration-indexed pick from an object class, evaluated via `matches_target_filter` // — is deferred to Phase 4/B3, where a live `FilterContext` source exists. @@ -755,19 +866,71 @@ pub(crate) fn resolve_source(src: &DecisionSource, state: &GameState) -> Option< } } +/// CR 608.2b + CR 114.2: re-bind a stored `DecisionSource` to the live ABILITY INSTANCE it +/// identifies — a different question from [`resolve_source`]'s, and the reason this accessor +/// exists rather than a second spelling at each caller. +/// +/// NOT YET THE ONLY SPELLING, and the honest statement is the useful one: `game::engine`'s +/// `pinned_targets_for_source` and `pinned_mana_color_for_source` ask this same question of a +/// pin's SLOT source through bare `resolve_source`, so a command-zone-sourced slot does not +/// match there and the drive fails closed to manual play. That is unchanged pre-existing +/// behaviour, not something this accessor introduced; migrating those two widens what the +/// drive accepts and so belongs to a commit that carries a row for it. +/// +/// A *pin's* source identifies a TARGET, so [`resolve_source`] is deliberately +/// BATTLEFIELD-ONLY and that filter IS the CR 608.2b legality re-check: a pinned target that +/// left the battlefield must stop matching, and it must not be widened. A *slot's* source +/// only identifies WHICH ability instance prompts, and CR 114.2 puts a planeswalker EMBLEM — +/// "both owned and controlled by that player" — in the COMMAND zone, where it stays for the +/// whole game and raises its triggers from. So the command-zone disjunct lives here, scoped +/// to object identity plus the pinned CR 400.7 incarnation, exactly as the battlefield arm +/// is. +/// +/// `AllCopies` is card-identity matching and an emblem has no card, so only `ThisObject` +/// participates in the command disjunct. Graveyard / exile / hand sources still resolve +/// `None` ⇒ every caller fails closed (`game::engine::slot_source_prompted` aborts the drive +/// to manual play; `evaluate_schedule`'s `Seat` arm raises `IllegalTarget`). +pub(crate) fn resolve_ability_instance( + src: &DecisionSource, + state: &GameState, +) -> Option { + if let Some(id) = resolve_source(src, state) { + return Some(id); + } + let YieldTarget::ThisObject { + source_id, + incarnation, + .. + } = src + else { + return None; + }; + state + .objects + .get(source_id) + .filter(|o| o.zone == Zone::Command) + .filter(|o| incarnation.is_none() || *incarnation == Some(o.incarnation)) + .map(|o| o.id) +} + /// CR 732.2a predictability firewall: EXHAUSTIVE `match` over [`TargetSchedule`] with NO /// wildcard arm — a future outcome-carrying variant breaks this build (mirrored by the /// `target_schedule_predictability_firewall_is_exhaustive` test). Every variant is a -/// pure fn of (iteration index, live set); each selects a `DecisionSource`, then -/// re-binds it to a live legal object (CR 608.2b, via `resolve_source`). +/// pure fn of (iteration index, live set); each selects a [`Ranking`], whose HEAD is then +/// re-bound against live state (CR 608.2b). +/// +/// HEAD-ONLY, and that is the CR 732.2a clause rather than a simplification: skipping to a +/// later entry because the head became illegal is a conditional action ("the outcome of a +/// game event determines the next action a player takes"). The tail is the NEXT episode's +/// pre-declaration; only an episode boundary may advance it. fn evaluate_schedule( sched: &TargetSchedule, slot: &DecisionSlot, iter: IterationIndex, state: &GameState, ) -> Result { - let source: &DecisionSource = match sched { - TargetSchedule::Constant(src) => src, + let ranking: &Ranking = match sched { + TargetSchedule::Constant(ranking) => ranking, TargetSchedule::RoundRobin(schedule) => { if schedule.is_empty() { return Err(ReplayFailure::ScheduleExhausted { slot: slot.clone() }); @@ -778,15 +941,37 @@ fn evaluate_schedule( .iter() .filter(|(start, _)| *start <= iter) .max_by_key(|(start, _)| *start) - .map(|(_, src)| src) + .map(|(_, ranking)| ranking) .ok_or_else(|| ReplayFailure::ScheduleExhausted { slot: slot.clone() })?, }; - resolve_source(source, state) - .map(ConcreteTarget::Object) - .ok_or_else(|| ReplayFailure::IllegalTarget { - slot: slot.clone(), - pin: TargetPin::Scheduled(sched.clone()), - }) + match ranking.head() { + // CR 608.2b: a pinned TARGET object must still be a live battlefield object — this + // is exactly the pre-parameterization `Constant` behaviour, unchanged. + AnnouncementSubject::Object(src) => resolve_source(src, state).map(ConcreteTarget::Object), + // CR 601.2c + CR 115.1: a ranked seat is a TARGET, so it is judged by + // `targeting::player_is_legal_target` (existence + CR 702.11c hexproof / + // CR 702.18a shroud / CR 702.16b protection) rather than by existence alone. Its two + // trailing arguments describe THE ABILITY INSTANCE that would name the seat, not a + // target object — hence `resolve_ability_instance` (which admits the CR 114.2 command + // zone) and NOT `resolve_source` (battlefield-only, and correctly so for a pin). + // + // A `None` ANYWHERE in this chain falls through to the `ok_or_else` below: with no + // live ability instance the engine cannot certify that the object it would ask the + // CR 702.11c question about still IS that instance (CR 400.7 / CR 608.2b), and + // CR 732.1 makes refusing a shortcut free — no declaration published just means the + // table plays the loop out manually. Announcing a target we cannot certify is not + // free. This is the fail-closed branch, not an oversight. + AnnouncementSubject::Seat(p) => resolve_ability_instance(&slot.source, state) + .and_then(|src_id| state.objects.get(&src_id).map(|o| (src_id, o.controller))) + .filter(|(src_id, ctrl)| { + crate::game::targeting::player_is_legal_target(state, *p, *src_id, *ctrl) + }) + .map(|_| ConcreteTarget::Player(*p)), + } + .ok_or_else(|| ReplayFailure::IllegalTarget { + slot: slot.clone(), + pin: TargetPin::Scheduled(sched.clone()), + }) } /// CR 732.2a firewall: a `Scheduled` template may auto-drive a shortcut only if every @@ -1102,6 +1287,16 @@ mod tests { } } + /// The one-element ranking every pre-parameterization schedule site now spells: a + /// `Ranking::one(Object(src))` IS the old `Constant(src)`, which is the migration. + fn obj_rank(src: DecisionSource) -> Ranking { + Ranking::one(AnnouncementSubject::Object(src)) + } + + fn seat_rank(player: PlayerId) -> Ranking { + Ranking::one(AnnouncementSubject::Seat(player)) + } + /// T6: `DecisionPointKind` serializes externally tagged (`{"ConvokeTaps":{...}}`) — the /// FE-consumable JSON shape the WASM bridge passes through — and round-trips equal. Revert: /// switching the enum to internal/adjacent tagging changes the top-level key and fails. @@ -1313,8 +1508,8 @@ mod tests { decisions: vec![PinnedDecision::Targets { slot, targets: vec![TargetPin::Scheduled(TargetSchedule::RoundRobin(vec![ - this_obj(20, None), - this_obj(21, None), + obj_rank(this_obj(20, None)), + obj_rank(this_obj(21, None)), ]))], }], replay: ReplayMode::Scheduled { @@ -1386,8 +1581,8 @@ mod tests { decisions: vec![PinnedDecision::Targets { slot: slot.clone(), targets: vec![TargetPin::Scheduled(TargetSchedule::Piecewise(vec![ - (0, this_obj(20, None)), - (2, this_obj(21, None)), + (0, obj_rank(this_obj(20, None))), + (2, obj_rank(this_obj(21, None))), ]))], }], replay: ReplayMode::Scheduled { @@ -1408,7 +1603,7 @@ mod tests { slot, targets: vec![TargetPin::Scheduled(TargetSchedule::Piecewise(vec![( 1, - this_obj(20, None), + obj_rank(this_obj(20, None)), )]))], }], replay: ReplayMode::Scheduled { @@ -1834,9 +2029,9 @@ mod tests { #[test] fn target_schedule_predictability_firewall_is_exhaustive() { let variants = [ - TargetSchedule::Constant(this_obj(1, None)), - TargetSchedule::RoundRobin(vec![this_obj(1, None)]), - TargetSchedule::Piecewise(vec![(0, this_obj(1, None))]), + TargetSchedule::Constant(obj_rank(this_obj(1, None))), + TargetSchedule::RoundRobin(vec![obj_rank(this_obj(1, None))]), + TargetSchedule::Piecewise(vec![(0, obj_rank(this_obj(1, None)))]), ]; for sched in &variants { // NO wildcard arm: each variant is a pure fn of (iteration index, live set), @@ -1933,4 +2128,490 @@ mod tests { "no live ManaPayment/pending_cast ⇒ UnpayableConvoke (never fabricate taps)" ); } + + // ── item-4 R1 — the parameterized announcement subject (`Ranking`) ── + + /// Insert an object into an arbitrary zone. `bf_object` above is battlefield-only, and + /// rows R1-h/i need the CR 114.2 command zone and the graveyard. + fn zoned_object(state: &mut GameState, id: u64, zone: Zone) -> ObjectId { + let oid = ObjectId(id); + let mut o = GameObject::new( + oid, + CardId(id), + PlayerId(0), + "Ability Source".to_string(), + zone, + ); + o.incarnation = 3; + state.objects.insert(oid, o); + oid + } + + /// CR 702.11c: give `player` hexproof through the transient-grant path + /// `static_abilities::player_has_hexproof` already reads + /// (`transient_grants_static_mode_to_player`). No layer pass is needed — that reader + /// scans `transient_continuous_effects` directly. + fn grant_player_hexproof(state: &mut GameState, player: PlayerId) { + use crate::types::ability::{ContinuousModification, Duration, TargetFilter}; + use crate::types::statics::StaticMode; + state.add_transient_continuous_effect( + ObjectId(9001), + player, + Duration::UntilEndOfTurn, + TargetFilter::SpecificPlayer { id: player }, + vec![ContinuousModification::AddStaticMode { + mode: StaticMode::Hexproof, + }], + None, + ); + } + + /// One `Targets` pin carrying one `Scheduled` pin, slotted on `slot_source`. + fn ranked_template(slot_source: DecisionSource, sched: TargetSchedule) -> DecisionTemplate { + DecisionTemplate { + owner: PlayerId(0), + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: slot_source, + index: 0, + }, + targets: vec![TargetPin::Scheduled(sched)], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: tri_key(), + } + } + + fn sole_target(out: &[ConcreteDecision]) -> ConcreteTarget { + match &out[0] { + ConcreteDecision::Targets { targets, .. } => targets[0], + other => panic!("expected Targets, got {other:?}"), + } + } + + /// **Row R1-a — the maintained-invariant equality row.** A one-element + /// `Constant(Ranking::one(Object(src)))` is behaviour-identical to the pre-parameterization + /// `Constant(src)`, and the HEAD is what every variant resolves. + /// + /// # Non-vacuity / discrimination + /// + /// The one-element half alone is satisfied by ANY entry-selection rule — first, last, + /// random — because on a one-element list they coincide. The paired reach-guard is + /// therefore a TWO-entry ranking whose head and tail are BOTH legal live objects: only a + /// head-selecting reader answers the head there. + /// + /// REVERT-PROBE: make `Ranking::head` return the LAST entry (`self.0.last().unwrap()`) ⇒ + /// every two-entry assertion below flips to ObjectId(21) ⇒ FAILS, on all three + /// `TargetSchedule` variants. The one-element assertions stay green under that mutation, + /// which is exactly why they are not the discriminator. + #[test] + fn r1a_a_one_element_ranking_is_the_old_constant_and_every_variant_resolves_its_head() { + let mut state = GameState::new_two_player(7); + bf_object(&mut state, 20, 20, 0); + bf_object(&mut state, 21, 21, 0); + let (a, b) = (this_obj(20, None), this_obj(21, None)); + let slot_src = this_obj(99, None); + + // ── the equality half: one element behaves as the old constant subject ── + let one = ranked_template( + slot_src.clone(), + TargetSchedule::Constant(obj_rank(a.clone())), + ); + assert_eq!( + sole_target(&resolve(&one, 0, &state).expect("a live battlefield head resolves")), + ConcreteTarget::Object(ObjectId(20)), + "a one-element ranking resolves exactly what `Constant(src)` resolved" + ); + + // ── the reach-guard: BOTH entries live, so only head-selection answers 20 ── + let two = Ranking::new(vec![ + AnnouncementSubject::Object(a.clone()), + AnnouncementSubject::Object(b.clone()), + ]) + .expect("two distinct subjects are a legal ranking"); + for (label, sched) in [ + ("Constant", TargetSchedule::Constant(two.clone())), + ("RoundRobin", TargetSchedule::RoundRobin(vec![two.clone()])), + ( + "Piecewise", + TargetSchedule::Piecewise(vec![(0, two.clone())]), + ), + ] { + let template = ranked_template(slot_src.clone(), sched); + let out = resolve(&template, 0, &state).expect("the head is a live object"); + assert_eq!( + sole_target(&out), + ConcreteTarget::Object(ObjectId(20)), + "{label}: with BOTH entries legal, the step resolves its ranking's HEAD — a \ + last-entry (or any-entry) reader answers 21 here" + ); + } + + // Attribution control: the tail IS reachable as a head, so 21 is not simply + // unresolvable on this board. + let tail_first = ranked_template(slot_src, TargetSchedule::Constant(obj_rank(b))); + assert_eq!( + sole_target(&resolve(&tail_first, 0, &state).expect("21 is live too")), + ConcreteTarget::Object(ObjectId(21)), + "the tail entry resolves fine when it IS the head — the row measures POSITION, not \ + a dead object" + ); + } + + /// **Row R1-b — the head-only discriminator (CR 732.2a).** An illegal head is + /// `IllegalTarget` even when a later entry is perfectly legal. Skipping to that later + /// entry would be the conditional action CR 732.2a bars ("the outcome of a game event + /// determines the next action a player takes"), and it is the load-bearing guard for the + /// whole cross-episode consumption model: a ranking advances only at an episode boundary. + /// + /// Both subject arms are exercised, because they fail through DIFFERENT predicates: + /// `Object` through `resolve_source`'s `None`, `Seat` through `player_is_legal_target`'s + /// `false` (CR 702.11c hexproof — an existence-only check would let it through). + /// + /// # Non-vacuity / discrimination + /// + /// An `IllegalTarget` is also what a wholly broken resolver returns, so each arm pairs + /// with the SAME ranking reordered to put the legal entry first, which must RESOLVE. + /// + /// REVERT-PROBE: implement first-legal-wins in `evaluate_schedule` + /// (`ranking.iter().find_map(..)` instead of `head()`) ⇒ both refusals below resolve to + /// the second entry ⇒ FAILS twice, while both positives stay green. + #[test] + fn r1b_an_illegal_head_refuses_even_when_a_later_entry_is_legal() { + let mut state = GameState::new_two_player(7); + bf_object(&mut state, 20, 20, 0); // the live object + let live_src = this_obj(20, None); + let absent_src = this_obj(u64::MAX, None); // never inserted ⇒ resolve_source None + let slot_src = this_obj(20, None); // a live battlefield ability instance + + // ── the OBJECT arm ── + let head_dead = Ranking::new(vec![ + AnnouncementSubject::Object(absent_src.clone()), + AnnouncementSubject::Object(live_src.clone()), + ]) + .expect("legal ranking"); + assert!( + matches!( + resolve( + &ranked_template(slot_src.clone(), TargetSchedule::Constant(head_dead)), + 0, + &state + ), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "CR 732.2a: a dead HEAD refuses — it must NOT skip to the live tail" + ); + let head_live = Ranking::new(vec![ + AnnouncementSubject::Object(live_src), + AnnouncementSubject::Object(absent_src.clone()), + ]) + .expect("legal ranking"); + assert_eq!( + sole_target( + &resolve( + &ranked_template(slot_src.clone(), TargetSchedule::Constant(head_live)), + 0, + &state + ) + .expect("the SAME two subjects, legal one first, resolve") + ), + ConcreteTarget::Object(ObjectId(20)), + "paired positive: the identical pair with the LIVE entry first resolves — the \ + refusal above is caused by POSITION, not by the resolver being broken" + ); + + // ── the SEAT arm: hexproof, so the head is illegal as a TARGET while existing ── + grant_player_hexproof(&mut state, PlayerId(1)); + let head_hexproofed = Ranking::new(vec![ + AnnouncementSubject::Seat(PlayerId(1)), + AnnouncementSubject::Seat(PlayerId(0)), + ]) + .expect("legal ranking"); + assert!( + matches!( + resolve( + &ranked_template(slot_src.clone(), TargetSchedule::Constant(head_hexproofed)), + 0, + &state + ), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "CR 702.11c: a hexproofed HEAD refuses — and it EXISTS, so an existence-only \ + authority (`player_exists_for_choice`) would have resolved it" + ); + let head_legal_seat = Ranking::new(vec![ + AnnouncementSubject::Seat(PlayerId(0)), + AnnouncementSubject::Seat(PlayerId(1)), + ]) + .expect("legal ranking"); + assert_eq!( + sole_target( + &resolve( + &ranked_template(slot_src, TargetSchedule::Constant(head_legal_seat)), + 0, + &state + ) + .expect("the SAME two seats, legal one first, resolve") + ), + ConcreteTarget::Player(PlayerId(0)), + "paired positive: seats DO resolve on this board (the source's own controller is \ + not an opponent, so CR 702.11c does not bite) — so the refusal above is the \ + hexproof, not a seat arm that never resolves" + ); + } + + /// **Row R1-d — multi-authority.** Two ranked slots on ONE source are resolved + /// INDEPENDENTLY; neither inherits the other's answer. + /// + /// # Non-vacuity / discrimination + /// + /// Arm A gives the two slots DIFFERENT legal seats, so a resolver that answered once and + /// reused the answer produces two identical targets and fails the vector comparison. Arm B + /// makes slot 1's seat hexproofed while slot 0's stays legal: the whole-template resolve + /// must fail NAMING SLOT 1, and slot 0 alone must still resolve — a copied answer would + /// have made slot 1 succeed. + /// + /// REVERT-PROBE: resolve once and reuse across slots ⇒ arm A's two answers agree ⇒ FAILS, + /// and arm B stops refusing ⇒ FAILS. + #[test] + fn r1d_two_ranked_slots_on_one_source_do_not_inherit_each_others_answer() { + // A 3-seat board so slot 0 and slot 1 name two DIFFERENT seats, neither of them the + // source's own controller — the shape the plan's fixture specifies. + let mut state = crate::game::scenario::GameScenario::new_n_player(3, 7) + .build() + .state() + .clone(); + bf_object(&mut state, 20, 20, 0); + let src = this_obj(20, None); + let slot_at = |index: u8| DecisionSlot { + source: src.clone(), + index, + }; + let pin_at = |index: u8, seat: PlayerId| PinnedDecision::Targets { + slot: slot_at(index), + targets: vec![TargetPin::Scheduled(TargetSchedule::Constant(seat_rank( + seat, + )))], + }; + let template_of = |pins: Vec| DecisionTemplate { + owner: PlayerId(0), + decisions: pins, + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: tri_key(), + }; + + // ── arm A: two DIFFERENT legal seats ⇒ two DIFFERENT answers ── + let both = template_of(vec![pin_at(0, PlayerId(1)), pin_at(1, PlayerId(2))]); + let out = resolve(&both, 0, &state).expect("both seats are legal targets here"); + let answers: Vec = out + .iter() + .map(|d| match d { + ConcreteDecision::Targets { targets, .. } => targets[0], + other => panic!("expected Targets, got {other:?}"), + }) + .collect(); + assert_eq!( + answers, + vec![ + ConcreteTarget::Player(PlayerId(1)), + ConcreteTarget::Player(PlayerId(2)) + ], + "each slot resolves its OWN ranking's head — a resolve-once-and-reuse \ + implementation answers PlayerId(1) twice" + ); + + // ── arm B: slot 1's seat is hexproofed; slot 0's stays legal ── + grant_player_hexproof(&mut state, PlayerId(2)); + let err = resolve(&both, 0, &state) + .expect_err("slot 1's seat is now an illegal TARGET (CR 702.11c)"); + match err { + ReplayFailure::IllegalTarget { slot, .. } => assert_eq!( + slot, + slot_at(1), + "the failure names SLOT 1 — slot 0's success was not copied onto it" + ), + other => panic!("expected IllegalTarget, got {other:?}"), + } + // Paired positives: each slot ALONE answers the way the combined run says it does. + assert_eq!( + sole_target( + &resolve(&template_of(vec![pin_at(0, PlayerId(1))]), 0, &state) + .expect("slot 0 alone still resolves") + ), + ConcreteTarget::Player(PlayerId(1)), + "slot 0 is unaffected by slot 1's refusal" + ); + assert!( + matches!( + resolve(&template_of(vec![pin_at(1, PlayerId(2))]), 0, &state), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "slot 1 alone refuses — so the combined refusal is slot 1's own verdict" + ); + } + + /// **Row R1-f — the LOAD-seam invariant.** A wire-supplied empty or duplicated ranking + /// fails deserialization, which is what makes [`Ranking::head`] infallible: no `Option` + /// and no panic path leak into the resolver. Same class as the wire-sourced + /// `max_iterations` defect `reject_zero_bound_shortcut_offer` closes. + /// + /// # Non-vacuity / discrimination + /// + /// The paired positive is a VALID two-entry ranking round-tripping equal — without it a + /// `Deserialize` impl that rejected everything would satisfy both negatives. + /// + /// REVERT-PROBE: drop `#[serde(try_from = "Vec")]` from `Ranking` ⇒ + /// `[]` deserializes into `Ranking(vec![])` ⇒ this row's `is_err()` FAILS (and `head()` + /// on that value would panic in production rather than refuse). + #[test] + fn r1f_an_empty_or_duplicated_ranking_fails_the_load() { + let seat = |p: u8| AnnouncementSubject::Seat(PlayerId(p)); + + assert!( + serde_json::from_str::("[]").is_err(), + "an empty ranking names nobody — it must not survive the load seam" + ); + + let duplicated = + serde_json::to_string(&vec![seat(1), seat(1)]).expect("the raw list serializes"); + assert!( + serde_json::from_str::(&duplicated).is_err(), + "CR 601.2c: a repeated subject is the same declaration twice, not an ordering" + ); + + // Paired positive: a legal ranking round-trips equal, so the refusals above are the + // invariant and not a broken codec. + let valid = Ranking::new(vec![seat(1), seat(0)]).expect("distinct subjects"); + let json = serde_json::to_string(&valid).expect("serialize"); + assert_eq!( + serde_json::from_str::(&json).expect("a legal ranking round-trips"), + valid, + "the newtype serializes as its inner list and reloads through the checked TryFrom" + ); + assert_eq!( + json, r#"[{"Seat":1},{"Seat":0}]"#, + "and the wire shape IS the bare list — the `try_from` shim adds no envelope" + ); + + // The constructor's own two clauses, named (the `TryFrom` above delegates here). + assert_eq!(Ranking::new(vec![]).unwrap_err(), RankingError::Empty); + assert_eq!( + Ranking::new(vec![seat(1), seat(1)]).unwrap_err(), + RankingError::DuplicateSubject + ); + } + + /// **Rows R1-g / R1-h / R1-i — the `Seat` arm's SOURCE, one instrument, three zones.** + /// + /// A ranked `Seat` is a TARGET (CR 601.2c), so `player_is_legal_target` needs the ABILITY + /// INSTANCE that would name it (CR 702.11c is source-controller-relative; CR 702.16b reads + /// the source's characteristics). That is `resolve_ability_instance`, NOT `resolve_source`: + /// + /// * **R1-g, battlefield** — resolves. The control arm; the only one of the three that can + /// fail for a boring reason (a dead harness). + /// * **R1-h, CR 114.2 command zone** — resolves. An emblem is "both owned and controlled by + /// that player" and lives in the command zone for the whole game, raising its triggers + /// from there. `crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz` is a + /// real board whose published `Targets` point names exactly such a source. + /// * **R1-i, graveyard** — refuses. With no live ability instance the engine cannot certify + /// that the object it would ask the CR 702.11c question about still IS that instance + /// (CR 400.7 / CR 608.2b). The seat still EXISTS and a graveyard object still carries a + /// `controller`, so the question is answerable — what is missing is the certification, + /// and CR 732.1 makes refusing free (no declaration ⇒ the table plays it out manually). + /// + /// # Non-vacuity / discrimination + /// + /// R1-g and R1-i must come out the OTHER way on the SAME instrument in the same run: one + /// resolving and one refusing is what proves the harness reports both values. R1-h is the + /// row that discriminates this specification from plain fail-closed-on-`resolve_source`. + /// + /// REVERT-PROBES: (a) derive the arm from `resolve_source` alone ⇒ the command-zone case + /// resolves `None` ⇒ **R1-h FAILS**; (b) break the battlefield disjunct ⇒ R1-g FAILS; + /// (c) fall back to `player_exists_for_choice` when the accessor is `None` ⇒ the graveyard + /// seat resolves ⇒ **R1-i FAILS**. + /// + /// These three SAMPLE the zone space; they do not enumerate it. Exile, a stale CR 400.7 + /// incarnation and a different object are pinned on one board by the shipped row + /// `game::engine::command_zone_sourced_slot_matches_and_graveyard_still_aborts` (row R1-l), + /// whose subject `slot_source_prompted` now delegates to `resolve_ability_instance` — so + /// that coverage transfers to this accessor rather than being skipped. + #[test] + fn r1ghi_a_ranked_seat_resolves_from_battlefield_and_command_but_fails_closed_elsewhere() { + let mut state = GameState::new_two_player(7); + let battlefield = zoned_object(&mut state, 900, Zone::Battlefield); + let emblem = zoned_object(&mut state, 901, Zone::Command); + let graveyard = zoned_object(&mut state, 902, Zone::Graveyard); + + let seat = PlayerId(1); + let resolve_from = |src: DecisionSource, state: &GameState| { + resolve( + &ranked_template(src, TargetSchedule::Constant(seat_rank(seat))), + 0, + state, + ) + }; + + // R1-g: battlefield ⇒ resolves. + assert_eq!( + sole_target( + &resolve_from(this_obj(battlefield.0, Some(3)), &state) + .expect("a live battlefield ability instance certifies the seat") + ), + ConcreteTarget::Player(seat), + "R1-g: the control arm resolves — the instrument can return a target" + ); + + // R1-h: CR 114.2 command zone ⇒ resolves. + assert_eq!( + sole_target( + &resolve_from(this_obj(emblem.0, Some(3)), &state) + .expect("CR 114.2: an emblem prompts from the command zone") + ), + ConcreteTarget::Player(seat), + "R1-h: a `resolve_source`-derived arm answers None here and would refuse the \ + emblem loop the drive built a CR 114.2 disjunct FOR" + ); + + // R1-i: graveyard ⇒ fails closed. + assert!( + matches!( + resolve_from(this_obj(graveyard.0, Some(3)), &state), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "R1-i: no live ability instance ⇒ refuse. The SEAT is fine — R1-g resolved it one \ + assertion ago on this same board — so the refusal is caused by the ZONE" + ); + + // Sibling agreement: an OBJECT head on that same dead source refuses too, so the two + // subject arms say the same thing about a source that is gone. + assert!( + matches!( + resolve( + &ranked_template( + this_obj(graveyard.0, Some(3)), + TargetSchedule::Constant(obj_rank(this_obj(graveyard.0, Some(3)))) + ), + 0, + &state + ), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "the `Object` arm refuses a graveyard subject too (CR 608.2b) — the two arms agree" + ); + + // CR 400.7, so the command disjunct is not a blanket zone exemption: a stale + // incarnation on the SAME emblem refuses. + assert!( + matches!( + resolve_from(this_obj(emblem.0, Some(2)), &state), + Err(ReplayFailure::IllegalTarget { .. }) + ), + "CR 400.7: the command arm re-binds ONE incarnation, exactly like the battlefield \ + arm — a re-created emblem does not certify the old pin" + ); + } } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 888580c707..36bb6d4034 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -3776,17 +3776,24 @@ fn until_lethal_fallback( } /// CR 732.2a: how many whole cycles one shortcut drive must aggregate before the measured -/// delta is complete. A `RoundRobin`/`Piecewise` target schedule rotates its OBJECT sources -/// over its length, so a full period is that length; every other pin (a `Constant` target, a +/// delta is complete. A `RoundRobin`/`Piecewise` target schedule rotates its STEPS over its +/// length, so a full period is that length; every other pin (a `Constant` target, a /// `Player` pin, a non-target pin, or no template at all) settles in ONE cycle. Returns the -/// max schedule length over the template's `Targets` pins, defaulting to 1. +/// max schedule length over the template's `Targets` pins, defaulting to 1. A step's subject +/// is a `Ranking`, which lives INSIDE the step and never changes the count — this seam is +/// type-only across that parameterization. /// -/// DORMANT for every Stage-2 crownable loop (Ruling B): `TargetSchedule` rotates DecisionSource -/// objects, not players, and `live_mandatory_loop_winner` crowns on PLAYER fallers — an -/// object-rotating loop produces no player faller, so it never crowns; the only crownable >2p -/// player drain pins ALL opponents every cycle (`TargetPin::Player` is constant, period 1). The -/// seam is built for generality; a multi-cycle aggregation is fail-safe (an object loop reaching -/// the arm measures 1 cycle, finds no faller, does not crown). +/// DORMANT for every Stage-2 crownable loop (Ruling B) — and the REASON needed restating once +/// a step's subject became parameterized. It is no longer "`TargetSchedule` rotates +/// DecisionSource objects, not players": the type now admits `AnnouncementSubject::Seat`. The +/// dormancy is a PRODUCER property instead, and it is measured rather than structural — no +/// in-tree producer emits a `Seat` into a schedule, so every schedule this engine mints still +/// rotates objects, `live_mandatory_loop_winner` crowns on PLAYER fallers, and an +/// object-rotating loop produces no player faller. The only crownable >2p player drain pins +/// ALL opponents every cycle (`TargetPin::Player` is constant, period 1). The seam is built +/// for generality and a multi-cycle aggregation is fail-safe either way (a loop reaching the +/// arm measures 1 cycle, finds no faller, does not crown), so a future seat-rotating producer +/// changes what must be re-argued here, not what this function returns. /// /// CR 732.2a SAFETY LIMIT: the returned period is clamped to `MAX_SHORTCUT_CYCLES`. Both /// consumers derive their `0..period` range from this one helper (`validate_pins` and @@ -4181,39 +4188,17 @@ fn inject_pinned_answer( /// CR 608.2b + CR 114.2: does this SLOT's source identify the ability instance that raised /// the prompt carrying `source_id`? /// -/// [`crate::analysis::decision_template::resolve_source`] is deliberately BATTLEFIELD-ONLY, -/// and that filter IS the CR 608.2b legality re-check for -/// `ByIdentity` **target** pins — a pinned target that left the battlefield must stop -/// matching. It must not be widened. But a SLOT's source only identifies WHICH ability -/// instance prompts, and CR 114.2 puts a planeswalker EMBLEM — "both owned and -/// controlled by that player" — in the **command zone**, where it stays for the whole game -/// and raises its triggers from. So the command-zone disjunct lives HERE, at the caller, -/// scoped to object identity + the pinned CR 400.7 incarnation. -/// -/// Graveyard / exile / hand sources still fail ⇒ the caller aborts to manual play. +/// The zone reasoning — why a SLOT's source admits the command zone while a PIN's source is +/// battlefield-only, and why graveyard / exile / hand still fail closed — now lives on +/// [`crate::analysis::decision_template::resolve_ability_instance`], the single accessor for +/// "which live ability instance is this". This call site is the identity comparison against +/// the prompting object; a `None` there means the caller aborts to manual play. fn slot_source_prompted( state: &GameState, src: &crate::analysis::decision_template::DecisionSource, source_id: ObjectId, ) -> bool { - if crate::analysis::decision_template::resolve_source(src, state) == Some(source_id) { - return true; - } - // CR 114.2: the command-zone arm. `AllCopies` is card-identity matching and an emblem - // has no card, so only `ThisObject` participates. - let crate::types::game_state::YieldTarget::ThisObject { - source_id: pinned_id, - incarnation, - .. - } = src - else { - return false; - }; - *pinned_id == source_id - && state.objects.get(pinned_id).is_some_and(|o| { - o.zone == crate::types::zones::Zone::Command - && (incarnation.is_none() || *incarnation == Some(o.incarnation)) - }) + crate::analysis::decision_template::resolve_ability_instance(src, state) == Some(source_id) } /// PR-7 Phase 4b: CR 732.2a finite materialization of a confirmed `Fixed(N)` loop @@ -16098,25 +16083,32 @@ mod stage2_injector_tests { }, key: DecisionGroupKey::from_sources(std::slice::from_ref(&a), DecisionKind::LoopChoice), }; + // A ranking lives INSIDE a schedule step; `shortcut_drive_period` still counts STEPS, + // which is why this migration is type-only at the seam under test. + let rank = |src| { + crate::analysis::decision_template::Ranking::one( + crate::analysis::decision_template::AnnouncementSubject::Object(src), + ) + }; let constant = mk(vec![TargetPin::Player(P1)]); assert_eq!(shortcut_drive_period(Some(&constant)), 1, "Player pin ⇒ 1"); let rr = mk(vec![TargetPin::Scheduled(TargetSchedule::RoundRobin( - vec![a.clone(), b.clone(), c.clone()], + vec![rank(a.clone()), rank(b.clone()), rank(c.clone())], ))]); assert_eq!(shortcut_drive_period(Some(&rr)), 3, "RoundRobin(3) ⇒ 3"); let pw = mk(vec![TargetPin::Scheduled(TargetSchedule::Piecewise(vec![ - (0, a.clone()), - (5, b.clone()), + (0, rank(a.clone())), + (5, rank(b.clone())), ]))]); assert_eq!(shortcut_drive_period(Some(&pw)), 2, "Piecewise(2) ⇒ 2"); // CR 732.2a SAFETY LIMIT: an over-cap schedule clamps to MAX_SHORTCUT_CYCLES. // Revert-probe: restore `.max(1)` (drop the `.clamp`) ⇒ returns MAX+5 (1005) ≠ 1000. let oversized = mk(vec![TargetPin::Scheduled(TargetSchedule::RoundRobin( - vec![a.clone(); (MAX_SHORTCUT_CYCLES + 5) as usize], + vec![rank(a.clone()); (MAX_SHORTCUT_CYCLES + 5) as usize], ))]); assert_eq!( shortcut_drive_period(Some(&oversized)), @@ -16395,7 +16387,15 @@ mod stage2_injector_tests { /// end-to-end by `injector_routes_pinned_targets_per_source` above and by the /// `kilo_live_offer_from_real_dump` rows, and this row asserts that arm is unchanged. /// - /// REVERT-PROBES: (a) delete the command-zone disjunct in `slot_source_prompted` ⇒ the + /// The zone disjuncts this row pins now live one call down, in + /// [`crate::analysis::decision_template::resolve_ability_instance`], which + /// `slot_source_prompted` delegates to — so all three probes below are run THERE. That + /// delegation is why this row is also the maintained-invariant row for the factoring: it + /// stays green unmodified, and a factoring that silently widened the zone set reds here. + /// (Measured: dropping the `Zone::Command` filter in that accessor fails the graveyard + /// assertion below.) + /// + /// REVERT-PROBES: (a) delete the command-zone disjunct ⇒ the /// Command assertion FAILS (and `inject_pinned_answer` would `RecastAbort` on an /// emblem-pinned drive); (b) widen the disjunct to accept any zone ⇒ the graveyard and /// exile assertions FAIL; (c) drop the incarnation conjunct ⇒ the CR 400.7 assertion @@ -17981,11 +17981,28 @@ mod stage2_injector_tests { // SET PRESERVATION (C3): unchanged. The other four entries live in `game/effects/mod.rs` // and `game/effects/scoped_library_search.rs`, neither of which C3 touches, and a comment // round adds no line matching the needle — total still 38, partition still 5/8/25. - // ⚠ REBASE #3: `:12652 ⇒ :12651`, located by content digest, offset from + // + // ⚠ item-4 R1 (the `Ranking` parameterization): `:12590 ⇒ :12575`, `-15`, LOCAL. + // Resolved BY CONTENT FIRST per the protocol above: the sha256 above matched exactly + // ONE line under a whole-file scan, at `:12575`, and the nearest preceding `fn` is + // still `begin_pending_trigger_target_selection` (`:12441`) with none intervening. + // Arithmetic CHECK afterwards: `git diff -U0` against the parent shows exactly four + // non-zero hunks above the old coordinate — `+2` and `+5` on `shortcut_drive_period`'s + // doc (Ruling B's dormancy REASON restated: the type now admits a seat subject, so the + // dormancy is a measured producer property rather than a structural one) and `-5`/`-17` + // for `slot_source_prompted`'s factoring into + // `analysis::decision_template::resolve_ability_instance` (a doc block and its two + // inlined zone arms, replaced by one delegating call and a pointer) — summing to `-15`. + // SET PRESERVATION: all four hunks are a doc block or a delegating call; none assigns + // `state.waiting_for` and none mints a prompt, and this round's remaining `engine.rs` + // hunks are inside `#[cfg(test)]` below this producer. The total (38) and the + // partition (5/8/25) both fired GREEN on the run that caught this; only this third + // assert panicked. + // ⚠ REBASE #3: `:12637 ⇒ :12636`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - // ⚠ REBASE #3: `:12651 ⇒ :12656`, located by content digest, offset from + // ⚠ REBASE #3: `:12636 ⇒ :12641`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - "game/engine.rs:12656".to_string(), + "game/engine.rs:12641".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 34ad5138d9..5c1642b99d 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -120,9 +120,10 @@ pub(crate) fn capture_library_search_card_view( /// mints — it is wired so a fourth writer cannot open the leak silently. /// /// `GameState::decision_templates` is the fourth carrier and deliberately does NOT route here: it -/// is redacted by owner-retain (`filtered.decision_templates.retain(|t| t.owner == viewer)`), so a -/// template the viewer does not own is REMOVED entirely and there is nothing left for this -/// predicate to answer about it. +/// is redacted wholesale by the private-access retain +/// (`filtered.decision_templates.retain(|t| can_view_private_for_player(t.owner))` — CR 723.4, the +/// SAME predicate carriers 1 and 2 apply), so a template this viewer may not privately view is +/// REMOVED entirely and there is nothing left for this predicate to answer about it. /// /// A `TargetPin::Player` needs no redaction, and that is an ENGINE property rather than a CR one — /// no rule makes seat identity public. This projection hides card identities and hidden-zone @@ -139,7 +140,7 @@ fn pins_name_hidden_source( target_hidden: &dyn Fn(ObjectId) -> bool, ) -> bool { use crate::analysis::decision_template::{ - DecisionSource, PinnedDecision, TargetPin, TargetSchedule, + AnnouncementSubject, DecisionSource, PinnedDecision, Ranking, TargetPin, TargetSchedule, }; let source_hidden = |source: &DecisionSource| match source { crate::types::game_state::YieldTarget::ThisObject { source_id, .. } => { @@ -148,14 +149,28 @@ fn pins_name_hidden_source( // A card identity, not a live object: it names no zone occupant to hide. crate::types::game_state::YieldTarget::AllCopies { .. } => false, }; + // A `Scheduled` step carries a whole `Ranking`, so the walk descends one level further + // than the pin: EVERY subject in every step is inspected, not just the head the current + // episode would resolve. The tail is a pre-declaration the responder receives now (it is + // part of the proposal they accept or shorten under CR 732.2b), so a hidden identity in + // the tail is a leak on exactly the same footing as one in the head. + // + // Wildcard-free over `AnnouncementSubject`: a future subject kind gets a compile-time + // visit here. The `Seat` arm is `false` for the reason already given above for + // `TargetPin::Player` — seat identity is public in this engine — and is not restated. + let subject_hidden = |subject: &AnnouncementSubject| match subject { + AnnouncementSubject::Object(source) => source_hidden(source), + AnnouncementSubject::Seat(_) => false, + }; + let ranking_hidden = |ranking: &Ranking| ranking.iter().any(&subject_hidden); let pin_hidden = |pin: &TargetPin| match pin { TargetPin::ByIdentity(source) => source_hidden(source), TargetPin::Player(_) => false, TargetPin::Scheduled(schedule) => match schedule { - TargetSchedule::Constant(source) => source_hidden(source), - TargetSchedule::RoundRobin(sources) => sources.iter().any(&source_hidden), + TargetSchedule::Constant(ranking) => ranking_hidden(ranking), + TargetSchedule::RoundRobin(rankings) => rankings.iter().any(&ranking_hidden), TargetSchedule::Piecewise(steps) => { - steps.iter().any(|(_, source)| source_hidden(source)) + steps.iter().any(|(_, ranking)| ranking_hidden(ranking)) } }, }; @@ -1571,7 +1586,17 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState filtered .may_trigger_auto_choices .retain(|record| record.key.player == viewer); - filtered.decision_templates.retain(|t| t.owner == viewer); + // CR 723.4: "If information about an object in the game would be visible to the player + // being controlled, it's visible to both that player and the player controlling them." + // The pin vector's other carriers already answer "may this viewer see it" with this same + // predicate (the `LoopShortcut` and `RespondToShortcut` blocks above), so carrier 4 uses + // it too rather than a strict owner-equality that would deny a controlling player a + // template they are entitled to. Absent turn control (and absent a latched + // search-decision authority) this is exactly owner-equality — see + // `turn_control::authorized_submitter_for_player` for the full arm set. + filtered + .decision_templates + .retain(|t| can_view_private_for_player(t.owner)); filtered.priority_yields.retain(|y| y.player == viewer); filtered .lands_tapped_for_mana @@ -2934,7 +2959,13 @@ mod tests { } /// CR 603.3b: saved trigger-ordering templates are per-player private preference - /// state — a viewer sees only their own, never an opponent's saved orderings. + /// state — a viewer sees only the ones they may privately view. + /// + /// The REASON is CR 723.4 private access, not opponent-ness: a player controlling another + /// player is normally an opponent and DOES see the controlled seat's templates (row R1-j + /// asserts that direction). This board has no turn control and no latched search-decision + /// authority, so `can_view_private_for_player` is exactly owner-equality here, which is + /// why this row is unmodified by the unification. #[test] fn filters_other_players_decision_templates() { use crate::analysis::decision_template::{ @@ -2972,6 +3003,97 @@ mod tests { assert_eq!(filtered.decision_templates[0].owner, PlayerId(0)); } + /// **Row R1-j — carrier 4 answers "may this viewer see it" with the SAME predicate as + /// carriers 1 and 2.** CR 723.4: "If information about an object in the game would be + /// visible to the player being controlled, it's visible to both that player and the + /// player controlling them." A controlling player is normally an opponent, so strict + /// owner-equality denied them a template they are entitled to — while the + /// `LoopShortcut` / `RespondToShortcut` carriers directly above already used + /// `can_view_private_for_player`. One question, one predicate. + /// + /// # Non-vacuity / discrimination + /// + /// BOTH directions ride ONE instrument on ONE board: turn control in effect ⇒ RETAINED; + /// the identical board with the control record removed ⇒ DROPPED. Without the second + /// half the change would read as "everyone now sees everything". The shipped row + /// `filters_other_players_decision_templates` above is the third guard: a plain + /// non-owner with no control still loses the template, and that row is unmodified. + /// + /// REVERT-PROBE: restore `retain(|t| t.owner == viewer)` ⇒ the controller loses the + /// controlled seat's template ⇒ the RETAINED assertion FAILS, while the DROPPED + /// assertion and the shipped row stay green. + #[test] + fn r1j_a_controlling_player_sees_the_controlled_seats_decision_template() { + use crate::analysis::decision_template::{ + DecisionGroupKey, DecisionKind, DecisionTemplate, PinnedDecision, ReplayMode, + }; + use crate::types::game_state::YieldTarget; + + let controller = PlayerId(0); + let controlled = PlayerId(1); + let src = YieldTarget::AllCopies { + card_id: CardId(100), + trigger_description: None, + }; + let template = DecisionTemplate { + owner: controlled, + decisions: vec![PinnedDecision::Order { + source: src.clone(), + pos: 0, + }], + replay: ReplayMode::Static, + key: DecisionGroupKey::from_sources(&[src], DecisionKind::TriggerOrdering), + }; + + let mut state = GameState::new_two_player(42); + state.set_trigger_order_template(template); + assert_eq!( + state.decision_templates.len(), + 1, + "reach-guard: the unprojected state really carries the template" + ); + + // ── DROPPED: no turn control, viewer != owner (the pre-unification behaviour, which + // the unification preserves) ── + assert!( + filter_state_for_viewer(&state, controller) + .decision_templates + .is_empty(), + "with no control in effect a non-owner still loses it — the predicate did not \ + become a pass-through" + ); + + // ── RETAINED: the SAME board with `controller` taking `controlled`'s turn. The + // control is scoped to the ACTIVE player's decisions — see + // `turn_control::effective_authority_for_player`, which is the authority the + // reach-guard below reads rather than restating. ── + let mut controlled_state = state.clone(); + controlled_state.active_player = controlled; + controlled_state.turn_decision_controller = Some(controller); + assert_eq!( + turn_control::authorized_submitter_for_player(&controlled_state, controlled), + controller, + "reach-guard: the control record really is in effect, so the retain below is \ + keyed to CR 723.4 and not to the fixture" + ); + let projected = filter_state_for_viewer(&controlled_state, controller); + assert_eq!( + projected.decision_templates.len(), + 1, + "CR 723.4: the controlling player sees the controlled seat's template" + ); + assert_eq!(projected.decision_templates[0].owner, controlled); + + // And the controlled player still sees their own — the widening is additive. + assert_eq!( + filter_state_for_viewer(&controlled_state, controlled) + .decision_templates + .len(), + 1, + "the owner never lost their own copy" + ); + } + /// CR 117.3d: priority yields are private preference state — a viewer sees /// only their own, never an opponent's. #[test] @@ -6246,4 +6368,127 @@ mod tests { "and it is genuinely present, not two matching `None`s" ); } + + /// **Row R1-k — the WITHIN-RANKING axis: a public subject ahead of a hidden one inside + /// ONE `Scheduled` pin still drops the WHOLE declaration.** + /// + /// This is `d5h2`'s shape one level further down. `d5h2` is a public *pin* ahead of a + /// hidden one; this is a public *subject* ahead of a hidden one inside a single pin — + /// which the `Ranking` parameterization newly makes possible. The redaction walk must + /// descend into every subject of every step, not stop at the head the current episode + /// would resolve: the tail is a pre-declaration the responder receives NOW, as part of + /// the proposal CR 732.2b lets them accept or shorten, so a hidden identity there leaks + /// on exactly the same footing as one in the head. + /// + /// # Coverage this row creates rather than repeats + /// + /// `TargetPin::Scheduled` has exactly ONE occurrence in this file — the production arm + /// inside `pins_name_hidden_source` — and zero in its tests, so before this row NOTHING + /// in the tree failed for either mutation below. + /// + /// # Non-vacuity / discrimination + /// + /// The PUBLIC subject is FIRST, so a walk that reads only `head()` keeps the declaration + /// and fails the negative. Paired positives: the proposer's own projection keeps it in + /// the hidden arm, and an ALL-PUBLIC two-subject ranking on the same board reaches the + /// non-proposer unchanged — so a redactor that dropped every ranked declaration fails + /// here. The head's publicness is asserted structurally on the proposer's copy, so a + /// fixture that silently built a hidden head cannot satisfy the negative for the wrong + /// reason. + /// + /// REVERT-PROBES: (a) walk only `ranking.head()` instead of `iter()` ⇒ the hidden TAIL is + /// never seen ⇒ the declaration survives for the non-proposer ⇒ FAILS; (b) write + /// `AnnouncementSubject::Object(_) => false` (mirroring the `Seat => false` line directly + /// above it) ⇒ FAILS. Both leave every other row in this module green. + /// + /// This row mints through `d5h_offer_decisions` and reads through + /// `d5h_projected_declaration`, so it adds NO new `WaitingFor::LoopShortcut {` literal — + /// `tests/integration/loop_shortcut_offer_writer_census.rs` pins this file's production + /// multiset at 2 and would red on a third. + #[test] + fn r1k_a_public_subject_ahead_of_a_hidden_one_in_a_ranking_still_drops_the_declaration() { + use crate::analysis::decision_template::{ + AnnouncementSubject, PinnedDecision, Ranking, TargetPin, TargetSchedule, + }; + use crate::types::game_state::YieldTarget; + + // A card identity occupies no zone, so `source_hidden` answers `false` for it by an + // explicit production arm — a head that is public BY RULE, not by absence. + let public_subject = AnnouncementSubject::Object(YieldTarget::AllCopies { + card_id: CardId(4242), + trigger_description: None, + }); + let ranked_pin = + |ranking: Ranking, slot: &crate::analysis::decision_template::DecisionSlot| { + vec![PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::Scheduled(TargetSchedule::Constant(ranking))], + }] + }; + + // ── the hostile arm: subject 1 is public, subject 2 names the hidden hand card ── + let hidden_state = d5h_offer_decisions(|hidden, slot| { + let ranking = Ranking::new(vec![ + public_subject.clone(), + AnnouncementSubject::Object(YieldTarget::ThisObject { + source_id: hidden, + incarnation: Some(1), + trigger_description: None, + }), + ]) + .expect("two distinct subjects"); + ranked_pin(ranking, slot) + }); + let proposer_copy = d5h_projected_declaration(&hidden_state, D5H_PROPOSER) + .expect("reach-guard + positive: the PROPOSER's own projection keeps the declaration"); + match &proposer_copy.decisions[0] { + PinnedDecision::Targets { targets, .. } => { + match &targets[0] { + TargetPin::Scheduled(TargetSchedule::Constant(ranking)) => { + assert_eq!( + ranking.iter().count(), + 2, + "reach-guard: the ranking really carries TWO subjects — on a \ + one-subject ranking `head()` and `iter()` are the same function, \ + which is why this row exists" + ); + assert_eq!( + ranking.head(), + &public_subject, + "reach-guard: the HEAD carries no hidden identity, so a walk that \ + stops at the head must look further to answer correctly" + ); + } + other => panic!("the fixture pins a Constant ranking, got {other:?}"), + }; + } + other => panic!("the fixture pins one Targets decision, got {other:?}"), + } + assert!( + d5h_projected_declaration(&hidden_state, D5H_VIEWER).is_none(), + "CR 732.2b: ONE subject naming an object this viewer may not see drops the ENTIRE \ + declaration, however many public subjects precede it in the ranking" + ); + + // ── the paired positive: the SAME two-subject shape with no hidden identity ── + let public_state = d5h_offer_decisions(|_hidden, slot| { + let ranking = Ranking::new(vec![ + public_subject.clone(), + AnnouncementSubject::Seat(D5H_VIEWER), + ]) + .expect("two distinct subjects"); + ranked_pin(ranking, slot) + }); + assert_eq!( + d5h_projected_declaration(&public_state, D5H_VIEWER), + d5h_projected_declaration(&public_state, D5H_PROPOSER), + "a two-subject ranking with no hidden identity reaches the opponent UNCHANGED — \ + without this arm a redactor that dropped every ranked declaration would pass the \ + negative above" + ); + assert!( + d5h_projected_declaration(&public_state, D5H_VIEWER).is_some(), + "and it is genuinely present, not two matching `None`s" + ); + } } diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 48bd452e11..ee3f880d4b 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -15,9 +15,9 @@ //! `match` perturbed even one event. Because the golden is pre-edit, this is not circular. use engine::analysis::decision_template::{ - DecisionGroupKey, DecisionKind, DecisionPoint, DecisionPointKind, DecisionSlot, - DecisionTemplate, IterationCount, PinnedDecision, ReplayMode, ShortcutDecisionSchema, - TargetPin, TargetSchedule, + AnnouncementSubject, DecisionGroupKey, DecisionKind, DecisionPoint, DecisionPointKind, + DecisionSlot, DecisionTemplate, IterationCount, PinnedDecision, Ranking, ReplayMode, + ShortcutDecisionSchema, TargetPin, TargetSchedule, }; use engine::analysis::loop_check::{LoopCertificate, ShortcutProposal, ShortcutResponse, WinKind}; use engine::analysis::resource::{loop_states_equal_modulo_resources, BoardDelta, ResourceAxis}; @@ -40,6 +40,14 @@ const P0: PlayerId = PlayerId(0); const P1: PlayerId = PlayerId(1); const P2: PlayerId = PlayerId(2); +/// The one-element ranking a schedule step carries when it names a single object: a +/// `Ranking::one(Object(src))` IS the pre-parameterization constant subject, so every site +/// below is a re-spelling with no behaviour delta (CR 732.2a — only the head is ever +/// resolved within one drive). +fn obj_rank(src: YieldTarget) -> Ranking { + Ranking::one(AnnouncementSubject::Object(src)) +} + const DRAIN_CLERIC: &str = "Whenever you gain life, each opponent loses 1 life."; const BLOOD_SIPPER: &str = "Whenever an opponent loses life, you gain 1 life."; const KICKOFF: &str = "You gain 1 life."; @@ -2058,9 +2066,9 @@ fn piecewise_cleric_template( source: valid.clone(), index: 0, }; - let mut schedule = vec![(0u32, valid.clone())]; + let mut schedule = vec![(0u32, obj_rank(valid.clone()))]; if let Some(at) = switch_to_bogus_at { - schedule.push((at, bogus)); + schedule.push((at, obj_rank(bogus))); } DecisionTemplate { owner, @@ -11605,7 +11613,9 @@ fn g1_declare_verdict( if pin_twice { // E-neg: two pins against a `min_targets == max_targets == 1` point. The cardinality // check sits OUTSIDE the per-index loop, so it must still refuse at count 0. - targets.push(TargetPin::Scheduled(TargetSchedule::Constant(a.clone()))); + targets.push(TargetPin::Scheduled(TargetSchedule::Constant(obj_rank( + a.clone(), + )))); } let template = DecisionTemplate { owner: P0, @@ -11633,15 +11643,15 @@ fn g1_declare_verdict( } fn piecewise_a_then_b(a: &YieldTarget, b: &YieldTarget) -> TargetSchedule { - TargetSchedule::Piecewise(vec![(0, a.clone()), (5, b.clone())]) + TargetSchedule::Piecewise(vec![(0, obj_rank(a.clone())), (5, obj_rank(b.clone()))]) } fn piecewise_b_then_a(a: &YieldTarget, b: &YieldTarget) -> TargetSchedule { - TargetSchedule::Piecewise(vec![(0, b.clone()), (5, a.clone())]) + TargetSchedule::Piecewise(vec![(0, obj_rank(b.clone())), (5, obj_rank(a.clone()))]) } fn round_robin_a_b(a: &YieldTarget, b: &YieldTarget) -> TargetSchedule { - TargetSchedule::RoundRobin(vec![a.clone(), b.clone()]) + TargetSchedule::RoundRobin(vec![obj_rank(a.clone()), obj_rank(b.clone())]) } /// R6 arms A / B / C — the validated range must COVER the driven range. diff --git a/crates/server-core/src/game_action_payload_guard.rs b/crates/server-core/src/game_action_payload_guard.rs index 6259c1337e..f3e4e72aed 100644 --- a/crates/server-core/src/game_action_payload_guard.rs +++ b/crates/server-core/src/game_action_payload_guard.rs @@ -477,8 +477,10 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { // ("`count` is a small enum — nothing unbounded") was FALSE: `IterationCount::Fixed` // wraps an unbounded `u32` and IS the real DoS vector — bounded here as a coarse // WS-level belt (mirrors `ChooseManaColor.count`; the engine's MAX_SHORTCUT_CYCLES is - // the authoritative cap). The nested template vecs (a `Targets` pin's `Vec` - // and each `Scheduled` pin's schedule `Vec`) are bounded as DEFENSE-IN-DEPTH: the + // the authoritative cap). The nested template vecs (a `Targets` pin's `Vec`, + // each `Scheduled` pin's schedule `Vec`, and each schedule step's `Ranking` — three + // levels, not two, since a step's subject became a list) are bounded as + // DEFENSE-IN-DEPTH: the // 8 KB inbound WS frame cap (phase-server/src/main.rs:409/1420) already keeps a remote // nested payload to a few hundred structs, and this guard runs POST-deserialize // (client_message_wire_guard.rs:50), so it bounds downstream compute/clone work — not @@ -486,7 +488,7 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { // Exhaustive matches (no wildcard) force a future variant to be classified here. GameAction::DeclareShortcut { count, template } => { use engine::analysis::decision_template::{ - IterationCount, PinnedDecision, TargetPin, TargetSchedule, + IterationCount, PinnedDecision, Ranking, TargetPin, TargetSchedule, }; // Exhaustive (no wildcard): a future `IterationCount` count variant build-breaks // here so its wire bound is a conscious decision, not a silent gap. @@ -501,16 +503,36 @@ pub fn guard_game_action_payload(action: &GameAction) -> Result<(), String> { PinnedDecision::Targets { targets, .. } => { bound_list("DeclareShortcut.template.targets", targets.len())?; for target in targets { + // CR 732.2a: each schedule STEP now carries a `Ranking` — its + // own `Vec` — so the outer schedule bound + // no longer covers the whole payload. Every arm bounds its + // rankings, INCLUDING `Constant`: it was a no-op only while it + // carried no vector, and leaving it out would make the one arm + // a hostile client can send unbounded. `Ranking::iter` is the + // newtype's length surface (the field is private). + let bound_ranking = |ranking: &Ranking| { + bound_list( + "DeclareShortcut.template.ranking", + ranking.iter().count(), + ) + }; match target { + TargetPin::Scheduled(TargetSchedule::Constant(r)) => { + bound_ranking(r)?; + } TargetPin::Scheduled(TargetSchedule::RoundRobin(v)) => { bound_list("DeclareShortcut.template.schedule", v.len())?; + for ranking in v { + bound_ranking(ranking)?; + } } TargetPin::Scheduled(TargetSchedule::Piecewise(v)) => { bound_list("DeclareShortcut.template.schedule", v.len())?; + for (_, ranking) in v { + bound_ranking(ranking)?; + } } - TargetPin::Scheduled(TargetSchedule::Constant(_)) - | TargetPin::ByIdentity(_) - | TargetPin::Player(_) => {} + TargetPin::ByIdentity(_) | TargetPin::Player(_) => {} } } } diff --git a/crates/server-core/tests/game_action_payload_guard.rs b/crates/server-core/tests/game_action_payload_guard.rs index e6375bb114..d007511207 100644 --- a/crates/server-core/tests/game_action_payload_guard.rs +++ b/crates/server-core/tests/game_action_payload_guard.rs @@ -2,8 +2,9 @@ //! `server_core::game_action_payload_guard`). use engine::analysis::decision_template::{ - DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, IterationCount, - MayChoiceOption, PinnedDecision, ReplayMode, TargetPin, TargetSchedule, + AnnouncementSubject, DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, + IterationCount, MayChoiceOption, PinnedDecision, Ranking, ReplayMode, TargetPin, + TargetSchedule, }; use engine::types::ability::{ Comparator, TriggerBaseSetInstanceRef, TriggerDefinitionOccurrenceRef, @@ -440,9 +441,9 @@ fn rejects_over_cap_shortcut_schedule() { decisions: vec![PinnedDecision::Targets { slot, targets: vec![TargetPin::Scheduled(TargetSchedule::RoundRobin(vec![ - src; - MAX_ACTION_LIST_LEN + 1 - ]))], + Ranking::one(AnnouncementSubject::Object(src)); + MAX_ACTION_LIST_LEN + 1 + ]))], }], replay: ReplayMode::Static, key: DecisionGroupKey { @@ -456,3 +457,117 @@ fn rejects_over_cap_shortcut_schedule() { "an over-cap loop-shortcut schedule vec must be rejected (nested memory bound)" ); } + +/// **Row R1-e — the arm the guard used to skip.** Each schedule step now carries a `Ranking`, +/// its own `Vec`, so the outer schedule bound no longer covers the whole +/// payload — and `Constant` newly carries a vector where it previously carried none, which +/// made it the ONE `Scheduled` arm a hostile client could send unbounded. +/// +/// # Non-vacuity / discrimination +/// +/// Every other list in each fixture is deliberately in-bounds (one decision, one target, and +/// for the nested arm a schedule of exactly `MAX_ACTION_LIST_LEN` steps), so ONLY the ranking +/// bound can reject. The paired positive is an in-bounds `Constant` ranking that must be +/// ACCEPTED, without which a guard that refused every `DeclareShortcut` would pass. +/// +/// REVERT-PROBES: (a) drop the `Constant` arm's `bound_ranking` ⇒ the first assertion FAILS; +/// (b) drop the per-step `bound_ranking` loop in the `RoundRobin` arm ⇒ the nested assertion +/// FAILS while `rejects_over_cap_shortcut_schedule` above stays green, because that row's +/// rankings are all one element. +#[test] +fn rejects_over_cap_shortcut_ranking_on_every_scheduled_arm() { + let src = YieldTarget::AllCopies { + card_id: CardId(1), + trigger_description: None, + }; + let slot = DecisionSlot { + source: src.clone(), + index: 0, + }; + // `Ranking::new` refuses duplicates, so the entries must be distinct. `PlayerId` is a + // `u8` and the cap is 10_000, so seats cannot supply enough of them — card identities can. + let ranking_of = |len: usize| -> Ranking { + let subjects: Vec = (0..len as u64) + .map(|i| { + AnnouncementSubject::Object(YieldTarget::AllCopies { + card_id: CardId(i), + trigger_description: None, + }) + }) + .collect(); + Ranking::new(subjects).expect("distinct subjects are a legal ranking") + }; + let declare = |targets: Vec| GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: Some(DecisionTemplate { + owner: PlayerId(0), + decisions: vec![PinnedDecision::Targets { + slot: slot.clone(), + targets, + }], + replay: ReplayMode::Static, + key: DecisionGroupKey { + sources: vec![], + kind: DecisionKind::LoopChoice, + }, + }), + }; + + // ── the `Constant` arm: the payload the pre-parameterization guard could not see ── + assert!( + guard_game_action_payload(&declare(vec![TargetPin::Scheduled( + TargetSchedule::Constant(ranking_of(MAX_ACTION_LIST_LEN + 1)) + )])) + .is_err(), + "an over-cap `Constant` ranking must be rejected — this arm carries a Vec now" + ); + + // ── the NESTED arm on the OTHER two variants: the outer schedule is in bounds, so only + // the per-step ranking bound can refuse. The over-cap step is LAST, so a guard that + // inspected only the first step would accept. + // + // The row's shape is "an in-bounds schedule of over-cap rankings"; it is not + // materialized at MAX × (MAX+1) because that literal reading is 10^8 subjects (~5 GB) + // and would measure the allocator, not the guard. Three steps discriminate identically. + for (label, sched) in [ + ( + "RoundRobin", + TargetSchedule::RoundRobin(vec![ + ranking_of(1), + ranking_of(2), + ranking_of(MAX_ACTION_LIST_LEN + 1), + ]), + ), + ( + "Piecewise", + TargetSchedule::Piecewise(vec![ + (0, ranking_of(1)), + (5, ranking_of(MAX_ACTION_LIST_LEN + 1)), + ]), + ), + ] { + assert!( + guard_game_action_payload(&declare(vec![TargetPin::Scheduled(sched)])).is_err(), + "{label}: an over-cap ranking nested inside an in-bounds schedule must be \ + rejected — the outer `bound_list` passes, so only the per-step bound can refuse" + ); + } + + // ── the paired positives: in-bounds payloads on every arm ARE accepted ── + assert!( + guard_game_action_payload(&declare(vec![TargetPin::Scheduled( + TargetSchedule::Constant(ranking_of(MAX_ACTION_LIST_LEN)) + )])) + .is_ok(), + "a ranking at exactly the cap is honest traffic and must pass — without this the row \ + would be satisfied by a guard that rejected every declaration" + ); + assert!( + guard_game_action_payload(&declare(vec![TargetPin::Scheduled( + TargetSchedule::RoundRobin(vec![ranking_of(3), ranking_of(4)]) + )])) + .is_ok(), + "and an in-bounds nested schedule passes, so the nested refusals above are the LENGTH \ + and not the nesting" + ); +} From d0a708403ad200028edf8caa8514597c35a903bf Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 12 Aug 2026 04:21:34 -0500 Subject: [PATCH 18/44] feat(engine): give a TARGET-class seat pin its own spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both producers of a loop-shortcut seat announcement now emit `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(pl))))` instead of `TargetPin::Player`: the engine ingress in `record_trigger_target_answer` and the human ingress in `materialize_loop_shortcut_response`. One shape per point kind, whoever submitted it. `TargetPin::Player` keeps its existence-only CHOICE-class authority (CR 115.10a) and its doc now says so. The two classes have two spellings, and the spelling *is* the provenance — a ranked seat is judged as a TARGET (CR 702.11c, via `player_is_legal_target`), a `TargetPin::Player` only as present. CR 601.2c is what makes this the right split: the player announces a choice for each target, so the seat is a selected authority carried in the pin, not something the engine re-derives. CR 115.7a is why the head is never advanced when it turns illegal mid-drive — the original target is unchanged, and the drive aborts to manual play instead of sliding to a tail entry. DOC-SWEEP (unnamed-obligation sweep, per changed property): property / needle edit true n/a sum producer spelling / TargetPin::Player 13 10 30 53 decoder range reason / validated_range 3 5 8 16 All 8 of the known production comment sites were re-found at their drifted coordinates rather than assumed, which is the row's acceptance condition. One doc reached only by the second needle confirms the sweep's unit is the property list, not a single needle. Notes for whoever rebases this: - `engine.rs`'s CR 603.5 prompt census carries a line pin, re-pinned here to :12606 by content hash first (unique file-wide, still inside `begin_pending_trigger_target_selection`), arithmetic as the check. Diagnose drift the same way; never edit the pin to match a tree. - `loop_shortcut_seat_pin_census.rs` pins exact file:line for five CHOICE-class sites. That is deliberate — a new TARGET-class mint must be a counted event — but it makes any insertion above them a drift source. Deliberately unchanged: `pinned_targets_for_source` and `pinned_mana_color_for_source` still resolve slot sources with the battlefield-only `resolve_source` while the `Seat` arm accepts the CR 114.2 command zone. That divergence is pre-existing and is its own ruled slice with its own CR analysis. Assisted-by: ClaudeCode:claude-opus-5 --- .../engine/src/analysis/decision_template.rs | 99 ++++-- crates/engine/src/analysis/resource.rs | 12 +- crates/engine/src/game/engine.rs | 108 +++++-- crates/engine/src/game/interaction.rs | 29 +- crates/engine/src/game/visibility.rs | 25 +- crates/engine/src/types/game_state.rs | 122 +++++++- .../fantastic_four_bounded_loop.rs | 249 +++++++++++++-- .../tests/integration/interaction_contract.rs | 179 +++++++++++ .../integration/loop_shortcut_ranking.rs | 216 +++++++++++++ .../loop_shortcut_seat_pin_census.rs | 294 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 2 + 11 files changed, 1243 insertions(+), 92 deletions(-) create mode 100644 crates/engine/tests/integration/loop_shortcut_ranking.rs create mode 100644 crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index 7da8ddd102..b862d3e1f3 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -521,10 +521,31 @@ impl TryFrom> for Ranking { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum TargetPin { ByIdentity(DecisionSource), - /// A CONSTANT target (CR 732.2a): this pin answers EVERY firing of its source within - /// the period with the declared player. A seat is state-independent by construction — - /// it can never denote "the newest copy" — so no iteration can turn the pin into the - /// conditional action CR 732.2a forbids. + /// A CONSTANT **CHOICE-class** seat (CR 115.10a): this pin answers EVERY firing of its + /// source within the period with the declared player. A seat is state-independent by + /// construction — it can never denote "the newest copy" — so no iteration can turn the + /// pin into the conditional action CR 732.2a forbids. + /// + /// # THE TWO CLASSES NOW HAVE TWO SPELLINGS, AND THIS ONE IS THE CHOICE CLASS + /// + /// CR 115.10a: "unless that object or player is identified by the word 'target' … it's + /// not a target". A seat this pin names was CHOSEN, not targeted, so [`resolve_target`] + /// judges it by `game::players::player_exists_for_choice` — EXISTENCE ONLY. Applying the + /// targeting-only exclusions (CR 702.11c hexproof / CR 702.18a shroud / CR 702.16b + /// protection) here would refuse legal CR 732.2a proposals; that over-veto is what + /// `game::engine`'s `a_shrouded_player_pin_is_still_published_by_the_offer_builder` and + /// `a_shrouded_seat_is_untargetable_yet_still_choosable_at_the_pin_recheck` (this + /// module) exist to keep out. Its live in-process producer is the CR 701.34a proliferate + /// arm (`game::engine::apply_action` → `record_loop_pin`). + /// + /// A CR 601.2c **TARGET**-class seat is a different question and takes the other + /// spelling: [`AnnouncementSubject::Seat`] inside a [`Ranking`] inside + /// [`TargetPin::Scheduled`], judged by `game::targeting::player_is_legal_target`. Both + /// TARGET-class producers emit that spelling — + /// `game::engine::record_trigger_target_answer` (the engine's own CR 601.2c + /// announcement journal) and `game::interaction::materialize_loop_shortcut_response` + /// (the human ingress of the same point kind). The spelling IS the provenance, so the + /// authority is selected by what the answer IS, never by who submitted it. Player(PlayerId), Scheduled(TargetSchedule), } @@ -796,36 +817,58 @@ fn resolve_target( // `static_abilities::player_cannot_be_targeted_by`. It is NOT enforced here, and // does not need to be. // - // OPEN RESIDUAL — the object-growth route. Stated in full here, because no shipped - // file states it elsewhere. INVARIANT: a `TargetPin::Player` must never reach + // THE RESIDUAL'S DEFERRED FIX SHAPE HAS LANDED FOR THE IN-PROCESS SURFACE: the two + // classes now have TWO SPELLINGS, so they are distinguishable AT THIS SEAM by the + // variant alone. `TargetPin::Player` is the CHOICE class (this arm, existence only); + // a CR 601.2c TARGET-class seat is `AnnouncementSubject::Seat` inside a `Ranking` + // inside `TargetPin::Scheduled`, resolved by the arm below through + // `targeting::player_is_legal_target`. THE PREVIOUS TEXT WAS SCOPED TOO NARROWLY and + // is corrected rather than deleted: it named only `record_loop_pin` (three sites, + // one of which — the CR 701.34a proliferate-target arm — is a genuine CHOICE) and + // was SILENT about the `record_loop_answer` route, along which + // `game::engine::record_trigger_target_answer` did produce a TARGET-class + // `TargetPin::Player` from a `WaitingFor::TriggerTargetSelection` announcement. That + // producer, and the human ingress of the same point kind + // (`game::interaction::materialize_loop_shortcut_response`), now emit the ranked + // spelling. So no IN-PROCESS producer can reach this arm with a target any more, and + // "not live today" is now an enforced property rather than a census result — pinned + // by `tests/integration/loop_shortcut_seat_pin_census.rs`. + // + // OPEN RESIDUAL — the object-growth route, which is why this arm is still not a + // sufficient authority on its own. INVARIANT: a `TargetPin::Player` must never reach // materialization validated only against a legal set derived from the declared pins // themselves. `try_offer_object_growth_shortcut` builds its points through // `pinned_decisions_to_points`, whose legal sets come FROM the pins, so on that // route the offer would ratify its own pin — and CR 732.2a admits only a sequence // "that may be legally taken based on the current game state", which a self-derived - // set cannot establish. NOT live today FROM ANY IN-PROCESS PRODUCER — and the scope - // word is load-bearing: the one `record_loop_pin` arm that can push a - // `TargetPin::Player` is the CR 701.34a proliferate-target arm, and a proliferate - // choice is not a target, so THIS call is its correct authority. + // set cannot establish. That hazard is class-independent: it is about WHERE the + // legal set came from, not about which spelling the pin uses, so the split narrows + // this residual's producer surface without closing it. // - // PINS ALSO ARRIVE WIRE-SOURCED, and no in-process invariant covers that. - // `LoopActionContext` is `#[serde(from = "LoopActionContextRepr")]`, and that shim's - // `From` impl installs the deserialized vector verbatim (`pins: r.pins`), so a - // restored save can carry a Player pin of UNKNOWN class. - // `GameState::migrate_transient_loop_sequence` keeps a loaded sequence ONLY for a - // save captured in a `LoopShortcut` / `RespondToShortcut` window, and on that route - // the pins are replayed by the accept→materialize drive through + // WHAT REMAINS OPEN, PRECISELY — PINS ARRIVE WIRE-SOURCED, and no in-process + // invariant covers that. `LoopActionContext` is + // `#[serde(from = "LoopActionContextRepr")]`, and that shim's `From` impl installs + // the deserialized vector verbatim (`pins: r.pins`), so a restored save can still + // carry a `TargetPin::Player` a foreign writer MEANT as a target. The wire carries + // the spelling, not the writer's intent, so the split cannot adjudicate that case — + // it can only make the honest spelling available and make the in-process producers + // use it. `GameState::migrate_transient_loop_sequence` keeps a loaded sequence ONLY + // for a save captured in a `LoopShortcut` / `RespondToShortcut` window, and on that + // route the pins are replayed by the accept→materialize drive through // `build_recast_template` → `decision_template::resolve`, i.e. through THIS call — // so a wire pin's EXISTENCE half is authority-enforced here too. Same class as the // wire-sourced `max_iterations` defect `reject_zero_bound_shortcut_offer` closes: a // load-seam value the in-process producer census cannot see. // - // The residual opens the moment any producer — IN-PROCESS OR WIRE — puts a - // TARGET-class Player pin into `LoopActionContext.pins`. DAMAGE MODE then: - // `CycleOutcome::Abort` rolls back only the crossing cycle, so cycles `0..k` stay - // committed under a pin no authority ever validated. Deferred fix shape: - // provenance-type the pin so the two classes are distinguishable at this seam — a - // change to a serialized type, hence not this phase. + // DAMAGE MODE if a wire producer does that: `CycleOutcome::Abort` rolls back only + // the crossing cycle, so cycles `0..k` stay committed under a pin no authority ever + // validated. Note what is NOT the damage mode, because the two are easy to conflate: + // a correctly-spelled ranked seat that becomes an illegal target mid-drive is + // handled BY CONSTRUCTION and is not a residual at all — `evaluate_schedule` + // resolves `head()` only and never slides to a later entry, so the drive aborts at + // the boundary (CR 115.7a: "if a target can't be changed to another legal target, + // the original target is unchanged, even if the original target is itself illegal by + // then"; CR 732.2a bars the conditional action sliding would be). TargetPin::Player(p) => crate::game::players::player_exists_for_choice(state, *p) .then_some(ConcreteTarget::Player(*p)) .ok_or_else(illegal), @@ -1239,9 +1282,13 @@ pub fn validate_pins( /// * the declare firewall passes `game::engine::shortcut_validated_range(&count, template)` — /// the range the ACCEPTED COUNT will drive; /// * the interaction decoder passes `1`, correct by construction there because it emits only -/// `TargetPin::Player` and `TargetPin::ByIdentity` pins, and `resolve_target` resolves both -/// WITHOUT reading `iteration` (only `TargetPin::Scheduled` consults it). Its verdict is -/// therefore identical at any range ≥ 1. +/// ITERATION-INVARIANT pins. That is the property, stated as a property because the variant +/// list has already moved once: the decoder emits [`TargetPin::ByIdentity`] (which +/// [`resolve_target`] resolves without reading `iteration` at all) and +/// [`TargetPin::Scheduled`] carrying [`TargetSchedule::Constant`], whose arm of +/// [`evaluate_schedule`] selects its [`Ranking`] without consulting the index — unlike the +/// `RoundRobin` / `Piecewise` arms beside it, which that decoder does not emit. Its verdict +/// is therefore identical at any range ≥ 1. /// /// Ranges are nested rather than contradictory — `0..n` re-checks are a superset of `0..m` for /// `m <= n`, so a wider range is strictly stricter — which is why a PUBLISHER must validate at diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 53b1b2abb7..3e257335c3 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -2578,8 +2578,16 @@ pub(crate) fn loop_states_cover_modulo_growth_scoped<'a>( // // PINS ARE PLUGGED IN HERE (`scope.pinned`, minted by the single authority // `game::engine::bounded_cycle_pin_slots`). Precondition (a) holds by construction: - // the pins that channel carries are `TargetPin::Player` / `MayChoice` designations, - // both state-independent (never "the newest copy"). Precondition (c) is NOT taken on + // the pins that channel carries are SEAT designations and `MayChoice` designations, + // both state-independent (never "the newest copy"). A seat designation now has TWO + // spellings and (a) holds for both: `TargetPin::Player` is the CR 115.10a CHOICE class, + // while a CR 601.2c TARGET-class seat is + // `Scheduled(TargetSchedule::Constant(Ranking::one(AnnouncementSubject::Seat(..))))` — + // one entry, selected without reading the iteration index, so it too can never denote + // "the newest copy". The split changes WHICH AUTHORITY judges a seat's legality, never + // whether the designation is state-independent, which is all this precondition asks. + // (This module reads no pin VARIANT at all — every `TargetPin::` occurrence in it is + // prose — so no relief verdict can move with the spelling.) Precondition (c) is NOT taken on // trust from the mint site: [`pinned_may_choice_relief`] re-runs the mint's own // per-entry acceptance test — controller conjunct included — for THIS entry, so the // relief predicate is the mint predicate rather than a coarser sibling of it. diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 36bb6d4034..042fbde395 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -2966,8 +2966,13 @@ fn entry_announces( // player shape while the ONE slot the announcement actually surfaces belongs to a // chained sub-ability targeting OBJECTS (measured: head `LoseLife` at // `TargetChoiceTiming::Resolution` contributing 0 slots + a chained - // `LoseLife{Typed{[Creature]}}` contributing 1, legal set three objects). A - // `TargetPin::Player` cannot specify such a choice, so publishing it would hand + // `LoseLife{Typed{[Creature]}}` contributing 1, legal set three objects). NO SEAT PIN + // can specify such a choice — neither spelling: not the CR 115.10a + // `TargetPin::Player`, and not the CR 601.2c + // `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))` the announcement + // journal now emits, since both resolve to a `ConcreteTarget::Player` and the slot + // wants objects. The provenance split changes which authority judges a seat, not what + // a seat can denote, so this conjunct is untouched by it. Publishing anyway would hand // gate (3)'s `continue` a slot no pin can answer. // // `Err` (no legal target, CR 603.3d) also yields `None` — fail-closed, matching this @@ -3783,17 +3788,29 @@ fn until_lethal_fallback( /// is a `Ranking`, which lives INSIDE the step and never changes the count — this seam is /// type-only across that parameterization. /// -/// DORMANT for every Stage-2 crownable loop (Ruling B) — and the REASON needed restating once -/// a step's subject became parameterized. It is no longer "`TargetSchedule` rotates -/// DecisionSource objects, not players": the type now admits `AnnouncementSubject::Seat`. The -/// dormancy is a PRODUCER property instead, and it is measured rather than structural — no -/// in-tree producer emits a `Seat` into a schedule, so every schedule this engine mints still -/// rotates objects, `live_mandatory_loop_winner` crowns on PLAYER fallers, and an -/// object-rotating loop produces no player faller. The only crownable >2p player drain pins -/// ALL opponents every cycle (`TargetPin::Player` is constant, period 1). The seam is built -/// for generality and a multi-cycle aggregation is fail-safe either way (a loop reaching the -/// arm measures 1 cycle, finds no faller, does not crown), so a future seat-rotating producer -/// changes what must be re-argued here, not what this function returns. +/// DORMANT for every Stage-2 crownable loop (Ruling B) — and the REASON has now been restated +/// TWICE, because each restatement was falsified by the next commit and the history is the +/// useful part. (i) It was "`TargetSchedule` rotates DecisionSource objects, not players"; +/// parameterizing a step's subject admitted `AnnouncementSubject::Seat` and killed that. +/// (ii) It was then "no in-tree producer emits a `Seat` into a schedule", which is FALSE as of +/// the provenance split: `record_trigger_target_answer` and +/// `game::interaction::materialize_loop_shortcut_response` both mint +/// `Scheduled(TargetSchedule::Constant(Ranking::one(AnnouncementSubject::Seat(..))))` for a +/// CR 601.2c announced seat. +/// +/// (iii) The property that actually holds, and the one this function's return value depends on, +/// is about ROTATION rather than about subjects: **no in-tree producer emits a multi-STEP +/// schedule** (`RoundRobin` / `Piecewise`) **or a multi-entry `Ranking`**. Every seat-carrying +/// schedule the engine mints is a one-step `Constant`, which lands on the `1` arm of the match +/// below — the same `1` a `TargetPin::Player` lands on, so the split moved the spelling and not +/// the period. `live_mandatory_loop_winner` crowns on PLAYER fallers, and a loop whose targets +/// do not rotate produces no NEW player faller per cycle to aggregate. The only crownable >2p +/// player drain pins ALL opponents every cycle (constant, period 1 — via the +/// `Scheduled(Constant(_))` arm below since the split, via `TargetPin::Player(_)` before it, +/// and those two arms return the same `1`). The seam is built for generality and a multi-cycle +/// aggregation is fail-safe either way (a loop reaching the arm measures 1 cycle, finds no +/// faller, does not crown), so a future ROTATING producer changes what must be re-argued here, +/// not what this function returns. /// /// CR 732.2a SAFETY LIMIT: the returned period is clamped to `MAX_SHORTCUT_CYCLES`. Both /// consumers derive their `0..period` range from this one helper (`validate_pins` and @@ -4634,7 +4651,8 @@ fn record_trigger_target_answer( targets: &[crate::types::ability::TargetRef], ) { use crate::analysis::decision_template::{ - DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + AnnouncementSubject, DecisionSlot, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, + TargetSchedule, }; use crate::types::ability::TargetRef; let announced_slots = match &state.waiting_for { @@ -4654,10 +4672,23 @@ fn record_trigger_target_answer( // CR 400.7: bind to the CURRENT incarnation, so a re-entered permanent stops // matching instead of being falsely replayed. TargetRef::Object(id) => object_decision_source(state, *id).map(TargetPin::ByIdentity), - // CR 732.2a: a seat is state-independent by construction — it can never denote - // "the newest copy" — so no iteration can turn the pin into a conditional - // action. - TargetRef::Player(pl) => Some(TargetPin::Player(*pl)), + // CR 601.2c: THIS PRODUCER IS TARGET CLASS, and the spelling says so. This + // writer is gated on `WaitingFor::TriggerTargetSelection`, i.e. on a CR 601.2c + // announcement ("the player announces … choices … including the targets"), so + // the seat it journals was TARGETED, not merely chosen. It therefore emits the + // announcement-subject spelling, whose resolver arm applies CR 702.11c hexproof / + // CR 702.18a shroud / CR 702.16b protection. A merely CHOSEN seat (CR 115.10a — + // e.g. the CR 701.34a proliferate arm) keeps `TargetPin::Player` and its + // existence-only authority; see that variant's own doc. Two questions, two + // spellings, and the spelling IS the provenance. + // + // CR 732.2a is still satisfied: a seat is state-independent by construction — it + // can never denote "the newest copy" — and a one-element `Ranking` under + // `Constant` is answered identically at every iteration index, so no iteration + // can turn the pin into a conditional action. + TargetRef::Player(pl) => Some(TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(*pl)), + ))), }) .collect::>>() else { @@ -16191,7 +16222,8 @@ mod stage2_injector_tests { #[test] fn c2a_row_t5_an_unresolvable_target_abandons_the_whole_journal_write() { use crate::analysis::decision_template::{ - DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + AnnouncementSubject, DecisionSlot, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, + TargetSchedule, }; use crate::types::ability::TargetRef; @@ -16236,7 +16268,11 @@ mod stage2_injector_tests { TargetPin::ByIdentity( object_decision_source(&state, live).expect("the live target resolves") ), - TargetPin::Player(P1), + // CR 601.2c: an ANNOUNCED seat, so the TARGET-class spelling — not + // `TargetPin::Player`, which is the CR 115.10a choice class. + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(P1) + ))), ]))), "CR 601.2c: the pins are journalled in ANNOUNCEMENT ORDER, and CR 400.7 binds \ the object member to its current incarnation" @@ -16307,7 +16343,8 @@ mod stage2_injector_tests { #[test] fn c2a_row_f2_a_multi_slot_announcement_is_refused_rather_than_collapsed() { use crate::analysis::decision_template::{ - DecisionSlot, LoopAnswer, LoopAnswerValue, TargetPin, + AnnouncementSubject, DecisionSlot, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, + TargetSchedule, }; use crate::types::ability::TargetRef; @@ -16324,7 +16361,10 @@ mod stage2_injector_tests { assert_eq!( state.loop_answer(&slot, P0), Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ - TargetPin::Player(P1) + // CR 601.2c TARGET class: an announced seat takes the ranked spelling. + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(P1) + ))) ]))), "a single-slot announcement is exactly what this journal key describes" ); @@ -17998,11 +18038,29 @@ mod stage2_injector_tests { // hunks are inside `#[cfg(test)]` below this producer. The total (38) and the // partition (5/8/25) both fired GREEN on the run that caught this; only this third // assert panicked. - // ⚠ REBASE #3: `:12637 ⇒ :12636`, located by content digest, offset from + // + // ⚠ item-4 R2 (the seat-pin provenance split): `:12575 ⇒ :12606`, `+31`, LOCAL. + // Resolved BY CONTENT FIRST per the protocol above: the sha256 recorded there + // matched exactly ONE line under a whole-file scan, at `:12606`, and the nearest + // preceding `fn` is still `begin_pending_trigger_target_selection` (`:12472`) with + // none intervening. Arithmetic CHECK afterwards: `git diff -U0` against the parent + // shows exactly four hunks above the old coordinate — `+5` on `entry_announces`' + // withhold rationale (a comment), `+12` on `shortcut_drive_period`'s dormancy doc + // (a comment), and `+1`/`+13` inside `record_trigger_target_answer` (its `use` + // list and the `TargetRef::Player` arm re-spelled to + // `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))`) — summing to + // `+31`, and `12575 + 31 = 12606` exactly. SET PRESERVATION: two of the four hunks + // are pure comment; the other two are a `use` list and ONE expression inside a + // `LoopAnswerValue::Targets` mapping, which assigns no `state.waiting_for` and + // mints no `OptionalEffectChoice` prompt. This round's remaining `engine.rs` hunks + // are all inside `#[cfg(test)] mod stage2_injector_tests`, BELOW this producer. The + // total (38) and the partition (5/8/25) both fired GREEN on the run that caught + // this — only this third assert (`:17342`) panicked. + // ⚠ REBASE #3: `:12668 ⇒ :12667`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - // ⚠ REBASE #3: `:12636 ⇒ :12641`, located by content digest, offset from + // ⚠ REBASE #3: `:12667 ⇒ :12672`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - "game/engine.rs:12641".to_string(), + "game/engine.rs:12672".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index ec659eba02..633c15b219 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -13,8 +13,8 @@ use crate::ai_support::{ FilterPipeline, TacticalClass, }; use crate::analysis::decision_template::{ - declaration_conforms, DecisionGroupKey, DecisionKind, DecisionTemplate, IterationCount, - PinnedDecision, ReplayMode, TargetPin, + declaration_conforms, AnnouncementSubject, DecisionGroupKey, DecisionKind, DecisionTemplate, + IterationCount, PinnedDecision, Ranking, ReplayMode, TargetPin, TargetSchedule, }; use crate::types::ability::{ AggregateFunction, ChoiceType, ChooseFromZoneConstraint, Comparator, CounterCostSelection, @@ -8955,8 +8955,18 @@ fn materialize_loop_shortcut_response( let targets = candidate_indices .iter() .map(|index| match &projection.candidates[*index] { + // CR 601.2c: the HUMAN ingress of the SAME point kind, so it emits + // the SAME spelling as the engine's own producer + // (`game::engine::record_trigger_target_answer`). A candidate on a + // `Targets` point is an announced TARGET, so the seat is judged by + // CR 702.11c hexproof / CR 702.18a shroud / CR 702.16b protection + // through the announcement-subject arm — never by existence alone. + // Emitting `TargetPin::Player` here instead would select the + // authority by WHO SUBMITTED the answer rather than by WHAT IT IS. LoopShortcutCandidateValue::Target(TargetRef::Player(player)) => { - Ok(TargetPin::Player(*player)) + Ok(TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(*player)), + ))) } LoopShortcutCandidateValue::Target(TargetRef::Object(object_id)) => { let object = authoritative_state @@ -9047,9 +9057,16 @@ fn materialize_loop_shortcut_response( if let Some(template) = &template { // TRAP REMOVAL, NOT A BUG FIX — recorded so the next reader does not "correct" this // literal into `shortcut_validated_range(..)` and then wonder what changed. This - // decoder emits only `Player` and `ByIdentity` pins, both of which resolve - // INDEPENDENTLY of `iteration`, so validating at index 0 alone is correct by - // construction here: a wider range would re-resolve the same pin to the same value. + // decoder emits only ITERATION-INVARIANT pins, so validating at index 0 alone is + // correct by construction here: a wider range would re-resolve the same pin to the + // same value. That is the property doing the work, and it is stated as the property + // rather than as a list of variant names — the list has already moved once. Today + // the emitted set is `ByIdentity` (never reads `iteration` at all) and + // `Scheduled(TargetSchedule::Constant(..))`, whose arm in + // `decision_template::evaluate_schedule` selects its `Ranking` without consulting + // `iter` (unlike the `RoundRobin` / `Piecewise` arms beside it, which this decoder + // does not emit). Emitting a genuinely iteration-VARYING pin here would invalidate + // the literal, not just this comment. // It is also strictly weaker than the declare-path firewall rather than a second // hole — `1` is a prefix of any range that path validates. It cannot mint a // `Fixed(0)` either: the count-spec projection's `Fixed` arm hard-codes `min: 1` diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 5c1642b99d..5c91ce0d87 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -125,12 +125,20 @@ pub(crate) fn capture_library_search_card_view( /// SAME predicate carriers 1 and 2 apply), so a template this viewer may not privately view is /// REMOVED entirely and there is nothing left for this predicate to answer about it. /// -/// A `TargetPin::Player` needs no redaction, and that is an ENGINE property rather than a CR one — -/// no rule makes seat identity public. This projection hides card identities and hidden-zone -/// contents; the seat list itself is never per-viewer filtered (`filtered.players[..]` is redacted -/// in place, never removed), so a `PlayerId` names something every viewer already has. CR 115.2 is -/// cited for the narrower thing it actually says: a spell or ability may target a player when it -/// specifies so, which is what makes a seat a legal pin value at all. +/// A SEAT needs no redaction, and that is an ENGINE property rather than a CR one — no rule makes +/// seat identity public. This projection hides card identities and hidden-zone contents; the seat +/// list itself is never per-viewer filtered (`filtered.players[..]` is redacted in place, never +/// removed), so a `PlayerId` names something every viewer already has. CR 115.2 is cited for the +/// narrower thing it actually says: a spell or ability may target a player when it specifies so, +/// which is what makes a seat a legal pin value at all. +/// +/// STATED ABOUT THE SEAT RATHER THAN ABOUT ONE SPELLING, because a seat now has two of them: +/// `TargetPin::Player` (the CR 115.10a CHOICE class) and `AnnouncementSubject::Seat` inside a +/// `Ranking` (the CR 601.2c TARGET class). Redaction asks "does this name an identity this viewer +/// may not see", a question the CHOICE/TARGET split does not bear on at all — which is why both +/// arms below answer `false` for this ONE reason rather than two. It is also why the split cannot +/// quietly open a leak here: the `AnnouncementSubject` match is wildcard-free, so a future subject +/// kind that DOES name a hidden identity gets a compile error instead of a `false`. /// /// `target_hidden` is passed in rather than re-derived so that the declaration's object identities /// and the offer schema's legal targets are answered by ONE hidden-info authority; two derivations @@ -156,8 +164,9 @@ fn pins_name_hidden_source( // the tail is a leak on exactly the same footing as one in the head. // // Wildcard-free over `AnnouncementSubject`: a future subject kind gets a compile-time - // visit here. The `Seat` arm is `false` for the reason already given above for - // `TargetPin::Player` — seat identity is public in this engine — and is not restated. + // visit here. The `Seat` arm is `false` for the SEAT reason given above — seat identity is + // public in this engine — which is stated about the seat rather than about either spelling + // precisely so both arms can cite it once. let subject_hidden = |subject: &AnnouncementSubject| match subject { AnnouncementSubject::Object(source) => source_hidden(source), AnnouncementSubject::Seat(_) => false, diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 36175930e9..4a2d160230 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -11094,10 +11094,16 @@ pub enum WaitingFor { /// Built by `game::engine::build_bounded_declaration`; `None` on every other mint. /// /// LATCHED at offer time, not a live view: it is a snapshot of the answers the - /// detection window observed. `TargetPin::Player` is state-independent and + /// detection window observed. Latching the pin SET latches no per-iteration OUTCOME, + /// because no pin kind stores one: a SEAT designation is state-independent under + /// either spelling — the CR 115.10a `TargetPin::Player` and the CR 601.2c + /// `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))` alike — and /// `TargetPin::ByIdentity` re-resolves live per iteration inside - /// `analysis::decision_template::resolve` (CR 608.2b), so latching the pin SET here - /// latches no per-iteration outcome. + /// `analysis::decision_template::resolve` (CR 608.2b). The ranked spelling is + /// re-resolved live too, and it is the STRONGER case rather than a new exposure: its + /// arm re-asks CR 702.11c hexproof / CR 702.18a shroud / CR 702.16b protection at + /// every iteration, so a latched TARGET-class seat can stop resolving mid-drive where + /// a latched CHOICE-class one could not. /// /// `#[serde(default)]` follows `schema`'s precedent on this same variant. Consequence, /// chosen rather than discovered: a pre-declaration save decodes with `None`, which is @@ -30462,4 +30468,114 @@ mod tests { ); assert_eq!(state.loop_answer(&slot, seat_b), Some(answer_b)); } + + /// **Row T3-R — the latch, exercised with the MIGRATED spelling.** CR 601.2c: after the + /// provenance split an announced seat is journalled as + /// `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))`, and this row + /// asserts the [`LoopAnswer::Conflicted`] latch behaves identically on that value. + /// + /// # Why it exists — the gap it closes + /// + /// [`c2a_row_t3_a_differing_target_answer_latches_conflicted_idempotently_and_seat_locally`] + /// and its T4 sibling HAND-BUILD their `LoopAnswer`s, so the producer migration cannot + /// reach them and they stay green unmodified. That leaves the latch never exercised with + /// the value production now writes: equality preservation across the re-spelling would + /// rest on derived structural `PartialEq` alone — sound, but unpinned. This pins it. + /// + /// # Discrimination + /// + /// Two independent mutants, each caught by a different arm, and BOTH RUN rather than + /// reasoned about (the mutation must target EQUALITY, not the producer — this row builds + /// its own values, so a producer mutation cannot reach it): + /// + /// * hand-write `impl PartialEq for Ranking` as `self.head() == other.head()` — the + /// head-only shape `evaluate_schedule` uses to RESOLVE, and therefore the plausible + /// wrong reading ⇒ arm (b)'s same-head/different-tail pair compares EQUAL ⇒ no latch ⇒ + /// **arm (b) FAILS while arms (a) and (c) stay green**, because (a)'s heads already + /// differ. The tail is the NEXT episode's pre-declaration, so two proposals agreeing + /// only about this episode are not the same answer; + /// * hand-write `impl PartialEq for AnnouncementSubject` with `(Seat(_), Seat(_)) => true` + /// ⇒ two DIFFERENT seats compare equal ⇒ arm (a)'s own reach-guard fires first and + /// names the vacuity by hand, which is the reach-guard doing its job rather than the + /// latch assertion doing it late. + /// + /// # Paired positive + /// + /// Arm (c) records the SAME ranking twice and requires the entry to stay `Uniform`. Without + /// it, a latch that fired on every write — i.e. an equality that is always false — would + /// satisfy both negative arms. + #[test] + fn c2a_row_t3r_the_conflicted_latch_is_unchanged_by_the_migrated_seat_spelling() { + use crate::analysis::decision_template::{ + AnnouncementSubject, DecisionSlot, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, + TargetSchedule, + }; + + let ranked = |subjects: Vec| { + LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![TargetPin::Scheduled( + TargetSchedule::Constant( + Ranking::new(subjects).expect("non-empty, duplicate-free by construction"), + ), + )])) + }; + let seat = |p: u8| AnnouncementSubject::Seat(PlayerId(p)); + + // ── (a) two rankings naming DIFFERENT seats ⇒ Conflicted ── + let mut state = journal_state(); + let slot_a = DecisionSlot::target(journal_source(913)); + let aimed_at_1 = ranked(vec![seat(1)]); + let aimed_at_2 = ranked(vec![seat(2)]); + assert_ne!( + aimed_at_1, aimed_at_2, + "reach-guard: the two announcements must DIFFER as values, else the latch has \ + nothing to fire on and every arm below is vacuous" + ); + state.record_loop_answer(slot_a.clone(), PlayerId(0), aimed_at_1.clone()); + assert_eq!( + state.loop_answer(&slot_a, PlayerId(0)), + Some(aimed_at_1.clone()), + "paired positive: the FIRST ranked answer is journalled as Uniform before any \ + disagreement, so a writer that stored nothing cannot reach the latch" + ); + state.record_loop_answer(slot_a.clone(), PlayerId(0), aimed_at_2); + assert_eq!( + state.loop_answer(&slot_a, PlayerId(0)), + Some(LoopAnswer::Conflicted), + "CR 732.2a: two announcements naming different seats are a disagreement in the \ + ranked spelling exactly as they were in the CHOICE-class one" + ); + + // ── (b) SAME head, DIFFERENT tail ⇒ still Conflicted ── + // + // The tail is a pre-declaration for the NEXT episode (CR 732.2a), not decoration, so + // two proposals that agree only about this episode are not the same answer. This is + // the arm a head-only equality would lose. + let slot_b = DecisionSlot::target(journal_source(914)); + let head1_tail2 = ranked(vec![seat(1), seat(2)]); + let head1_tail3 = ranked(vec![seat(1), seat(3)]); + state.record_loop_answer(slot_b.clone(), PlayerId(0), head1_tail2.clone()); + assert_eq!( + state.loop_answer(&slot_b, PlayerId(0)), + Some(head1_tail2), + "reach-guard: the multi-entry ranking round-trips as Uniform first" + ); + state.record_loop_answer(slot_b.clone(), PlayerId(0), head1_tail3); + assert_eq!( + state.loop_answer(&slot_b, PlayerId(0)), + Some(LoopAnswer::Conflicted), + "equality over a `Ranking` is STRUCTURAL, not head-only: the tail is the next \ + episode's pre-declaration, so differing tails are differing answers" + ); + + // ── (c) the SAME ranking twice ⇒ still Uniform ── + let slot_c = DecisionSlot::target(journal_source(915)); + state.record_loop_answer(slot_c.clone(), PlayerId(0), aimed_at_1.clone()); + state.record_loop_answer(slot_c.clone(), PlayerId(0), aimed_at_1.clone()); + assert_eq!( + state.loop_answer(&slot_c, PlayerId(0)), + Some(aimed_at_1), + "the latch fires on DISAGREEMENT, not on repetition — without this arm an \ + always-false equality would satisfy (a) and (b)" + ); + } } diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index cda81ac9dc..00d5dcbafe 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -328,7 +328,8 @@ fn f4_pin_template( count: u32, ) -> engine::analysis::decision_template::DecisionTemplate { use engine::analysis::decision_template::{ - DecisionGroupKey, DecisionTemplate, MayChoiceOption, PinnedDecision, ReplayMode, TargetPin, + AnnouncementSubject, DecisionGroupKey, DecisionTemplate, MayChoiceOption, PinnedDecision, + Ranking, ReplayMode, TargetPin, TargetSchedule, }; DecisionTemplate { owner, @@ -344,9 +345,17 @@ fn f4_pin_template( // chosen when the trigger goes on the stack and re-checked for legality at // each resolution. P1 is the constant seat `f4_drive_one_beat` aims at and is // living on this board, so the pin stays legal for every driven cycle. + // + // CR 601.2c: "target opponent" makes this an ANNOUNCED target, so the + // reference spells the TARGET class — a one-entry `Ranking` naming the seat — + // and not the CR 115.10a `TargetPin::Player` choice class. This literal is + // the conformance oracle row D1 compares the live publisher against, so it + // has to track the publisher's spelling exactly. DecisionPointKind::Targets { .. } => PinnedDecision::Targets { slot: p.slot.clone(), - targets: vec![TargetPin::Player(P1)], + targets: vec![TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(P1)), + ))], }, other => panic!("unexpected point kind {other:?}"), }) @@ -675,6 +684,182 @@ fn r1_the_bounded_offer_fires_on_the_real_f4_dump() { ); } +/// **Row R2-a — REAL DUMP.** The MAINTAINED-INVARIANT row for the provenance split: after both +/// TARGET-class producers moved to the ranked spelling, this real 4p board still fires its +/// CR 732.2a bounded offer, still publishes a `Some` declaration, still carries the same bound — +/// and Torch's `Targets` pin is now the CR 601.2c TARGET-class spelling. +/// +/// # The two halves, and why one without the other is worthless +/// +/// `declaration.is_some()` alone passes on the OLD spelling, so it cannot see the migration at +/// all. The pin-VALUE assertion alone would pass on a publisher that emitted the right shape +/// while the offer machinery had quietly broken. Both are asserted, on one board, in one run. +/// +/// # Discrimination +/// +/// REVERT-PROBE (the commit itself): restore `record_trigger_target_answer`'s player arm to +/// `Some(TargetPin::Player(*pl))` ⇒ the journal holds the CHOICE-class spelling ⇒ +/// `build_bounded_declaration` copies it through ⇒ the pin-value assertion FAILS while +/// `is_some()` stays green. That asymmetry is the row. +/// +/// # The hostile arm, and the ORDERING that makes it reachable +/// +/// The split's whole content is WHICH AUTHORITY judges a seat, so the hostile fixture makes the +/// seat untargetable and requires the declaration to be REFUSED. The hexproof is applied AFTER +/// the real drive has latched the pin, and the ordering is load-bearing rather than convenient: +/// Torch's "target opponent" has three legal opponents on this board, so a board that was +/// hexproofed BEFORE the drive would let the announcement name a different opponent, and the +/// row would be measuring the announcement's choice instead of the pin's legality. Latch first, +/// then remove the seat from the target set, is also the CR 115.7a shape — "the original target +/// is unchanged, even if the original target is itself illegal by then". +/// +/// PAIRED POSITIVE, same board, same instrument: `validate_pins` on the very same +/// (schema, declaration) pair is `Ok` BEFORE the grantor lands. Without it, `Err` afterwards is +/// equally explained by a seat pin that never validates at all. +/// +/// REVERT-PROBE (hostile arm): the same restore of the producer ⇒ the pin is a +/// `TargetPin::Player`, `resolve_target`'s CHOICE arm asks existence only, the hexproof is not +/// consulted, `validate_pins` returns `Ok` ⇒ the refusal assertion FAILS. This is the real-dump +/// sibling of the resolver-level row in `loop_shortcut_ranking.rs`. +#[test] +fn r2a_split_the_bounded_offer_still_publishes_a_ranked_seat_pin_and_refuses_a_hexproofed_one() { + use engine::analysis::decision_template::{ + validate_pins, AnnouncementSubject, PinnedDecision, Ranking, TargetPin, TargetSchedule, + }; + use engine::types::ability::{ControllerRef, StaticDefinition, TypedFilter}; + use engine::types::game_state::LayersDirty; + use engine::types::identifiers::CardId; + use engine::types::statics::StaticMode; + use engine::types::zones::Zone; + + let mut state = load_f4(); + let beat = drive_f4_to_offer(&mut state, 400) + .expect("REACH-GUARD: the bounded offer must still FIRE after the provenance split"); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + + assert_eq!( + schema.max_iterations, 18, + "MAINTAINED INVARIANT: the CR 704.5a-derived bound at beat {beat} is unchanged by a \ + change of pin SPELLING — the split moves which authority judges a seat, not how much \ + the loop consumes" + ); + + let declaration = offer_declaration(&state) + .expect("MAINTAINED INVARIANT: the offer still publishes a declaration"); + assert_eq!( + declaration.owner, proposer, + "reach-guard: the published declaration is the proposer's own" + ); + + let target_slot = schema + .points + .iter() + .find(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .map(|p| p.slot.clone()) + .expect("reach-guard: the offer publishes Torch's CR 601.2c Targets point"); + let pinned = declaration + .decisions + .iter() + .find_map(|pin| match pin { + PinnedDecision::Targets { slot, targets } if *slot == target_slot => Some(targets), + _ => None, + }) + .expect("reach-guard: the declaration pins the published Targets slot"); + assert_eq!( + *pinned, + vec![TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(P1)) + ))], + "CR 601.2c: Torch's announced opponent is a TARGET, so the published pin carries the \ + TARGET-class spelling. Without this half the row passes unchanged on the pre-split \ + `TargetPin::Player(P1)`" + ); + + // ── PAIRED POSITIVE: the pin is LEGAL against the offer's own schema, before the hostile + // change lands ── + assert!( + validate_pins(&schema, &declaration, schema.max_iterations, &state).is_ok(), + "paired positive: the ranked pin validates at the FULL declared range on the \ + un-hexproofed board — otherwise the refusal below is explained by a seat pin that \ + never validates at all" + ); + + // ── HOSTILE: P1 gains hexproof from a permanent P1 controls, AFTER the pin is latched ── + let mut hostile = state.clone(); + // Built with production `zones::create_object` rather than a raw `objects.insert`: a raw + // insert never joins `state.battlefield`, so the grantor would be invisible to + // `game_functioning_statics` and the hexproof would silently never apply. + let grantor = engine::game::zones::create_object( + &mut hostile, + CardId(9401), + P1, + "You Have Hexproof Source".to_string(), + Zone::Battlefield, + ); + hostile + .objects + .get_mut(&grantor) + .expect("the grantor was just created") + .static_definitions = vec![StaticDefinition::new(StaticMode::Hexproof).affected( + engine::types::ability::TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::You), + ), + )] + .into(); + // MEASURED, and the reach-guard below is what caught it: after a completed drive this + // board's `layers_dirty` is `Clean`, and `create_object` does not re-dirty it — so a bare + // `flush_layers` returns immediately, `refresh_static_mode_presence` never runs, and the + // O(1) `static_mode_presence` gate answers `false` for `Hexproof` no matter what the + // grantor carries. Marking the pass dirty is fixture bookkeeping, not a rule: it requests + // exactly the re-evaluation an ETB would have requested. + hostile.layers_dirty = LayersDirty::Full; + engine::game::layers::flush_layers(&mut hostile); + + // The grant must actually bite at the TARGET seam, or the refusal below proves nothing. + // CR 702.11c is opponent-scoped, so it is asked with Torch's own controller as the source + // controller — the same question `evaluate_schedule`'s `Seat` arm asks. + let torch = resolve_by_name(&hostile, TORCH); + let torch_controller = hostile.objects[&torch].controller; + assert!( + engine::game::players::is_opponent(&hostile, P1, torch_controller), + "reach-guard: CR 702.11c only excludes OPPONENTS' spells and abilities, so Torch's \ + controller {torch_controller:?} must be P1's opponent" + ); + assert!( + engine::game::static_abilities::player_cannot_be_targeted_by( + &hostile, + P1, + torch, + torch_controller + ), + "reach-guard: the hexproof grant must bite at the TARGET seam for Torch's ability. \ + grantor_on_battlefield={} player_has_hexproof={} — if the second is false while the \ + first is true, the layers pass did not re-run and the O(1) `static_mode_presence` \ + gate is stale", + hostile.battlefield.contains(&grantor), + engine::game::static_abilities::player_has_hexproof(&hostile, P1), + ); + assert!( + !engine::game::static_abilities::player_cannot_be_targeted_by( + &hostile, + PlayerId(2), + torch, + torch_controller + ), + "reach-guard: a DIFFERENT seat on the same board is still targetable, so the exclusion \ + above is the hexproof and not a blanket refusal" + ); + + assert!( + validate_pins(&schema, &declaration, schema.max_iterations, &hostile).is_err(), + "CR 601.2c + CR 702.11c: a TARGET-class seat that has become untargetable is an \ + ILLEGAL pin value, so the declaration is REFUSED rather than driven at a wrong seat. \ + Under the pre-split `TargetPin::Player` this returns Ok — existence alone — which is \ + exactly the over-veto-free CHOICE authority the split moved this pin off" + ); +} + /// §6 R1, SECOND HALF — the published point set, pinned so it cannot drift silently. /// /// R1 as written expects `points ≡ {Targets(403 Torch), MayChoice(401 Reed), @@ -1599,7 +1784,8 @@ fn c1_row1_the_may_journal_is_populated_at_the_f4_offer_under_the_proposers_own_ #[test] fn c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot() { use engine::analysis::decision_template::{ - LoopAnswer, LoopAnswerValue, MayChoiceOption, TargetPin, + AnnouncementSubject, LoopAnswer, LoopAnswerValue, MayChoiceOption, Ranking, TargetPin, + TargetSchedule, }; let mut state = load_f4(); @@ -1636,10 +1822,13 @@ fn c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot assert_eq!( state.loop_answer(slot, proposer), Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ - TargetPin::Player(P1) + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(P1) + ))) ]))), "CR 608.2b: the published Targets slot must hold the announcement the drive made \ - (a constant CR 115.2 player target), under the PROPOSER's own key; slot \ + (a constant CR 115.2 player target, in the CR 601.2c TARGET-class spelling), \ + under the PROPOSER's own key; slot \ {slot:?}, proposer {proposer:?}, journal holds {} entries", state.loop_answers_recorded() ); @@ -1677,15 +1866,18 @@ fn c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot /// /// [`c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot`] drives /// the shipped policy, which aims at P1. A writer that IGNORED the announcement and stored -/// the constant `TargetPin::Player(P1)` would satisfy it exactly. Only a second seat -/// discriminates that, and it must be a REAL drive: the seat is announced through production -/// `apply()` at Torch's CR 601.2c choice, never injected. +/// the constant seat P1 would satisfy it exactly. Only a second seat discriminates that, and it +/// must be a REAL drive: the seat is announced through production `apply()` at Torch's +/// CR 601.2c choice, never injected. /// /// # Discrimination /// /// In `record_trigger_target_answer`, replace the mapped `targets` with -/// `vec![TargetPin::Player(PlayerId(1))]` ⇒ this row reds on the value while T1 stays GREEN. -/// That asymmetry is the point: T1 alone cannot see this mutation. +/// `vec![TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one(AnnouncementSubject::Seat(PlayerId(1)))))]` +/// ⇒ this row reds on the value while T1 stays GREEN. That asymmetry is the point: T1 alone +/// cannot see this mutation. The mutant is spelled in the CURRENT producer spelling on purpose: +/// the discrimination is seat-vs-seat and survives any re-spelling, but a recipe naming a +/// spelling the producer no longer emits is a recipe that no longer compiles. /// /// # Reach-guards /// @@ -1694,7 +1886,9 @@ fn c2a_row_t1_the_announced_target_is_journalled_at_the_f4_offers_published_slot /// publish a `Targets` point, and the aimed seat must differ from T1's. #[test] fn c2a_row_t1p_the_journalled_pin_follows_the_announced_seat_not_a_constant() { - use engine::analysis::decision_template::{LoopAnswer, LoopAnswerValue, TargetPin}; + use engine::analysis::decision_template::{ + AnnouncementSubject, LoopAnswer, LoopAnswerValue, Ranking, TargetPin, TargetSchedule, + }; const AIMED: PlayerId = PlayerId(2); assert_ne!( @@ -1733,7 +1927,9 @@ fn c2a_row_t1p_the_journalled_pin_follows_the_announced_seat_not_a_constant() { assert_eq!( state.loop_answer(slot, proposer), Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ - TargetPin::Player(AIMED) + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(AIMED) + ))) ]))), "PROVENANCE: the journal must hold the seat this drive ANNOUNCED ({AIMED:?}), not \ the seat the shipped policy happens to aim at; slot {slot:?}" @@ -1756,9 +1952,10 @@ fn offer_declaration( /// CONFORMS to the reference shape this suite already accepts, on all three tracked dumps. /// /// ⚠ **THIS IS A CONFORMANCE ORACLE, NEVER A PROVENANCE ONE.** [`f4_pin_template`] is a pure -/// function of `(schema, owner, count)` — it hard-codes `MayChoiceOption::Take` and -/// `TargetPin::Player(P1)` and never reads the journal — so a consumer that ignored the journal -/// entirely and emitted those same constants passes this row. That is exactly what +/// function of `(schema, owner, count)` — it hard-codes `MayChoiceOption::Take` and the seat P1 +/// (as `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(P1))))`, the CR 601.2c +/// TARGET-class spelling the publisher emits) and never reads the journal — so a consumer that +/// ignored the journal entirely and emitted those same constants passes this row. That is exactly what /// [`d1p_the_published_pin_follows_the_journal_not_a_constant`] and its P3 sibling are for. /// /// # The count trap, measured @@ -1849,9 +2046,10 @@ fn d1_the_bounded_offer_publishes_a_conformant_declaration_on_every_tracked_dump /// /// # The asymmetry IS the row /// -/// On the shipped P1 board, replacing the journalled targets with the constant -/// `vec![TargetPin::Player(PlayerId(1))]` is GREEN — that mutant is indistinguishable there. -/// At a second seat it is RED. Only a second seat discriminates a journal-blind consumer. +/// On the shipped P1 board, replacing the journalled targets with the constant seat P1 +/// (`vec![TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one(AnnouncementSubject::Seat(PlayerId(1)))))]`) +/// is GREEN — that mutant is indistinguishable there. At a second seat it is RED. Only a second +/// seat discriminates a journal-blind consumer. /// /// # Reach-guards, asserted BEFORE the claim /// @@ -1860,7 +2058,7 @@ fn d1_the_bounded_offer_publishes_a_conformant_declaration_on_every_tracked_dump /// `Targets` point's journal entry already reads the aimed seat before the consumer is called. /// /// REVERT-PROBE: in `build_bounded_declaration`'s `(Targets, Targets)` arm, replace the -/// journalled `targets` with `vec![TargetPin::Player(PlayerId(1))]` ⇒ this row flips on the pin +/// journalled `targets` with the same constant-P1 vector named above ⇒ this row flips on the pin /// VALUE while D1 stays green. /// /// *What wrong implementation would still pass this row?* One that reads the journal but ignores @@ -1879,8 +2077,15 @@ fn d1p_sib_the_published_pin_provenance_is_not_specific_to_one_second_seat() { fn d1p_provenance_at_seat(aimed: PlayerId) { use engine::analysis::decision_template::{ - validate_pins, LoopAnswer, LoopAnswerValue, PinnedDecision, TargetPin, + validate_pins, AnnouncementSubject, LoopAnswer, LoopAnswerValue, PinnedDecision, Ranking, + TargetPin, TargetSchedule, }; + // CR 601.2c: the one spelling this row expects at BOTH tiers — the journal's own write and + // the declaration the publisher derives from it. Built once so the two `assert_eq!`s below + // cannot drift apart; it is still a fully-determined VALUE, not a pattern. + let announced_seat = TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(aimed), + ))); assert_ne!( aimed, P1, @@ -1915,7 +2120,7 @@ fn d1p_provenance_at_seat(aimed: PlayerId) { assert_eq!( state.loop_answer(&target_slot, proposer), Some(LoopAnswer::Uniform(LoopAnswerValue::Targets(vec![ - TargetPin::Player(aimed) + announced_seat.clone() ]))), "reach-guard: the journal holds the ANNOUNCED seat {aimed:?} at the published slot" ); @@ -1931,7 +2136,7 @@ fn d1p_provenance_at_seat(aimed: PlayerId) { .expect("the declaration pins the published Targets slot"); assert_eq!( *pinned, - vec![TargetPin::Player(aimed)], + vec![announced_seat], "PROVENANCE: the declaration must pin the seat this drive ANNOUNCED ({aimed:?}), not the \ seat the shipped policy happens to aim at" ); diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index f7be80a549..3bd288fa18 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -3028,6 +3028,185 @@ fn loop_shortcut_schema_and_materializer_cover_every_decision_point_kind() { ); } +/// **Row R2-f — the HUMAN ingress emits the same spelling as the engine's own producer.** +/// +/// CR 601.2c: one shape per point kind, whoever submitted it. `materialize_loop_shortcut_response` +/// decodes a submitted player candidate on a `Targets` point into +/// `Scheduled(Constant(Ranking::one(AnnouncementSubject::Seat(..))))` — the same value +/// `game::engine::record_trigger_target_answer` journals for an announced seat — and an OBJECT +/// candidate on the SAME point into `TargetPin::ByIdentity`, unchanged. +/// +/// # Discrimination +/// +/// Migrate only the engine's producer and leave this decoder emitting `TargetPin::Player(*player)` +/// ⇒ one `Targets` point yields two different pin spellings depending on WHO submitted the +/// answer, and the seat assertion below FAILS while the object assertion stays green. That +/// asymmetry — one arm moving, one not — is what makes this a spelling row rather than a +/// smoke test. +/// +/// # Paired positive reach-guard +/// +/// The decoder must still ACCEPT end to end: `resolve_interaction_response` returns +/// `Ok(GameAction::DeclareShortcut { .. })`, which means `declaration_conforms` ran +/// `predictability_gate` and `validate_pins` at range 1 and passed. Without it the row would be +/// satisfied by a decoder that had simply started refusing everything. +/// +/// # ⚠ WHY THIS ROW BUILDS ITS OWN BOARD (measured, not preference) +/// +/// The file's other shortcut rows share a schema whose only slot source is +/// `AllCopies { CardId(9001) }`, which no battlefield object carries. After the split a `Seat` +/// pin on such a slot resolves through `resolve_ability_instance` ⇒ `resolve_source`'s +/// `AllCopies` arm (`.filter(|o| o.zone == Zone::Battlefield && o.card_id == *card_id)`) ⇒ +/// `None` ⇒ `IllegalTarget` ⇒ `validate_pins` ⇒ `declaration_conforms == false` ⇒ +/// `ConstraintUnsatisfied`. The positive reach-guard above would be UNSATISFIABLE there, and the +/// cheapest-looking repair would be to loosen a fail-closed predicate. So the slot source here is +/// a `ThisObject` naming a live battlefield creature, at that object's LIVE incarnation read from +/// state (CR 400.7) — never a hard-coded one. `AllCopies` cannot take the CR 114.2 command-zone +/// disjunct either: an emblem has no card, so only `ThisObject` participates. +/// +/// The three shipped `Shortcut` rows in this file are untouched by the split, but by INDEX +/// ORDERING rather than by design: the file has exactly one candidate-selection site and it takes +/// `candidate_ids[0]`, which on the one board offering both is the OBJECT. That vector must not +/// be reordered. +/// +/// This row's own board deliberately exercises BOTH indices, and the two arms key each other: if +/// the projection's candidate order did not follow `legal_targets`, both assertions would fail +/// rather than one silently passing on the wrong candidate. +#[test] +fn loop_shortcut_human_ingress_emits_the_target_class_spelling_for_a_submitted_seat() { + use engine::analysis::decision_template::{ + AnnouncementSubject, PinnedDecision, Ranking, TargetPin, TargetSchedule, + }; + + let mut scenario = GameScenario::new(); + let target = scenario.add_creature(P0, "R2f Ability Source", 1, 1).id(); + let mut runner = scenario.build(); + let incarnation = runner.state().objects[&target].incarnation; + let slot = DecisionSlot { + source: engine::types::game_state::YieldTarget::ThisObject { + source_id: target, + incarnation: Some(incarnation), + trigger_description: None, + }, + index: 0, + }; + runner.state_mut().waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: Some(P0), + certificate: engine::analysis::loop_check::LoopCertificate { + unbounded: Vec::new(), + win_kind: engine::analysis::loop_check::WinKind::Advantage, + mandatory: false, + residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: None, + }, + schema: ShortcutDecisionSchema { + iteration_count: IterationCount::Fixed(2), + max_iterations: ShortcutDecisionSchema::default().max_iterations, + points: vec![DecisionPoint { + slot: slot.clone(), + kind: DecisionPointKind::Targets { + // Index 0 is the OBJECT, index 1 is the SEAT. Both are exercised below. + legal_targets: vec![TargetRef::Object(target), TargetRef::Player(P1)], + min_targets: 1, + max_targets: 1, + ordered: true, + }, + }], + convoke_tappable_count: 0, + }, + declaration: None, + }; + bind(runner.state_mut(), "r2f-human-seat-pin"); + + let view = priority_view(runner.state()); + let InteractionOpportunityResponse::Schema { + spec: InteractionResponseSpec::Shortcut { points, .. }, + .. + } = &view.opportunities[0].response + else { + panic!("the loop shortcut offer uses a shortcut schema"); + }; + assert_eq!( + points.len(), + 1, + "reach-guard: exactly one published point, so the pin below addresses the point this \ + row is about" + ); + assert_eq!( + points[0].candidate_ids.len(), + 2, + "reach-guard: BOTH legal targets must be offered as candidates, else one of the two \ + arms below is unreachable" + ); + + let decode = |candidate: usize| { + resolve_interaction_response( + runner.state(), + P0, + &InteractionSubmission { + interaction_id: view.opportunities[0].interaction_id.clone(), + response: InteractionResponse::Shortcut { + decision: InteractionShortcutDecision::AcceptSuggested, + pins: vec![InteractionShortcutPin { + group: 0, + choice_ids: vec![points[0].candidate_ids[candidate].clone()], + }], + }, + }, + ) + }; + + // ── THE CLAIM: a submitted SEAT decodes to the CR 601.2c TARGET-class spelling ── + let GameAction::DeclareShortcut { + template: Some(seat_template), + .. + } = decode(1).expect( + "paired positive: the human ingress still ACCEPTS end to end — `declaration_conforms` \ + ran `predictability_gate` and `validate_pins` at range 1 and passed", + ) + else { + panic!("a shortcut acceptance carrying pins materializes a template"); + }; + assert_eq!( + seat_template.decisions, + vec![PinnedDecision::Targets { + slot: slot.clone(), + targets: vec![TargetPin::Scheduled(TargetSchedule::Constant( + Ranking::one(AnnouncementSubject::Seat(P1)) + ))], + }], + "CR 601.2c: a candidate on a `Targets` point is an ANNOUNCED target, so a submitted \ + seat takes the TARGET-class spelling — the same value the engine's own producer \ + journals. `TargetPin::Player(P1)` here would select the authority by WHO SUBMITTED \ + the answer rather than by WHAT IT IS" + ); + + // ── THE SIBLING: an OBJECT candidate on the SAME point is unchanged ── + let GameAction::DeclareShortcut { + template: Some(object_template), + .. + } = decode(0).expect("the object candidate is accepted on the same point") + else { + panic!("a shortcut acceptance carrying pins materializes a template"); + }; + assert_eq!( + object_template.decisions, + vec![PinnedDecision::Targets { + slot, + targets: vec![TargetPin::ByIdentity( + engine::types::game_state::YieldTarget::ThisObject { + source_id: target, + incarnation: Some(incarnation), + trigger_description: None, + } + )], + }], + "the migration re-spelled the SEAT branch only: an object candidate still binds by \ + CR 400.7 identity" + ); +} + #[test] fn coin_flip_sequence_supports_multi_keep_and_rejects_duplicates() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/tests/integration/loop_shortcut_ranking.rs b/crates/engine/tests/integration/loop_shortcut_ranking.rs new file mode 100644 index 0000000000..9c587444bf --- /dev/null +++ b/crates/engine/tests/integration/loop_shortcut_ranking.rs @@ -0,0 +1,216 @@ +//! Cross-episode CR 732.2a ranking rows that need a whole board rather than a resolver fixture. +//! +//! **Row R2-d** lives here: a ranked `AnnouncementSubject::Seat` is judged as a CR 601.2c +//! TARGET, not merely as PRESENT — and the CR 115.10a CHOICE class keeps its existence-only +//! authority on the very same board. The resolver-level zone sampling for the same arm is +//! `analysis::decision_template`'s `r1ghi_*`; this file is the board-level statement, which is +//! where the two authorities can be contrasted on ONE state. + +use engine::analysis::decision_template::{ + resolve, AnnouncementSubject, ConcreteDecision, ConcreteTarget, DecisionGroupKey, DecisionKind, + DecisionSlot, DecisionTemplate, IterationCount, PinnedDecision, Ranking, ReplayFailure, + ReplayMode, TargetPin, TargetSchedule, +}; +use engine::game::scenario::GameScenario; +use engine::types::ability::{ControllerRef, StaticDefinition, TargetFilter, TypedFilter}; +use engine::types::game_state::{GameState, LayersDirty, YieldTarget}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::player::PlayerId; +use engine::types::statics::StaticMode; +use engine::types::zones::Zone; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); +const P2: PlayerId = PlayerId(2); + +/// A 3-player board carrying one P0-controlled ability source on the battlefield. +/// +/// The source's ZONE is load-bearing, not scenery: the `Seat` arm asks +/// `player_is_legal_target(state, seat, src_id, src_controller)`, and both of its trailing +/// arguments come from re-binding the SLOT's source through `resolve_ability_instance`. A slot +/// whose source does not resolve fails closed before any hexproof question is asked, which would +/// make every arm below refuse for the wrong reason. +fn board_with_source() -> (GameState, ObjectId) { + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + // Production `zones::create_object`, never a raw `objects.insert`: a raw insert never joins + // `state.battlefield`, so `game_functioning_statics` would not see it and the grant applied + // in `grant_hexproof` below would silently never apply. + let source = engine::game::zones::create_object( + &mut state, + CardId(950), + P0, + "Ranked Seat Ability Source".to_string(), + Zone::Battlefield, + ); + (state, source) +} + +/// "You have hexproof" (the Leyline of Sanctity shape), on a permanent `player` controls. +fn grant_hexproof(state: &mut GameState, player: PlayerId) { + let grantor = engine::game::zones::create_object( + state, + CardId(951), + player, + "You Have Hexproof Source".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&grantor) + .expect("the grantor was just created") + .static_definitions = + vec![ + StaticDefinition::new(StaticMode::Hexproof).affected(TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::You), + )), + ] + .into(); + // Fixture bookkeeping, not a rule: a new continuous-effect source needs a layer pass to be + // seen, which an ETB would have requested. MEASURED on this lane: + // `create_object` does NOT re-dirty `layers_dirty`, so on a board whose pass has already run + // (`Clean`) a bare `flush_layers` returns immediately, `refresh_static_mode_presence` never + // runs, and the O(1) `static_mode_presence` gate answers `false` for `Hexproof` regardless of + // what the grantor carries. Marking `Full` is what an ETB would have requested. + state.layers_dirty = LayersDirty::Full; + engine::game::layers::flush_layers(state); +} + +fn slot_for(source: ObjectId, state: &GameState) -> DecisionSlot { + DecisionSlot { + source: YieldTarget::ThisObject { + source_id: source, + // CR 400.7: bind the LIVE incarnation, read from state rather than hard-coded — a + // hard-coded one would fail closed the moment the harness re-enters the object and + // the row would refuse for a bookkeeping reason instead of the rules one. + incarnation: Some(state.objects[&source].incarnation), + trigger_description: None, + }, + index: 0, + } +} + +fn one_pin_template(slot: DecisionSlot, pin: TargetPin) -> DecisionTemplate { + let sources = vec![slot.source.clone()]; + DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot, + targets: vec![pin], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: DecisionGroupKey::from_sources(&sources, DecisionKind::LoopChoice), + } +} + +fn ranked_seat(player: PlayerId) -> TargetPin { + TargetPin::Scheduled(TargetSchedule::Constant(Ranking::one( + AnnouncementSubject::Seat(player), + ))) +} + +/// **Row R2-d.** CR 601.2c + CR 702.11c: a ranked `Seat` is judged as a TARGET. The same seat, +/// on the same board, at the same slot, is REFUSED in the TARGET-class spelling and ADMITTED in +/// the CR 115.10a CHOICE-class one — which is the whole content of the provenance split, stated +/// as one measurement. +/// +/// # The three arms, and why none of them is redundant +/// +/// * **(a) paired positive** — the ranked seat resolves to `ConcreteTarget::Player(P1)` on the +/// board with NO hexproof. Without it, arm (b) is equally explained by a `Seat` arm that +/// never resolves anything (a dead accessor, a slot source that does not re-bind, a +/// fail-closed branch taken for a bookkeeping reason). +/// * **(b) the claim** — the identical template on the identical board plus one "You have +/// hexproof" permanent P1 controls is `ReplayFailure::IllegalTarget`. +/// * **(c) the CHOICE-class sibling, on the SAME hexproofed board** — a `TargetPin::Player(P1)` +/// at the same slot still resolves. This is the arm that proves (b) is AUTHORITY SELECTION +/// and not a newly-strict engine: CR 115.10a says a player who is not identified by the word +/// "target" is not a target, so applying hexproof to a merely CHOSEN seat would be an +/// over-veto that refuses legal CR 732.2a proposals. Losing arm (c) is the failure mode the +/// shipped `a_shrouded_player_pin_is_still_published_by_the_offer_builder` guards from the +/// other side. +/// +/// # Discrimination +/// +/// * swap `evaluate_schedule`'s `Seat` arm from `targeting::player_is_legal_target` to +/// `players::player_exists_for_choice` ⇒ the hexproofed seat resolves ⇒ **(b) FAILS** while +/// (a) and (c) stay green — the exact asymmetry that makes this row about the AUTHORITY and +/// not about the board; +/// * conversely, route `resolve_target`'s `TargetPin::Player` arm through +/// `player_is_legal_target` ⇒ **(c) FAILS** ⇒ the over-veto is caught here too. +/// +/// # Reach-guards +/// +/// The hexproof is asserted to bite at the TARGET seam for THIS source and controller before +/// (b) is claimed, and asserted NOT to bite for a third seat on the same board — so the +/// exclusion is the hexproof rather than a blanket refusal. CR 702.11c is opponent-scoped, so +/// the source's controller is asserted to be P1's opponent. +#[test] +fn r2d_a_ranked_seat_is_judged_as_a_target_while_the_choice_class_keeps_existence_only() { + let (clean, source) = board_with_source(); + let slot = slot_for(source, &clean); + + // ── (a) PAIRED POSITIVE: no hexproof ⇒ the ranked seat resolves ── + let ranked = one_pin_template(slot.clone(), ranked_seat(P1)); + assert_eq!( + resolve(&ranked, 0, &clean), + Ok(vec![ConcreteDecision::Targets { + slot: slot.clone(), + targets: vec![ConcreteTarget::Player(P1)], + }]), + "CR 601.2c: a ranked seat whose slot source is a live battlefield object RESOLVES — \ + without this arm the refusal below is satisfied by a `Seat` arm that never resolves \ + at all" + ); + + // ── the hostile board: "You have hexproof" on a permanent P1 controls ── + let mut hostile = clean.clone(); + grant_hexproof(&mut hostile, P1); + let controller = hostile.objects[&source].controller; + assert!( + engine::game::players::is_opponent(&hostile, P1, controller), + "reach-guard: CR 702.11c excludes only OPPONENTS' spells and abilities, so the ability \ + source's controller {controller:?} must be P1's opponent" + ); + assert!( + engine::game::static_abilities::player_cannot_be_targeted_by( + &hostile, P1, source, controller + ), + "reach-guard: the grant must actually bite at the TARGET seam for THIS source, else \ + arm (b) proves nothing" + ); + assert!( + !engine::game::static_abilities::player_cannot_be_targeted_by( + &hostile, P2, source, controller + ), + "reach-guard: a third seat on the same board is still targetable, so the exclusion is \ + the hexproof and not an empty legal space" + ); + + // ── (b) THE CLAIM: the TARGET-class spelling is refused ── + assert_eq!( + resolve(&ranked, 0, &hostile), + Err(ReplayFailure::IllegalTarget { + slot: slot.clone(), + pin: ranked_seat(P1), + }), + "CR 601.2c + CR 702.11c: an ANNOUNCED seat is a target, so hexproof makes it an \ + ILLEGAL one. `player_exists_for_choice` would say yes here — that is the authority \ + this spelling exists to move off" + ); + + // ── (c) THE CHOICE-CLASS SIBLING on the SAME board: existence only, still admitted ── + let chosen = one_pin_template(slot.clone(), TargetPin::Player(P1)); + assert_eq!( + resolve(&chosen, 0, &hostile), + Ok(vec![ConcreteDecision::Targets { + slot, + targets: vec![ConcreteTarget::Player(P1)], + }]), + "CR 115.10a: a seat that is CHOSEN rather than targeted is not subject to CR 702.11c, \ + so the choice class still resolves on the very board the target class refuses. \ + Applying the targeting exclusions here would be the over-veto — it would refuse legal \ + CR 732.2a proposals, e.g. a CR 701.34a proliferate choice" + ); +} diff --git a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs new file mode 100644 index 0000000000..e27e967290 --- /dev/null +++ b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs @@ -0,0 +1,294 @@ +//! **Row R2-b — the PROVENANCE census.** CR 601.2c vs CR 115.10a: a seat pin's SPELLING is its +//! provenance, so no TARGET-class producer may construct a `TargetPin::Player`. +//! +//! # Why a census and not a validator narrowing +//! +//! The alternative — teaching `validate_pins` / `declaration_conforms` to REJECT a +//! `TargetPin::Player` on a player-valued `Targets` slot — is refused for a measured reason. +//! `game::engine`'s `a_shrouded_player_pin_is_still_published_by_the_offer_builder` carries the +//! revert-probe *"route `resolve_target`'s `TargetPin::Player` arm through +//! `targeting::player_is_legal_target` ⇒ both assertions FAIL"*: that narrowing restores the +//! CR 115.10a over-veto, where a merely CHOSEN player (a CR 701.34a proliferate choice) is +//! refused for a targeting-only exclusion it is not subject to. It would also put a rejection +//! rule on a WIRE-VISIBLE type in order to stop a FUTURE producer from picking the wrong +//! spelling. A source census stops the same thing at no wire-compatibility cost, which is what +//! this file is. +//! +//! # What it asserts, in three conjuncts +//! +//! 1. **Neither TARGET-class producer constructs a `TargetPin::Player`.** The two producers are +//! named individually — `game::engine::record_trigger_target_answer` (the engine's own +//! CR 601.2c announcement journal) and +//! `game::interaction::materialize_loop_shortcut_response` (the human ingress of the same +//! point kind). Naming them individually is the point: a census asserting only a TOTAL +//! production count would stay green with EITHER producer reverted, since a revert removes +//! a ranked construction and adds a `TargetPin::Player` one, leaving the total unchanged. +//! 2. **The CHOICE-class and pass-through sites are still PRESENT and classified.** A census +//! that counts zero of everything is vacuous, so every surviving production site is +//! enumerated with its disposition and the list is pinned exactly. +//! 3. **Both producers do construct the ranked spelling**, keyed to the same instrument — one +//! census returning a non-zero answer for one needle and a zero for another, on the same +//! files, is what makes conjunct 1's zero a measurement rather than a broken grep. +//! +//! # Anti-vacuity: the instrument is validated before it is trusted +//! +//! [`the_seat_pin_census_instrument_reports_both_answers_on_planted_input`] feeds the classifier +//! a synthetic source carrying BOTH spellings inside and outside a `#[cfg(test)]` scope and +//! requires it to separate them. Without that arm, a needle that silently matched nothing would +//! report conjunct 1 as a pass. +//! +//! The `#[cfg(test)]` scope classifier and the directory walk are REUSED from +//! [`super::loop_shortcut_offer_writer_census`] rather than re-derived: that file records a +//! measured defect in the naive "nearest preceding attribute" rule, and a second copy of the +//! rule is a second place for it to be got wrong. + +use std::collections::BTreeMap; +use std::path::Path; + +use super::loop_shortcut_offer_writer_census::{cfg_test_scoped_lines, rs_files}; + +/// The CHOICE-class needle, ASSEMBLED AT RUNTIME for the same reason the sibling census +/// assembles its own: this file lives under `crates/engine/tests/`, which the walk does not +/// visit, but an instrument that would report its own text as a finding after a future move is +/// an instrument that lies about the surface it measures. +fn choice_needle() -> String { + format!("{}::{}(", "TargetPin", "Player") +} + +/// The TARGET-class needle — the subject kind that only ever reaches the resolver through a +/// `Ranking`, i.e. the spelling that IS the CR 601.2c provenance. +fn target_needle() -> String { + format!("{}::{}(", "AnnouncementSubject", "Seat") +} + +/// One classified construction/match site. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Site { + file: String, + line: usize, + text: String, +} + +/// Non-comment hits of `needle` in production (non-`#[cfg(test)]`) scope. +/// +/// COMMENT LINES ARE EXCLUDED, and this is the same deviation the sibling census records: prose +/// writes no pin and reads none, so a doc mentioning a spelling is not a construction site. The +/// doc surface is swept separately (the commit's per-property bucket table); counting it here +/// would make the tripwire fire on prose. `//!`, `///` and `//` are all excluded; a trailing +/// comment on a code line still counts, because the CODE on that line is real. +fn production_sites(needle: &str) -> Vec { + let engine_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let server_src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("server-core") + .join("src"); + let ai_src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("phase-ai") + .join("src"); + let mut out = Vec::new(); + for (root, prefix) in [ + (engine_src, "engine/src"), + (server_src, "server-core/src"), + (ai_src, "phase-ai/src"), + ] { + for path in rs_files(&root) { + let src = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let scoped = cfg_test_scoped_lines(&src); + let rel = path + .strip_prefix(&root) + .expect("walked path is under its root") + .to_string_lossy() + .replace('\\', "/"); + for (n, line) in src.lines().enumerate() { + if line.contains(needle) && !line.trim_start().starts_with("//") && !scoped[n] { + out.push(Site { + file: format!("{prefix}/{rel}"), + line: n + 1, + text: line.trim().to_string(), + }); + } + } + } + } + out.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line))); + out +} + +/// CONJUNCT 1 + 2 — neither TARGET-class producer constructs a `TargetPin::Player`, and every +/// surviving production site is CHOICE class or a pass-through arm. +/// +/// # Discrimination — TWO independent revert-probes, one per producer +/// +/// * restore `game::engine::record_trigger_target_answer`'s `TargetRef::Player(pl)` arm to +/// `Some(TargetPin::Player(*pl))` ⇒ `engine/src/game/engine.rs` gains an unclassified +/// `TargetPin::Player` construction ⇒ the pinned site list below FAILS with its file:line; +/// * restore `game::interaction::materialize_loop_shortcut_response`'s +/// `Target(TargetRef::Player(player))` arm to `Ok(TargetPin::Player(*player))` ⇒ +/// `engine/src/game/interaction.rs` re-appears in the list ⇒ FAILS. +/// +/// A census asserting only a TOTAL would catch NEITHER: each revert swaps one ranked +/// construction for one `TargetPin::Player` construction and leaves the sum where it was. +/// +/// # Paired positive — the CHOICE class must still be THERE +/// +/// The surviving sites are asserted PRESENT, not merely bounded: the CR 701.34a proliferate arm +/// in `engine.rs`, `resolve_target`'s CHOICE authority arm and the drive-period arm, the +/// redaction arm in `visibility.rs`, and the wire-bound arm in `server-core`. A census that +/// counted zero of everything — a broken needle, a walk that visited nothing — would satisfy +/// conjunct 1 alone. +#[test] +fn no_target_class_producer_constructs_a_choice_class_player_pin() { + let sites = production_sites(&choice_needle()); + let located: Vec<(String, usize)> = sites + .iter() + .map(|s| (s.file.clone(), s.line)) + .collect::>(); + let files: Vec<&str> = sites.iter().map(|s| s.file.as_str()).collect(); + + // CONJUNCT 1: the two TARGET-class producers' files may still hold CHOICE-class sites + // (`engine.rs` holds two), but a construction inside either producer is what this row + // forbids — so the sites are pinned by exact file:line and text, not merely by file. + assert_eq!( + files, + vec![ + "engine/src/analysis/decision_template.rs", + "engine/src/game/engine.rs", + "engine/src/game/engine.rs", + "engine/src/game/visibility.rs", + "server-core/src/game_action_payload_guard.rs", + ], + "CR 601.2c / CR 115.10a PROVENANCE SPLIT VIOLATED, or a new unclassified \ + `TargetPin::Player` production site appeared.\n\ + The FIVE surviving production sites and their dispositions:\n\ + 1. `analysis/decision_template.rs` — `resolve_target`'s CHOICE authority arm \ + (CR 115.10a existence-only). This IS the class's authority; do not narrow it, \ + `a_shrouded_player_pin_is_still_published_by_the_offer_builder` is the shipped \ + guard against exactly that.\n\ + 2. `game/engine.rs` — `shortcut_drive_period`'s period arm; the migrated pin lands on \ + the `Scheduled(Constant(_))` arm of the SAME match, which also returns 1.\n\ + 3. `game/engine.rs` — `apply_action`'s CR 701.34a proliferate arm feeding \ + `record_loop_pin`. A proliferate choice is NOT a target, so this construction is \ + correct and is this census's positive control.\n\ + 4. `game/visibility.rs` — `pins_name_hidden_source`'s redaction arm (`false`: seat \ + identity is public in this engine), kept for wire-sourced pins.\n\ + 5. `server-core/src/game_action_payload_guard.rs` — the wire pass-through arm.\n\ + A hit in `game/interaction.rs` means the HUMAN ingress reverted to the choice-class \ + spelling; a THIRD hit in `game/engine.rs` means the announcement journal did. Either \ + re-creates two authorities selected by WHO SUBMITTED an answer rather than by WHAT IT \ + IS. got {sites:?}" + ); + + // The `engine.rs` pair is pinned to its two ARMS by text, so a construction added inside + // `record_trigger_target_answer` cannot hide behind the file already being listed twice. + let engine_texts: Vec<&str> = sites + .iter() + .filter(|s| s.file == "engine/src/game/engine.rs") + .map(|s| s.text.as_str()) + .collect(); + assert_eq!( + engine_texts.len(), + 2, + "the file-level list above is pinned at two `engine.rs` sites; if that changes the \ + text pin below is measuring the wrong things. got {engine_texts:?}" + ); + assert!( + engine_texts[0].starts_with("| TargetPin::Player(_) =>"), + "site 2 must remain `shortcut_drive_period`'s MATCH arm — a match arm reads a pin, it \ + cannot produce one. got {:?}", + engine_texts[0] + ); + assert!( + engine_texts[1].contains("TargetPin::Player(*pl)"), + "site 3 must remain the CR 701.34a proliferate CONSTRUCTION — this census's positive \ + control, and the one production construction of the choice class. got {:?}", + engine_texts[1] + ); + + // CONJUNCT 3, keyed to the SAME instrument: both TARGET-class producers construct the + // ranked spelling. One needle returning five sites and the other returning the two + // producers, over the same walk, is what makes conjunct 1's absence a measurement. + let ranked = production_sites(&target_needle()); + let mut ranked_per_file: BTreeMap<&str, usize> = BTreeMap::new(); + for s in &ranked { + *ranked_per_file.entry(s.file.as_str()).or_default() += 1; + } + let ranked_files: Vec<(&str, usize)> = ranked_per_file.into_iter().collect(); + assert_eq!( + ranked_files, + vec![ + ("engine/src/analysis/decision_template.rs", 1), + ("engine/src/game/engine.rs", 1), + ("engine/src/game/interaction.rs", 1), + ("engine/src/game/visibility.rs", 1), + ], + "the TARGET-class spelling must be CONSTRUCTED by BOTH producers — `game/engine.rs` \ + (`record_trigger_target_answer`) and `game/interaction.rs` \ + (`materialize_loop_shortcut_response`) — beside its two READ sites: \ + `evaluate_schedule`'s CR 601.2c resolver arm in `analysis/decision_template.rs` and \ + the wildcard-free redaction arm in `game/visibility.rs`. A MISSING producer is the \ + revert this row exists to catch; an EXTRA file is a new producer that must be \ + classified rather than absorbed. got {ranked:?}" + ); + assert!( + !located.is_empty(), + "keying control: the choice-class needle must return a NON-EMPTY set, or its \ + per-producer absence above would be the answer a dead instrument gives" + ); +} + +/// ANTI-VACUITY ARM — the instrument returns BOTH answers on planted input. +/// +/// The classifier is fed one synthetic source carrying the choice-class spelling and the +/// target-class spelling, each in production scope, inside a `#[cfg(test)] pub(crate) mod`, and +/// on a comment line. It must report exactly the production, non-comment hits. +/// +/// # Discrimination +/// +/// * delete the `!scoped[n]` conjunct ⇒ the mod-scoped plants count ⇒ `(1, 1)` becomes +/// `(2, 2)` ⇒ FAILS; +/// * delete the comment filter ⇒ the prose plants count ⇒ `(1, 1)` becomes `(2, 2)` ⇒ FAILS. +/// +/// It is measured on the real tree too, in the row above: the same classifier returns five +/// sites for one needle and four files for the other, so it is not constant in either +/// direction. +#[test] +fn the_seat_pin_census_instrument_reports_both_answers_on_planted_input() { + let choice = choice_needle(); + let target = target_needle(); + let src = format!( + "fn production_side() {{\n\ + \x20 let a = {choice}PlayerId(0));\n\ + \x20 let b = Ranking::one({target}PlayerId(1)));\n\ + \x20 // prose about {choice}..) and {target}..) that constructs nothing\n\ + }}\n\ + \n\ + #[cfg(test)]\n\ + pub(crate) mod tests {{\n\ + \x20 fn test_side() {{\n\ + \x20 let a = {choice}PlayerId(0));\n\ + \x20 let b = Ranking::one({target}PlayerId(1)));\n\ + \x20 }}\n\ + }}\n" + ); + + let scoped = cfg_test_scoped_lines(&src); + let count = |needle: &str| { + src.lines() + .enumerate() + .filter(|(n, line)| { + line.contains(needle) && !line.trim_start().starts_with("//") && !scoped[*n] + }) + .count() + }; + + assert_eq!( + (count(&choice), count(&target)), + (1, 1), + "the classifier must see exactly the ONE production, non-comment construction of each \ + spelling: the `#[cfg(test)] pub(crate) mod` copies belong in the test column and the \ + prose line is not a construction site. Dropping either filter makes this (2, 2).\n\ + src:\n{src}" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index f4f9e15420..6238194dd7 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1185,6 +1185,8 @@ mod loop_shortcut; mod loop_shortcut_activation; mod loop_shortcut_mana_engine; mod loop_shortcut_offer_writer_census; +mod loop_shortcut_ranking; +mod loop_shortcut_seat_pin_census; mod lose_control_this_turn_delayed_trigger; mod lost_mine_fungi_cavern_duration_runtime; mod lost_mine_storeroom_targeting_runtime; From 5f23a52d29d77d36151adbd2f980e1819caddaff Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 12 Aug 2026 07:09:07 -0500 Subject: [PATCH 19/44] test(engine): describe the seat-pin census by what it actually asserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conjunct-1 comment claimed the five CHOICE-class sites were "pinned by exact file:line and text, not merely by file". Line numbers are compared nowhere: `located` carried them but its only use was a non-emptiness check, while the assertion compares `files` — paths alone. That sentence misled twice in one place. It warned of a drift burden that does not exist (no insertion above a site can fail this test), and it hid the real limitation: three of the five sites are pinned by file identity and count alone, so swapping one of their constructions for a different one in the same file leaves the census green. Now stated accurately. The compared value is the file MULTISET — identity and multiplicity, in the sorted order `production_sites` already produces. That is the right instrument for this row: multiplicity catches a new construction anywhere, including a third `engine.rs` hit from reverting `record_trigger_target_answer`, and `interaction.rs` is pinned by ABSENCE, which no rearrangement inside that file can satisfy. The `engine.rs` pair — the one place multiplicity alone would let a third construction hide behind a file already listed twice — is narrowed by the text pins on its two arms. The limitation is now an explicit, greppable `LIMITATION:` paragraph rather than an absence. Deliberately NOT fixed by pinning line numbers. A line pin would manufacture exactly the drift burden the false comment warned about, and buys no discrimination the text pins do not already provide. If a later round closes the three-site gap, the honest close is text pins on those arms — and that paragraph must move in lockstep, or this file re-acquires the same defect in mirror image. `located` is deleted; the keying control now reads `sites`, a receiver swap that is equivalent by construction (`located` was a 1:1 map of `sites`). No assertion's compared value changes, and the failure message still reports file:line, because it interpolates `sites` and `Site` carries `line`. Supersedes the rebase note in bce786d4f, which repeated the same false "pins exact file:line" claim and its nonexistent drift warning. Found by independent review of bce786d4f; verified against the code rather than the report before acting. Assisted-by: ClaudeCode:claude-opus-5 --- .../loop_shortcut_seat_pin_census.rs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs index e27e967290..168d6c4b4a 100644 --- a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs @@ -141,15 +141,22 @@ fn production_sites(needle: &str) -> Vec { #[test] fn no_target_class_producer_constructs_a_choice_class_player_pin() { let sites = production_sites(&choice_needle()); - let located: Vec<(String, usize)> = sites - .iter() - .map(|s| (s.file.clone(), s.line)) - .collect::>(); let files: Vec<&str> = sites.iter().map(|s| s.file.as_str()).collect(); - // CONJUNCT 1: the two TARGET-class producers' files may still hold CHOICE-class sites - // (`engine.rs` holds two), but a construction inside either producer is what this row - // forbids — so the sites are pinned by exact file:line and text, not merely by file. + // CONJUNCT 1: what is compared is the FILE MULTISET — file identity AND multiplicity, in + // sorted order. Line numbers are not compared; they appear only in the failure message. + // That is deliberate: a line pin fails on any insertion above a site, which is drift + // burden without discrimination. Multiplicity carries the load instead — a new construction + // anywhere changes the compared value, including a THIRD `engine.rs` hit from reverting + // `record_trigger_target_answer`, and `interaction.rs` is pinned by ABSENCE, which no + // rearrangement inside that file can satisfy. The `engine.rs` pair, where multiplicity + // alone would let a third construction hide behind the file already being listed twice, is + // narrowed by the text pins further down that name each of its two arms. + // + // LIMITATION: the three singly-listed files (`decision_template.rs`, `visibility.rs`, + // `server-core/...`) are pinned by file identity and count alone. Swapping one of their + // constructions for a DIFFERENT one in the same file leaves this census green. Those are + // not the producers this row is about, and none of them is text-pinned. assert_eq!( files, vec![ @@ -232,7 +239,7 @@ fn no_target_class_producer_constructs_a_choice_class_player_pin() { classified rather than absorbed. got {ranked:?}" ); assert!( - !located.is_empty(), + !sites.is_empty(), "keying control: the choice-class needle must return a NON-EMPTY set, or its \ per-producer absence above would be the answer a dead instrument gives" ); From 4ab31f617254a1da46f261938092f718abf6c994 Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 12 Aug 2026 08:11:29 -0500 Subject: [PATCH 20/44] test(engine): rewrite the census comment from the probe table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round replaced a false comment and introduced three new false claims. An independent 10-probe mutation harness measured what each assertion actually catches; this comment is now written from that table rather than from reasoning about the code. What was wrong: - The central corrective sentence said multiplicity alone would let a third `engine.rs` construction hide, "narrowed by the text pins". False in both directions. A third construction fails the multiset assert BEFORE the text pins are reached (P2), and in the one scenario where the text pins would be the last defence — producer reverted, count held at 2, both texts still matching — both text pins PASS and conjunct 3 makes the catch (P9). - "Line numbers appear only in the failure message" — `s.line` is the secondary sort key and decides which site lands in `engine_texts[0]` vs `[1]`. Relocating arm 0 below arm 1, with count and both texts unchanged, fails the first text pin (P7). - "A new construction anywhere" — the walk visits three roots only (`engine/src`, `server-core/src`, `phase-ai/src`). A fourth sentence above `engine_texts` restated the first false claim verbatim and is corrected in the same round; fixing one and leaving the other would have left the file self-contradictory and the named hazard un-foreclosed. The comment now names the three layers by role rather than by line: the multiset catches an added construction in a walked file; the text pins catch a substitution that holds the count at 2, and a change in the arms' relative order; a substitution that also preserves both texts is caught only by conjunct 3's per-file count. It carries an explicit "do NOT de-duplicate `files`" warning, because that is the concrete damage the false version invited. The unwalked-crate gap (`engine-wasm`, `seat-reducer`, `phase-server` all depend on the engine) is disclosed as latent: `TargetPin` appears today in no crate but `engine` and `server-core`, both walked. Naming assertions by role rather than coordinate is deliberate — this round also shifted every line coordinate the probe table cited. Prose only. Non-comment content is byte-identical to 6ffd07f53: no assertion, compared value, or message string changed, and both census tests pass. Supersedes the pin-granularity claims in bce786d4f and 6ffd07f53. Assisted-by: ClaudeCode:claude-opus-5 --- .../loop_shortcut_seat_pin_census.rs | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs index 168d6c4b4a..43d9be083e 100644 --- a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs @@ -144,14 +144,31 @@ fn no_target_class_producer_constructs_a_choice_class_player_pin() { let files: Vec<&str> = sites.iter().map(|s| s.file.as_str()).collect(); // CONJUNCT 1: what is compared is the FILE MULTISET — file identity AND multiplicity, in - // sorted order. Line numbers are not compared; they appear only in the failure message. - // That is deliberate: a line pin fails on any insertion above a site, which is drift - // burden without discrimination. Multiplicity carries the load instead — a new construction - // anywhere changes the compared value, including a THIRD `engine.rs` hit from reverting + // sorted order. Line numbers are not compared against any pinned value; they order the sites + // (`production_sites` sorts by `(file, line)`) and appear in the failure message. That is + // deliberate: a line pin fails on any insertion above a site, which is drift burden without + // discrimination — measured: a 200-line insertion above every site leaves this census green. + // Multiplicity carries the load instead — a new construction anywhere THE WALK VISITS changes + // the compared value, including a THIRD `engine.rs` hit from reverting // `record_trigger_target_answer`, and `interaction.rs` is pinned by ABSENCE, which no - // rearrangement inside that file can satisfy. The `engine.rs` pair, where multiplicity - // alone would let a third construction hide behind the file already being listed twice, is - // narrowed by the text pins further down that name each of its two arms. + // rearrangement inside that file can satisfy. The walk visits three roots only + // (`engine/src`, `server-core/src`, `phase-ai/src`), so a construction added in a crate + // outside them — `engine-wasm`, `seat-reducer` and `phase-server` all depend on the engine — + // is invisible here. That gap is latent, not live: today `TargetPin` appears in no crate + // other than `engine` and `server-core`, and both of their `src` roots are walked. + // + // The doubled `engine.rs` entry is guarded by MULTIPLICITY, not by the text pins below: a + // third construction in that file fails THIS assertion, before the text pins are reached. + // Do NOT relax `files` to a de-duplicated set on the theory that the text pins cover the + // doubling — they are a different layer, and layer 3 below exists because a change can pass + // both of them. Three measured layers, in the order they fire: + // 1. this multiset — a THIRD `engine.rs` construction, i.e. either producer reverted; + // 2. the text pins below — a SUBSTITUTION at either arm that holds the count at 2, and a + // change in the two arms' relative ORDER (relocating arm 0 below arm 1 fails the first + // text pin with the count and both texts otherwise unchanged); + // 3. conjunct 3's per-file `AnnouncementSubject::Seat` count — a substitution that ALSO + // preserves both texts (producer reverted AND the positive control dropped) passes both + // text pins and is caught only there. // // LIMITATION: the three singly-listed files (`decision_template.rs`, `visibility.rs`, // `server-core/...`) are pinned by file identity and count alone. Swapping one of their @@ -187,8 +204,9 @@ fn no_target_class_producer_constructs_a_choice_class_player_pin() { IS. got {sites:?}" ); - // The `engine.rs` pair is pinned to its two ARMS by text, so a construction added inside - // `record_trigger_target_answer` cannot hide behind the file already being listed twice. + // The `engine.rs` pair is pinned to its two ARMS by text: a SUBSTITUTION at either arm that + // holds the file count at 2 fails here, as does relocating the arms relative to each other. + // An ADDED construction in this file does not reach here — the multiset above fails first. let engine_texts: Vec<&str> = sites .iter() .filter(|s| s.file == "engine/src/game/engine.rs") From 5b5e9317b4811fe25ced6c1ad60b4b25ead3cec7 Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 12 Aug 2026 09:07:28 -0500 Subject: [PATCH 21/44] test(engine): correct two overstated clauses in the census comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two one-clause transcriptions, both restoring wording this file already uses correctly elsewhere. `i.e. either producer reverted` equated two different mutations. Reverting `record_trigger_target_answer` adds a third `engine.rs` construction, caught by multiplicity. Reverting `materialize_loop_shortcut_response` leaves `engine.rs` at two and makes `interaction.rs` appear, caught by absence-pinning. The file already states that split; the layer-1 summary had drifted from it. `a new construction anywhere the walk visits` overstated the guarantee. The walk does visit `#[cfg(test)]` scopes, but `production_sites` drops them, so a construction inside a walked file's test module changes nothing — which this file's own anti-vacuity arm measures by planting cfg-test-scoped copies of both spellings and asserting the classifier still sees one of each. Now says "in production scope", matching `production_sites`' own doc comment. Both defects were summaries that contradicted their own sources a few lines away. That is the recurring shape here: this comment is on its fourth generation because compressed restatements drift from the measurements they summarise. Comment-only. Non-comment content is byte-identical to 1c962c6c: comment -stripped blobs hash the same, and a whole-file word-stream diff shows exactly two changes — "in production scope" inserted, and "i.e." replaced. The remaining line-break moves are a rewrap forced by column width, word-identical by that same diff. Assisted-by: ClaudeCode:claude-opus-5 --- .../integration/loop_shortcut_seat_pin_census.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs index 43d9be083e..2648d2e3d2 100644 --- a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs @@ -148,10 +148,10 @@ fn no_target_class_producer_constructs_a_choice_class_player_pin() { // (`production_sites` sorts by `(file, line)`) and appear in the failure message. That is // deliberate: a line pin fails on any insertion above a site, which is drift burden without // discrimination — measured: a 200-line insertion above every site leaves this census green. - // Multiplicity carries the load instead — a new construction anywhere THE WALK VISITS changes - // the compared value, including a THIRD `engine.rs` hit from reverting - // `record_trigger_target_answer`, and `interaction.rs` is pinned by ABSENCE, which no - // rearrangement inside that file can satisfy. The walk visits three roots only + // Multiplicity carries the load instead — a new construction anywhere THE WALK VISITS in + // production scope changes the compared value, including a THIRD `engine.rs` hit from + // reverting `record_trigger_target_answer`, and `interaction.rs` is pinned by ABSENCE, + // which no rearrangement inside that file can satisfy. The walk visits three roots only // (`engine/src`, `server-core/src`, `phase-ai/src`), so a construction added in a crate // outside them — `engine-wasm`, `seat-reducer` and `phase-server` all depend on the engine — // is invisible here. That gap is latent, not live: today `TargetPin` appears in no crate @@ -162,7 +162,8 @@ fn no_target_class_producer_constructs_a_choice_class_player_pin() { // Do NOT relax `files` to a de-duplicated set on the theory that the text pins cover the // doubling — they are a different layer, and layer 3 below exists because a change can pass // both of them. Three measured layers, in the order they fire: - // 1. this multiset — a THIRD `engine.rs` construction, i.e. either producer reverted; + // 1. this multiset — a THIRD `engine.rs` construction, and, via ABSENCE, either producer + // reverted; // 2. the text pins below — a SUBSTITUTION at either arm that holds the count at 2, and a // change in the two arms' relative ORDER (relocating arm 0 below arm 1 fails the first // text pin with the count and both texts otherwise unchanged); From bc04573ec02e974e2b0c48038b3bdf84b616f46b Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 12 Aug 2026 16:01:48 -0500 Subject: [PATCH 22/44] fix(engine): ask the slot question through the named accessor at the last three sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pinned_targets_for_source`, `pinned_mana_color_for_source` and `resolve_pin`'s `Order` arm asked "which ability instance prompts?" with `resolve_source`, whose battlefield filter is the CR 608.2b TARGET re-check — so a command-zone-sourced slot (emblem CR 114.4; plane, scheme, conspiracy CR 113.6p; phenomenon CR 901.7; Eminence commander CR 113.6b) never matched. The first two now call `slot_source_prompted`; the third calls `resolve_ability_instance`. `resolve_source` is unchanged and stays battlefield-only. For the two `pinned_*` sites this is what the drive accepts: a command-zone-sourced tap-cost / mana-color / proliferate pin now answers its beat instead of falling back to manual play. The `Order` arm is NOT capability restoration. Six production points consume `resolve`'s output. The FIVE that read the vec's ELEMENTS never read a `ConcreteDecision::Order`'s payload — they skip it, or abort on its mere presence — so an Order-only template still fails closed there and only the abort's LOCATION moves, out of `resolve()` and into the consumer. The SIXTH, `materialize_fixed_shortcut`'s per-cycle re-check, reads only the `is_err()` verdict: there the abort does not move, it disappears — a pin that fails to re-bind breaks the cycle loop at that iteration, and for a source the accessor never admits, that is iteration 0, so no cycle commits at all. That holds for every template shape. Because `resolve()` is a per-pin `Result` collect, a command-zone `Order` pin was discarding every OTHER pin's answer in the same template; it no longer does. That payoff is per CONSUMER, not per template: on the measured `[Order @ command, Targets @ battlefield]` — the Order pin re-binding, the Targets pin resolving — `pinned_targets_for_source` now returns that Targets pin's answer where it aborted before, while a reader with no element of its own kind still aborts and the `ManaPayment` beat still aborts on the Order element's mere presence, after this commit exactly as before it. The neutrality bound is drive EQUALITY, not submittability: when an Order element re-binds, resolving with it yields the identical answer to resolving without it at every consumer except the `ManaPayment` beat, where it forces an abort — strictly more fail-closed; when it does not re-bind the whole template fails at every consumer. Where the Order-less template is itself submittable, that drive was already expressible by omitting the pin. It is not always submittable: `pin_slot` addresses an Order pin to `{source, index 0}` and `validate_pins`' Order arm is the one arm that checks nothing, so an Order pin can be the sole cover of a required point, and dropping it then fails `predictability_gate`. That shape is pre-existing and zone-independent — only the set of spellable sources changes. No template's ACCEPTANCE changes here: `declaration_conforms` is `predictability_gate` + `validate_pins` and neither consults `resolve_pin`. Behaviour was fail-closed before, so nothing illegal shipped; this is missing wire-tier acceptance, not a rules fix. Not widened: the `AllCopies` (card-identity) arm still resolves battlefield-only, so a command-zone source named by card identity still fails closed — disclosed and pinned by a row. Assisted-by: ClaudeCode:claude-opus-5 --- .../engine/src/analysis/decision_template.rs | 326 ++++++++- crates/engine/src/game/engine.rs | 663 +++++++++++++++++- .../tests/integration/interaction_contract.rs | 6 +- 3 files changed, 930 insertions(+), 65 deletions(-) diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index b862d3e1f3..9bf4722c5c 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -668,9 +668,11 @@ pub enum ReplayFailure { /// name neither. Parameterizing the existing variant keeps ONE "target went illegal" /// failure instead of growing a per-pin-kind sibling cluster. IllegalTarget { slot: DecisionSlot, pin: TargetPin }, - /// CR 400.7: an ORDER pin's source (`Order`) is absent from the current battlefield - /// ⇒ the ordering template no longer matches ⇒ fall through to a normal manual - /// prompt. Raised ONLY for the `Order` pin kind, in any `ReplayMode`. + /// CR 400.7: an ORDER pin's source does not re-bind to a live ability instance — + /// [`resolve_ability_instance`] finds no object of that identity at that incarnation in a + /// zone its abilities function from (CR 113.6b) ⇒ the ordering template no longer matches + /// ⇒ fall through to a normal manual prompt. Raised ONLY for the `Order` pin kind, in any + /// `ReplayMode`. MissingSource { source: DecisionSource }, /// A `RoundRobin`/`Piecewise` schedule has no entry covering this iteration index. ScheduleExhausted { slot: DecisionSlot }, @@ -710,12 +712,79 @@ fn resolve_pin( state: &GameState, ) -> Result { match pin { - // CR 603.3b: replay this source's trigger at its pinned ordering position. The - // source must still be on the battlefield or the ordering template no longer - // matches (CR 400.7). + // CR 603.3b: replay this source's trigger at its pinned ordering position. The pin + // re-binds to the SAME live ability instance (CR 400.7 incarnation), in a zone that + // instance's abilities function from (CR 113.6b; emblems CR 114.4, planes / schemes / + // conspiracies CR 113.6p) — not merely to something still on the battlefield. + // + // Resolving an `Order` pin GRANTS NO CAPABILITY, and the six points that consume + // `resolve`'s output split two ways. FIVE read the vec's ELEMENTS, and not one of them + // reads a `ConcreteDecision::Order`'s PAYLOAD: `inject_pinned_answer`'s two arms + // `find_map` for their own kind, `pinned_targets_for_source` and + // `pinned_mana_color_for_source` skip it in an `if let`, and the `ManaPayment` beat's + // exhaustive match aborts on its mere presence (`Order { .. }`, fields discarded). ONE + // — the per-cycle re-check in `materialize_fixed_shortcut` — reads only the `is_err()` + // VERDICT and discards the vec entirely. (LABELED CODE READ: the six consumption + // points, enumerated and read at both revisions.) + // + // At the five ELEMENT readers an ORDER-ONLY template still fails closed; what the + // re-bind changes is only WHERE: the abort moves out of `resolve()` and into the + // consumer — the trailing `Err(RecastAbort)` of the two `pinned_*` seams, the + // `find_map`'s `ok_or(RecastAbort)` in the injector, the match arm at the + // `ManaPayment` beat. + // + // At the VERDICT reader nothing moves downstream, because there is nothing downstream + // to move to: that gate consults only `resolve`'s `Result`. A pin that fails to + // re-bind breaks the cycle loop AT THAT ITERATION, committing only the cycles before + // it — and for a source the accessor never admits, that is iteration 0, so no cycle + // commits at all. Once every pin re-binds, the gate stops breaking; what happens after + // it is governed by the element readers above, not by this gate. That is true for + // every template shape, Order-only and mixed alike, because the gate never looks at an + // element. + // + // For a mixed template whose OTHER pins themselves resolve, the payoff lands at an + // element reader that has an element of its own kind. Two of the five answer from a + // `Targets` element — `pinned_targets_for_source` and the injector's + // `TriggerTargetSelection` arm — and the one measured here is + // `pinned_targets_for_source`: on the measured value `[Order @ command zone, + // Targets @ battlefield]`, with the `Order` pin re-binding and the `Targets` pin itself + // resolving, it now returns that `Targets` pin's own answer, where before the + // command-zone `Order` pin discarded it and the seam aborted. Where a pin does NOT + // resolve, the template still fails whole at every one of the six consumers, because + // `resolve` is a per-pin `Result` collect that each consumer gates on before reading + // anything: a mixed template carrying a `Targets` pin whose own target is not on the + // battlefield (CR 608.2b) aborts after this commit exactly as before it. It is NOT + // that every element reader succeeds on such a template: a reader that finds no + // element of its own kind (`pinned_mana_color_for_source`, the injector's `MayChoice` + // arm) still reaches its `Err(RecastAbort)`, and the `ManaPayment` beat still aborts on + // the `Order` element's mere presence — after this commit exactly as before it, for + // every mixed template. + // + // The bound that makes this capability-neutral at the `DeclareShortcut` wire is DRIVE + // EQUALITY, not submittability. When an `Order` element re-binds, resolving WITH it + // yields the identical answer to resolving without it at every one of the six + // consumers, EXCEPT at the `ManaPayment` beat, where its presence forces + // `Err(RecastAbort)` — strictly more fail-closed, never more permissive. When it does + // NOT re-bind, the `Result` collect fails the whole template at every consumer; this + // commit changes which sources re-bind, never that disposition. Where the same + // template minus its `Order` pins is itself submittable, that drive was therefore + // already expressible by omitting them. It is NOT always submittable: `pin_slot` + // addresses an `Order` pin to `{source, index 0}` and `validate_pins`' `Order` arm is + // the one arm that checks nothing, so an `Order` pin can be the sole cover of a + // required point, and dropping it then fails `predictability_gate`. That shape is + // pre-existing and zone-independent — this commit changes only which sources can be + // spelled into it, never the shape itself. + // + // No template's ACCEPTANCE changes here: `declaration_conforms` is + // `predictability_gate` + `validate_pins`, and neither reaches `resolve_pin`. What + // changes is what an already-accepted template does. (Pinned by + // `game::engine::stage2_injector_tests::a_command_zone_order_pin_stops_poisoning_the_template_without_gaining_capability` + // rows R/N/N2/P/P-minus; the structural clauses above are labeled code reads.) PinnedDecision::Order { source, pos } => { - let id = resolve_source(source, state).ok_or_else(|| ReplayFailure::MissingSource { - source: source.clone(), + let id = resolve_ability_instance(source, state).ok_or_else(|| { + ReplayFailure::MissingSource { + source: source.clone(), + } })?; Ok(ConcreteDecision::Order { source: id, @@ -879,8 +948,14 @@ fn resolve_target( /// Re-bind a stored `DecisionSource` to a live battlefield `ObjectId`. The battlefield /// analogue of `GameState::is_priority_yielded`'s matching arms. KIND-AGNOSTIC: returns /// `None` on no match, and the CALLER maps that to the pin-kind-appropriate -/// `ReplayFailure` (`Order` ⇒ `MissingSource`, a target ⇒ `IllegalTarget`) — the single -/// seam where G2's per-pin-kind failure selection is realized. +/// `ReplayFailure` (`Order` ⇒ `MissingSource`, a target ⇒ `IllegalTarget`) — G2's +/// per-pin-kind failure selection. +/// +/// The `Order` pin and the two `game::engine` slot seams enter through +/// [`resolve_ability_instance`] rather than here; this function is that accessor's +/// BATTLEFIELD DISJUNCT, and it remains the whole answer for the TARGET path +/// ([`resolve_target`]'s `Object` arm and `evaluate_schedule`'s `Object` head), where the +/// battlefield filter IS the CR 608.2b legality re-check. pub(crate) fn resolve_source(src: &DecisionSource, state: &GameState) -> Option { match src { // CR 400.7: bind ONE incarnation — a re-entered permanent bumps `incarnation` @@ -909,30 +984,54 @@ pub(crate) fn resolve_source(src: &DecisionSource, state: &GameState) -> Option< } } -/// CR 608.2b + CR 114.2: re-bind a stored `DecisionSource` to the live ABILITY INSTANCE it -/// identifies — a different question from [`resolve_source`]'s, and the reason this accessor -/// exists rather than a second spelling at each caller. +/// CR 608.2b + CR 114.4 + CR 113.6p: re-bind a stored `DecisionSource` to the live ABILITY +/// INSTANCE it identifies — a different question from [`resolve_source`]'s, and the reason +/// this accessor exists rather than a second spelling at each caller. /// -/// NOT YET THE ONLY SPELLING, and the honest statement is the useful one: `game::engine`'s -/// `pinned_targets_for_source` and `pinned_mana_color_for_source` ask this same question of a -/// pin's SLOT source through bare `resolve_source`, so a command-zone-sourced slot does not -/// match there and the drive fails closed to manual play. That is unchanged pre-existing -/// behaviour, not something this accessor introduced; migrating those two widens what the -/// drive accepts and so belongs to a commit that carries a row for it. +/// THE ONLY SPELLING of that question, as of the commit that migrated the last two callers. +/// Every production asker routes here: `resolve_pin`'s `Order` arm and `evaluate_schedule`'s +/// `Seat` arm call it directly, and `game::engine::slot_source_prompted` is the `bool`-valued +/// wrapper its four seams use — `inject_pinned_answer`'s `TriggerTargetSelection` and +/// `MayChoice` `find_map` guards, `pinned_targets_for_source`, and +/// `pinned_mana_color_for_source`. There is no bare `resolve_source` slot comparison left in +/// `game::engine`. /// /// A *pin's* source identifies a TARGET, so [`resolve_source`] is deliberately /// BATTLEFIELD-ONLY and that filter IS the CR 608.2b legality re-check: a pinned target that /// left the battlefield must stop matching, and it must not be widened. A *slot's* source -/// only identifies WHICH ability instance prompts, and CR 114.2 puts a planeswalker EMBLEM — -/// "both owned and controlled by that player" — in the COMMAND zone, where it stays for the -/// whole game and raises its triggers from. So the command-zone disjunct lives here, scoped -/// to object identity plus the pinned CR 400.7 incarnation, exactly as the battlefield arm -/// is. +/// only identifies WHICH ability instance prompts. CR 114.2 puts an EMBLEM — "both owned and +/// controlled by that player" — into the COMMAND zone; that is PLACEMENT. Whether the thing +/// placed there can prompt at all is a separate rule, and it is per ABILITY rather than per +/// object: CR 113.6b, "an ability that states which zones it functions in functions only from +/// those zones". So the command-zone disjunct lives here, scoped to object identity plus the +/// pinned CR 400.7 incarnation, exactly as the battlefield arm is. +/// +/// The class this disjunct serves is every command-zone-functioning ability source, not +/// emblems alone — which is why the filter is NOT tightened to `obj.is_emblem`: /// -/// `AllCopies` is card-identity matching and an emblem has no card, so only `ThisObject` -/// participates in the command disjunct. Graveyard / exile / hand sources still resolve -/// `None` ⇒ every caller fails closed (`game::engine::slot_source_prompted` aborts the drive -/// to manual play; `evaluate_schedule`'s `Seat` arm raises `IllegalTarget`). +/// * **emblems** — CR 114.4, "abilities of emblems function in the command zone"; +/// * **planes, schemes, conspiracies** — CR 113.6p, whose enumeration is "emblems, plane +/// cards, vanguard cards, scheme cards, and conspiracy cards"; `database::synthesis`'s +/// `synthesize_planechase` / `synthesize_archenemy` / `synthesize_conspiracy` stamp +/// `Zone::Command` onto each such face's triggers and statics; +/// * **phenomena** — CR 901.7, "any abilities of a FACE-UP plane card or phenomenon card in +/// the command zone function from that zone" (CR 113.6p's enumeration does not reach this +/// card type; `synthesize_planechase` covers both faces); +/// * **Eminence commanders** — an ordinary card whose own ability declares its zones, i.e. +/// CR 113.6b again, opted in per definition rather than per card type. +/// +/// `game::functioning_abilities` is where that opt-in is read (`active_zones` for a static, +/// `trigger_zones` for a trigger), and it — not this accessor — decides whether an ability +/// functions. A single object can carry a Command-functioning static and Battlefield-only +/// triggers at once, so an object-level zone test could never be the functioning authority; +/// identity is what this accessor selects on. +/// +/// RESIDUAL, measured and disclosed rather than closed: the command disjunct is +/// `ThisObject`-only. `AllCopies` matches by CARD identity and is battlefield-only, so a +/// command-zone source spelled by card identity — a conspiracy, an Eminence commander — still +/// resolves `None` and fails closed. Graveyard / exile / hand sources resolve `None` too ⇒ +/// every caller fails closed (`game::engine::slot_source_prompted` aborts the drive to manual +/// play; `evaluate_schedule`'s `Seat` arm raises `IllegalTarget`). pub(crate) fn resolve_ability_instance( src: &DecisionSource, state: &GameState, @@ -995,8 +1094,9 @@ fn evaluate_schedule( // `targeting::player_is_legal_target` (existence + CR 702.11c hexproof / // CR 702.18a shroud / CR 702.16b protection) rather than by existence alone. Its two // trailing arguments describe THE ABILITY INSTANCE that would name the seat, not a - // target object — hence `resolve_ability_instance` (which admits the CR 114.2 command - // zone) and NOT `resolve_source` (battlefield-only, and correctly so for a pin). + // target object — hence `resolve_ability_instance` (which admits the command zone — + // CR 114.4 / CR 113.6p) and NOT `resolve_source` (battlefield-only, and correctly so + // for a pin). // // A `None` ANYWHERE in this chain falls through to the `ok_or_else` below: with no // live ability instance the engine cannot certify that the object it would ask the @@ -2179,7 +2279,7 @@ mod tests { // ── item-4 R1 — the parameterized announcement subject (`Ranking`) ── /// Insert an object into an arbitrary zone. `bf_object` above is battlefield-only, and - /// rows R1-h/i need the CR 114.2 command zone and the graveyard. + /// rows R1-h/i need the CR 114.4 command zone and the graveyard. fn zoned_object(state: &mut GameState, id: u64, zone: Zone) -> ObjectId { let oid = ObjectId(id); let mut o = GameObject::new( @@ -2560,10 +2660,11 @@ mod tests { /// /// * **R1-g, battlefield** — resolves. The control arm; the only one of the three that can /// fail for a boring reason (a dead harness). - /// * **R1-h, CR 114.2 command zone** — resolves. An emblem is "both owned and controlled by - /// that player" and lives in the command zone for the whole game, raising its triggers - /// from there. `crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz` is a - /// real board whose published `Targets` point names exactly such a source. + /// * **R1-h, command zone** — resolves. CR 114.2 puts an emblem — "both owned and + /// controlled by that player" — there, and CR 114.4 is why its abilities function from + /// there (CR 113.6p for the plane / scheme / conspiracy members of the same class). + /// `crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz` is a real board + /// whose published `Targets` point names exactly such a source. /// * **R1-i, graveyard** — refuses. With no live ability instance the engine cannot certify /// that the object it would ask the CR 702.11c question about still IS that instance /// (CR 400.7 / CR 608.2b). The seat still EXISTS and a graveyard object still carries a @@ -2612,15 +2713,15 @@ mod tests { "R1-g: the control arm resolves — the instrument can return a target" ); - // R1-h: CR 114.2 command zone ⇒ resolves. + // R1-h: CR 114.4 / CR 113.6p command zone ⇒ resolves. assert_eq!( sole_target( &resolve_from(this_obj(emblem.0, Some(3)), &state) - .expect("CR 114.2: an emblem prompts from the command zone") + .expect("CR 114.4: an emblem's abilities function in the command zone") ), ConcreteTarget::Player(seat), "R1-h: a `resolve_source`-derived arm answers None here and would refuse the \ - emblem loop the drive built a CR 114.2 disjunct FOR" + emblem loop the drive built a CR 114.4 / CR 113.6p disjunct FOR" ); // R1-i: graveyard ⇒ fails closed. @@ -2661,4 +2762,153 @@ mod tests { arm — a re-created emblem does not certify the old pin" ); } + + /// **T4 — an `Order` pin resolves from the command zone through the PUBLIC [`resolve`], + /// and still fails closed everywhere else.** + /// + /// CR 603.3b is the pin's framing (replay this source's trigger at its pinned ordering + /// position); what changed is that "this source" is now re-bound by + /// [`resolve_ability_instance`] — same identity, same CR 400.7 incarnation, in a zone + /// that instance's abilities function from (CR 113.6b; emblems CR 114.4) — rather than + /// by `resolve_source`'s battlefield-only filter. + /// + /// **Resolving an `Order` pin grants NO capability.** No production consumer of + /// `resolve`'s output reads an `Order` element's payload: four element readers + /// discriminate on the variant and skip it, the `ManaPayment` beat aborts on its mere + /// presence, and the per-cycle re-check reads only the `is_err()` verdict. What the + /// re-bind buys is that a command-zone `Order` pin stops discarding every OTHER pin's + /// answer in the same template — `resolve` is a per-pin `Result` collect. That payoff and + /// its bound are pinned by + /// `game::engine::stage2_injector_tests::a_command_zone_order_pin_stops_poisoning_the_template_without_gaining_capability`, + /// whose five rows are the measurement; this row measures only the re-bind itself. + /// + /// # Non-vacuity / discrimination + /// + /// Rows a and b are one field apart (the zone) and both `Ok`; rows b/c and b/d are one + /// field apart and OPPOSITE; rows e and f are one field apart — the SAME object, moved — + /// and OPPOSITE. Each negative row asserts its subject exists in the intended state + /// before the negative assertion. + /// + /// REVERT-PROBES: revert the `Order` arm to `resolve_source` ⇒ row **b** fails alone; + /// widen the accessor's command disjunct to any zone ⇒ row **c** fails; drop the CR 400.7 + /// incarnation conjunct ⇒ row **d** fails; widen the `AllCopies` arm to the command zone + /// ⇒ row **f** fails. + #[test] + fn an_order_pin_resolves_from_the_command_zone_and_still_fails_closed_elsewhere() { + let mut state = GameState::new_two_player(7); + let battlefield = zoned_object(&mut state, 900, Zone::Battlefield); + let command = zoned_object(&mut state, 901, Zone::Command); + let graveyard = zoned_object(&mut state, 902, Zone::Graveyard); + let copy = zoned_object(&mut state, 910, Zone::Battlefield); + + let template = |source: DecisionSource| DecisionTemplate { + owner: PlayerId(0), + decisions: vec![PinnedDecision::Order { source, pos: 0 }], + replay: ReplayMode::Static, + key: tri_key(), + }; + let order_source = |out: &[ConcreteDecision]| match out { + [ConcreteDecision::Order { source, .. }] => *source, + other => panic!("expected exactly one Order decision, got {other:?}"), + }; + + // row a — the shipped battlefield arm, and the control that `resolve` can answer Ok. + assert_eq!( + order_source( + &resolve(&template(this_obj(battlefield.0, Some(3))), 0, &state) + .expect("row a: a live battlefield source still re-binds") + ), + battlefield, + "row a: control" + ); + + // row b — THE FIX. One field from a: the source's zone. + assert_eq!( + order_source( + &resolve(&template(this_obj(command.0, Some(3))), 0, &state).expect( + "row b: CR 114.4 — an ability functioning in the command zone \ + re-binds there" + ) + ), + command, + "row b: the CR 603.3b ordering pin no longer needs its source on the battlefield" + ); + + // row c — one field from b: the zone again, the other way. + assert_eq!( + state + .objects + .get(&graveyard) + .expect("reach-guard: row c's source object was built") + .zone, + Zone::Graveyard, + "reach-guard: row c's source exists and is in the graveyard, so its failure is \ + about the ZONE and not about an absent object" + ); + assert!( + matches!( + resolve(&template(this_obj(graveyard.0, Some(3))), 0, &state), + Err(ReplayFailure::MissingSource { .. }) + ), + "row c: the zone set is {{Battlefield, Command}} and nothing else" + ); + + // row d — one field from b: the pinned CR 400.7 incarnation. + assert_ne!( + state + .objects + .get(&command) + .expect("reach-guard: row d's source object was built") + .incarnation, + 2, + "reach-guard: the LIVE incarnation differs from the pinned one" + ); + assert!( + matches!( + resolve(&template(this_obj(command.0, Some(2))), 0, &state), + Err(ReplayFailure::MissingSource { .. }) + ), + "row d: CR 400.7 — a re-created source is a new object with no relation to the \ + pinned one, in the command zone exactly as on the battlefield" + ); + + // rows e/f — ONE object, moved. The widening is `ThisObject`-only, so a + // card-identity-spelled command-zone source still fails closed: the disclosed + // residual, in executable form. + let by_card = YieldTarget::AllCopies { + card_id: CardId(910), + trigger_description: None, + }; + assert_eq!( + order_source( + &resolve(&template(by_card.clone()), 0, &state) + .expect("row e: the card's only copy is on the battlefield") + ), + copy, + "row e: the `AllCopies` arm's control positive" + ); + state + .objects + .get_mut(©) + .expect("reach-guard: row f moves the SAME object row e just resolved") + .zone = Zone::Command; + assert_eq!( + state + .objects + .get(©) + .expect("reach-guard: the object still exists after the move") + .card_id, + CardId(910), + "reach-guard: row f differs from row e in ZONE ONLY — same object, same card id" + ); + assert!( + matches!( + resolve(&template(by_card), 0, &state), + Err(ReplayFailure::MissingSource { .. }) + ), + "row f: DISCLOSED RESIDUAL — the command disjunct is `ThisObject`-only, so a \ + command-zone source named by CARD identity (a conspiracy, an Eminence \ + commander) still fails closed. Disclosed, not closed." + ); + } } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 042fbde395..00f31e53d2 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -3211,8 +3211,11 @@ fn entry_announces( /// with an unbindable slot), so the schema can only ever under-publish. /// /// Class served: every proposer-controlled triggered ability on the stack whose declared -/// target is a player — never a named card. Command-zone sources (CR 114.2 emblems) are -/// included; [`slot_source_prompted`] is the matching half at replay time. +/// target is a player — never a named card. Command-zone sources are included — every +/// command-zone-functioning ability source, not emblems alone (emblem CR 114.4; plane, scheme, +/// conspiracy CR 113.6p; a face-up phenomenon CR 901.7; an Eminence commander per its own +/// ability's declared zones, CR 113.6b) — and [`slot_source_prompted`] is the matching half at +/// replay time. /// /// PER SOURCE, NOT PER ENTRY: N stack entries from ONE source mint N byte-identical /// `DecisionSlot`s (real boards reach 35 entries on one source), and the sub-index @@ -4202,14 +4205,18 @@ fn inject_pinned_answer( } } -/// CR 608.2b + CR 114.2: does this SLOT's source identify the ability instance that raised -/// the prompt carrying `source_id`? +/// CR 608.2b + CR 114.4 + CR 113.6p: does this SLOT's source identify the ability instance +/// that raised the prompt carrying `source_id`? /// /// The zone reasoning — why a SLOT's source admits the command zone while a PIN's source is /// battlefield-only, and why graveyard / exile / hand still fail closed — now lives on /// [`crate::analysis::decision_template::resolve_ability_instance`], the single accessor for /// "which live ability instance is this". This call site is the identity comparison against /// the prompting object; a `None` there means the caller aborts to manual play. +/// +/// FOUR production seams ask through here: `inject_pinned_answer`'s `TriggerTargetSelection` +/// and `MayChoice` `find_map` guards, and the drive's `pinned_targets_for_source` / +/// `pinned_mana_color_for_source`. fn slot_source_prompted( state: &GameState, src: &crate::analysis::decision_template::DecisionSource, @@ -4711,17 +4718,22 @@ fn record_trigger_target_answer( /// WHOLE `template` means ANY pin that no longer resolves to a live legal object (a target left /// its zone) aborts the whole beat fail-closed — a broken loop never certifies. `Err(RecastAbort)` /// if no `Targets` pin's source matches `source_id`. +/// +/// "Whose slot source re-binds to `source_id`" is asked through [`slot_source_prompted`], the one +/// spelling of that question — so the slot may name any ability instance the beat's prompt could +/// have come from (CR 608.2b keeps the pinned TARGETS battlefield-only through `resolve_source`; +/// the SLOT's source is a different question and admits the command zone). fn pinned_targets_for_source( template: &crate::analysis::decision_template::DecisionTemplate, iteration: crate::analysis::decision_template::IterationIndex, clone: &GameState, source_id: ObjectId, ) -> Result, RecastAbort> { - use crate::analysis::decision_template::{resolve, resolve_source, ConcreteDecision}; + use crate::analysis::decision_template::{resolve, ConcreteDecision}; let decisions = resolve(template, iteration, clone).map_err(|_| RecastAbort)?; for d in decisions { if let ConcreteDecision::Targets { slot, targets } = d { - if resolve_source(&slot.source, clone) == Some(source_id) { + if slot_source_prompted(clone, &slot.source, source_id) { return Ok(targets); } } @@ -4731,17 +4743,21 @@ fn pinned_targets_for_source( /// FIX-1 (CR 608.2d): the recorded mana color of the `ManaColor` pin whose slot source is /// `source_id` (the driving mana ability's source). `Err(RecastAbort)` if unpinned. +/// +/// The slot-source question is [`slot_source_prompted`]'s, exactly as in +/// [`pinned_targets_for_source`]: CR 608.2d is the choice this pin records, and which ability +/// instance offered that choice is what the predicate answers. fn pinned_mana_color_for_source( template: &crate::analysis::decision_template::DecisionTemplate, iteration: crate::analysis::decision_template::IterationIndex, clone: &GameState, source_id: ObjectId, ) -> Result { - use crate::analysis::decision_template::{resolve, resolve_source, ConcreteDecision}; + use crate::analysis::decision_template::{resolve, ConcreteDecision}; let decisions = resolve(template, iteration, clone).map_err(|_| RecastAbort)?; for d in decisions { if let ConcreteDecision::ManaColor { slot, color } = d { - if resolve_source(&slot.source, clone) == Some(source_id) { + if slot_source_prompted(clone, &slot.source, source_id) { return Ok(color); } } @@ -15810,8 +15826,8 @@ mod bounded_declaration_tests { mod stage2_injector_tests { use super::*; use crate::analysis::decision_template::{ - DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, IterationCount, - PinnedDecision, ReplayMode, TargetPin, TargetSchedule, + ConcreteTarget, DecisionGroupKey, DecisionKind, DecisionSlot, DecisionTemplate, + IterationCount, PinnedDecision, ReplayMode, TargetPin, TargetSchedule, }; use crate::game::scenario::GameScenario; use crate::types::game_state::{LoopDetectionMode, YieldTarget}; @@ -16418,13 +16434,14 @@ mod stage2_injector_tests { ); } - /// CR 114.2 + CR 608.2b: a pinned SLOT whose source is a command-zone emblem must match + /// CR 114.4 + CR 608.2b: a pinned SLOT whose source is a command-zone emblem must match /// the prompt that emblem raised; a graveyard or exile source must NOT. /// /// This is the zone predicate `inject_pinned_answer`'s `TriggerTargetSelection` arm - /// dispatches on. Its production drive lands with the bounded offer in a later commit, - /// so it is pinned here at the seam — the shipped BATTLEFIELD arm is exercised - /// end-to-end by `injector_routes_pinned_targets_per_source` above and by the + /// dispatches on, and it now has two further production callers on the drive side: + /// `pinned_targets_for_source` and `pinned_mana_color_for_source` ask the same question + /// through the same `slot_source_prompted` predicate. The shipped BATTLEFIELD arm is + /// exercised end-to-end by `injector_routes_pinned_targets_per_source` above and by the /// `kilo_live_offer_from_real_dump` rows, and this row asserts that arm is unchanged. /// /// The zone disjuncts this row pins now live one call down, in @@ -16460,10 +16477,11 @@ mod stage2_injector_tests { slot_source_prompted(&state, &pin(battlefield, Some(3)), battlefield), "the shipped CR 608.2b battlefield arm must be untouched" ); - // NEW: CR 114.2 — an emblem lives in the command zone and prompts from there. + // CR 114.2 — an emblem lives in the command zone; CR 114.4 — its abilities function + // there, which is why the slot may prompt from there. assert!( slot_source_prompted(&state, &pin(emblem, Some(3)), emblem), - "CR 114.2: a command-zone emblem's slot must match the prompt it raised" + "CR 114.4: a command-zone emblem's slot must match the prompt it raised" ); // Fail-closed: every other off-battlefield zone still misses ⇒ `RecastAbort`. assert!( @@ -16487,6 +16505,277 @@ mod stage2_injector_tests { ); } + /// **T1 — CR 608.2b + CR 114.4: the drive's tap-cost / proliferate seam matches a + /// COMMAND-zone slot source, and still refuses every other way of missing.** + /// + /// `pinned_targets_for_source` asks "which pinned `Targets` belongs to the ability + /// instance that raised this beat?" through [`slot_source_prompted`] — the predicate + /// `inject_pinned_answer` already used — instead of through the battlefield-only + /// `resolve_source`. CR 114.4 (CR 113.6p for the plane / scheme / conspiracy members of + /// the same class) is why an ability may prompt from the command zone at all; CR 608.2b + /// is why the pinned TARGETS stay battlefield-only, which row c pins. + /// + /// # Non-vacuity / discrimination + /// + /// Every row is ONE field from row b and comes out OPPOSITE it. An input that never + /// arrived cannot produce that table — it would answer the same way on both sides of the + /// field. Each negative row asserts its subject exists in the intended zone BEFORE the + /// negative assertion, so it provably fails for the stated reason rather than because the + /// object was never built. + /// + /// REVERT-PROBES: restore `resolve_source` at this seam ⇒ row **b** fails alone; widen + /// `resolve_source` to admit `Zone::Command` ⇒ row **c** fails; widen the accessor's + /// command disjunct to any zone ⇒ row **d** fails; drop the CR 400.7 incarnation + /// conjunct ⇒ row **e** fails; make the accessor answer "any command-zone object" ⇒ row + /// **f** fails. + #[test] + fn pinned_targets_for_source_matches_a_command_zone_slot_and_still_refuses_elsewhere() { + use crate::types::zones::Zone; + let mut state = GameScenario::new_n_player(2, 7).build().state().clone(); + let battlefield = place(&mut state, 900, Zone::Battlefield); + let command = place(&mut state, 901, Zone::Command); + let graveyard = place(&mut state, 902, Zone::Graveyard); + let other_command = place(&mut state, 904, Zone::Command); + let command_target = place(&mut state, 905, Zone::Command); + + let live_src = |id: ObjectId| object_decision_source(&state, id).expect("placed above"); + let bf_src = live_src(battlefield); + let cmd_src = live_src(command); + let gy_src = live_src(graveyard); + let stale_cmd_src = YieldTarget::ThisObject { + source_id: command, + incarnation: Some(2), + trigger_description: None, + }; + let bf_target = TargetPin::ByIdentity(live_src(battlefield)); + let cmd_target = TargetPin::ByIdentity(live_src(command_target)); + + let template = |slot_source: &YieldTarget, target: &TargetPin| DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: slot_source.clone(), + index: 0, + }, + targets: vec![target.clone()], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources( + std::slice::from_ref(slot_source), + DecisionKind::LoopChoice, + ), + }; + + // row a — the shipped battlefield arm, unchanged. Also the control that proves the + // instrument can return targets at all. + assert_eq!( + pinned_targets_for_source(&template(&bf_src, &bf_target), 0, &state, battlefield) + .expect("row a: a battlefield-sourced slot still answers its own beat"), + vec![ConcreteTarget::Object(battlefield)], + "row a: control" + ); + + // row b — THE FIX. One field from row a: the slot source's ZONE. + assert_eq!( + pinned_targets_for_source(&template(&cmd_src, &bf_target), 0, &state, command) + .expect("row b: CR 114.4 — a command-zone ability instance's slot matches"), + vec![ConcreteTarget::Object(battlefield)], + "row b: the command-zone slot source answers the beat it raised" + ); + + // row c — one field from b: the TARGET's zone. CR 608.2b is not widened. + assert_eq!( + state + .objects + .get(&command_target) + .expect("reach-guard: row c's target object was built") + .zone, + Zone::Command, + "reach-guard: row c's TARGET is in the command zone, so its Err is about the \ + target path and not about a missing object" + ); + assert!( + pinned_targets_for_source(&template(&cmd_src, &cmd_target), 0, &state, command) + .is_err(), + "row c: CR 608.2b — a pinned TARGET off the battlefield stays illegal; widening \ + the SLOT question does not widen the TARGET question" + ); + + // row d — one field from b: the slot source's zone again, the other way. + assert_eq!( + state + .objects + .get(&graveyard) + .expect("reach-guard: row d's slot-source object was built") + .zone, + Zone::Graveyard, + "reach-guard: row d's slot source exists and is in the graveyard" + ); + assert!( + pinned_targets_for_source(&template(&gy_src, &bf_target), 0, &state, graveyard) + .is_err(), + "row d: the zone set is {{Battlefield, Command}} and nothing else — a graveyard \ + source aborts the drive to manual play" + ); + + // row e — one field from b: the pinned CR 400.7 incarnation. + assert_ne!( + state + .objects + .get(&command) + .expect("reach-guard: row e's slot-source object was built") + .incarnation, + 2, + "reach-guard: the LIVE incarnation differs from the pinned one, so row e's Err \ + is about CR 400.7 and not about an absent object" + ); + assert!( + pinned_targets_for_source(&template(&stale_cmd_src, &bf_target), 0, &state, command) + .is_err(), + "row e: CR 400.7 — a re-created source is a new object and does not answer the \ + old pin, in the command zone exactly as on the battlefield" + ); + + // row f — one field from b: the identity being asked about. + for (id, label) in [(command, "the pinned"), (other_command, "the asking")] { + assert_eq!( + state + .objects + .get(&id) + .unwrap_or_else(|| panic!("reach-guard: {label} object was built")) + .zone, + Zone::Command, + "reach-guard: BOTH command-zone objects exist, so row f cannot pass by \ + absence — only by identity" + ); + } + assert!( + pinned_targets_for_source(&template(&cmd_src, &bf_target), 0, &state, other_command) + .is_err(), + "row f: the matcher is keyed on IDENTITY, not on 'some object in the command \ + zone' — a second command-zone source does not inherit this slot's answer" + ); + } + + /// **T2 — CR 608.2d + CR 114.4: the same migration at the mana-color seam.** + /// + /// `pinned_mana_color_for_source` records the CR 608.2d color choice a mana ability + /// offered; which ability instance offered it is [`slot_source_prompted`]'s question, now + /// asked with the same spelling as at the tap-cost seam. Rows a/b/d/e/f mirror T1's; a + /// `ManaColor` pin carries no targets, so T1's target-legality row c has no analogue. + /// + /// # Non-vacuity / discrimination + /// + /// Same shape as T1: one field from row b, opposite verdict, every negative row reach- + /// guarded on its subject's existence and zone. + /// + /// REVERT-PROBES: restore `resolve_source` at this seam ⇒ row **b** fails alone; the + /// accessor-side probes (any-zone widening, dropped incarnation conjunct, zone-not- + /// identity matching) fail rows **d**, **e**, **f** respectively. + #[test] + fn pinned_mana_color_for_source_matches_a_command_zone_slot_and_still_refuses_elsewhere() { + use crate::types::mana::ManaColor; + use crate::types::zones::Zone; + let mut state = GameScenario::new_n_player(2, 7).build().state().clone(); + let battlefield = place(&mut state, 900, Zone::Battlefield); + let command = place(&mut state, 901, Zone::Command); + let graveyard = place(&mut state, 902, Zone::Graveyard); + let other_command = place(&mut state, 904, Zone::Command); + + let live_src = |id: ObjectId| object_decision_source(&state, id).expect("placed above"); + let bf_src = live_src(battlefield); + let cmd_src = live_src(command); + let gy_src = live_src(graveyard); + let stale_cmd_src = YieldTarget::ThisObject { + source_id: command, + incarnation: Some(2), + trigger_description: None, + }; + + let template = |slot_source: &YieldTarget| DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::ManaColor { + slot: DecisionSlot { + source: slot_source.clone(), + index: 0, + }, + color: ManaColor::Blue, + }], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources( + std::slice::from_ref(slot_source), + DecisionKind::LoopChoice, + ), + }; + + // row a — shipped battlefield arm + the control that the instrument returns a color. + assert_eq!( + pinned_mana_color_for_source(&template(&bf_src), 0, &state, battlefield) + .expect("row a: a battlefield-sourced slot still answers its own beat"), + ManaColor::Blue, + "row a: control" + ); + + // row b — THE FIX, one field from a: the slot source's zone. + assert_eq!( + pinned_mana_color_for_source(&template(&cmd_src), 0, &state, command) + .expect("row b: CR 114.4 — a command-zone ability instance's slot matches"), + ManaColor::Blue, + "row b: the command-zone slot source answers the CR 608.2d choice it offered" + ); + + // row d — one field from b: the slot source's zone, the other way. + assert_eq!( + state + .objects + .get(&graveyard) + .expect("reach-guard: row d's slot-source object was built") + .zone, + Zone::Graveyard, + "reach-guard: row d's slot source exists and is in the graveyard" + ); + assert!( + pinned_mana_color_for_source(&template(&gy_src), 0, &state, graveyard).is_err(), + "row d: a graveyard source aborts the drive to manual play" + ); + + // row e — one field from b: the pinned CR 400.7 incarnation. + assert_ne!( + state + .objects + .get(&command) + .expect("reach-guard: row e's slot-source object was built") + .incarnation, + 2, + "reach-guard: the LIVE incarnation differs from the pinned one" + ); + assert!( + pinned_mana_color_for_source(&template(&stale_cmd_src), 0, &state, command).is_err(), + "row e: CR 400.7 — a stale incarnation does not match in the command zone either" + ); + + // row f — one field from b: the identity being asked about. + for (id, label) in [(command, "the pinned"), (other_command, "the asking")] { + assert_eq!( + state + .objects + .get(&id) + .unwrap_or_else(|| panic!("reach-guard: {label} object was built")) + .zone, + Zone::Command, + "reach-guard: BOTH command-zone objects exist, so row f cannot pass by absence" + ); + } + assert!( + pinned_mana_color_for_source(&template(&cmd_src), 0, &state, other_command).is_err(), + "row f: identity, not zone, selects whose color this is" + ); + } + /// CR 732.2a + CR 603.5: `bounded_cycle_pin_slots` publishes the per-iteration TARGET /// choice for a proposer-controlled player-targeting trigger, plus a second `MayChoice` /// point (disambiguated by `slot.index`) when that trigger is optional. @@ -16931,7 +17220,7 @@ mod stage2_injector_tests { ); } - /// CR 114.2 + CR 608.2b, on a REAL restored 4p board: `inject_pinned_answer` accepts a + /// CR 114.4 + CR 608.2b, on a REAL restored 4p board: `inject_pinned_answer` accepts a /// pin whose slot source is the COMMAND-zone emblem (obj 541) that raised the prompt. /// /// This is the production-path row for [`slot_source_prompted`]. The seam is live @@ -16992,7 +17281,7 @@ mod stage2_injector_tests { let src = object_decision_source(&state, EMBLEM).expect("the emblem object exists"); // The control that makes this row non-vacuous: the shipped battlefield-only // `resolve_source` does NOT match this source, so an accept can only come from the - // CR 114.2 disjunct. + // CR 114.4 disjunct. assert_eq!( crate::analysis::decision_template::resolve_source(&src, &state), None, @@ -17018,7 +17307,7 @@ mod stage2_injector_tests { // ── ACCEPT: the command-zone pin answers the prompt on the real board ── let mut work = state.clone(); inject_pinned_answer(&mut work, Some(&template(src.clone())), 0, &prompt) - .expect("CR 114.2: the emblem's own pin must answer the prompt it raised"); + .expect("CR 114.4: the emblem's own pin must answer the prompt it raised"); assert_ne!( work.waiting_for, prompt, "the prompt was actually consumed, not silently skipped" @@ -17062,6 +17351,307 @@ mod stage2_injector_tests { ); } + /// **T3 — the slot-source VALUE is one a REAL board produces, and the migrated drive + /// seam accepts it** (CR 114.4 + CR 608.2b, on the restored 4p dellian board). + /// + /// Modelled on `a_command_zone_pin_answers_a_real_restored_boards_prompt` above, whose + /// structure — reach guards read off the loaded board, then a `resolve_source == None` + /// non-vacuity control, then the accept — is reused here at the OTHER consumer. + /// + /// # What this row does NOT claim + /// + /// It does not claim that a shipped card drives a command-zone-sourced tap-cost / + /// mana-color / proliferate beat *in a recorded loop period* today. It claims the + /// slot-source VALUE is one a real board produces and that `pinned_targets_for_source` + /// accepts it. + /// + /// # Non-vacuity / discrimination + /// + /// The control is load-bearing: `resolve_source` answers `None` for this very source on + /// this very board, so row a's `Ok` can only have come from the command-zone disjunct. + /// Rows b–e are each one field from row a and come out opposite. Every object is chosen + /// by PREDICATE off the loaded board (except `EMBLEM`, already a module const), so a + /// re-derived fixture cannot silently blank a row. + /// + /// REVERT-PROBES: restore `resolve_source` at this seam ⇒ row **a** fails; widen the + /// accessor to any zone ⇒ row **b** fails; drop the CR 400.7 conjunct ⇒ row **c** fails; + /// match on zone rather than identity ⇒ rows **d** and **e** fail. + #[test] + fn a_command_zone_slot_from_the_real_4p_board_answers_the_recast_beat() { + use crate::types::zones::Zone; + let state = load_dellian_dump(); + + // ── reach guards, all read off the loaded board ── + let emblem = state + .objects + .get(&EMBLEM) + .expect("reach-guard: dump B carries the emblem object"); + assert_eq!( + emblem.zone, + Zone::Command, + "reach-guard: CR 114.2 puts the emblem in the command zone" + ); + assert!( + emblem.is_emblem, + "reach-guard: CR 114.4 is the rule under test, so the object must really be an \ + emblem" + ); + let emblem_incarnation = emblem.incarnation; + + let lowest_battlefield = state + .objects + .values() + .filter(|o| o.zone == Zone::Battlefield) + .min_by_key(|o| o.id.0) + .map(|o| o.id) + .expect("reach-guard: the board has battlefield objects to target"); + let graveyard_object = state + .objects + .values() + .filter(|o| o.zone == Zone::Graveyard) + .min_by_key(|o| o.id.0) + .map(|o| o.id) + .expect("reach-guard: the board has a graveyard object for row b"); + + let src = object_decision_source(&state, EMBLEM).expect("the emblem object exists"); + // Non-vacuity control: the shipped battlefield-only `resolve_source` does NOT match + // this source, so an accept can only come from the CR 114.4 disjunct. + assert_eq!( + crate::analysis::decision_template::resolve_source(&src, &state), + None, + "CR 608.2b: `resolve_source` is battlefield-only and must stay so" + ); + + let template = |slot_source: YieldTarget| DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: slot_source.clone(), + index: 0, + }, + targets: vec![TargetPin::ByIdentity(object_decision_source_of( + &state, + lowest_battlefield, + ))], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources(&[slot_source], DecisionKind::LoopChoice), + }; + + // row a — positive: the real board's command-zone source answers its own beat. + assert_eq!( + pinned_targets_for_source(&template(src.clone()), 0, &state, EMBLEM) + .expect("CR 114.4: the emblem's own slot answers the beat it raised"), + vec![ConcreteTarget::Object(lowest_battlefield)], + "row a: the drive seam accepts a slot source a REAL board produced" + ); + + // row b — one field: a graveyard source off the same board. + let gy_src = object_decision_source_of(&state, graveyard_object); + assert!( + pinned_targets_for_source(&template(gy_src), 0, &state, graveyard_object).is_err(), + "row b: the zone set is {{Battlefield, Command}}; a graveyard source still aborts" + ); + + // row c — one field: the pinned CR 400.7 incarnation. + let stale = YieldTarget::ThisObject { + source_id: EMBLEM, + incarnation: Some(emblem_incarnation + 1), + trigger_description: None, + }; + assert!( + pinned_targets_for_source(&template(stale), 0, &state, EMBLEM).is_err(), + "row c: CR 400.7 — a re-created emblem does not answer the old pin" + ); + + // row d — one field: the identity being asked about. + assert!( + pinned_targets_for_source(&template(src.clone()), 0, &state, lowest_battlefield) + .is_err(), + "row d: identity, not zone, selects whose slot this is" + ); + + // row e — MULTI-AUTHORITY hostile fixture. This board holds TWO command-zone + // objects, and the second is not an emblem: object 400's two TRIGGERS declare + // `trigger_zones == ["Battlefield"]` while its cost-reduction STATIC declares + // `active_zones ∋ Command`; one object, two abilities, two different zones of + // function (CR 113.6b). The accessor admits it by zone either way, so only identity + // can exclude it. + let commander = state + .objects + .values() + .filter(|o| o.zone == Zone::Command && o.id != EMBLEM) + .min_by_key(|o| o.id.0) + .map(|o| o.id) + .expect("reach-guard: dump B carries a SECOND command-zone object"); + assert!( + !state.objects[&commander].is_emblem, + "reach-guard: the second command-zone object is not an emblem, so row e is a \ + genuine multi-authority case and not a duplicate of row a" + ); + let commander_src = object_decision_source_of(&state, commander); + assert!( + pinned_targets_for_source(&template(commander_src), 0, &state, EMBLEM).is_err(), + "row e: a DIFFERENT command-zone ability instance's slot must not answer the \ + emblem's beat — zone admits both, identity separates them" + ); + } + + /// `object_decision_source` with the test's own existence guard folded in, so a row that + /// picks its object by predicate cannot silently degrade into `None`. + fn object_decision_source_of(state: &GameState, id: ObjectId) -> YieldTarget { + object_decision_source(state, id) + .unwrap_or_else(|| panic!("reach-guard: object {id:?} is on the loaded board")) + } + + /// **T6 — the capability-neutrality pin.** Five rows on ONE board with ONE template + /// value each, pinning what resolving a command-zone `Order` pin does and does not buy. + /// + /// Both sides of the Order-only claim return `Err(RecastAbort)` — a unit struct — so the + /// two abort points are not distinguishable by return value. The instrument therefore + /// observes the abort point INDIRECTLY, through a second pin in the same template, using + /// the mechanism `resolve` is built on: it is a per-pin `Result` collect, so one failing + /// pin discards every other pin's answer. + /// + /// | row | template | seam | expect | + /// |---|---|---|---| + /// | **R** (delivery) | `order_only` | `decision_template::resolve` | `Ok`, carrying the `Order` element | + /// | **N** (neutrality, `ok_or` half) | `order_only`, the SAME value | `pinned_targets_for_source` | `Err(RecastAbort)` | + /// | **N2** (neutrality, `find_map` half) | `order_only`, the SAME value | `inject_pinned_answer` | `Err(RecastAbort)` | + /// | **P** (poisoning removed) | `mixed` | `pinned_targets_for_source` | `Ok` | + /// | **P-minus** (omit-the-pin equivalence) | `mixed` minus its `Order` element | `pinned_targets_for_source` | `Ok`, EQUAL to P's | + /// + /// # Why no row is vacuous + /// + /// * **P vs N** differ by exactly one field — the presence of the `Targets` pin — and + /// come out OPPOSITE. + /// * **N and N2 are revert-INSENSITIVE on purpose, and that insensitivity IS the + /// measurement**: "the beat still fails closed" is the claim that the verdict does not + /// move. **R is their reach guard.** R calls the same `resolve` with the same + /// `(template, 0, &state)` triple those two seams call internally, so `R == Ok` proves + /// the input arrived and resolved; N's and N2's `Err` can then only be the + /// post-resolve fall-through, never "the input never arrived". **This is the + /// load-bearing condition of the whole test: R, N and N2 must use the byte-same + /// template VALUE on the same `&state` VALUE.** If R built its own template, N and N2 + /// would become indistinguishable from non-delivery and this row would read as proof + /// while measuring nothing. + /// * **P-minus** is the security row. Its discriminating assertion is the EQUALITY, not + /// the `Ok`: it fails the moment an `Order` element contributes anything to the answer. + /// `mixed_no_order` is `mixed` minus exactly its `Order` element, built from the same + /// `targets_pin` value on the same board — if the two templates were constructed + /// independently, the `assert_eq!` would measure two hand-written templates agreeing + /// instead of the element contributing nothing. + /// + /// # What this does NOT measure + /// + /// ACCEPTANCE. Whether `mixed_no_order` is *submittable* is `declaration_conforms`' + /// question; no row here asks it, and the answer is not always yes (an `Order` pin can be + /// the sole cover of a required point, in which case dropping it fails + /// `predictability_gate` — pre-existing and zone-independent). + /// + /// REVERT-PROBES: reverting the `Order` arm to `resolve_source` ⇒ rows **R** and **P** + /// fail; **N**, **N2** and **P-minus** are deliberately revert-insensitive. + #[test] + fn a_command_zone_order_pin_stops_poisoning_the_template_without_gaining_capability() { + use crate::types::zones::Zone; + let mut state = GameScenario::new_n_player(2, 7).build().state().clone(); + let battlefield = place(&mut state, 900, Zone::Battlefield); + let command = place(&mut state, 901, Zone::Command); + stand_up_target_prompt(&mut state, P0, command, 1); + let prompt = state.waiting_for.clone(); + + let order_source = object_decision_source(&state, command).expect("placed above"); + let order_pin = PinnedDecision::Order { + source: order_source.clone(), + pos: 0, + }; + // ONE template value, bound once, shared by rows R, N and N2 (the preservation + // condition above). + let order_only = DecisionTemplate { + owner: P0, + decisions: vec![order_pin.clone()], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources( + std::slice::from_ref(&order_source), + DecisionKind::LoopChoice, + ), + }; + + // ── row R (delivery): the pin re-binds through the public `resolve`. ── + let resolved = crate::analysis::decision_template::resolve(&order_only, 0, &state) + .expect("row R: a command-zone Order pin re-binds to its live ability instance"); + assert!( + resolved.iter().any(|d| matches!( + d, + crate::analysis::decision_template::ConcreteDecision::Order { source, .. } + if *source == command + )), + "row R: the resolved vec carries THIS source's Order element — the delivery \ + this row exists to prove for N and N2" + ); + + // ── row N (neutrality, the trailing-`Err` half): the SAME value, same board. ── + assert!( + pinned_targets_for_source(&order_only, 0, &state, battlefield).is_err(), + "row N: an ORDER-only template still fails closed at this element reader. Row R \ + proved the internal `resolve` returned Ok on this exact value, so this Err is \ + the post-resolve fall-through — the abort MOVED, it was not avoided" + ); + + // ── row N2 (neutrality, the `find_map` half): the SAME value, cloned board. ── + let mut injector_work = state.clone(); + assert!( + inject_pinned_answer(&mut injector_work, Some(&order_only), 0, &prompt).is_err(), + "row N2: the injector beat still fails closed too — its `find_map` looks for a \ + `Targets` element and an `Order` element is not one" + ); + + // ── the mixed pair. `mixed_no_order` is `mixed` with the FIRST element dropped and + // NOTHING else changed: one `targets_pin` value, cloned into both. ── + let targets_pin = PinnedDecision::Targets { + slot: DecisionSlot { + source: object_decision_source(&state, battlefield).expect("placed above"), + index: 0, + }, + targets: vec![TargetPin::ByIdentity( + object_decision_source(&state, battlefield).expect("placed above"), + )], + }; + let mixed = DecisionTemplate { + decisions: vec![order_pin.clone(), targets_pin.clone()], + ..order_only.clone() + }; + let mixed_no_order = DecisionTemplate { + decisions: vec![targets_pin.clone()], + ..order_only.clone() + }; + + // ── row P (poisoning removed): the OTHER pin in the same template now answers. ── + let p = pinned_targets_for_source(&mixed, 0, &state, battlefield).expect( + "row P: with the Order pin re-binding and the Targets pin resolving, the seam \ + returns the Targets pin's own answer", + ); + assert_eq!( + p, + vec![ConcreteTarget::Object(battlefield)], + "row P: one field from row N (the second pin) and OPPOSITE it" + ); + + // ── row P-minus (omit-the-pin equivalence): the security row. ── + let p_minus = pinned_targets_for_source(&mixed_no_order, 0, &state, battlefield) + .expect("row P-minus: the Order-less template resolves at both revisions"); + assert_eq!( + p, p_minus, + "row P-minus: a re-binding `Order` element contributes NOTHING to this \ + consumer's answer — the equality is the discriminating assertion, and it \ + fails the moment any consumer starts reading `ConcreteDecision::Order`" + ); + } + // ───────────────────────── 5d U2 — the shape-(B) mint ───────────────────────── use crate::types::ability::ResolvedAbility; @@ -18056,11 +18646,32 @@ mod stage2_injector_tests { // are all inside `#[cfg(test)] mod stage2_injector_tests`, BELOW this producer. The // total (38) and the partition (5/8/25) both fired GREEN on the run that caught // this — only this third assert (`:17342`) panicked. - // ⚠ REBASE #3: `:12668 ⇒ :12667`, located by content digest, offset from + // + // R2b (the slot-question accessor migration at the last three call sites): + // `:12606 ⇒ :12622`, +16. LOCAL, not upstream — the CI-vs-local diagnosis in the + // header does not apply. Arithmetic CHECK: `git diff -U0` against the parent has + // NINE hunks above the old coordinate, netting exactly `+16`, and + // `12606 + 16 = 12622`. SET PRESERVATION: every one of those nine is either a + // doc/comment rewrite (the CR 114.2 → CR 114.4 / CR 113.6p sweep, the two + // `pinned_*` headers, `slot_source_prompted`'s header, `bounded_cycle_pin_slots`' + // class list) or ONE of the two production call-site swaps + // (`resolve_source(&slot.source, clone) == Some(source_id)` ⇒ + // `slot_source_prompted(clone, &slot.source, source_id)`), which changes which + // predicate answers a slot question and assigns no `state.waiting_for` — it mints + // no `OptionalEffectChoice` prompt. This round's remaining `engine.rs` hunks are + // inside `#[cfg(test)] mod stage2_injector_tests`, BELOW this producer. The total + // (38) and the partition (5/8/25) both fired GREEN on the run that caught this — + // only this third assert panicked. Identity re-established rather than assumed: + // line `:12622` is byte-identical by sha256 + // (`8a544e878d3e77fb…5cc7d63`, the SAME hash this log recorded for `:11549` and + // `:11583`) to `10e80db9c:engine.rs:12606`, and it is still inside + // `begin_pending_trigger_target_selection`, which moved by the same +16 (opens + // `:12472 ⇒ :12488`). + // ⚠ REBASE #3: `:12684 ⇒ :12683`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - // ⚠ REBASE #3: `:12667 ⇒ :12672`, located by content digest, offset from + // ⚠ REBASE #3: `:12683 ⇒ :12688`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - "game/engine.rs:12672".to_string(), + "game/engine.rs:12688".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ @@ -18368,9 +18979,11 @@ mod stage2_injector_tests { /// dispatched" from "the injector returned `Ok(())` having done nothing": an empty board /// would answer `Ok(())` just as happily. /// - /// The pinned source is a BATTLEFIELD object because `resolve_source` is battlefield-only - /// (CR 400.7 incarnation binding) — on any other zone `slot_source_prompted` would refuse - /// every arm below for a reason none of them is about. + /// The pinned source is a BATTLEFIELD object because `slot_source_prompted` asks + /// `resolve_ability_instance`, whose zone set is {Battlefield, Command} at the pinned + /// CR 400.7 incarnation — on a graveyard / exile / hand source it would refuse every arm + /// below for a reason none of them is about. (A command-zone source would be admitted; + /// the battlefield one is chosen because these rows are not about the zone at all.) fn u4_may_board(asked: PlayerId) -> (GameState, ObjectId) { use crate::types::ability::{Effect, QuantityExpr, TargetFilter}; let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 3bd288fa18..7c9f9f9e3f 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -3061,8 +3061,10 @@ fn loop_shortcut_schema_and_materializer_cover_every_decision_point_kind() { /// `ConstraintUnsatisfied`. The positive reach-guard above would be UNSATISFIABLE there, and the /// cheapest-looking repair would be to loosen a fail-closed predicate. So the slot source here is /// a `ThisObject` naming a live battlefield creature, at that object's LIVE incarnation read from -/// state (CR 400.7) — never a hard-coded one. `AllCopies` cannot take the CR 114.2 command-zone -/// disjunct either: an emblem has no card, so only `ThisObject` participates. +/// state (CR 400.7) — never a hard-coded one. `AllCopies` cannot take the CR 114.4 / CR 113.6p +/// command-zone disjunct either: that disjunct is `ThisObject`-only, so a command-zone source +/// named by CARD identity (a conspiracy, an Eminence commander — both of which DO have cards) +/// still resolves `None` and fails closed. Measured residual, disclosed rather than closed. /// /// The three shipped `Shortcut` rows in this file are untouched by the split, but by INDEX /// ORDERING rather than by design: the file has exactly one candidate-selection site and it takes From 58c509ef135cfffd995254a793f3361922a5e630 Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 12 Aug 2026 17:18:21 -0500 Subject: [PATCH 23/44] docs(engine): correct two CR bearings in decision_template Comment-only. Two independent CR-citation defects, both measured, neither changing behaviour. 1. CR 732 bearing (2 sites). Two comments attributed "refusing a shortcut is free" to CR 732.1. CR 732.1 is the general statement that players use mutually understood shortcuts; the permission is CR 732.2a, "the player with priority MAY suggest a shortcut" -- may, not must. CR 732.2b is the RESPONDER rule (each OTHER player accepts or shortens a proposal that already exists), so it cannot govern a proposer that publishes nothing. Sharpened to "CR 732.1 + CR 732.2a", matching the already-correct site in the same file. Provenance: these sites were introduced by this series, not inherited -- git blame puts both at ef0f72524, which is not an ancestor of upstream/main. The series would otherwise have shipped a bearing it disproved elsewhere. 2. CR 113.6b bearing (3 sites). Three comments described resolve_ability_instance's guard as "in a zone its abilities function from (CR 113.6b)". That is not the implemented predicate: the accessor admits an object by ZONE PRESENCE (Battlefield, plus Command for a ThisObject source) and discriminates by identity at the pinned CR 400.7 incarnation. It performs no per-ability functioning test -- active_zones/trigger_zones appear nowhere in its body. Two other comments in the same commit already said so ("it -- not this accessor -- decides whether an ability functions"; "the accessor admits it by zone either way, so only identity can exclude it"), and its own fixtures falsify the old wording: objects built by GameObject::new carry zero abilities, yet re-bind from Zone::Command. The per-ability citations survive with their role corrected -- they explain WHY Command is in the admitted set (a class of sources functions from there: emblems CR 114.4; plane/scheme/conspiracy cards CR 113.6p; face-up plane and phenomenon cards CR 901.7; Eminence commanders CR 113.6b) rather than describing a per-object test. This is the per-ability/per-object distinction CR 113.6b actually draws. The AllCopies asymmetry the old wording elided is now stated: Command is admitted for ThisObject only, so a source named by card identity still fails closed from the command zone. All six CR numbers verified against docs/MagicCompRules.txt before writing. Assisted-by: ClaudeCode:claude-opus-5 --- .../engine/src/analysis/decision_template.rs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index 9bf4722c5c..d713225ba4 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -670,7 +670,8 @@ pub enum ReplayFailure { IllegalTarget { slot: DecisionSlot, pin: TargetPin }, /// CR 400.7: an ORDER pin's source does not re-bind to a live ability instance — /// [`resolve_ability_instance`] finds no object of that identity at that incarnation in a - /// zone its abilities function from (CR 113.6b) ⇒ the ordering template no longer matches + /// zone it admits (`Battlefield`; also `Command` for a `ThisObject` source) ⇒ the ordering + /// template no longer matches /// ⇒ fall through to a normal manual prompt. Raised ONLY for the `Order` pin kind, in any /// `ReplayMode`. MissingSource { source: DecisionSource }, @@ -713,9 +714,14 @@ fn resolve_pin( ) -> Result { match pin { // CR 603.3b: replay this source's trigger at its pinned ordering position. The pin - // re-binds to the SAME live ability instance (CR 400.7 incarnation), in a zone that - // instance's abilities function from (CR 113.6b; emblems CR 114.4, planes / schemes / - // conspiracies CR 113.6p) — not merely to something still on the battlefield. + // re-binds to the SAME live ability instance (CR 400.7 incarnation) still present in + // a zone the accessor admits — `Battlefield`, plus `Command` for a `ThisObject` + // source — not merely to something still on the battlefield. `Command` is admitted + // because a whole CLASS of sources functions from there (emblems CR 114.4; plane / + // scheme / conspiracy cards CR 113.6p; face-up plane and phenomenon cards CR 901.7; + // Eminence commanders CR 113.6b), but the accessor tests zone presence and identity + // only — whether a given ability functions is `game::functioning_abilities`' + // question, not this accessor's. // // Resolving an `Order` pin GRANTS NO CAPABILITY, and the six points that consume // `resolve`'s output split two ways. FIVE read the vec's ELEMENTS, and not one of them @@ -1101,8 +1107,12 @@ fn evaluate_schedule( // A `None` ANYWHERE in this chain falls through to the `ok_or_else` below: with no // live ability instance the engine cannot certify that the object it would ask the // CR 702.11c question about still IS that instance (CR 400.7 / CR 608.2b), and - // CR 732.1 makes refusing a shortcut free — no declaration published just means the - // table plays the loop out manually. Announcing a target we cannot certify is not + // CR 732.1 + CR 732.2a make refusing a shortcut free — "the player with priority MAY + // suggest a shortcut" is a permission, not an obligation, so no declaration published + // just means the table plays the loop out manually. (CR 732.2b is the RESPONDER rule + // — each OTHER player accepting or shortening a proposal that already exists — so it + // cannot govern a proposer that publishes nothing.) Announcing a target we cannot + // certify is not // free. This is the fail-closed branch, not an oversight. AnnouncementSubject::Seat(p) => resolve_ability_instance(&slot.source, state) .and_then(|src_id| state.objects.get(&src_id).map(|o| (src_id, o.controller))) @@ -2669,7 +2679,8 @@ mod tests { /// that the object it would ask the CR 702.11c question about still IS that instance /// (CR 400.7 / CR 608.2b). The seat still EXISTS and a graveyard object still carries a /// `controller`, so the question is answerable — what is missing is the certification, - /// and CR 732.1 makes refusing free (no declaration ⇒ the table plays it out manually). + /// and CR 732.1 + CR 732.2a make refusing free — "may suggest" is a permission, not an + /// obligation (no declaration ⇒ the table plays it out manually). /// /// # Non-vacuity / discrimination /// @@ -2768,8 +2779,8 @@ mod tests { /// /// CR 603.3b is the pin's framing (replay this source's trigger at its pinned ordering /// position); what changed is that "this source" is now re-bound by - /// [`resolve_ability_instance`] — same identity, same CR 400.7 incarnation, in a zone - /// that instance's abilities function from (CR 113.6b; emblems CR 114.4) — rather than + /// [`resolve_ability_instance`] — same identity, same CR 400.7 incarnation, present in a + /// zone it admits (`Battlefield`, plus `Command` for a `ThisObject` source) — rather than /// by `resolve_source`'s battlefield-only filter. /// /// **Resolving an `Order` pin grants NO capability.** No production consumer of From 62f9007dee65f37f6be5f892be232ebb21273b1c Mon Sep 17 00:00:00 2001 From: lgray Date: Wed, 12 Aug 2026 23:09:36 -0500 Subject: [PATCH 24/44] test(engine): pin the loop-shortcut episode boundary Pins the CR 732.2a episode boundary and its cross-episode carrier with four contract rows, and lands the S279 instrument correction. R3 changes no production behaviour: `crates/engine/src` is comment-only except one `#[cfg(test)]` census coordinate re-derived after Doc A's insertion shifted it. Rows - R3-a (fantastic_four_bounded_loop): a completed drive ends at the CR 732.2a ending point with the detection window discarded, driven on the real 4p dump (ring/journal 9/3 at the offer beat, 0/0 after). Abort arm pinned separately at ring=16. - R3-b (loop_shortcut_ranking): a `LoopChoice` carrier survives the CR 603.3b batch boundary while the ephemeral `TriggerOrdering` cell does not, and is owner-scoped per viewer (CR 723.4) against all three authority arms. - R3-c (loop_shortcut): exactly two `WaitingFor` variants carry a `DecisionTemplate`, both redacted; 128 variants total; four plant arms. - S279 (loop_shortcut_seat_pin_census): `sites_in_source` extracted and used by all three consumers; the census counts OCCURRENCES, not lines. Blast radius re-measured: all pinned values unchanged (parity, no finding). Docs - Doc A (game/engine.rs): the drive-end seam's CR 732.2a clauses. - Doc B (analysis/decision_template.rs): `DecisionKind::LoopChoice`'s cross-episode consumer, still unproduced today. - loop_shortcut_offer_writer_census rustdoc header corrected to five adjudications (12=>13=>14=>16=>17=>21, test half 21), and the assert's failure-message count with it. PIN-INVARIANT holds byte-identical: evaluated tuple, pinned (22, 21), `classify` body and per-file multiset all unchanged. DOC-SWEEP (R3): 264 classified not about this property ............ 147 reason still true .................. 116 reason falsified => this commit ..... 1 ------------------------------------------ sum ................................ 264 introduced by this commit .......... 1 The one falsified hit is decision_template.rs:33-38 ("`LoopChoice` has no Phase-2 consumer"), falsified by Doc B in this commit. The one introduced hit is Doc B's own `decision_templates` mention (needle 35 -> 36; every other needle delta 0). `classify` in the offer-writer census was checked and is deliberately divergent: it shares the boolean rule text but not the classification UNIT -- it counts LINES by a recorded considered choice, where the seat-pin census counts CONSTRUCTIONS. Kept for the unit, not the count. Assisted-by: ClaudeCode:claude-opus-5 --- .../engine/src/analysis/decision_template.rs | 13 +- crates/engine/src/game/engine.rs | 47 +- .../fantastic_four_bounded_loop.rs | 119 +++++ .../engine/tests/integration/loop_shortcut.rs | 410 ++++++++++++++++++ .../loop_shortcut_offer_writer_census.rs | 35 +- .../integration/loop_shortcut_ranking.rs | 173 ++++++++ .../loop_shortcut_seat_pin_census.rs | 152 ++++++- 7 files changed, 908 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index d713225ba4..3e46f6b7a3 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -33,9 +33,16 @@ pub type IterationIndex = u32; /// CR 603.3b (TriggerOrdering) / CR 732.2a (LoopChoice): which decision family a /// template captures. The `key` discriminant that lets one `decision_templates` Vec /// hold both the trigger-order templates B2 consults and the loop-choice templates -/// B3/B5 will add, so the gate can filter to `TriggerOrdering` only. `LoopChoice` has -/// no Phase-2 consumer (reserved), but the FILTER it enables is load-bearing now (the -/// gate must ignore non-ordering templates). +/// B3/B5 will add, so the gate can filter to `TriggerOrdering` only. The FILTER it +/// enables is load-bearing now (the gate must ignore non-ordering templates), and +/// `LoopChoice`'s own consumer is now known: it is the CROSS-EPISODE CARRIER. A +/// `LoopChoice` entry in `GameState::decision_templates` survives the CR 603.3b batch +/// boundary — `GameState::clear_ephemeral_trigger_order_templates`' retain predicate is +/// scoped to `TriggerOrdering` — so it is the vehicle a later episode's declaration can +/// ride, and it is still POPULATED BY PHASE 4 AND BY NOTHING TODAY. PROBE-PINNED: a +/// planted `LoopChoice` ephemeral template survives a whole accepted 4-player drive (probe +/// arm CONTROL), and is removed once that predicate is widened to cover `LoopChoice` +/// (probe arm `MUT_LOOPCHOICE`). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum DecisionKind { TriggerOrdering, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 00f31e53d2..4c5c2ac1c4 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -4450,6 +4450,30 @@ fn materialize_fixed_shortcut( // partial-cycle event leak). Ring-clear BEFORE handback so this same `apply()` does // not instantly re-emit a fresh offer for the same (now-interrupted) loop; a later // beat re-detects genuinely. + // + // CR 732.2a: "The ending point of this sequence must be a place where a player has + // priority, though it need not be the player proposing the shortcut." THIS BLOCK IS + // THAT ENDING POINT, and it is the ending point for BOTH entry paths above — `n` + // cycles done with no cross-lethal, and `break 'cycles`. + // + // What the boundary means for a declared `Ranking`: within one accepted drive only its + // HEAD is ever resolved (`evaluate_schedule`), so this seam is where the NEXT episode + // may legitimately re-evaluate the tail. The reasoning is not restated here — it lives + // on `analysis::decision_template::Ranking` ("CONSUMED AT AN EPISODE BOUNDARY, NEVER + // MID-DRIVE"), and a second copy is the drift the R1 doc sweep exists to prevent. + // + // PROBE-PINNED (probe arm `MUT_SEAM`): the window clear here is load-bearing, not a + // backstop. MEASURED — skipping it on the f4 accepted drive leaves `loop_detect_ring` + // non-empty (12) and the journal populated (3 answers), and this same `apply()` + // re-emits a `LoopShortcut` offer. + // PROBE-PINNED: the abort entry reaches here with the window equally live. MEASURED + // `ring=16, answers=0` on `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle`. + // + // LABELLED INTERPRETATION, not a pinned claim: the `waiting_for` re-seat below is a + // NORMALIZATION whose load-bearing case no fixture in this repo exercises today. On + // all four fixtures measured reaching this seam the state is ALREADY + // `WaitingFor::Priority` on entry, and skipping the re-seat changes nothing observable + // (probe arm `MUT_PRIORITY`). *state = committed; state.loop_detect_ring.clear(); // CR 603.5: the recorded "may" answers describe the window that just ended. @@ -18667,11 +18691,28 @@ mod stage2_injector_tests { // `:11583`) to `10e80db9c:engine.rs:12606`, and it is still inside // `begin_pending_trigger_target_selection`, which moved by the same +16 (opens // `:12472 ⇒ :12488`). - // ⚠ REBASE #3: `:12684 ⇒ :12683`, located by content digest, offset from + // + // ⚠ item-4 R3 (the drive-end seam's CR 732.2a doc amendment): `:12622 ⇒ :12646`, + // `+24`. LOCAL, and a COMMENT-ONLY round. Resolved BY CONTENT FIRST per the + // protocol above: the sha256 this log already records for this producer + // (`8a544e878d3e77fb…5cc7d63`) matches EXACTLY ONE line under a whole-file scan + // of the new tree, at `:12646` — and exactly one in the parent, at `:12622` — and + // it is still inside `begin_pending_trigger_target_selection`, which moved by the + // same +24 (opens `:12488 ⇒ :12512`). Arithmetic CHECK afterwards, never as the + // source: `git diff -U0` against the parent shows this file has exactly ONE hunk, + // `@@ -4452,0 +4453,24 @@` inside `materialize_fixed_shortcut` — the CR 732.2a + // episode-boundary amendment — which is ABOVE this producer, and + // `12622 + 24 = 12646` exactly. SET PRESERVATION: all 24 inserted lines are `//` + // comments (R3's entire `crates/engine/src` diff is comment-only — both files are + // byte-identical to the parent with comment lines stripped), so no + // `waiting_for = ` or `Ok(Some(` line was added and a comment round cannot mint a + // prompt. The total (38) and the partition (5/8/25) both fired GREEN on the run + // that caught this — only this third assert panicked. + // ⚠ REBASE #3: `:12708 ⇒ :12707`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - // ⚠ REBASE #3: `:12683 ⇒ :12688`, located by content digest, offset from + // ⚠ REBASE #3: `:12707 ⇒ :12712`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. - "game/engine.rs:12688".to_string(), + "game/engine.rs:12712".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index 00d5dcbafe..e68d6805c2 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -1091,6 +1091,125 @@ fn r2a_an_accepted_declaration_commits_exactly_n_cycles_because_reeds_may_is_ann ); } +/// **R3-a** — the CR 732.2a EPISODE BOUNDARY, driven on the real dump: a completed drive +/// hands back at the priority point with the detection window CLEARED (`loop_detect_ring` +/// empty, `loop_answer_journal == None`), and this same `apply()` does NOT re-offer. +/// +/// The seam is the drive-end block in `game::engine` — `*state = committed;`, then the ring +/// clear and journal clear, then the priority handback. That is **site 2** of the eight +/// ring-clear sites [`c1_every_ring_clear_site_also_clears_the_loop_answer_journal`] +/// enumerates, and that census covers site 2 STRUCTURALLY only (its own doc says so). This +/// row drives it. +/// +/// # Why the f4 board, and why it is not substitutable +/// +/// Four shipped fixtures reach this seam. MEASURED, this dump is the only one whose journal +/// is non-empty there (`answers=3`; the three `loop_shortcut.rs` fixtures arrive at +/// `answers=0`). The `loop_answer_journal` half of the claim is therefore unpinnable +/// anywhere else — which is what makes this row REAL-DUMP rather than convenient. The ABORT +/// entry to the same seam is covered where its fixtures already live, on +/// `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle` in `loop_shortcut.rs`. +/// +/// # Discrimination — REVERT-PROBE, RUN, not adopted from a code read +/// +/// Delete the seam's `state.loop_detect_ring.clear();` + `state.loop_answer_journal = None;` +/// ⇒ MEASURED `ring=12, answers=3, wf=LoopShortcut` against this drive's `0, 0, Priority`: +/// all three assertions below flip together and the engine re-offers within the same +/// `apply()`. +/// +/// The ANTI-PROBE, also run: deleting `apply_action`'s PRE-ACTION clear instead leaves the +/// final state MEASURED-unchanged at `0, 0, Priority`. This row keys on the drive-end seam +/// and not on the upstream clear, and must not be attributed to it. +/// +/// ⚠ **Do NOT assert that the ring/journal are non-empty immediately before the seam.** +/// MEASURED: they read `0/0` at the post-declare beat, because `apply_action`'s pre-action +/// clear fires on `DeclareShortcut`. The `12/3` the seam itself receives is internal and +/// unobservable from a test. The paired positive below is taken at the OFFER beat, which is +/// observable. +/// +/// ⚠ **Do NOT add a revert-probe on the `WaitingFor::Priority` re-seat** that follows the +/// clear: MEASURED VACUOUS on all four fixtures reaching this seam — they are already at +/// `Priority` on entry. The seam's own comment block carries that as labelled +/// interpretation, deliberately not as a pinned claim. +#[test] +fn r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + + // ── PAIRED POSITIVE (i): the window is LIVE at the offer beat, same board, same run. + // MEASURED at `2160f6e2c`: ring=9, answers=3. Without it every zero below is + // satisfiable by a board that never sampled or never answered a `may`. ── + let ring_at_offer = state.loop_detect_ring.len(); + let answers_at_offer = state.loop_answers_recorded(); + assert!( + ring_at_offer > 0 && answers_at_offer > 0, + "paired positive: at the CR 732.2a offer beat this board must carry BOTH a populated \ + detection ring and a populated CR 603.5 answer journal, else the cleared-window \ + assertions after the drive are vacuous. ring={ring_at_offer} answers={answers_at_offer}" + ); + + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + let template = f4_pin_template(&schema, proposer, 3); + apply( + &mut state, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(3), + template: Some(template), + }, + ) + .expect("the declaration is dispatched"); + // Reach-guard, not the claim: a REFUSED declaration hands priority straight back, and + // then the cleared window below would be the pre-action clear's work rather than the + // drive-end seam's. + assert!( + matches!(state.waiting_for, WaitingFor::RespondToShortcut { .. }), + "reach-guard: the declaration carrying the full published pin set must be accepted \ + and open the CR 732.2b window, got {:?}", + state.waiting_for + ); + let responders = accept_all_opponents(&mut state); + assert!( + responders > 0, + "reach-guard: the CR 732.2b response window must actually have opened and been \ + answered — the shortcut is taken only once the last opponent has accepted \ + (CR 732.2c) — else the drive never ran and no seam was reached" + ); + + // ── THE CLAIM: the drive ended at the CR 732.2a ending point with the window discarded ── + assert!( + state.loop_detect_ring.is_empty(), + "CR 732.2a: the accepted drive ends at the ending point with the detection window \ + DISCARDED, so the next episode re-detects from scratch. ring still carries {} \ + sample(s) (it carried {ring_at_offer} at the offer beat)", + state.loop_detect_ring.len() + ); + assert_eq!( + state.loop_answers_recorded(), + 0, + "CR 603.5: the recorded `may` answers describe the window that just ended, and the \ + drive-end seam drops them with the ring (it carried {answers_at_offer} at the offer \ + beat)" + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "CR 732.2a: the ending point of the taken sequence is a place where a player has \ + priority — and a `LoopShortcut` here would be the re-offer the seam's ring clear \ + exists to prevent. got {:?}", + state.waiting_for + ); + + // ── PAIRED POSITIVE (ii): the sampler is still ON at handback, so an empty ring is a + // CLEARED ring and not a disabled detector. ── + assert!( + state.loop_detection.samples(), + "paired positive: the detector must still be sampling after the handback ({:?}), \ + else `ring.is_empty()` above says nothing about the seam", + state.loop_detection + ); +} + // ───────────────────────────────────────────────────────────────────────────────────────── // R23 conjunct (5-reach) — the beat guard's reachability on the real dump // ───────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index ee3f880d4b..3e24e8e09a 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -4497,6 +4497,394 @@ fn respond_to_shortcut_template_redacts_a_hidden_pin_for_non_proposers() { ); } +// ───────────────────────────────────────────────────────────────────────────────────────── +// R3-c — the `DecisionTemplate` carrier census over `WaitingFor` +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// HOW a `WaitingFor` variant reaches a `DecisionTemplate`: in its own body, or through a +/// field type that carries one. The intermediate type is NAMED rather than flattened to a +/// bool — "via which type" is a real axis (`RespondToShortcut` reaches the template through +/// `ShortcutProposal`) and a bool would erase it. +#[derive(Debug, Clone, PartialEq, Eq)] +enum CarrierKind { + Direct, + Via(String), +} + +/// A variant start line inside an enum body — `^ [A-Z][A-Za-z0-9]*( \{|,|\()`, spelled with +/// `std` string ops instead of a dependency. +/// +/// MEASURED set-identical to `syn 2.0.117`'s variant list over this enum (128 names each, 0 +/// regex-only, 0 syn-only), which is why this census ships no enum-body parser: doc lines, +/// attributes and field lines all fail the shape, and nothing else in the body passes it. +fn is_variant_start(line: &str) -> Option<&str> { + let rest = line.strip_prefix(" ")?; + if !rest.starts_with(|c: char| c.is_ascii_uppercase()) { + return None; + } + let end = rest + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(rest.len()); + let (ident, tail) = rest.split_at(end); + (tail.starts_with(" {") || tail.starts_with(',') || tail.starts_with('(')).then_some(ident) +} + +/// The lines between `pub enum {` and its column-0 closing brace. +fn enum_body<'a>(src: &'a str, enum_name: &str) -> Vec<&'a str> { + let open = format!("pub enum {enum_name} {{"); + let mut out = Vec::new(); + let mut inside = false; + for line in src.lines() { + if !inside { + inside = line.trim_start().starts_with(&open); + continue; + } + if line == "}" { + break; + } + out.push(line); + } + out +} + +/// `(variant name, the lines from its start up to the next variant start)`. +fn variants<'a>(body: &[&'a str]) -> Vec<(String, Vec<&'a str>)> { + let starts: Vec = body + .iter() + .enumerate() + .filter(|(_, l)| is_variant_start(l).is_some()) + .map(|(i, _)| i) + .collect(); + starts + .iter() + .enumerate() + .map(|(k, &i)| { + let end = starts.get(k + 1).copied().unwrap_or(body.len()); + ( + is_variant_start(body[i]) + .expect("filtered above") + .to_string(), + body[i..end].to_vec(), + ) + }) + .collect() +} + +/// Every `pub struct` in the walked corpus that spells `marker` in its own body. THE DEPTH-1 +/// STEP, and the whole of it — see the row's disclosed limitation. +/// +/// Computed ONCE per corpus rather than re-scanned per candidate identifier: the walk is 500+ +/// files and the enum is 128 variants, so the per-identifier form is quadratic in the corpus +/// for no extra signal. +fn structs_carrying( + corpus: &[(String, String)], + marker: &str, +) -> std::collections::BTreeSet { + let mut out = std::collections::BTreeSet::new(); + for (_, src) in corpus { + let mut open: Option = None; + for line in src.lines() { + let Some(name) = open.as_deref() else { + if let Some(rest) = line.strip_prefix("pub struct ") { + if let Some(ident) = rest.strip_suffix(" {") { + if ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + open = Some(ident.to_string()); + } + } + } + continue; + }; + if line == "}" { + open = None; + continue; + } + if !line.trim_start().starts_with("//") && line.contains(marker) { + out.insert(name.to_string()); + open = None; + } + } + } + out +} + +/// Every variant of `enum_name` in `enum_src` that reaches `marker`, DIRECTLY or through one +/// field type found in `corpus`. +/// +/// SOURCE-PARAMETERIZED on purpose: every probe below plants into an in-memory `String`, so the +/// census's own discrimination costs no compile and mutates no worktree file. Mirrors +/// `super::loop_shortcut_offer_writer_census::classify`'s `(src, needle, file)` shape. +fn carriers_in_source( + enum_src: &str, + enum_name: &str, + corpus: &[(String, String)], + marker: &str, + walk_via: bool, +) -> Vec<(String, CarrierKind)> { + let via_types = structs_carrying(corpus, marker); + let body = enum_body(enum_src, enum_name); + let mut out = Vec::new(); + for (name, vbody) in variants(&body) { + let code: Vec<&&str> = vbody + .iter() + .filter(|l| !l.trim_start().starts_with("//")) + .collect(); + if code.iter().any(|l| l.contains(marker)) { + out.push((name, CarrierKind::Direct)); + continue; + } + if !walk_via { + continue; + } + let via = code.iter().find_map(|l| { + let mut rest: &str = l; + loop { + let p = rest.find(|c: char| c.is_ascii_uppercase())?; + rest = &rest[p..]; + let e = rest + .find(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .unwrap_or(rest.len()); + let (id, tail) = rest.split_at(e); + if id != marker && via_types.contains(id) { + return Some(id.to_string()); + } + rest = tail; + } + }); + if let Some(t) = via { + out.push((name, CarrierKind::Via(t))); + } + } + out +} + +/// Does `filter_state_for_viewer`'s body carry an `if let WaitingFor::` dispatch arm? +fn redaction_arm_present(visibility_src: &str, name: &str) -> bool { + let needle = format!("if let {}::{name}", "WaitingFor"); + let mut inside = false; + for line in visibility_src.lines() { + if !inside { + inside = line.starts_with("pub fn filter_state_for_viewer("); + continue; + } + if line == "}" { + break; + } + if line.contains(&needle) { + return true; + } + } + false +} + +/// Splice `injected` in immediately above `enum_name`'s closing brace, on a COPY of `src`. +fn plant_into_enum(src: &str, enum_name: &str, injected: &str) -> String { + let open = format!("pub enum {enum_name} {{"); + let mut out = String::new(); + let mut inside = false; + let mut planted = false; + for line in src.lines() { + if !planted { + if inside && line == "}" { + out.push_str(injected); + planted = true; + } else if !inside && line.trim_start().starts_with(&open) { + inside = true; + } + } + out.push_str(line); + out.push('\n'); + } + assert!( + planted, + "the plant anchor `{open}` … `}}` must exist in the copy" + ); + out +} + +/// **R3-c** — exactly TWO `WaitingFor` variants carry a `DecisionTemplate`, and both have a +/// redaction arm inside `filter_state_for_viewer`. +/// +/// MEASURED at this tip: `{(LoopShortcut, Direct), (RespondToShortcut, Via(ShortcutProposal))}` +/// out of 128 variants; the VIA target is `analysis::loop_check::ShortcutProposal`'s +/// `pub template: Option`. +/// +/// # Why a census exists here at all +/// +/// The REDACTION DISPATCH in `filter_state_for_viewer` is two `if let`s, not a `match`, so a +/// THIRD carrier gets no compile error THERE — that is the gap this row closes. It is not true +/// elsewhere: **at least 9** exhaustive `match`es on `WaitingFor` would fail E0004, spread over +/// two crates (`engine`, `phase-ai`) — measured by whole-workspace AST enumeration over +/// `crates/`, which is a **lower bound**, not a total. Those matches make a new variant hard to +/// ADD; not one of them makes it hard to add UNREDACTED. **The site list is deliberately not +/// enumerated here** — a frozen list in a doc comment is a claim no test defends, and it rots +/// the moment a crate is added. +/// +/// # Why the probes plant into a `String` instead of adding a variant +/// +/// MEASURED: a real third variant yields 6 E0004 under `cargo check -p phase-engine --lib` and +/// 7 with `--features test-support`, and BOTH runs abort at the lib — so the dependent crates +/// and this ~4 800-row integration target are never type-checked, and the repair list is +/// neither stable nor bounded. Source injection has neither problem and needs no build. +/// +/// # Discrimination — four plant arms, all RUN, all over in-memory copies +/// +/// * a 3rd DIRECT and a 4th VIA carrier planted into a copy of the enum source ⇒ the set +/// assertion fails NAMING both new variants (`n = 4`); +/// * the depth-1 VIA step disabled ⇒ the set shrinks to `{LoopShortcut}` ⇒ fails, so the +/// transitive step is load-bearing rather than decoration; +/// * a synthetic enum with no carrier at all ⇒ `n = 0`, so the classifier cannot only ever +/// return the answer this row wants; +/// * the `RespondToShortcut` arm deleted from a copy of `visibility.rs` ⇒ the redaction half +/// flips to `false` for that carrier while `LoopShortcut` stays `true`. The mutation is on a +/// COPY, so the shipped row `respond_to_shortcut_template_redacts_a_hidden_pin_for_non_proposers` +/// above is not perturbed. +/// +/// # DISCLOSED LIMITATIONS +/// +/// 1. **The walk is DEPTH-1.** A `WaitingFor` field whose type reaches a `DecisionTemplate` two +/// levels down is invisible to it. Measured today: zero such types exist. That is a latent +/// gap, not a covered case. +/// 2. **The E0004 figure above is a LOWER BOUND from a named instrument**, not a total, and this +/// row must never be "helpfully" upgraded into a site list. +#[test] +fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redacted() { + use super::loop_shortcut_offer_writer_census::rs_files; + + // Assembled at runtime for the same reason both sibling censuses assemble their anchors: + // an instrument that can count its own needle after a future move is one that lies about + // the surface it measures. + let marker = format!("{}{}", "Decision", "Template"); + let engine_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let corpus: Vec<(String, String)> = rs_files(&engine_src) + .into_iter() + .map(|path| { + let src = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + let rel = path + .strip_prefix(&engine_src) + .expect("walked path is under its root") + .to_string_lossy() + .replace('\\', "/"); + (rel, src) + }) + .collect(); + let file_of = |suffix: &str| -> String { + corpus + .iter() + .find(|(f, _)| f.ends_with(suffix)) + .unwrap_or_else(|| panic!("the walk must reach {suffix}")) + .1 + .clone() + }; + let enum_src = file_of("types/game_state.rs"); + let visibility_src = file_of("game/visibility.rs"); + + // ── the classifier's own reach-guard: the enum was actually found ── + let total = variants(&enum_body(&enum_src, "WaitingFor")).len(); + assert_eq!( + total, 128, + "`WaitingFor` has 128 variants at this tip (cross-checked against `syn 2.0.117`, which \ + reports the same NAME SET). This number is pinned so a variant REMOVED is as visible \ + as one added; if you added a variant and it carries no `DecisionTemplate`, update this \ + number. A wildly different count means the enum-body reader lost its anchor, and every \ + assertion below would then be measuring an empty body" + ); + + let carriers = carriers_in_source(&enum_src, "WaitingFor", &corpus, &marker, true); + assert_eq!( + carriers, + vec![ + ("LoopShortcut".to_string(), CarrierKind::Direct), + ( + "RespondToShortcut".to_string(), + CarrierKind::Via("ShortcutProposal".to_string()) + ), + ], + "CR 732.2a / CR 723.4: exactly TWO `WaitingFor` variants carry a `DecisionTemplate` — \ + `LoopShortcut` directly and `RespondToShortcut` through `ShortcutProposal` — and both \ + are named rather than counted, because a census asserting `>= 1 carrier` is vacuous. A \ + THIRD carrier is a new per-viewer redaction obligation: the dispatch in \ + `filter_state_for_viewer` is `if let`s, so nothing will fail to compile. got {carriers:?}" + ); + + // ── the redaction half, read from the production source ── + for (name, kind) in &carriers { + assert!( + redaction_arm_present(&visibility_src, name), + "CR 723.4: every `DecisionTemplate` carrier needs its own dispatch arm inside \ + `filter_state_for_viewer`; `{name}` ({kind:?}) has none" + ); + } + + // ── PLANT 1 — a 3rd DIRECT and a 4th VIA carrier, into COPIES ── + let planted_src = plant_into_enum( + &enum_src, + "WaitingFor", + &format!( + " ProbeThirdCarrier {{\n template: Option<{marker}>,\n }},\n\ + \x20 ProbeFourthCarrier {{\n holder: ProbeViaHolder,\n }},\n" + ), + ); + let mut planted_corpus = corpus.clone(); + planted_corpus.push(( + "planted.rs".to_string(), + format!("pub struct ProbeViaHolder {{\n pub template: Option<{marker}>,\n}}\n"), + )); + let planted = carriers_in_source(&planted_src, "WaitingFor", &planted_corpus, &marker, true); + assert_eq!( + planted + .iter() + .map(|(n, k)| (n.as_str(), k.clone())) + .collect::>(), + vec![ + ("LoopShortcut", CarrierKind::Direct), + ( + "RespondToShortcut", + CarrierKind::Via("ShortcutProposal".to_string()) + ), + ("ProbeThirdCarrier", CarrierKind::Direct), + ( + "ProbeFourthCarrier", + CarrierKind::Via("ProbeViaHolder".to_string()) + ), + ], + "ANTI-VACUITY: the census must NAME a planted third (direct) and fourth (transitive) \ + carrier, else the two-carrier answer above is what a dead instrument returns. \ + got {planted:?}" + ); + + // ── PLANT 2 — the depth-1 VIA step disabled ── + let no_via = carriers_in_source(&enum_src, "WaitingFor", &corpus, &marker, false); + assert_eq!( + no_via, + vec![("LoopShortcut".to_string(), CarrierKind::Direct)], + "the transitive step is LOAD-BEARING: without it `RespondToShortcut` is invisible and \ + the shipped set assertion above would be a one-element claim. got {no_via:?}" + ); + + // ── PLANT 3 — a synthetic enum with no carrier ── + let synthetic = "pub enum WaitingFor {\n Alpha {\n player: PlayerId,\n },\n \ + Beta {\n x: u32,\n },\n}\n"; + let none = carriers_in_source(synthetic, "WaitingFor", &corpus, &marker, true); + assert!( + none.is_empty(), + "a carrier-free enum must classify as carrier-free; an instrument that can only ever \ + return {{LoopShortcut, RespondToShortcut}} is what this arm forecloses. got {none:?}" + ); + + // ── PLANT 4 — the redaction arm deleted, on a COPY of `visibility.rs` ── + let mutated_visibility = visibility_src.replace( + &format!("if let {}::RespondToShortcut", "WaitingFor"), + &format!("if let {}::ZzzDeletedArm", "WaitingFor"), + ); + assert!( + redaction_arm_present(&mutated_visibility, "LoopShortcut") + && !redaction_arm_present(&mutated_visibility, "RespondToShortcut"), + "the redaction half must FLIP when its arm is deleted from the copy, and only for the \ + carrier whose arm was deleted — otherwise the `for` loop above asserts nothing" + ); +} + /// F4 (review finding): the THIRD carrier of the same `Vec` — /// `GameState::last_loop_action_sequence[].pins` — routes through the same authority. /// @@ -11344,6 +11732,28 @@ fn bounded_fixed_drive_rolls_back_a_partial_crossing_cycle() { crossing eliminates at most one of three, so there is no winner to crown and the \ aborted drive hands back ordinary priority rather than ending the game" ); + + // (d) R3-a's ABORT ARM — the drive-end seam is the CR 732.2a ending point for this + // entry path too, and it discards the detection window before handing back. + // MEASURED: this fixture enters that seam with a LIVE ring (`ring=16`), so the + // emptiness below is a CLEARED ring and not an absent one. Its journal is + // ALREADY empty there (`answers=0`) — the populated-journal half of the same + // seam is pinned on the f4 dump by + // `fantastic_four_bounded_loop::r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared`, + // the only fixture measured reaching this seam with answers recorded. + assert!( + doctored.loop_detect_ring.is_empty(), + "n={n}: CR 732.2a — the aborted drive ends at the priority handback with the \ + detection window DISCARDED, so a later beat re-detects genuinely instead of this \ + same `apply()` re-offering the interrupted loop; ring still carries {} sample(s)", + doctored.loop_detect_ring.len() + ); + assert_eq!( + doctored.loop_answers_recorded(), + 0, + "n={n}: CR 603.5 — the recorded `may` answers describe the window that just ended, \ + and the same seam drops them together with the ring" + ); } } diff --git a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs index ba8d30a32f..c2983fd4f8 100644 --- a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs @@ -121,9 +121,9 @@ pub(super) fn cfg_test_scoped_lines(src: &str) -> Vec { /// re-measured by the very commit that ships the row. Excluding comment lines /// restores the plan's PRODUCTION count of 22 exactly, INCLUDING its per-file /// production multiset. (It does not restore the plan's original test-half count -/// of 12: that half has since been adjudicated to 14, twice, and the assert below -/// is the authority for the pair. Prose that repeats a number is prose that can go -/// stale — this defers to the assert rather than restating it.) +/// of 12: that half has since been adjudicated five times, to 21, and the assert +/// below is the authority for the pair. Prose that repeats a number is prose that +/// can go stale — this defers to the assert rather than restating it.) fn classify(src: &str, needle: &str, file: &str) -> Vec { let scoped = cfg_test_scoped_lines(src); src.lines() @@ -184,10 +184,10 @@ fn census(needle: &str) -> Vec { } /// R8 CONJUNCT 1 — the offer-writer surface, pinned BIDIRECTIONALLY (`== 22` / -/// `== 14`, so a REMOVED site fails too) and by per-file multiset. +/// `== 21`, so a REMOVED site fails too) and by per-file multiset. /// -/// ⚠ THE `#[cfg(test)]` HALF HAS MOVED TWICE, 12 ⇒ 13 ⇒ 14, AND EACH -/// ADJUDICATION IS RECORDED RATHER THAN THE ASSERT RELAXED. +/// ⚠ THE `#[cfg(test)]` HALF HAS MOVED FIVE TIMES, 12 ⇒ 13 ⇒ 14 ⇒ 16 ⇒ 17 ⇒ 21, +/// AND EACH ADJUDICATION IS RECORDED RATHER THAN THE ASSERT RELAXED. /// * 12 ⇒ 13: §6 R27 (b) /// (`analysis::resource::tests::r27_b_a_stored_may_auto_choice_survives_the_ring`) /// destructures the offer the mint RETURNED to count its published CR 603.5 @@ -197,11 +197,24 @@ fn census(needle: &str) -> Vec { /// arm (b) can assert that the DECLARE firewall refuses a hostile /// `template.owner` — i.e. that arm (b)'s drive-seam configuration is /// production-unreachable. +/// * 14 ⇒ 16: both in `phase-ai/src/policies/loop_shortcut.rs`'s `#[cfg(test)]` +/// module — `bounded_offer_with_period`, a builder minting an offer whose +/// certificate carries a real `per_cycle` so the proposer-elimination arm can +/// be driven, and `certificate_of`, a READ accessor for the same rows. +/// * 16 ⇒ 17: item-4 C2a's cap-round row +/// `the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for` in +/// `engine/src/analysis/resource.rs` — A READ, NOT A WRITER: it destructures +/// the offer it minted to assert an EMPTY `schema.points` beside a +/// `victim_slot` that still names the forced victim. +/// * 17 ⇒ 21: item-4 C2b's two in-crate rows spell the anchor FOUR times between +/// them — `game/visibility.rs` row D5-h's mint and its projection read, and +/// `ai_support/candidates.rs` row D6-n's mint and its reach-guard read. /// -/// Both are WRITES in a `#[cfg(test)]` scope, which is the benign case this -/// row's own failure message names: a test fixture cannot make the period -/// machinery certify. The PRODUCTION half is unchanged at 22 and so is the -/// per-file multiset below, which is the half §10 ruling condition (2) is about. +/// All five are in a `#[cfg(test)]` scope — mints and reads both — which is the +/// benign case this row's own failure message names: a test fixture cannot make +/// the period machinery certify. The PRODUCTION half is unchanged at 22 and so is +/// the per-file multiset below, which is the half §10 ruling condition (2) is +/// about. /// /// R8 CONJUNCT 2, same test — pin VALUE-legality has exactly ONE production /// consumer (`analysis::decision_template::declaration_conforms`), that consumer @@ -247,7 +260,7 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid certify without declaring or driving — §10 ruling condition (2), i.e. \ answer-legality-at-certification becomes OWED WORK and the U-series stops. A new READ \ site is the benign case; adjudicate, do not relax the assert.\n\ - THE TEST HALF HAS BEEN ADJUDICATED THREE TIMES (12 ⇒ 13, §6 R27 (b)'s schema read in \ + THE TEST HALF HAS BEEN ADJUDICATED FIVE TIMES (12 ⇒ 13, §6 R27 (b)'s schema read in \ `engine/src/analysis/resource.rs`; 13 ⇒ 14, 5d U4's `u4_park_on_offer` fixture in \ `engine/src/game/engine.rs`, which parks a constructed board on an offer so §6 R28 \ arm (b) can assert the DECLARE firewall refuses a hostile `template.owner`; 14 ⇒ 16, \ diff --git a/crates/engine/tests/integration/loop_shortcut_ranking.rs b/crates/engine/tests/integration/loop_shortcut_ranking.rs index 9c587444bf..33d33aa47d 100644 --- a/crates/engine/tests/integration/loop_shortcut_ranking.rs +++ b/crates/engine/tests/integration/loop_shortcut_ranking.rs @@ -214,3 +214,176 @@ fn r2d_a_ranked_seat_is_judged_as_a_target_while_the_choice_class_keeps_existenc CR 732.2a proposals, e.g. a CR 701.34a proliferate choice" ); } + +/// One `(kind × ephemerality)` cell of R3-b's grid. Ephemerality is a property of the KEY's +/// source — `ThisObject` is per-incarnation (CR 400.7) and therefore ephemeral, `AllCopies` +/// latches card identity and is persistent — so the cell is built by choosing the source, never +/// by setting a flag. +fn grid_template( + owner: PlayerId, + kind: DecisionKind, + ephemeral: bool, + anchor: ObjectId, +) -> DecisionTemplate { + let source = if ephemeral { + YieldTarget::ThisObject { + source_id: anchor, + incarnation: Some(1), + trigger_description: None, + } + } else { + YieldTarget::AllCopies { + card_id: CardId(9_002), + trigger_description: None, + } + }; + DecisionTemplate { + owner, + decisions: vec![], + replay: ReplayMode::Static, + key: DecisionGroupKey::from_sources(&[source], kind), + } +} + +/// **Row R3-b.** The CROSS-EPISODE CARRIER: a `DecisionKind::LoopChoice` template SURVIVES the +/// CR 603.3b batch boundary, and it is owner-scoped per viewer under CR 723.4. This is P4's +/// precondition, pinned before P4 leans on it. +/// +/// The seam is `GameState::clear_ephemeral_trigger_order_templates`, whose retain predicate is +/// `!(kind == TriggerOrdering && is_ephemeral())` — scoped on BOTH axes. +/// +/// # Reachability was established before cause was attributed +/// +/// MEASURED on the real 4-player drive: planted templates go `3 → 2` at the accepting beat of +/// the f4 bounded drive, survivors `[(LoopChoice, ephemeral), (TriggerOrdering, persistent)]`. +/// So this boundary is reached in production, and the grid below states WHICH cell it removes. +/// +/// # The row is the 2×2 GRID, not one cell +/// +/// A fixture planting only `LoopChoice` cannot distinguish "kind-scoped" from +/// "ephemerality-scoped": both readings keep it. All four `(kind × ephemerality)` cells are +/// planted and exactly one — `TriggerOrdering` + ephemeral — must be removed. The +/// `TriggerOrdering` EPHEMERAL cell is therefore also this row's paired positive: without it a +/// retain predicate that kept everything would pass. +/// +/// # Discrimination — REVERT-PROBE, RUN +/// +/// Widen the retain predicate to cover `LoopChoice` (drop the `kind ==` conjunct) ⇒ MEASURED: +/// the planted `LoopChoice` ephemeral template is gone, survivors +/// `[(TriggerOrdering, persistent)]` and the count drops `2 → 1` ⇒ the first assertion fails. +/// +/// # The CR 603.5 journal half is pinned on the f4 dump, and that is a MEASURED constraint +/// +/// The contrast this carrier lives inside is "the template survives, the answer journal does +/// not". The journal half is asserted by +/// `fantastic_four_bounded_loop::r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared` +/// — with the `> 0` reach-guard that makes it non-vacuous — and NOT here, because +/// `GameState::loop_answer_journal` and its single writer `record_loop_answer` are +/// `pub(crate)`: a board this file can build never populates the journal, so a +/// `loop_answers_recorded() == 0` assertion here would be a vacuous negative with no reachable +/// paired positive. +/// +/// # The hostile arm is MULTI-AUTHORITY, and it says so structurally +/// +/// `viewer_has_private_access_to_player` is +/// `player == viewer || authorized_submitter_for_player(state, player) == viewer`, and +/// `authorized_submitter_for_player` has THREE arms (`LatchedController`, `SearcherFallback`, +/// `effective_authority_for_player`). A fixture that only says "no turn control" silences one +/// arm and leaves the search-decision arm live, which would read post-unification behaviour and +/// call it pre-unification. Both widening conjuncts are asserted absent below. +#[test] +fn r3b_a_loop_choice_carrier_survives_the_batch_boundary_and_is_owner_scoped_per_viewer() { + let (mut state, source) = board_with_source(); + + // ── the 2×2 grid: every (kind × ephemerality) cell, planted on one board ── + state.decision_templates = vec![ + grid_template(P0, DecisionKind::LoopChoice, true, source), + grid_template(P0, DecisionKind::TriggerOrdering, true, source), + grid_template(P0, DecisionKind::TriggerOrdering, false, source), + grid_template(P0, DecisionKind::LoopChoice, false, source), + ]; + let cells = |state: &GameState| -> Vec<(DecisionKind, bool)> { + state + .decision_templates + .iter() + .map(|t| (t.key.kind, t.key.is_ephemeral())) + .collect() + }; + // Reach-guard on the INSTRUMENT: both axes must be genuinely distinguishable on this board, + // else "exactly one cell removed" could be an artefact of four identical keys. + assert_eq!( + cells(&state), + vec![ + (DecisionKind::LoopChoice, true), + (DecisionKind::TriggerOrdering, true), + (DecisionKind::TriggerOrdering, false), + (DecisionKind::LoopChoice, false), + ], + "reach-guard: the planted grid must present all four cells, with `is_ephemeral()` \ + tracking the KEY SOURCE (CR 400.7 `ThisObject` vs latched `AllCopies`)" + ); + assert!( + state + .decision_templates + .iter() + .all(|t| t.key.is_ephemeral() != t.key.is_persistent()), + "reach-guard: the two predicates must be complementary on every planted cell, else the \ + boundary's second conjunct is being read off a degenerate axis" + ); + + state.clear_ephemeral_trigger_order_templates(); + + assert_eq!( + cells(&state), + vec![ + (DecisionKind::LoopChoice, true), + (DecisionKind::TriggerOrdering, false), + (DecisionKind::LoopChoice, false), + ], + "CR 603.3b: the batch boundary drops EXACTLY the `TriggerOrdering` + ephemeral cell. \ + The ephemeral `LoopChoice` surviving is the CR 732.2a cross-episode carrier P4 rides; \ + the ephemeral `TriggerOrdering` being dropped is the paired positive that stops a \ + keep-everything predicate from passing. A `LoopChoice` missing here means the \ + predicate lost its KIND conjunct; a `TriggerOrdering`/ephemeral survivor means it lost \ + its EPHEMERALITY conjunct" + ); + + // ── the hostile arm: CR 723.4 owner scoping, on a board with NO second authority ── + let (mut projected_board, anchor) = board_with_source(); + projected_board.decision_templates = vec![ + grid_template(P0, DecisionKind::LoopChoice, true, anchor), + grid_template(P1, DecisionKind::LoopChoice, true, anchor), + ]; + // BOTH widening conjuncts of `authorized_submitter_for_player` asserted absent + // STRUCTURALLY. Asserting only "no turn control" leaves the search-decision arm live. + assert!( + projected_board.turn_decision_controller.is_none(), + "reach-guard (arm 3): no player controls another's turn decisions on this board" + ); + assert!( + projected_board.active_search_decision_controls.is_empty() + && projected_board.pending_search_found_batch.is_none(), + "reach-guard (arms 1 and 2): no LATCHED search-decision controller and no pending \ + search batch, so `authorized_submitter_for_player` cannot widen private access \ + through the search path either" + ); + let owners = |viewer: PlayerId| -> Vec { + engine::game::visibility::filter_state_for_viewer(&projected_board, viewer) + .decision_templates + .iter() + .map(|t| t.owner) + .collect() + }; + assert_eq!( + owners(P0), + vec![P0], + "CR 723.4: a viewer is projected their OWN carrier and not a non-owner's — the retain \ + is keyed to private access, and on this board the only access is self-access" + ); + assert_eq!( + owners(P1), + vec![P1], + "paired positive, taken on the same board: the other seat sees THEIR carrier, so the \ + absence above is owner scoping and not a sweep that dropped every template" + ); +} diff --git a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs index 2648d2e3d2..0a2cb1896f 100644 --- a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs @@ -42,7 +42,7 @@ //! measured defect in the naive "nearest preceding attribute" rule, and a second copy of the //! rule is a second place for it to be got wrong. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use super::loop_shortcut_offer_writer_census::{cfg_test_scoped_lines, rs_files}; @@ -69,13 +69,63 @@ struct Site { text: String, } -/// Non-comment hits of `needle` in production (non-`#[cfg(test)]`) scope. +/// Non-comment, production-scope OCCURRENCES of `needle` in `src`, one [`Site`] each. +/// +/// THE SINGLE HOME OF THIS CENSUS'S MATCHING RULE. Source-parameterized, so the anti-vacuity +/// and matched-pair rows below exercise THE SHIPPED RULE rather than a copy of it — a copy +/// validates a rule the file no longer ships. Mirrors +/// [`super::loop_shortcut_offer_writer_census`]'s `classify(src, needle, file)` shape, and +/// computes its own `#[cfg(test)]` scope map for the same reason that one does: a +/// caller-supplied map is a second thing a call site can get wrong. +/// +/// OCCURRENCE granularity, not line granularity: `line.matches(needle).count()` (the `std` +/// building block for non-overlapping matches) emits one `Site` per construction, so two +/// constructions co-located on one line are two counted sites. The boolean rule this replaced +/// counted them once — measured on this tree, 146 non-comment lines already carry two or more +/// occurrences of the same `Enum::Variant(` spelling, including the +/// `TargetRef::Object(id) => Some(TargetRef::Object(*id))` match-arm-plus-construction shape a +/// producer revert would take. /// /// COMMENT LINES ARE EXCLUDED, and this is the same deviation the sibling census records: prose /// writes no pin and reads none, so a doc mentioning a spelling is not a construction site. The /// doc surface is swept separately (the commit's per-property bucket table); counting it here /// would make the tripwire fire on prose. `//!`, `///` and `//` are all excluded; a trailing /// comment on a code line still counts, because the CODE on that line is real. +fn sites_in_source(src: &str, needle: &str, file: &str) -> Vec { + let scoped = cfg_test_scoped_lines(src); + let mut out = Vec::new(); + for (n, line) in src.lines().enumerate() { + if line.trim_start().starts_with("//") || scoped[n] { + continue; + } + for _ in 0..line.matches(needle).count() { + out.push(Site { + file: file.to_string(), + line: n + 1, + text: line.trim().to_string(), + }); + } + } + out.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line))); + out +} + +/// The LINE count the pre-S279 boolean rule produced, derived from the SAME `Vec`. +/// +/// NOT a second copy of the rule: [`sites_in_source`] emits one `Site` per OCCURRENCE, so the +/// distinct `(file, line)` keys are exactly the lines `line.contains(needle)` would have +/// counted — provably equal to the old rule without shipping the old rule. The key is +/// `(file, line)` and not `line`, so two files sharing a line number cannot collapse. +fn distinct_lines(sites: &[Site]) -> usize { + sites + .iter() + .map(|s| (s.file.as_str(), s.line)) + .collect::>() + .len() +} + +/// Production-scope OCCURRENCES of `needle` across the three walked crate roots — the walk, +/// with the matching rule delegated to [`sites_in_source`]. fn production_sites(needle: &str) -> Vec { let engine_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let server_src = Path::new(env!("CARGO_MANIFEST_DIR")) @@ -95,21 +145,12 @@ fn production_sites(needle: &str) -> Vec { for path in rs_files(&root) { let src = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); - let scoped = cfg_test_scoped_lines(&src); let rel = path .strip_prefix(&root) .expect("walked path is under its root") .to_string_lossy() .replace('\\', "/"); - for (n, line) in src.lines().enumerate() { - if line.contains(needle) && !line.trim_start().starts_with("//") && !scoped[n] { - out.push(Site { - file: format!("{prefix}/{rel}"), - line: n + 1, - text: line.trim().to_string(), - }); - } - } + out.extend(sites_in_source(&src, needle, &format!("{prefix}/{rel}"))); } } out.sort_by(|a, b| (&a.file, a.line).cmp(&(&b.file, b.line))); @@ -149,7 +190,9 @@ fn no_target_class_producer_constructs_a_choice_class_player_pin() { // deliberate: a line pin fails on any insertion above a site, which is drift burden without // discrimination — measured: a 200-line insertion above every site leaves this census green. // Multiplicity carries the load instead — a new construction anywhere THE WALK VISITS in - // production scope changes the compared value, including a THIRD `engine.rs` hit from + // production scope changes the compared value, and multiplicity is counted per OCCURRENCE, + // not per line, so a construction co-located with an existing one on the same line still + // moves the compared value. That includes a THIRD `engine.rs` hit from // reverting `record_trigger_target_answer`, and `interaction.rs` is pinned by ABSENCE, // which no rearrangement inside that file can satisfy. The walk visits three roots only // (`engine/src`, `server-core/src`, `phase-ai/src`), so a construction added in a crate @@ -158,7 +201,9 @@ fn no_target_class_producer_constructs_a_choice_class_player_pin() { // other than `engine` and `server-core`, and both of their `src` roots are walked. // // The doubled `engine.rs` entry is guarded by MULTIPLICITY, not by the text pins below: a - // third construction in that file fails THIS assertion, before the text pins are reached. + // third construction in that file fails THIS assertion, before the text pins are reached — + // including one written onto a line that already carries a construction, since the count is + // per OCCURRENCE. // Do NOT relax `files` to a de-duplicated set on the theory that the text pins cover the // doubling — they are a different layer, and layer 3 below exists because a change can pass // both of them. Three measured layers, in the order they fire: @@ -207,7 +252,9 @@ fn no_target_class_producer_constructs_a_choice_class_player_pin() { // The `engine.rs` pair is pinned to its two ARMS by text: a SUBSTITUTION at either arm that // holds the file count at 2 fails here, as does relocating the arms relative to each other. - // An ADDED construction in this file does not reach here — the multiset above fails first. + // An ADDED construction in this file does not reach here — the multiset above fails first, + // and it fails per OCCURRENCE, so co-locating the addition on an existing hit line does not + // slip past it either. let engine_texts: Vec<&str> = sites .iter() .filter(|s| s.file == "engine/src/game/engine.rs") @@ -299,15 +346,10 @@ fn the_seat_pin_census_instrument_reports_both_answers_on_planted_input() { }}\n" ); - let scoped = cfg_test_scoped_lines(&src); - let count = |needle: &str| { - src.lines() - .enumerate() - .filter(|(n, line)| { - line.contains(needle) && !line.trim_start().starts_with("//") && !scoped[*n] - }) - .count() - }; + // Routed through THE SHIPPED RULE, never a local re-implementation of it: an inline copy + // here would validate a rule this file no longer ships, which is the drift the census + // itself exists to catch. + let count = |needle: &str| sites_in_source(&src, needle, "planted.rs").len(); assert_eq!( (count(&choice), count(&target)), @@ -318,3 +360,65 @@ fn the_seat_pin_census_instrument_reports_both_answers_on_planted_input() { src:\n{src}" ); } + +/// S279 INSTRUMENT — the census counts OCCURRENCES, and the two rules are separated on input +/// that distinguishes them. +/// +/// The pre-S279 rule was `line.contains(needle)`, a BOOLEAN: two constructions on one line +/// counted once. That is not a synthetic worry — measured over this census's own three walk +/// roots, 146 non-comment lines already carry two or more occurrences of the same +/// `Enum::Variant(` spelling, two of them in exactly the match-arm-plus-construction shape a +/// producer revert would take (`ability_utils.rs`'s `TargetRef::Object(id) => +/// Some(TargetRef::Object(*id))` and `targeting.rs`'s `Player` sibling). `TargetPin::Player(` +/// reads at line/occurrence parity today BY ACCIDENT, not by property. +/// +/// # The matched pair, and why the second number is a PROJECTION +/// +/// Each arm asserts BOTH numbers: `sites_in_source(..).len()` (occurrences) and +/// [`distinct_lines`] (the lines the old boolean rule would have counted). `distinct_lines` is +/// derived from the SAME `Vec`, so the two numbers can diverge only because the DATA +/// differs — never because two instruments differ. A second inline implementation of the line +/// rule is the defect this row exists to remove, not a shortcut it may take. +/// +/// # Discrimination — one arm flips, two do not +/// +/// Revert `sites_in_source` to the boolean form (`if line.contains(needle) { push once }`) ⇒ +/// SAME-LINE's occurrence count becomes 1 ⇒ THIS ROW FAILS, while BASE and DISTINCT-LINE stay +/// green. That asymmetry is what makes it a matched pair rather than a single-sided assertion. +/// +/// # Wiring requirement +/// +/// The arms call `sites_in_source` with their OWN source. They must never call +/// `production_sites`, which takes no source, reads the real tree, and would report the same +/// answer under both rules — measuring nothing. +#[test] +fn the_seat_pin_census_counts_occurrences_and_not_lines() { + let needle = choice_needle(); + let base = format!("fn production() {{\n let a = {needle}PlayerId(0));\n}}\n"); + // The SECOND construction is added TO THE EXISTING HIT LINE — the whole point of the arm. + let same_line = format!( + "fn production() {{\n let a = match t {{ {needle}p) => {needle}*p), _ => x }};\n}}\n" + ); + // The same second construction, on its OWN line. + let distinct = format!( + "fn production() {{\n let a = {needle}PlayerId(0));\n let b = \ + {needle}PlayerId(1));\n}}\n" + ); + + for (label, src, expected) in [ + ("BASE", &base, (1, 1)), + ("SAME-LINE", &same_line, (2, 1)), + ("DISTINCT-LINE", &distinct, (2, 2)), + ] { + let sites = sites_in_source(src, &needle, "synthetic.rs"); + assert_eq!( + (sites.len(), distinct_lines(&sites)), + expected, + "arm {label}: (occurrences, lines) must be {expected:?}. BASE is the reach-guard — \ + both rules agree on the trivial case; SAME-LINE is the arm that FLIPS, and it \ + reads (1, 1) if `sites_in_source` is reverted to `line.contains(needle)`; \ + DISTINCT-LINE proves the flip is about CO-LOCATION and not about the second \ + construction existing. got {sites:?}\nsrc:\n{src}" + ); + } +} From daad5bd38ff2b4911644e30f094c6d6b1ca0844b Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 01:52:04 -0500 Subject: [PATCH 25/44] fix(engine-tests): replace hand-rolled carrier scan with syn; pin the cross-episode carrier claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2 findings on the R3 candidate. MED-1+2: `structs_carrying` parsed Rust with `strip_prefix("pub struct ")` on the raw line, so it saw only `pub struct {` at column 0 — blind to `pub(crate)`, generic, enum and aliased holders, four measured false GREENs. Over `crates/engine/src` that rule matched 486 declarations while 649 `pub enum` + 108 `pub(crate) struct` + 12 generic were invisible, so the blind surface exceeded the covered one. Replaced with a `syn::Item` walk (`syn 2` is already a dev-dependency and already used by a sibling row in this same test binary), which treats every holder form uniformly. The shipped answer is unchanged under the wider instrument — still exactly {(LoopShortcut, Direct), (RespondToShortcut, Via(ShortcutProposal))} over 128 variants — and the four original plant arms still pass. MED-3: `decision_template.rs` asserted PROBE-PINNED for the cross-episode carrier with no shipped row driving it. Promoted the probe to a shipped f4 row that plants the (kind x ephemerality) cells on the real 4-player board and drives a whole accepted CR 732.2a shortcut through `apply()`; the `LoopChoice` cell survives (3 -> 2) while the `TriggerOrdering` ephemeral cell beside it does not. Label now names that row. LOW-4: the census comment claimed "exactly ONE hunk" (measured 2) and "comment-only" (comment-stripped, engine.rs differs at the pin string). Reworded to what is true. LOW-5: the abort arm's `loop_answers_recorded() == 0` is measurably non-discriminating on its own fixture; labelled a forward tripwire and cross-referenced to the f4 row that states the journal half discriminatingly. Assisted-by: ClaudeCode:claude-opus-4.8 --- .../engine/src/analysis/decision_template.rs | 10 +- crates/engine/src/game/engine.rs | 21 +- .../fantastic_four_bounded_loop.rs | 128 ++++++++ .../engine/tests/integration/loop_shortcut.rs | 303 +++++++++++------- .../integration/loop_shortcut_ranking.rs | 15 +- 5 files changed, 354 insertions(+), 123 deletions(-) diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index 3e46f6b7a3..4471bfbc17 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -39,10 +39,12 @@ pub type IterationIndex = u32; /// `LoopChoice` entry in `GameState::decision_templates` survives the CR 603.3b batch /// boundary — `GameState::clear_ephemeral_trigger_order_templates`' retain predicate is /// scoped to `TriggerOrdering` — so it is the vehicle a later episode's declaration can -/// ride, and it is still POPULATED BY PHASE 4 AND BY NOTHING TODAY. PROBE-PINNED: a -/// planted `LoopChoice` ephemeral template survives a whole accepted 4-player drive (probe -/// arm CONTROL), and is removed once that predicate is widened to cover `LoopChoice` -/// (probe arm `MUT_LOOPCHOICE`). +/// ride, and it is still POPULATED BY PHASE 4 AND BY NOTHING TODAY. PINNED BY A SHIPPED +/// ROW: `fantastic_four_bounded_loop::r3b_driven_a_loop_choice_carrier_survives_a_whole_ +/// accepted_f4_drive` plants the `(kind × ephemerality)` cells on the real 4-player board +/// and drives a whole accepted CR 732.2a shortcut through `apply()` — the `LoopChoice` +/// cell survives (`3 → 2`) while the `TriggerOrdering` ephemeral cell beside it does not. +/// `loop_shortcut_ranking::r3b_*` is the seam-level statement of the same predicate. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum DecisionKind { TriggerOrdering, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 4c5c2ac1c4..44e02ed02f 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -18699,15 +18699,20 @@ mod stage2_injector_tests { // of the new tree, at `:12646` — and exactly one in the parent, at `:12622` — and // it is still inside `begin_pending_trigger_target_selection`, which moved by the // same +24 (opens `:12488 ⇒ :12512`). Arithmetic CHECK afterwards, never as the - // source: `git diff -U0` against the parent shows this file has exactly ONE hunk, - // `@@ -4452,0 +4453,24 @@` inside `materialize_fixed_shortcut` — the CR 732.2a - // episode-boundary amendment — which is ABOVE this producer, and - // `12622 + 24 = 12646` exactly. SET PRESERVATION: all 24 inserted lines are `//` - // comments (R3's entire `crates/engine/src` diff is comment-only — both files are - // byte-identical to the parent with comment lines stripped), so no + // source: `git diff -U0` against the parent shows exactly ONE hunk ABOVE this + // producer, `@@ -4452,0 +4453,24 @@` inside `materialize_fixed_shortcut` — the + // CR 732.2a episode-boundary amendment — and `12622 + 24 = 12646` exactly. (The + // file carries a SECOND hunk, this very comment block; it is BELOW the producer + // and so contributes nothing to the coordinate. Counting whole-file hunks instead + // of hunks-above-the-producer is the arithmetic slip to avoid here.) + // SET PRESERVATION: all 24 inserted lines are `//` comments, so no // `waiting_for = ` or `Ok(Some(` line was added and a comment round cannot mint a - // prompt. The total (38) and the partition (5/8/25) both fired GREEN on the run - // that caught this — only this third assert panicked. + // prompt. R3's `crates/engine/src` diff is comment-only APART FROM THIS PIN + // STRING: with comment lines stripped, `analysis/decision_template.rs` is + // byte-identical to the parent and `game/engine.rs` differs in exactly one line — + // the `:12622 ⇒ :12646` literal directly below. The total (38) and the partition + // (5/8/25) both fired GREEN on the run that caught this — only this third assert + // panicked. // ⚠ REBASE #3: `:12708 ⇒ :12707`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. // ⚠ REBASE #3: `:12707 ⇒ :12712`, located by content digest, offset from diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index e68d6805c2..9a4a1f5a2a 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -1210,6 +1210,134 @@ fn r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared() { ); } +/// **R3-b, DRIVEN ARM** — the CROSS-EPISODE CARRIER claim, taken on the real 4-player board +/// across a whole accepted drive rather than at helper level. +/// +/// `analysis::decision_template::DecisionKind`'s doc states that a `LoopChoice` template +/// SURVIVES the CR 603.3b batch boundary and is therefore the vehicle a later episode's +/// declaration rides. Its sibling `loop_shortcut_ranking::r3b_*` states the same property at +/// the seam — it calls `clear_ephemeral_trigger_order_templates()` directly — which pins WHICH +/// CELL the predicate removes but says nothing about whether an accepted production drive ever +/// reaches that predicate, or reaches it only once, or leaves the survivor intact afterwards. +/// This row is that missing half: `DeclareShortcut` → the full CR 732.2b APNAP window → +/// `apply_confirmed_shortcut` → `materialize_fixed_shortcut`, every beat through `apply()`. +/// +/// # Non-vacuity +/// +/// The `TriggerOrdering` + ephemeral cell is the paired positive: it is REMOVED by the same +/// drive that keeps the `LoopChoice` one, so "the drive never reached the boundary" and "the +/// drive dropped everything" both fail here. MEASURED `3 → 2`. +/// +/// # Discrimination +/// +/// The asserted vector is two-sided, and each side names the mutant that flips it: +/// +/// * drop the seam's `kind ==` conjunct ⇒ the `(LoopChoice, ephemeral)` element disappears; +/// * never reach the seam at all ⇒ the `(TriggerOrdering, ephemeral)` element is still there. +/// +/// The second is MEASURED by this row passing (`3 → 2`, with that cell and only that cell +/// gone). The first is attributed rather than mutated HERE, and the attribution is licensed by +/// a census rather than by a code read: over `crates/engine/src` the only `retain` on a LIVE +/// `decision_templates` is `GameState::clear_ephemeral_trigger_order_templates` — `visibility`'s +/// retain runs on the per-viewer CLONE (`filtered.decision_templates`), and no other site +/// clears, drains, removes or reassigns the Vec. So a drive that demonstrably removed one cell +/// ran that predicate, and the survivor beside it is that predicate's `kind ==` conjunct doing +/// work. The predicate-level mutant itself is RUN on the seam-level sibling +/// `loop_shortcut_ranking::r3b_*`, which is where a production-source mutation belongs. +/// +/// The planted cells are inert as far as the drive is concerned — they key on a source no F4 +/// trigger raises — so they observe the boundary without steering it. +#[test] +fn r3b_driven_a_loop_choice_carrier_survives_a_whole_accepted_f4_drive() { + use super::loop_shortcut_ranking::grid_template; + + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + let template = f4_pin_template(&schema, proposer, 3); + + // Planted at the OFFER beat, keyed to a real battlefield object resolved BY NAME so a + // re-dump that renumbers `ObjectId`s flows through (see `resolve_by_name`). + // + // The plant is purely ADDITIVE, and that is ASSERTED rather than assumed: had the drive + // left real templates here, overwriting them could steer the very drive this row observes, + // and the survivor set below would be reporting the fixture's own damage. + let anchor = resolve_by_name(&state, THING); + assert!( + state.decision_templates.is_empty(), + "reach-guard: the F4 drive reaches its offer beat carrying NO templates, so the grid \ + below is planted onto an empty vector and displaces nothing; got {:?}", + state + .decision_templates + .iter() + .map(|t| (t.key.kind, t.key.is_ephemeral())) + .collect::>() + ); + state.decision_templates = vec![ + grid_template(P0, DecisionKind::LoopChoice, true, anchor), + grid_template(P0, DecisionKind::TriggerOrdering, true, anchor), + grid_template(P0, DecisionKind::TriggerOrdering, false, anchor), + ]; + let cells = |state: &GameState| -> Vec<(DecisionKind, bool)> { + state + .decision_templates + .iter() + .map(|t| (t.key.kind, t.key.is_ephemeral())) + .collect() + }; + assert_eq!( + cells(&state), + vec![ + (DecisionKind::LoopChoice, true), + (DecisionKind::TriggerOrdering, true), + (DecisionKind::TriggerOrdering, false), + ], + "reach-guard on the INSTRUMENT: both axes must be genuinely distinguishable on the real + board too, else 'exactly one cell removed' could be an artefact of three identical keys" + ); + + apply( + &mut state, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(3), + template: Some(template), + }, + ) + .expect("the declaration is dispatched"); + assert!( + matches!(state.waiting_for, WaitingFor::RespondToShortcut { .. }), + "reach-guard: the declaration carrying the full published pin set must be accepted and \ + open the CR 732.2b window, got {:?}", + state.waiting_for + ); + let responders = accept_all_opponents(&mut state); + assert!( + responders > 0, + "reach-guard: the CR 732.2b window must actually have opened and been answered \ + (CR 732.2c), else no drive ran and no batch boundary was crossed" + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "reach-guard: the accepted drive ran to its CR 732.2a ending point, got {:?}", + state.waiting_for + ); + + assert_eq!( + cells(&state), + vec![ + (DecisionKind::LoopChoice, true), + (DecisionKind::TriggerOrdering, false), + ], + "CR 732.2a + CR 603.3b: across a whole ACCEPTED drive the ephemeral `LoopChoice` \ + carrier SURVIVES — it is the cross-episode vehicle P4 rides — while the ephemeral \ + `TriggerOrdering` cell beside it is dropped at the batch boundary the drive crosses. \ + A missing `LoopChoice` means the retain predicate lost its KIND conjunct; a surviving \ + ephemeral `TriggerOrdering` means the drive never reached the boundary at all" + ); +} + // ───────────────────────────────────────────────────────────────────────────────────────── // R23 conjunct (5-reach) — the beat guard's reachability on the real dump // ───────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 3e24e8e09a..d5cd9f9328 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -4511,96 +4511,132 @@ enum CarrierKind { Via(String), } -/// A variant start line inside an enum body — `^ [A-Z][A-Za-z0-9]*( \{|,|\()`, spelled with -/// `std` string ops instead of a dependency. -/// -/// MEASURED set-identical to `syn 2.0.117`'s variant list over this enum (128 names each, 0 -/// regex-only, 0 syn-only), which is why this census ships no enum-body parser: doc lines, -/// attributes and field lines all fail the shape, and nothing else in the body passes it. -fn is_variant_start(line: &str) -> Option<&str> { - let rest = line.strip_prefix(" ")?; - if !rest.starts_with(|c: char| c.is_ascii_uppercase()) { - return None; +use syn::{GenericArgument, Item, PathArguments, Type}; + +/// Every type identifier `ty` names, outermost first: `Option>` ⇒ +/// `["Option", "Vec", "Foo"]`. +/// +/// A NAME list rather than a `contains(marker)` over rendered text: the latter answers yes for +/// `DecisionTemplateAudit` and for the word inside a doc comment, and both would be carriers +/// this census invented. +fn type_names(ty: &Type) -> Vec { + fn walk(ty: &Type, out: &mut Vec) { + match ty { + Type::Path(p) => { + for seg in &p.path.segments { + out.push(seg.ident.to_string()); + if let PathArguments::AngleBracketed(args) = &seg.arguments { + for arg in &args.args { + if let GenericArgument::Type(inner) = arg { + walk(inner, out); + } + } + } + } + } + Type::Reference(r) => walk(&r.elem, out), + Type::Slice(s) => walk(&s.elem, out), + Type::Array(a) => walk(&a.elem, out), + Type::Group(g) => walk(&g.elem, out), + Type::Paren(p) => walk(&p.elem, out), + Type::Tuple(t) => t.elems.iter().for_each(|e| walk(e, out)), + _ => {} + } } - let end = rest - .find(|c: char| !c.is_ascii_alphanumeric()) - .unwrap_or(rest.len()); - let (ident, tail) = rest.split_at(end); - (tail.starts_with(" {") || tail.starts_with(',') || tail.starts_with('(')).then_some(ident) + let mut out = Vec::new(); + walk(ty, &mut out); + out } -/// The lines between `pub enum {` and its column-0 closing brace. -fn enum_body<'a>(src: &'a str, enum_name: &str) -> Vec<&'a str> { - let open = format!("pub enum {enum_name} {{"); - let mut out = Vec::new(); - let mut inside = false; - for line in src.lines() { - if !inside { - inside = line.trim_start().starts_with(&open); - continue; - } - if line == "}" { - break; +/// Every item in `items`, INCLUDING the ones nested in inline `mod` blocks — a holder does not +/// stop being a holder for living inside a module. +fn flatten<'a>(items: &'a [Item], out: &mut Vec<&'a Item>) { + for item in items { + out.push(item); + if let Item::Mod(m) = item { + if let Some((_, inner)) = &m.content { + flatten(inner, out); + } } - out.push(line); } - out } -/// `(variant name, the lines from its start up to the next variant start)`. -fn variants<'a>(body: &[&'a str]) -> Vec<(String, Vec<&'a str>)> { - let starts: Vec = body - .iter() - .enumerate() - .filter(|(_, l)| is_variant_start(l).is_some()) - .map(|(i, _)| i) - .collect(); - starts +/// `(variant name, its field types)` for `enum_name`, in declaration order. +fn enum_variants(src: &str, enum_name: &str) -> Vec<(String, Vec)> { + let parsed = + syn::parse_file(src).unwrap_or_else(|e| panic!("parse the `{enum_name}` source: {e}")); + let mut items = Vec::new(); + flatten(&parsed.items, &mut items); + items .iter() - .enumerate() - .map(|(k, &i)| { - let end = starts.get(k + 1).copied().unwrap_or(body.len()); - ( - is_variant_start(body[i]) - .expect("filtered above") - .to_string(), - body[i..end].to_vec(), - ) + .find_map(|item| match item { + Item::Enum(e) if e.ident == enum_name => Some( + e.variants + .iter() + .map(|v| { + ( + v.ident.to_string(), + v.fields.iter().map(|f| f.ty.clone()).collect(), + ) + }) + .collect(), + ), + _ => None, }) - .collect() + .unwrap_or_else(|| panic!("`{enum_name}` must be declared in the parsed source")) } -/// Every `pub struct` in the walked corpus that spells `marker` in its own body. THE DEPTH-1 -/// STEP, and the whole of it — see the row's disclosed limitation. +/// Every type declaration in the walked corpus whose own body names `marker` — struct, enum or +/// alias, at any visibility, generic or not, nested in a `mod` or not. THE DEPTH-1 STEP, and +/// the whole of it — see the row's disclosed limitation. +/// +/// # Why `syn` and not string ops +/// +/// This step used to scan for `pub struct {` at column 0, which is a small fraction of +/// the declarations that can hold a `marker`. MEASURED over this walk root: that shape matches +/// **486** declarations, while **649** `pub enum`, **108** `pub(crate) struct` and **12** +/// generic `pub struct` heads are invisible to it — and a carrier the depth-1 step cannot see +/// is scored as a clean GREEN, which is the one failure mode a census must not have. `syn` is +/// already a `[dev-dependencies]` entry of this crate and already the instrument +/// `deterministic_game_state_serde` parses production sources with, so this is reuse and not a +/// new dependency. `PLANT 5` below is the arm that holds the four recovered forms. /// /// Computed ONCE per corpus rather than re-scanned per candidate identifier: the walk is 500+ /// files and the enum is 128 variants, so the per-identifier form is quadratic in the corpus /// for no extra signal. -fn structs_carrying( - corpus: &[(String, String)], - marker: &str, -) -> std::collections::BTreeSet { +fn types_carrying(corpus: &[(String, String)], marker: &str) -> std::collections::BTreeSet { let mut out = std::collections::BTreeSet::new(); - for (_, src) in corpus { - let mut open: Option = None; - for line in src.lines() { - let Some(name) = open.as_deref() else { - if let Some(rest) = line.strip_prefix("pub struct ") { - if let Some(ident) = rest.strip_suffix(" {") { - if ident.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { - open = Some(ident.to_string()); - } - } - } - continue; + for (file, src) in corpus { + // A SOUND prefilter, not a shortcut: a declaration can only NAME `marker` if its file + // text contains `marker`, so this skips ~500 parses without narrowing the answer. + if !src.contains(marker) { + continue; + } + let parsed = syn::parse_file(src).unwrap_or_else(|e| panic!("parse {file}: {e}")); + let mut items = Vec::new(); + flatten(&parsed.items, &mut items); + for item in items { + let (name, field_types): (String, Vec<&Type>) = match item { + Item::Struct(s) => ( + s.ident.to_string(), + s.fields.iter().map(|f| &f.ty).collect(), + ), + Item::Enum(e) => ( + e.ident.to_string(), + e.variants + .iter() + .flat_map(|v| v.fields.iter().map(|f| &f.ty)) + .collect(), + ), + Item::Type(t) => (t.ident.to_string(), vec![t.ty.as_ref()]), + _ => continue, }; - if line == "}" { - open = None; - continue; - } - if !line.trim_start().starts_with("//") && line.contains(marker) { - out.insert(name.to_string()); - open = None; + if field_types + .iter() + .flat_map(|t| type_names(t)) + .any(|n| n == marker) + { + out.insert(name); } } } @@ -4620,38 +4656,21 @@ fn carriers_in_source( marker: &str, walk_via: bool, ) -> Vec<(String, CarrierKind)> { - let via_types = structs_carrying(corpus, marker); - let body = enum_body(enum_src, enum_name); + let via_types = types_carrying(corpus, marker); let mut out = Vec::new(); - for (name, vbody) in variants(&body) { - let code: Vec<&&str> = vbody - .iter() - .filter(|l| !l.trim_start().starts_with("//")) - .collect(); - if code.iter().any(|l| l.contains(marker)) { + for (name, field_types) in enum_variants(enum_src, enum_name) { + // Outermost-first across the variant's fields in declaration order, so `Via` names the + // holder a reader would name. + let named: Vec = field_types.iter().flat_map(type_names).collect(); + if named.iter().any(|n| n == marker) { out.push((name, CarrierKind::Direct)); continue; } if !walk_via { continue; } - let via = code.iter().find_map(|l| { - let mut rest: &str = l; - loop { - let p = rest.find(|c: char| c.is_ascii_uppercase())?; - rest = &rest[p..]; - let e = rest - .find(|c: char| !c.is_ascii_alphanumeric() && c != '_') - .unwrap_or(rest.len()); - let (id, tail) = rest.split_at(e); - if id != marker && via_types.contains(id) { - return Some(id.to_string()); - } - rest = tail; - } - }); - if let Some(t) = via { - out.push((name, CarrierKind::Via(t))); + if let Some(via) = named.into_iter().find(|n| via_types.contains(n)) { + out.push((name, CarrierKind::Via(via))); } } out @@ -4726,7 +4745,7 @@ fn plant_into_enum(src: &str, enum_name: &str, injected: &str) -> String { /// and this ~4 800-row integration target are never type-checked, and the repair list is /// neither stable nor bounded. Source injection has neither problem and needs no build. /// -/// # Discrimination — four plant arms, all RUN, all over in-memory copies +/// # Discrimination — five plant arms, all RUN, all over in-memory copies /// /// * a 3rd DIRECT and a 4th VIA carrier planted into a copy of the enum source ⇒ the set /// assertion fails NAMING both new variants (`n = 4`); @@ -4737,7 +4756,11 @@ fn plant_into_enum(src: &str, enum_name: &str, injected: &str) -> String { /// * the `RespondToShortcut` arm deleted from a copy of `visibility.rs` ⇒ the redaction half /// flips to `false` for that carrier while `LoopShortcut` stays `true`. The mutation is on a /// COPY, so the shipped row `respond_to_shortcut_template_redacts_a_hidden_pin_for_non_proposers` -/// above is not perturbed. +/// above is not perturbed; +/// * a `pub(crate)`, a GENERIC, an ENUM, an ALIAS and a `mod`-NESTED holder planted at once ⇒ +/// all five are NAMED as `Via` carriers, and a non-carrier planted beside them is not. These +/// are the forms the retired `pub struct {{` string scan could not see, and each was +/// a false GREEN rather than a false alarm. /// /// # DISCLOSED LIMITATIONS /// @@ -4780,14 +4803,13 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac let visibility_src = file_of("game/visibility.rs"); // ── the classifier's own reach-guard: the enum was actually found ── - let total = variants(&enum_body(&enum_src, "WaitingFor")).len(); + let total = enum_variants(&enum_src, "WaitingFor").len(); assert_eq!( total, 128, - "`WaitingFor` has 128 variants at this tip (cross-checked against `syn 2.0.117`, which \ - reports the same NAME SET). This number is pinned so a variant REMOVED is as visible \ - as one added; if you added a variant and it carries no `DecisionTemplate`, update this \ - number. A wildly different count means the enum-body reader lost its anchor, and every \ - assertion below would then be measuring an empty body" + "`WaitingFor` has 128 variants at this tip, read off the `syn` parse. This number is \ + pinned so a variant REMOVED is as visible as one added; if you added a variant and it \ + carries no `DecisionTemplate`, update this number. A wildly different count means the \ + reader lost its anchor, and every assertion below would then be measuring an empty enum" ); let carriers = carriers_in_source(&enum_src, "WaitingFor", &corpus, &marker, true); @@ -4883,6 +4905,67 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac "the redaction half must FLIP when its arm is deleted from the copy, and only for the \ carrier whose arm was deleted — otherwise the `for` loop above asserts nothing" ); + + // ── PLANT 5 — the four holder FORMS the retired string scan could not see, plus a + // non-carrier control. Each is a real declaration shape from this walk root: over + // `crates/engine/src` the old `pub struct {` shape matched 486 declarations + // while 649 `pub enum`, 108 `pub(crate) struct` and 12 generic `pub struct` heads were + // invisible — every one of them a carrier that would have scored a clean GREEN. ── + let forms = format!( + "pub(crate) struct CrateVisHolder {{\n pub template: Option<{marker}>,\n}}\n\ + pub struct GenericHolder<'a, T> {{\n pub template: &'a {marker},\n pub t: T,\n}}\n\ + pub enum EnumHolder {{\n WithTemplate({marker}),\n Without,\n}}\n\ + pub type AliasHolder = Option<{marker}>;\n\ + pub struct NotAHolder {{\n pub seat: PlayerId,\n}}\n\ + mod inner {{\n pub struct NestedHolder {{\n pub template: \ + Option,\n }}\n}}\n" + ); + let mut forms_corpus = corpus.clone(); + forms_corpus.push(("forms.rs".to_string(), forms)); + let forms_src = plant_into_enum( + &enum_src, + "WaitingFor", + " ProbeCrateVis {\n h: CrateVisHolder,\n },\n\ + \x20 ProbeGeneric {\n h: GenericHolder<'static, u8>,\n },\n\ + \x20 ProbeEnumHolder {\n h: EnumHolder,\n },\n\ + \x20 ProbeAlias {\n h: AliasHolder,\n },\n\ + \x20 ProbeNested {\n h: NestedHolder,\n },\n\ + \x20 ProbeNonCarrier {\n h: NotAHolder,\n },\n", + ); + let by_forms: Vec<(String, CarrierKind)> = + carriers_in_source(&forms_src, "WaitingFor", &forms_corpus, &marker, true) + .into_iter() + .filter(|(n, _)| n.starts_with("Probe")) + .collect(); + assert_eq!( + by_forms, + vec![ + ( + "ProbeCrateVis".to_string(), + CarrierKind::Via("CrateVisHolder".to_string()) + ), + ( + "ProbeGeneric".to_string(), + CarrierKind::Via("GenericHolder".to_string()) + ), + ( + "ProbeEnumHolder".to_string(), + CarrierKind::Via("EnumHolder".to_string()) + ), + ( + "ProbeAlias".to_string(), + CarrierKind::Via("AliasHolder".to_string()) + ), + ( + "ProbeNested".to_string(), + CarrierKind::Via("NestedHolder".to_string()) + ), + ], + "the depth-1 step must see a `pub(crate)`, a GENERIC, an ENUM, an ALIAS and a \ + `mod`-NESTED holder alike — and `ProbeNonCarrier` must be ABSENT, because an \ + instrument that reports every variant is as useless as one that reports too few. \ + got {by_forms:?}" + ); } /// F4 (review finding): the THIRD carrier of the same `Vec` — @@ -11752,7 +11835,13 @@ fn bounded_fixed_drive_rolls_back_a_partial_crossing_cycle() { doctored.loop_answers_recorded(), 0, "n={n}: CR 603.5 — the recorded `may` answers describe the window that just ended, \ - and the same seam drops them together with the ring" + and the same seam drops them together with the ring. ⚠ FORWARD TRIPWIRE, not a \ + co-equal half of that claim: MEASURED non-discriminating on THIS fixture — under a \ + mutant neutering only the seam's `loop_answer_journal = None` this clause stays \ + green (the journal already reads 0 when this fixture reaches the seam) while the \ + f4 row fails `left: 3, right: 0`. It earns its place by failing if a future writer \ + ever populates the journal on this entry path and the seam stops clearing it; the \ + DISCRIMINATING statement of the journal half is the f4 row named above" ); } } diff --git a/crates/engine/tests/integration/loop_shortcut_ranking.rs b/crates/engine/tests/integration/loop_shortcut_ranking.rs index 33d33aa47d..5bb782a8bc 100644 --- a/crates/engine/tests/integration/loop_shortcut_ranking.rs +++ b/crates/engine/tests/integration/loop_shortcut_ranking.rs @@ -219,7 +219,11 @@ fn r2d_a_ranked_seat_is_judged_as_a_target_while_the_choice_class_keeps_existenc /// source — `ThisObject` is per-incarnation (CR 400.7) and therefore ephemeral, `AllCopies` /// latches card identity and is persistent — so the cell is built by choosing the source, never /// by setting a flag. -fn grid_template( +/// +/// `pub(super)` because `fantastic_four_bounded_loop`'s cross-episode-carrier row plants the +/// same cells on the REAL 4-player board: two builders would be two definitions of +/// "ephemeral", and the one thing this grid must not have is a second opinion about its axis. +pub(super) fn grid_template( owner: PlayerId, kind: DecisionKind, ephemeral: bool, @@ -254,9 +258,12 @@ fn grid_template( /// /// # Reachability was established before cause was attributed /// -/// MEASURED on the real 4-player drive: planted templates go `3 → 2` at the accepting beat of -/// the f4 bounded drive, survivors `[(LoopChoice, ephemeral), (TriggerOrdering, persistent)]`. -/// So this boundary is reached in production, and the grid below states WHICH cell it removes. +/// The real 4-player drive reaches this boundary, and that is a SHIPPED ROW rather than a +/// retired probe: `fantastic_four_bounded_loop::r3b_driven_a_loop_choice_carrier_survives_a_ +/// whole_accepted_f4_drive` plants the cells on the f4 dump and drives an accepted CR 732.2a +/// shortcut through `apply()` — MEASURED `3 → 2`, survivors +/// `[(LoopChoice, ephemeral), (TriggerOrdering, persistent)]`. So the boundary is reached in +/// production, and the grid below states WHICH cell it removes. /// /// # The row is the 2×2 GRID, not one cell /// From db70af42428b8ad081afedde52ace67c32fccde5 Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 02:32:20 -0500 Subject: [PATCH 26/44] fix(engine-tests): correct the abort-entry PROBE-PINNED clause and re-derive the census pin The abort-entry PROBE-PINNED annotation claimed the seam is reached with the window "equally live", implying both the ring-clear and the journal-clear are load-bearing on that path. Measured, they are not: on bounded_fixed_drive_rolls_back_a_partial_crossing_cycle the ring carries 16 but the journal is already empty, so `loop_answer_journal = None` is a forward tripwire there, not a co-equal half of the CR 603.5 claim. Reword to state the asymmetry and name the row that does discriminate the journal half, mirroring the LOW-5 exemplar clause in tests/integration/loop_shortcut.rs. Growing that comment by 5 lines shifts the CR 603.5 producer 12646 -> 12651. The census pin is re-derived by CONTENT (sha256 of the verbatim producer line, matching exactly one line whole-file in each tree), with arithmetic used only as an after-check; the census test independently derives the same coordinate and fails when the pin is stale. Also drops a coordinate from neighbouring drift-log prose that this very change would have falsified, so it cannot restale. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/engine.rs | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 44e02ed02f..fbb9613385 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -4466,8 +4466,13 @@ fn materialize_fixed_shortcut( // backstop. MEASURED — skipping it on the f4 accepted drive leaves `loop_detect_ring` // non-empty (12) and the journal populated (3 answers), and this same `apply()` // re-emits a `LoopShortcut` offer. - // PROBE-PINNED: the abort entry reaches here with the window equally live. MEASURED - // `ring=16, answers=0` on `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle`. + // PROBE-PINNED: the abort entry reaches this seam ASYMMETRICALLY. MEASURED + // `ring=16, answers=0` on `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle`: the + // ring is LIVE there, so the ring-clear stays load-bearing on this path, but the journal is + // ALREADY empty — the `loop_answer_journal = None` below is a ⚠ FORWARD TRIPWIRE on this + // entry path, not a co-equal half of the CR 603.5 claim. The DISCRIMINATING statement of the + // journal half is the f4 row + // `fantastic_four_bounded_loop::r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared`. // // LABELLED INTERPRETATION, not a pinned claim: the `waiting_for` re-seat below is a // NORMALIZATION whose load-bearing case no fixture in this repo exercises today. On @@ -18710,12 +18715,26 @@ mod stage2_injector_tests { // prompt. R3's `crates/engine/src` diff is comment-only APART FROM THIS PIN // STRING: with comment lines stripped, `analysis/decision_template.rs` is // byte-identical to the parent and `game/engine.rs` differs in exactly one line — - // the `:12622 ⇒ :12646` literal directly below. The total (38) and the partition + // the pin literal directly below. The total (38) and the partition // (5/8/25) both fired GREEN on the run that caught this — only this third assert // panicked. - // ⚠ REBASE #3: `:12708 ⇒ :12707`, located by content digest, offset from - // `begin_pending_trigger_target_selection` unchanged at 134. - // ⚠ REBASE #3: `:12707 ⇒ :12712`, located by content digest, offset from + // + // ⚠ item-4 R3 FIX-ROUND 3 (reword of that same block's abort-entry PROBE-PINNED + // clause, which called the window "equally live" while reporting `answers=0`): + // `:12646 ⇒ :12651`, `+5`. LOCAL, COMMENT-ONLY again, same protocol: the recorded + // sha256 (`8a544e878d3e77fb…5cc7d63`, verbatim line + trailing newline) matches + // EXACTLY ONE line under a whole-file scan of the new tree, at `:12651` — and + // exactly one in the parent, at `:12646` — and it is still inside + // `begin_pending_trigger_target_selection`, which moved by the same +5 (opens + // `:12512 ⇒ :12517`). Arithmetic CHECK afterwards, never as the source: `git diff + // -U0` shows exactly ONE hunk ABOVE this producer, `@@ -4469,2 +4469,7 @@` inside + // `materialize_fixed_shortcut` (2 comment lines ⇒ 7), and `12646 + 5 = 12651`. + // The other hunk is this very block plus the pin below it — BELOW the producer, + // contributing nothing, the same slip the entry above flags. SET PRESERVATION + // holds identically: all 5 net inserted lines are `//` comments, so no + // `waiting_for = ` or `Ok(Some(` line was added, and the pin below is once more + // the ONLY non-comment line in this round's `crates/engine/src` diff. + // ⚠ REBASE #3: `:12713 ⇒ :12712`, located by content digest, offset from // `begin_pending_trigger_target_selection` unchanged at 134. "game/engine.rs:12712".to_string(), ], From 60f70b3750d3b8f06274cee4e2fadc31e3fbda23 Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 11:49:52 -0500 Subject: [PATCH 27/44] test(engine): pin the offer-writer census header with a probe-pin manifest The offer-writer census carries a hand-maintained header: a pinned (production, test) pair, a per-file multiset, and a five-step adjudication record. Every step of that record was corrected by hand, and the last correction was wrong twice before it was right. This replaces the hand-maintenance with a manifest so the header is regenerated from measurement rather than transcribed. What ships: - `probe-pin/engine-census.toml`, a nine-probe manifest over the census. Each probe mutates one counted site and asserts the count the census must then see; all four `find` strings are unique in their target files, which the tool requires. The manifest documents where the pin is enforced and, more importantly, where it is not: nothing under `.github/workflows/` references probe-pin (positive control: `clippy` matches, so the zero is a result), and CI enrollment is policy-blocked by `.agents/pr-review-policy.toml`, so the local Tilt resource is the only venue. That is stated as a limit, not papered over. - `probe-pin-census`, the Tilt resource that runs it. It watches exactly what can move the verdict -- the two walk roots, the tool, the manifest, the pinned test, the module declaration that keeps that test in the binary, and the date-pinned toolchain the digest covers -- and deliberately not ENGINE_SRC, which is a superset that would re-trigger the whole probe set on every card-data promote. It builds the tool into its own CARGO_TARGET_DIR so it cannot relink a binary that `probe-pin-check` is mid-execution on; the inner engine resolve still uses the shared target/, which is what the price rows measure. - the census header itself, now generated into a PROBE-PIN block. Two things worth knowing for anyone extending this. `assert_count` counts raw substrings including comments, so a prose edit inside a counted file can move a pinned number -- `cargo probe-pin check` is the instrument that catches it, not review. And the manifest's TOML comments sit outside the digest, so a comment-only edit is self-consistently green; the schema-bearing bytes are what `check` actually proves. Verified at this tip: `cargo probe-pin check` RC 0 in an isolated target dir; `tilt alpha tiltfile-result` exit 0 with 17 manifests and the resource's two cmd fragments joined into one argv string; and the resource is enabled under its profile (`-- lint`, TriggerMode 0), an arm added because plain parse-validity cannot see enablement and a typo there would silently un-enrol the only venue this pin has. Assisted-by: ClaudeCode:claude-opus-5 --- Tiltfile | 180 ++++++++++++- .../loop_shortcut_offer_writer_census.rs | 120 +++++++-- probe-pin/engine-census.toml | 245 ++++++++++++++++++ 3 files changed, 520 insertions(+), 25 deletions(-) create mode 100644 probe-pin/engine-census.toml diff --git a/Tiltfile b/Tiltfile index ed59bf2a84..7e2f430fa4 100644 --- a/Tiltfile +++ b/Tiltfile @@ -281,14 +281,21 @@ local_resource('coverage', # The only local enforcement venue for `probe-pin check` (CI enrollment needs a workflow edit, # which is a hard stop). Measured cost: 6.25s cold, 0.11s incremental, 0.046s for 10 isolated # probe runs -- so an automatic trigger with narrow deps, not a manual knob. This price holds -# ONLY while the manifest pins probe-pin's own test binary; an engine-census manifest is ~25s -# of runs plus an engine build and must be re-priced before it is added here. +# ONLY while the manifest pins probe-pin's own test binary. RE-PRICED IN PART for the engine-census +# manifest added below: the estimate was right that an engine build is additional (measured 32.86s +# on an ENGINE_SRC edit, even after build-native) and high on the runs (measured 14.7s, not ~25s). +# The AS-SHIPPED price of 'probe-pin-census''s own cmd is cold 22.99s / incremental 15.61s in an +# isolated tree; the price under a real `tilt up` -- shared target/, build-lock contention -- is +# NOT measured and is owed by whoever next prices this pair. local_resource('probe-pin-check', # Separate CARGO_TARGET_DIR (same reason as 'clippy'): probe-pin's dep tree is disjoint # from the engine's, so a shared dir would mutually invalidate fingerprints. cmd = ['bash', '-c', 'CARGO_TARGET_DIR=target/probe-pin cargo probe-pin check crates/probe-pin/tests/fixtures/dogfood.toml'], - deps = ['crates/probe-pin/', 'docs/probe-pin.md'], + # 'rust-toolchain.toml': the digest covers used_instruments() (unconditionally [Toolchain]), so + # a rustc move alone is `check` exit 1. The channel is date-pinned, so watching it puts that red + # on the causing commit. Full reasoning at the 'probe-pin-census' resource (named, not adjacent). + deps = ['crates/probe-pin/', 'docs/probe-pin.md', 'rust-toolchain.toml'], # TMP_IGNORE is a FILENAME glob ('**/*.tmp.*') and does not match a tmp/ DIRECTORY, so the # Tier-2 tests' scratch writes under tests/fixtures/tmp/ would retrigger this resource. ignore = TMP_IGNORE + ['**/tmp/**'], @@ -330,3 +337,170 @@ local_resource('probe-pin-e2e', allow_parallel = True, labels = ['lint'], ) + +# The engine-census pin -- the re-pricing the `probe-pin-check` note above demands, measured in an +# isolated tree with a dedicated CARGO_TARGET_DIR (never shared), against the probe set exactly as +# the manifest authors it. ⚠ NO TREE SHA IS STAMPED HERE, deliberately. The earlier stamp +# (`59cc90a8`) named a tree the manifest DOES NOT EXIST IN -- a coordinate that identifies nothing. +# (An earlier draft also called that object UNREACHABLE. Deleted, because it is false whenever a +# worktree HEAD still points at it -- and "absent from that tree" was always the whole reason.) +# THE OPERATIVE GUARD IS THE PROBE COUNT below: +# it is a property of this manifest, re-checkable from this file, and it is what actually decides +# whether these figures still describe the set. THE ROWS BELOW ARE A CLOSED RECORD OF ONE MEASUREMENT -- +# the set held 9 probes when timed -- and NOT a description of whatever the manifest holds when you +# read this: if a probe is added or removed these figures must be RE-TAKEN, not re-labelled. +# +# tool build, cold (fresh target/probe-pin) 6.91s tool build, incremental 0.10s +# the full probe set, engine target unchanged 14.7s (two runs: 14.69 / 14.80) +# the full probe set, AFTER an ENGINE_SRC edit 47.1s SEQUENTIALLY AFTER build-native, and +# with the shared target/ already warm +# THIS RESOURCE'S OWN cmd, as shipped: cold 22.99s incremental 15.61s (isolated tree) +# +# EVERY row above, the last included, was timed BEFORE this resource existed -- the earlier wording +# ("every row except the last") implied the last one was taken through the resource, and it was not. +# The rows above it ran through the `cargo probe-pin` alias; the last is the resource's own cmd, run +# by hand, which is the price 'probe-pin-check''s note actually +# asks for; it is taken in an isolated tree whose dedicated CARGO_TARGET_DIR stands in for the +# shared target/, so it does NOT cover the two hazards below -- those need a real `tilt up`. +# +# => resource cold: read the as-shipped row above (22.99s), which measures it directly; the +# earlier SUM-of-alias-rows derivation is deleted, superseded by that row per this file's rule. +# ~14.8s when only this manifest or the census file changed; +# ~47.2s on an engine-source edit, which is the common trigger -- under the two premises +# named on the 47.1s row above, both of which this resource's own wiring can violate. See +# "TWO PRICE HAZARDS" below; they are DISCLOSED, not measured. +# +# THE ENGINE BUILD *IS* ADDITIONAL -- 'probe-pin-check''s note was right to flag it, and measuring +# it was not a formality. `build-native` runs `cargo nextest run -p phase-engine -p phase-ai --no-run`; +# probe-pin's inner resolve runs `cargo test -p phase-engine --test integration --no-run`. The +# two keep SEPARATE artifact sets (different feature unification across the -p set), so after an +# ENGINE_SRC touch the inner resolve costs 32.86s even with build-native already complete -- +# against 31.20s with no build-native at all. It saves 1.7s of 32.9s. Both sets then sit at a +# fixed point until the next source change: 0.42s / 0.12s respectively, so they do NOT ping-pong. +# +# TWO PRICE HAZARDS THE FIGURES ABOVE DO NOT COVER -- INCLUDING the as-shipped row. Stated, NOT +# measured: both are properties of a real `tilt up` and neither can be timed from a shell, so +# pricing this resource's cmd in an isolated tree does not settle them. BOTH NUMBERS ARE STILL +# OWED (see 'probe-pin-check''s note above). +# 1. `tilt up -- lint` PAYS A PARTIAL COLD ENGINE BUILD. Every figure above was taken against a +# warm target tree -- the alias rows against the shared target/, the as-shipped row against +# its isolated stand-in. Of the labels=['lint'] resources this is the only one whose cargo work +# lands in the shared target/ -- clippy uses target/clippy, probe-pin-check target/probe-pin, +# probe-pin-e2e target/probe-pin-e2e, check-frontend runs no cargo at all. BUT THE LABEL IS +# NOT THE OPERATIVE SET: everything with auto_init = True also runs under `tilt up -- lint`, +# and 'draft-pools' (auto_init = True) runs `cargo run --bin draft-pool-gen` with NO +# CARGO_TARGET_DIR, dev profile, on draft-core -- which depends on phase-engine. So the +# shared tree is PARTIALLY warmed at init: phase-engine's lib and its dependency graph get +# built there. What is NOT warmed is the part this resource pays for -- the integration test +# binary, the engine's dev-dependencies, and anything only `build-native` builds +# (auto_init = 'test' in enabled, so it does not run under a lint-only profile). +# THE SIZE OF THE RESIDUAL IS UNMEASURED, and no figure in this comment bounds it: a cold +# `nextest --no-run` over these packages is both a different command and a FULLY cold tree, +# which is not the state a lint-only `tilt up` leaves. Re-pricing this under a real +# lint-only `tilt up` settles the number; it is owed, not measured. +# 2. THE 47.2s ASSUMES SEQUENTIAL ORDERING, which nothing now imposes. It was timed as +# build-native then probe-pin -- the order `resource_deps` would have forced, and there is +# deliberately no `resource_deps` (below). Both are allow_parallel and both fire on a +# crates/engine/src/ edit, so the inner `cargo test --no-run` can queue on the SHARED build +# lock behind build-native's ~82s nextest. The clippy comment above states the same +# mechanism from the other side: a separate CARGO_TARGET_DIR "gives it its own build lock, +# so it never queues behind the native test builds". The TOOL build here does have such a dir; +# the INNER engine resolve does not, by the choice in the next paragraph -- and it is the inner +# one that queues. 47.2s is therefore a floor, not a ceiling. Under a lint-only +# profile the contender is not build-native (which never starts) but 'card-data': auto_init +# = True, deps = ENGINE_SRC, and gen-card-data.sh runs `cargo build --profile tool` into +# ${CARGO_TARGET_DIR:-target}/tool -- a different profile dir in the SAME target root, and +# the cargo build lock is per target ROOT (the 'wasm' comment above states this). +# +# The tool is built into target/probe-pin-census (its own dir, see the cmd comment) and then +# invoked as a BINARY. The split is deliberate and it is the SHELL that draws it: a prefix +# assignment binds only the command it prefixes, so CARGO_TARGET_DIR covers `cargo build` and is +# already unset by the time the binary runs. probe-pin's own inner `cargo test --no-run` therefore +# INHERITS an unset CARGO_TARGET_DIR and resolves the integration binary out of the SHARED target/ +# -- the same tree every other cargo resource builds into, rather than a second private one. +# Putting CARGO_TARGET_DIR on the `cargo probe-pin` alias instead would push a second, cold engine +# build into target/probe-pin-census. What warms the shared tree is whatever else +# happened to run; this resource orders itself behind nothing, which is hazard 2. +# NO `resource_deps`. MEASURED, not preferred: 'build-native' is auto_init = 'test' in enabled +# and this resource is auto_init = 'lint' in enabled, so under `tilt up -- lint` it would wait +# forever on a resource that never starts -- merged, green in the file, and enforced NOWHERE, in +# the only venue this manifest has. Every other resource_deps pair in this Tiltfile satisfies +# "dependent auto-inits => dependency auto-inits" (test-engine/test-ai -> build-native, same +# group; test-frontend -> wasm and coverage -> card-data, dependency always inits; caddy -> +# frontend violates it only under `tilt up -- https tauri`, which nothing rejects, so that pair +# is already a violation and this one would be the second -- the first that fires under a profile +# the file itself documents). Price of not depending on it: build-native saves 1.7s +# of 32.9s on the inner resolve WHEN THE TWO RUN IN SEQUENCE (see the numbers above) -- not worth +# a venue that silently does not run. The parallel case is hazard 2, and it is the price of this +# choice, stated rather than netted out. +local_resource('probe-pin-census', + cmd = ['bash', '-c', + # Starlark has NO implicit adjacent-string-literal concatenation (a Python rule this + # paste inherited): the `+` is load-bearing, not style. Without it the whole file + # fails to LOAD -- `tilt alpha tiltfile-result` exits 5 at this line -- which takes + # every other resource down with it, not just this one. + # Its OWN CARGO_TARGET_DIR, for the reason 'probe-pin-check' states two resources up: + # this resource and that one both watch 'crates/probe-pin/', both are allow_parallel, + # and a shared dir lets one relink the binary the other is mid-execution on. + 'CARGO_TARGET_DIR=target/probe-pin-census cargo build -q -p probe-pin && ' + + 'target/probe-pin-census/debug/probe-pin check probe-pin/engine-census.toml'], + # deps watches everything that can move THIS RESOURCE'S VERDICT **by changing what the pin + # measures**: census()'s two walk roots, the tool, the manifest, the pinned test file, the module + # declaration that puts that test in the binary, and the toolchain the digest covers -- + # deliberately NOT ENGINE_SRC + AI_SRC. + # ⚠ THE PREDICATE IS STATED BECAUSE THE EARLIER WORD WAS "EVERYTHING", AND THAT WAS FALSE. + # Disclosed residual, not a gap that was missed: the target is resolved by building + # `--test integration`, so ANY sibling file under crates/engine/tests/integration/ (plus that + # crate's dev-deps and Cargo.lock) can move the verdict -- by breaking that build, or by adding + # a test that FAILS under the manifest's control. Those are NOT watched, on purpose: watching + # them would re-trigger this pin on every unrelated integration-test edit, and there are over a + # thousand of them. Both failure classes surface as exit 2 with the cause named in the output. + # The pin is over exactly what census() reads; ENGINE_SRC is a superset BY DESIGN (its own + # comment requires it to stay a superset of the engine cache key: src + data + build.rs + + # Cargo.toml), and census() reads none of the extras. The live cost of the wider set is not + # hypothetical: gen-card-data.sh PROMOTES tracked files under crates/engine/data/, and + # 'card-data' (auto_init = True, so under EVERY profile) documents that watching its own + # outputs "makes every promote re-trigger card-data -> an infinite regen loop" and ignores + # them for that reason. This resource's ignore list does not cover them, so with ENGINE_SRC + # every promote would re-run the whole probe set at full price for a pin that cannot move. + # AI_SRC happens to EQUAL its walk root today; not depending on it is the same point -- + # "what makes the engine rebuild" and "what census() reads" are different concepts that + # currently share a value, and a later path added to either symbol re-opens this silently. + deps = [ + 'crates/engine/src/', + 'crates/phase-ai/src/', + # The TOOL that renders the block, watched for the same reason 'probe-pin-check' watches + # it: a change to block::render or the digest moves the rendered block, and without this + # nothing re-triggers the check until an unrelated engine edit happens to fire it. + 'crates/probe-pin/', + 'probe-pin/engine-census.toml', + 'crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs', + # The MODULE DECLARATION. Deleting `mod loop_shortcut_offer_writer_census;` from main.rs + # takes the pinned test out of the binary. MEASURED, not reasoned: three siblings already + # import from `super::loop_shortcut_offer_writer_census` (two take + # `{cfg_test_scoped_lines, rs_files}`, one takes `rs_files`), so that deletion is an + # E0432 BUILD failure, not the zero-selection execution floor -- `check` + # aborts either way and every Abort maps to exit 2. It is watched not because its failure is + # quieter than a sibling's (it is the same class) but because it is ONE file with a tiny, + # stable edit surface: the cost of watching it rounds to zero, which is exactly what is not + # true of the thousand-odd siblings above. + 'crates/engine/tests/integration/main.rs', + # The TOOLCHAIN, for the same reason and by the same rule. `used_instruments()` returns + # [Instrument::Toolchain] UNCONDITIONALLY and the digest covers that list, so a rustc move + # with NO measured number changing is still `check` exit 1 (reported as an instrument + # change, not code drift). `channel` here is date-pinned, so in this repo a rustc move IS + # an edit to this file: watching it puts the red on the commit that caused it instead of + # on the next unrelated engine edit, where it would read as census drift. It does not + # cover a rustc moved WITHOUT this file (`rustup override`, RUSTUP_TOOLCHAIN). + 'rust-toolchain.toml', + ], + # '**/tmp/**' rides along with the 'crates/probe-pin/' dep, NOT as boilerplate: TMP_IGNORE is + # a FILENAME glob ('**/*.tmp.*') that does not match a tmp/ DIRECTORY, and probe-pin's Tier-2 + # tests scratch-write under crates/probe-pin/tests/fixtures/tmp/. 'probe-pin-check' carries + # this exact pairing and states the reason; watching the crate without it re-imports the + # retrigger loop that comment exists to document. + ignore = TMP_IGNORE + ['**/tmp/**'], + auto_init = 'lint' in enabled, + allow_parallel = True, + labels = ['lint'], +) diff --git a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs index c2983fd4f8..3e90759a4e 100644 --- a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs @@ -9,9 +9,35 @@ //! number written into a plan cannot fire; this can. //! //! WHAT IT PINS, and why it is an INVARIANCE claim rather than a re-measurement: -//! 22 production + 14 test sites across `crates/engine/src` and -//! `crates/phase-ai/src`. A failure reads *"5d (or a successor) changed the -//! offer-writer surface"*, not *"someone re-measured"*. +//! the offer-writer surface across `crates/engine/src` and `crates/phase-ai/src`. +//! NO NUMBER IS RESTATED HERE — not even to narrate the incident that caused this +//! rewrite. This header once carried a production/test pair that had drifted from +//! the assert below; quoting that pair *here* would reintroduce the defect, +//! because probe-pin validates only between its BEGIN/END markers and any number +//! above them is unanchored by construction. The incident, with both figures, is +//! recorded in `probe-pin/engine-census.toml`, whose anchors an adjudicator has to +//! edit anyway. The assert below is where the pair is authoritative. NO rustdoc in +//! this file states either half of it — the adjudication history names SUPERSEDED +//! test-half values only, each chain deferring to the assert rather than restating +//! it (the chains end at "the value the assert below pins", "the pinned value", and +//! "the assert below is the authority for the pair"; no single phrase is shared, so +//! none is quoted as if it were). Both +//! figures do recur inside the assert's own failure MESSAGE, beside the literal +//! they describe, where an adjudicator editing one has the other in view; P1's +//! anchors cover that literal and one sentence of that message. NOTHING covers +//! rustdoc, which is why the rustdoc now carries no figure OF THE PINNED PAIR. +//! ⚠ READ THAT SCOPE LITERALLY — it was written wider once and was false. This file's +//! SIBLING test (`the_cfg_scope_classifier_...`) still has its OWN asserted figures +//! restated in its rustdoc, and NOTHING pins them: that test builds its +//! input as an in-binary String, so no bind-mount can reach it and the manifest +//! declares it unpinnable. Those restatements can go stale exactly as the pinned +//! pair's did. They are disclosed rather than struck because striking them would edit +//! a test this commit does not pin and was not reviewed against. The +//! PROBE-PIN block below re-measures both halves on every `probe-pin check` — its +//! rows anchor the pinned tuple and the adjudication sentence inside the assert — +//! so moving either without regenerating the block turns the pin red. A failure +//! reads *"5d (or a successor) changed the offer-writer surface"*, not +//! *"someone re-measured"*. //! //! THE ANCHOR IS BARE — `WaitingFor::LoopShortcut {`, with no `= ` / `Ok(` //! qualifier. A prefix-anchored regex cannot be completed by adding prefixes: @@ -25,6 +51,50 @@ //! for a `#[test]` that reads the source tree through //! `Path::new(env!("CARGO_MANIFEST_DIR"))` and asserts a structural invariant. +// ── PROBE-PIN ──────────────────────────────────────────────────────────────── +// The claims below are MEASURED, not asserted in prose: each row is a mutation of +// the walked tree plus the verdict it produced. Regenerate with +// cargo probe-pin run --write probe-pin/engine-census.toml +// A row whose anchor stops matching is a number that moved without an adjudication. +// +// DISCLOSURE — the SHAPE named as the anchor lint's residual now occurs in a MANIFEST for the +// first time. The predicate is stated because a bare "first time" is false: `docs/probe-pin.md` +// already prints an anchor of this shape as a worked example, so the shape's first appearance +// in-tree is the doc's, not this file's. What is new is the first one AUTHORED IN A SHIPPING +// MANIFEST. `docs/probe-pin.md` rejects anchors embedding a line number, and names one +// shape it cannot reject: a positional integer in a `("", )` slot. The block +// below carries anchors of that shape. In each, the integer is a COUNT (a per-file multiset +// entry), which is the legitimate form the lint deliberately admits — never a line. Said +// here because no instrument distinguishes the two. The doc's REVISIT CONDITION is narrower +// than the shape and is NOT met by this commit: it asks for a revisit only "if an anchor of +// the `("", )` shape is ever authored with a *line* in the integer slot". No +// revisit is owed here; what is owed is this disclosure. +// ⚠ THAT QUOTATION IS UNANCHORED, and saying so is the point: no probe in the manifest matches +// it, and `docs/probe-pin.md` is not one of this resource's deps, so editing the doc leaves this +// quotation silently stale. It is quoted rather than paraphrased because a paraphrase was wrong +// here once; it is disclosed rather than pinned because pinning another file's prose from this +// file would make an unrelated doc edit fail the census pin. +// +// VENUE LIMIT — the block below is checked by ONE venue: the Tiltfile's `probe-pin-census` +// resource, locally, on engine-source edits. It is NOT checked in GitHub CI, and CI +// enrollment is policy-blocked (`.agents/pr-review-policy.toml` `[hard_stops]` lists +// `.github/workflows/**`). A green block in a merged commit is not a CI-verified block. +// PROBE-PIN:BEGIN manifest=probe-pin/engine-census.toml digest=sha256:38836c2e1f2fddb8 +// instrument rustc = rustc 1.97.0-nightly (0febdbab2 2026-04-18) +// | probe | mutation | expect | verdict | firing assertion (anchor) | provenance | +// |---|---|---|---|---|---| +// | P0_control | (none) | pass | pass | (control; no mounts) | — | +// | P1_production_site_removed | scenario.rs ×1 | fail | fail | left: (21, 21) / right: (22, 21) / THE TEST HALF HAS BEEN ADJUDICATED FIVE TIMES | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P2_test_site_removed | projection.rs ×1 | fail | fail | left: (22, 20) / right: (22, 21) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P3_walk_reaches_phase_ai_and_skips_comments | lib.rs ×1 | fail | fail | left: (23, 21) / right: (22, 21) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P4_counting_is_per_line | lib.rs ×1 | fail | fail | left: (24, 21) / right: (22, 21) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P5_relocation_preserves_the_count | scenario.rs ×1, interaction.rs ×1 | fail | fail | the COUNT can be preserved by a move that relocates a writer / ("engine/src/game/interaction.rs", 6) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P6_second_validate_pins_consumer | scenario.rs ×1 | fail | fail | expected `validate_pins(` to appear in production exactly twice | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P7_coverage_half_unpaired | decision_template.rs ×1 | fail | fail | validating pin VALUES without also running | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// | P8_authority_call_site_removed | engine.rs ×1 | fail | fail | expected 1 definition + 3 call sites / ("engine/src/game/engine.rs", 1) | crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs | +// probe-pin validates only the lines between BEGIN and END. Prose outside is never checked. +// PROBE-PIN:END + use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -115,13 +185,13 @@ pub(super) fn cfg_test_scoped_lines(src: &str) -> Vec { /// U1–U6 introduce no `WaitingFor::LoopShortcut {` token. Measured on this tree: /// 5d U2's declare-time owner firewall added the DOC LINE /// `// copied from `WaitingFor::LoopShortcut { proposer }`.` to `game/engine.rs`, -/// which a comment-blind bare anchor counts as a 23rd production site. A comment +/// which a comment-blind bare anchor counts as one MORE production site. A comment /// is not a code surface — it writes no offer and consumes none — so counting it /// would make the tripwire fire on prose and would force the pinned number to be /// re-measured by the very commit that ships the row. Excluding comment lines -/// restores the plan's PRODUCTION count of 22 exactly, INCLUDING its per-file +/// restores the plan's PRODUCTION count exactly, INCLUDING its per-file /// production multiset. (It does not restore the plan's original test-half count -/// of 12: that half has since been adjudicated five times, to 21, and the assert +/// of 12: that half has since been adjudicated repeatedly, and the assert /// below is the authority for the pair. Prose that repeats a number is prose that /// can go stale — this defers to the assert rather than restating it.) fn classify(src: &str, needle: &str, file: &str) -> Vec { @@ -183,11 +253,12 @@ fn census(needle: &str) -> Vec { hits } -/// R8 CONJUNCT 1 — the offer-writer surface, pinned BIDIRECTIONALLY (`== 22` / -/// `== 21`, so a REMOVED site fails too) and by per-file multiset. +/// R8 CONJUNCT 1 — the offer-writer surface, pinned BIDIRECTIONALLY (an EQUALITY +/// on each half, so a REMOVED site fails too) and by per-file multiset. /// -/// ⚠ THE `#[cfg(test)]` HALF HAS MOVED FIVE TIMES, 12 ⇒ 13 ⇒ 14 ⇒ 16 ⇒ 17 ⇒ 21, -/// AND EACH ADJUDICATION IS RECORDED RATHER THAN THE ASSERT RELAXED. +/// ⚠ THE `#[cfg(test)]` HALF HAS MOVED REPEATEDLY — 12 ⇒ 13 ⇒ 14 ⇒ 16 ⇒ 17 ⇒ the +/// value the assert below pins — AND EACH ADJUDICATION IS RECORDED RATHER THAN +/// THE ASSERT RELAXED. /// * 12 ⇒ 13: §6 R27 (b) /// (`analysis::resource::tests::r27_b_a_stored_may_auto_choice_survives_the_ring`) /// destructures the offer the mint RETURNED to count its published CR 603.5 @@ -201,20 +272,21 @@ fn census(needle: &str) -> Vec { /// module — `bounded_offer_with_period`, a builder minting an offer whose /// certificate carries a real `per_cycle` so the proposer-elimination arm can /// be driven, and `certificate_of`, a READ accessor for the same rows. -/// * 16 ⇒ 17: item-4 C2a's cap-round row +/// * 16 ⇒ 17: the cap-round row /// `the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for` in /// `engine/src/analysis/resource.rs` — A READ, NOT A WRITER: it destructures /// the offer it minted to assert an EMPTY `schema.points` beside a /// `victim_slot` that still names the forced victim. -/// * 17 ⇒ 21: item-4 C2b's two in-crate rows spell the anchor FOUR times between -/// them — `game/visibility.rs` row D5-h's mint and its projection read, and -/// `ai_support/candidates.rs` row D6-n's mint and its reach-guard read. +/// * 17 ⇒ the pinned value: the CR 732.2a declaration change's two in-crate +/// rows spell the anchor FOUR +/// times between them — `game/visibility.rs` row D5-h's mint and its projection +/// read, and `ai_support/candidates.rs` row D6-n's mint and its reach-guard read. /// /// All five are in a `#[cfg(test)]` scope — mints and reads both — which is the /// benign case this row's own failure message names: a test fixture cannot make -/// the period machinery certify. The PRODUCTION half is unchanged at 22 and so is -/// the per-file multiset below, which is the half §10 ruling condition (2) is -/// about. +/// the period machinery certify. The PRODUCTION half is UNCHANGED across all five +/// — and so is the per-file multiset below, which is the half §10 ruling condition +/// (2) is about. Its VALUE is the assert's, not this comment's. /// /// R8 CONJUNCT 2, same test — pin VALUE-legality has exactly ONE production /// consumer (`analysis::decision_template::declaration_conforms`), that consumer @@ -269,7 +341,7 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid real `per_cycle` so the proposer-elimination arm can be driven, and `certificate_of`, \ a read accessor for the same rows. PRODUCTION STAYED AT 22 across that change, which \ is the half this pin exists to protect: the new policy arm READS the certificate and \ - writes no offer). FOURTH ADJUDICATION, 16 => 17: item-4 C2a's cap-round row \ + writes no offer). FOURTH ADJUDICATION, 16 => 17: the cap-round row \ `the_bounded_offer_charges_a_forced_victim_it_publishes_no_point_for` in \ `engine/src/analysis/resource.rs`, whose `WaitingFor::LoopShortcut` DESTRUCTURE reads the \ offer it minted to assert the combination that decoupling CR 732.2a publication from CR \ @@ -278,8 +350,8 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid multiset, and that conjunct is what makes this the benign case rather than a surface \ change; if it moves again, name the new site here too rather than only moving the \ number.\n\ - FIFTH ADJUDICATION, 17 => 21: item-4 C2b publishes the bounded offer's own CR 732.2a \ - `declaration` on `WaitingFor::LoopShortcut`, and its two in-crate rows spell the anchor \ + FIFTH ADJUDICATION, 17 => 21: publishing the bounded offer's own CR 732.2a \ + `declaration` on `WaitingFor::LoopShortcut` added two in-crate rows that spell the anchor \ FOUR times between them. Named individually, because this census counts LINES (its \ `classify()` is `line.contains(needle)`, deliberately replacing a construction-shaped \ detector), so a mint and a read of the same fixture are two counted sites: (1) \ @@ -427,8 +499,12 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid /// anchor): remove the cfg-scope filter — i.e. make `cfg_test_scoped_lines` /// return all-`false` — and the four mod-scoped plants count as production, so /// `(4, 4)` becomes `(8, 0)` and this test FLIPS TO FAIL. The classifier is also -/// measured keyed on the real tree: it returns BOTH 22 production AND 12 test -/// above, so it is not constant in either direction. +/// measured keyed on the real tree: the census assert above resolves a production +/// half and a test half that differ from each other and from this test's `(4, 4)`, +/// so it is not constant in either direction. THE PAIR IS NOT RESTATED HERE — this +/// sentence used to restate it, the test half moved under adjudication, and nothing +/// went red. The assert above is where that pair is authoritative, and the PROBE-PIN +/// block below re-measures it on every `probe-pin check`. #[test] fn the_cfg_scope_classifier_sees_four_foreign_forms_the_construction_anchor_misses() { let bare = anchor(); diff --git a/probe-pin/engine-census.toml b/probe-pin/engine-census.toml new file mode 100644 index 0000000000..40f4643a56 --- /dev/null +++ b/probe-pin/engine-census.toml @@ -0,0 +1,245 @@ +# The engine-census pin: the offer-writer census's own claims, MEASURED rather than restated. +# +# Why this manifest exists: this census's module header carried "22 production + 14 test sites" +# while its assert read `(22, 21)`. The prose was hand-corrected across five adjudications +# (12 => 13 => 14 => 16 => 17 => 21) and drifted out of sync with its own live assert anyway. +# Rows P1-P4 below anchor `right: (22, 21)`, and P1 anchors the adjudication sentence inside the +# assert, so moving either without regenerating this block turns `probe-pin check` red. +# +# mode = "runtime-read" is the ENABLING CONDITION, not a formality: `census()` reads its sources +# with `std::fs::read_to_string` under `Path::new(env!("CARGO_MANIFEST_DIR")).join("src")`. `env!` +# bakes the PATH at compile time, the CONTENT is read at execution -- so a bind-mounted mutant is +# visible WITHOUT rebuilding the engine (measured end-to-end). Had the census used `include_str!`, +# probe-pin could not pin it at all. +# +# The walk roots are `crates/engine/src` and `crates/phase-ai/src`. NOTHING in this commit is +# written into either of them, and this manifest is deliberately NOT stored under them: a +# documentation-adding change over the very constructs a census counts is how a prose mention +# becomes a counted site. ⚠ State the REACHABLE form of that hazard, not a bigger one: `census()` +# collects through `rs_files()`, whose predicate is `extension == "rs"`, so a `.toml` under a walk +# root is never read at all -- the walk roots hold non-`.rs` files (insta snapshots, docs) that the +# green assert does not count. NO count of them is quoted here, for the same reason the workflow +# count below is omitted: nothing turns red when it moves, and `rs_files()`'s predicate is the +# whole proof. The real hazard is this file's TEXT being moved under a walk root **into a `.rs` file**, +# where P1's `find` string alone would add a production hit and move the number it pins. +# +# NOTE ON `assert_count`: it counts RAW substring occurrences over the whole mutant text +# (`str::matches`), including comments. The census counts NON-COMMENT LINES. The two numbers +# differ on purpose -- P3 asserts 4 raw occurrences for a mutation the census must see as +1. +# +# ============================ WHERE THIS IS ENFORCED, AND WHERE IT IS NOT ==================== +# ENFORCED: locally, by the Tiltfile's `probe-pin-census` resource, on every edit under +# `crates/engine/src` or `crates/phase-ai/src` -- in ANY running Tilt session, including a plain +# `tilt up`. MEASURED against the live session, because the weaker reading is the tempting one: +# the `lint` group gates `auto_init` ONLY, i.e. whether the resource also runs once at startup. +# It does not gate the file watch. RE-RUNNABLE CHECK rather than a quoted timing: in a `tilt up` +# started with no profile, `tilt get filewatch` lists `local:probe-pin-check` -- same +# `auto_init = 'lint' in enabled` shape as this resource -- and for any such resource the +# FileWatch `lastEventTime` is followed within milliseconds by a `buildHistory` entry, i.e. a +# file-change-triggered build on a resource that never auto-inits. Deliberately NO session +# timestamps are quoted: they die with the Tilt session, and the recipe reproduces without them. +# The Tiltfile header states the same rule in prose: "opt-in groups just control which auto-start". +# NOT ENFORCED IN CI. MEASURED, not assumed: ZERO files under `.github/workflows/` reference +# probe-pin (positive control: `clippy` matches at least one workflow, so the instrument +# discriminates and the zero is a result). NO workflow COUNT is quoted here: nothing in this +# repo turns red when a workflow is added, and a zero that went stale would go stale in the +# SAFE direction -- a workflow that DID reference probe-pin would make this pin more enforced +# than this comment claims, not less. CI enrollment is not merely undone, it is POLICY-BLOCKED +# -- `.agents/pr-review-policy.toml` `[hard_stops]` lists `.github/workflows/**`, so an agent +# change cannot add it. +# READ THAT AS A LIMIT, NOT AS COVERAGE. A merged pin is not a CI-enforced pin. If the local +# Tilt resource is not running, these claims are measured by NOTHING -- which is why the +# resource is an AUTOMATIC trigger and not a manual knob (see the Tiltfile comment there). +# This manifest also does NOT retire the OUT-OF-TREE `.r3-drift-gate.sh` (it lives in the working +# run directory and is not committed, so a reader will not find it here): FOUR of its rows (seat-pin assert +# hash, the `WaitingFor` variant set, the CR 603.5 producer pin, clear-site counts) are still +# uncovered here. This pins the INTEGRATION-venue census; the lib-venue CR 603.5 producer +# census is NOT pinned and remains covered by the shell drift-gate. +# ============================================================================================ +version = 1 + +[target] +mode = "runtime-read" +package = "phase-engine" # the PACKAGE; the lib target is named `engine` +test = "integration" +# Selects exactly ONE test. MEASURED, NOT ENFORCED -- the same distinction this file draws for CI +# above, drawn here because the opposite reading was written first and was WRONG. Nothing in the +# tool compels the "one": `DigestInput` (`crates/probe-pin/src/block.rs`) carries the [target] +# TABLE, the probes, the control, the projections and the instruments -- not the number of tests +# SELECTED -- so a filter that grew to match a second PASSING test yields `selected: 2` with +# `probe-pin check` still exit 0 and the digest UNMOVED (refuted by experiment; the claim that such +# a filter "would change the run and move the digest" was false). What IS enforced is the EXECUTION +# FLOOR in `verdict.rs`: `passed + failed == 0` aborts, i.e. AT LEAST one test body must have run +# (`NothingRan::NoneSelected` when the filter matches nothing, `AllSkipped` under an `#[ignore]`). +# One-ness is therefore a property of the MATCH SET, not of the string. Be precise about which, +# because the first correction here was wrong in the other direction: the filter STRING **is** +# digest-covered -- `DigestInput` carries `&manifest.target`, and `filter` is a field of that table, +# so editing the string turns the pin RED even when every verdict is identical +# (`crates/probe-pin/tests/pure_logic.rs::digest_target` asserts exactly this). What nothing guards +# is the string being left AS AUTHORED while its matched set GROWS -- a newly added test whose NAME +# happens to contain it. That is the unguarded direction, and it is the only one. +# No test-count denominator is quoted either: the binary's test population is a live external +# number nothing here turns red on. +# Deliberately NOT the whole census module: the +# sibling test `the_cfg_scope_classifier_sees_four_foreign_forms_...` builds its input as an +# in-binary String, so no bind-mount can reach it and no row here may imply it was pinned. +filter = "the_loop_shortcut_offer_writer_surface_is_pinned" +filter_match = "substring" +timeout_secs = 120 + +[output] +file = "crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs" +marker = "PROBE-PIN" + +[[probe]] +id = "P0_control" +claim = "no mounts, unmodified tree -- the instrument itself" + [probe.expect] + outcome = "pass" + +[[probe]] +id = "P1_production_site_removed" +claim = "the production half fails DOWNWARD, and the pinned tuple + adjudication prose are what they say" + [[probe.mutation]] + kind = "replace" + file = "crates/engine/src/game/scenario.rs" + find = " WaitingFor::LoopShortcut { .. } => \"LoopShortcut\",\n" + replace = "" + [[probe.assert_count]] + file = "crates/engine/src/game/scenario.rs" + text = "WaitingFor::LoopShortcut {" + count = 0 + [probe.expect] + outcome = "fail" + anchor = ["left: (21, 21)", + "right: (22, 21)", + "THE TEST HALF HAS BEEN ADJUDICATED FIVE TIMES"] + +[[probe]] +id = "P2_test_site_removed" +claim = "the cfg(test) half is pinned INDEPENDENTLY of the production half" + [[probe.mutation]] + kind = "replace" + file = "crates/phase-ai/src/projection.rs" + find = " state.waiting_for = WaitingFor::LoopShortcut {\n" + replace = "" + [[probe.assert_count]] + file = "crates/phase-ai/src/projection.rs" + text = "WaitingFor::LoopShortcut {" + count = 1 + [probe.expect] + outcome = "fail" + anchor = ["left: (22, 20)", "right: (22, 21)"] + +[[probe]] +id = "P3_walk_reaches_phase_ai_and_skips_comments" +claim = "the phase-ai root is walked, zero-hit files are visited, and COMMENT lines are not counted" + [[probe.mutation]] + kind = "prepend" + files = ["crates/phase-ai/src/lib.rs"] + text = "let _pp = WaitingFor::LoopShortcut { proposer };\n// pad WaitingFor::LoopShortcut { proposer };\n// pad WaitingFor::LoopShortcut { proposer };\n// pad WaitingFor::LoopShortcut { proposer };\n" + repeat = 1 + # FOUR raw occurrences in the mutant; the census must count ONE. That gap IS the claim. + [[probe.assert_count]] + file = "crates/phase-ai/src/lib.rs" + text = "WaitingFor::LoopShortcut {" + count = 4 + [probe.expect] + outcome = "fail" + anchor = ["left: (23, 21)", "right: (22, 21)"] + +[[probe]] +id = "P4_counting_is_per_line" +claim = "the count sums LINES, not files -- two planted lines in one file move it by two" + [[probe.mutation]] + kind = "prepend" + files = ["crates/engine/src/lib.rs"] + text = "let _pp = WaitingFor::LoopShortcut { proposer };\n" + repeat = 2 + [[probe.assert_count]] + file = "crates/engine/src/lib.rs" + text = "WaitingFor::LoopShortcut {" + count = 2 + [probe.expect] + outcome = "fail" + anchor = ["left: (24, 21)", "right: (22, 21)"] + +[[probe]] +id = "P5_relocation_preserves_the_count" +claim = "the per-file multiset is pinned INDEPENDENTLY of the total -- the relocation the count cannot see" + [[probe.mutation]] + kind = "replace" + file = "crates/engine/src/game/scenario.rs" + find = " WaitingFor::LoopShortcut { .. } => \"LoopShortcut\",\n" + replace = "" + [[probe.mutation]] + kind = "prepend" + files = ["crates/engine/src/game/interaction.rs"] + text = "let _pp = WaitingFor::LoopShortcut { proposer };\n" + repeat = 1 + [[probe.assert_count]] + file = "crates/engine/src/game/scenario.rs" + text = "WaitingFor::LoopShortcut {" + count = 0 + [[probe.assert_count]] + file = "crates/engine/src/game/interaction.rs" + text = "WaitingFor::LoopShortcut {" + count = 6 + [probe.expect] + outcome = "fail" + # The integer in the second anchor is a COUNT, not a line number -- see the DISCLOSURE in the + # census file's own prose. No lint distinguishes the two in that slot. + anchor = ["the COUNT can be preserved by a move that relocates a writer", + "(\"engine/src/game/interaction.rs\", 6)"] + +[[probe]] +id = "P6_second_validate_pins_consumer" +claim = "pin-VALUE legality has exactly ONE production consumer" + [[probe.mutation]] + kind = "prepend" + files = ["crates/engine/src/game/scenario.rs"] + text = "let _pp = validate_pins(schema, template, 1, state);\n" + repeat = 1 + [[probe.assert_count]] + file = "crates/engine/src/game/scenario.rs" + text = "validate_pins(" + count = 1 + [probe.expect] + outcome = "fail" + anchor = ["expected `validate_pins(` to appear in production exactly twice"] + +[[probe]] +id = "P7_coverage_half_unpaired" +claim = "the one consumer runs the COVERAGE half too -- adjacency is load-bearing" + [[probe.mutation]] + kind = "replace" + file = "crates/engine/src/analysis/decision_template.rs" + find = " predictability_gate(template, &required).is_ok()\n && validate_pins(schema, template, validated_range, state).is_ok()\n" + replace = " predictability_gate(template, &required).is_ok()\n // probe pad\n // probe pad\n // probe pad\n && validate_pins(schema, template, validated_range, state).is_ok()\n" + [[probe.assert_count]] + file = "crates/engine/src/analysis/decision_template.rs" + text = "// probe pad" + count = 3 + [probe.expect] + outcome = "fail" + anchor = ["validating pin VALUES without also running"] + +[[probe]] +id = "P8_authority_call_site_removed" +claim = "every declare-time site routes through the shared authority" + [[probe.mutation]] + kind = "replace" + file = "crates/engine/src/game/engine.rs" + find = " crate::analysis::decision_template::declaration_conforms(\n" + replace = "" + # RAW occurrences: 4 in the pristine file (one is a `///` line, one is cfg(test)-scoped), so 3 + # after the drop. The census's PRODUCTION count for the same file goes 2 -> 1. Two different + # instruments, two different numbers, both stated. + [[probe.assert_count]] + file = "crates/engine/src/game/engine.rs" + text = "declaration_conforms(" + count = 3 + [probe.expect] + outcome = "fail" + anchor = ["expected 1 definition + 3 call sites", + "(\"engine/src/game/engine.rs\", 1)"] From 59101ad6b13900ad23e120f69173fa4be5e8ba4d Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 12:29:46 -0500 Subject: [PATCH 28/44] fix(engine-tests): stop a comment counting itself, and adjudicate the WaitingFor reach-guard Two pinned census tests were failing at this lane's tip. Neither was caused by the rebase; both were already red at the previous tip, and the first unfiltered run of the engine suite is what surfaced them. Recording that plainly, because four review rounds of narrow gates had all been green over a red suite. `the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event` measured 39 against a pin of 38, with producers (5) and readers (8) both correct -- so the extra hit was a `#[cfg(test)]` line, 25 to 26. The site is a comment this lane added in `journal CR 603.5 "may" answers`, which quoted the census needle verbatim while explaining a producer's identity. That census has no comment filter; its only exclusion is lines containing `..`. So the sentence counted itself. The fix drops the opening brace from the quotation rather than moving the pin to 39. Bumping would have recorded a comment as a census site permanently, which is the one thing this instrument must never do -- it exists to count code. The test already assembles its own needle with `format!` so that its source cannot match; prose that names the construct owes the same care, and the comment now says so. Exactly one such line exists in the crate and none upstream, so the class is closed, not sampled. `exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redacted` measured 129 variants against a reach-guard pinned at 128. Upstream #7336 ("make dig entries attack") added `EntryAttackTargetChoice { player, object_id, valid_targets }`, and the enum reads 129 at every tree involved -- the old upstream base, current upstream, this lane before the rebase, and after -- so the pin had been stale since before this work. It is updated with the variant named and with the fact that decides whether the row's real assertion still holds: that variant carries no `DecisionTemplate`, so it is not a third carrier. A reach-guard that is bumped without that check stops guarding anything. Verified: both tests pass, and `cargo probe-pin check` stays RC 0 -- which is not incidental here, since the first fix edits a comment inside a census walk root and `assert_count` counts raw substrings including comments. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/engine.rs | 8 ++++++-- crates/engine/tests/integration/loop_shortcut.rs | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index fbb9613385..9387d4984d 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -18473,8 +18473,12 @@ mod stage2_injector_tests { // // Producer identity re-established rather than assumed: the line at the new // coordinate is byte-identical to the base's `:12004` and to upstream's `:12003` - // (`return Ok(Some(WaitingFor::OptionalEffectChoice {`), and it is still inside - // `begin_pending_trigger_target_selection`. + // (`return Ok(Some(WaitingFor::OptionalEffectChoice`), and it is still inside + // `begin_pending_trigger_target_selection`. The opening brace is dropped from that + // quotation ON PURPOSE: this census has no comment filter, so quoting the needle + // whole makes the sentence count ITSELF as a site. It did — this line was the + // 39th hit against a pin of 38. The test assembles its own needle with `format!` + // for exactly this reason; prose that names the construct owes the same care. // // TO BE UNAMBIGUOUS FOR THE NEXT READER: the `+1` in `apply_action`'s // `DecideOptionalEffect` arm is a READER, NOT A SIXTH PRODUCER. It destructures the cloned `state.waiting_for` diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index d5cd9f9328..f1fe871bc8 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -4805,12 +4805,18 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac // ── the classifier's own reach-guard: the enum was actually found ── let total = enum_variants(&enum_src, "WaitingFor").len(); assert_eq!( - total, 128, - "`WaitingFor` has 128 variants at this tip, read off the `syn` parse. This number is \ + total, 129, + "`WaitingFor` has 129 variants at this tip, read off the `syn` parse. This number is \ pinned so a variant REMOVED is as visible as one added; if you added a variant and it \ carries no `DecisionTemplate`, update this number. A wildly different count means the \ reader lost its anchor, and every assertion below would then be measuring an empty enum" ); + // 128 ⇒ 129 is ADJUDICATED, not bumped: upstream #7336 ("make dig entries attack") added + // `EntryAttackTargetChoice { player, object_id, valid_targets }`. Measured, because the + // number alone cannot say it: that variant carries NO `DecisionTemplate` (zero matches in + // its body), so it is not a third carrier and the assertion below is unchanged by it. The + // count moved for a reason that does not touch this row's subject — which is exactly the + // case this reach-guard exists to make visible rather than silent. let carriers = carriers_in_source(&enum_src, "WaitingFor", &corpus, &marker, true); assert_eq!( From d40f701a28a07e8656e66b8bee5978a7b70cc0d8 Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 12:52:48 -0500 Subject: [PATCH 29/44] fix(engine-wasm): stop a native test aborting the process, and correct three measured figures The tip review of this lane found one blocking defect and two wrong numbers. All three predate the rebase. engine-wasm's `ai_scoring_rng_bridge_tests` did not run at all: six tests died with SIGABRT. The module plants a state and restores it through the `#[wasm_bindgen]` shell `restore_game_state`. That restore fails, and the shell's error path builds a `JsValue`, which off wasm32 panics inside a function that cannot unwind -- so a returned error becomes a process abort rather than a test failure. CI runs this crate (`--workspace --exclude phase-tauri --exclude mtgish-import`), so it would have opened red. The commit that added the module argued for exactly this split and then missed a seam: it introduced `scored_candidates_inner` *because* the shell cannot run off wasm32, and then called a different, unsplit shell for setup. So the fix is the pattern already in the file -- `restore_game_state_inner(&str) -> Result<(), String>` holding the body, the shell reduced to `.map_err(JsValue::from_str)`, and the test calling the inner. That is worth more than the six tests: any future restore error in a native test now fails readably instead of killing the run. With the abort gone the real cause was readable rather than inferred: "card database is not loaded". The module needs a database to exist, not card data -- `rehydrate_restored_state_from_card_db` errors only on absence, and the work it guards no-ops on unknown names -- so it plants an empty one. Three sibling modules each carry their own twenty-line card literal; a fourth copy would have been consistent and wrong. `restored_card_db_requirements_tests` already pins the requirement itself. Two figures were stale, both re-measured with the instruments their own files name: - `game_state_size.rs` recorded `GameState` at 12,800 B; `-Zprint-type-sizes` reports 12,816. The ceiling matters more than the record: the file's stated rule is `measured.next_multiple_of(256) + 256`, which gives 13,312, but it shipped 13,056 -- the rounded value with no bucket at all, so the "one full 256 B bucket of deliberate slack" it budgets for was 240 B of accident. Both corrected. - `engine-inventory-gen` claimed 647 enum declarations yielding 646 entries; the generator itself emits 653 from 654 declarations. Its conclusion survives unchanged -- exactly one ident (`LayoutKind`) collides -- so only the integers move. The same measurement is quoted twice more in `loop_shortcut.rs`, where 486/649 are now 491/654; the two figures beside them, 108 and 12, were re-measured and had not moved. Not fixed here, disclosed instead: `dina_noff_turn5_loader::gunzip` duplicates `shorten_efficacy::gunzip_dump` byte for byte in the same test binary, a collision the rebase created when #7101 landed the fixture upstream. Removing it needs `shorten_efficacy`'s helpers widened to `pub(super)`, and that file is not one this lane touches. Verified: `cargo nextest run -p engine-wasm` 32/32 pass, up from 26/32 with six aborts. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine-inventory-gen/src/main.rs | 4 +- crates/engine-wasm/src/lib.rs | 38 ++++++++++++++++--- crates/engine/src/types/game_state_size.rs | 4 +- .../engine/tests/integration/loop_shortcut.rs | 6 +-- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/crates/engine-inventory-gen/src/main.rs b/crates/engine-inventory-gen/src/main.rs index 6829aefee5..22061dc0af 100644 --- a/crates/engine-inventory-gen/src/main.rs +++ b/crates/engine-inventory-gen/src/main.rs @@ -77,14 +77,14 @@ struct ClusterSmell { /// Every directory whose `pub enum`s are engine surface a variant proposal must be able to /// discover. CLAUDE.md makes an inventory grep the mandatory discoverability gate before /// proposing a variant and scopes it to "any other engine enum", so the walk is the WHOLE -/// engine crate rather than a hand-kept subset: `types/` + `analysis/` left 85 of the 647 +/// engine crate rather than a hand-kept subset: `types/` + `analysis/` left 85 of the 654 /// top-level `pub enum`s under `crates/engine/src` structurally invisible to the gate /// (`game/` 61, `ai_support/` 13, `parser/` 7, `database/` 4). One root is also shorter than /// the list it replaces. /// /// MEASURED COST, disclosed rather than absorbed: the catalogue is a `BTreeMap` keyed on the /// enum IDENT, and across the whole crate exactly one ident collides — `LayoutKind`, declared -/// in both `types/card.rs` and `database/synthesis.rs` — so 647 declarations yield 646 entries +/// in both `types/card.rs` and `database/synthesis.rs` — so 654 declarations yield 653 entries /// and the later walk order wins. The gate this feeds is an existence/parameterization lookup /// by name, which still answers for `LayoutKind`; a module-qualified key is the fix if a /// second collision ever makes the per-variant listing ambiguous. diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index dbafbcbc25..a0bfb98f6f 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -2021,13 +2021,23 @@ fn load_minimal_test_card_database() { /// game on the wire. Undo is a single-player affordance only. #[wasm_bindgen] pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { + restore_game_state_inner(json_str).map_err(|error| JsValue::from_str(&error)) +} + +/// The natively-callable body of [`restore_game_state`]. +/// +/// Split for the same reason — and in the same shape — as `resolve_all_inner` +/// and `scored_candidates_inner`: the `#[wasm_bindgen]` shell may only run on +/// wasm32. Off-target, `JsValue::from_str` panics inside a function that cannot +/// unwind, so a shell that merely RETURNS an error aborts the whole process with +/// SIGABRT instead of failing the test. A native test that calls the shell is +/// therefore only safe while restore succeeds; the moment it errors, the failure +/// is unreadable. Tests call this function. +fn restore_game_state_inner(json_str: &str) -> Result<(), String> { if MULTIPLAYER_MODE.with(|cell| cell.get()) { - return Err(JsValue::from_str( - "restore_game_state refused: undo is disabled in multiplayer sessions", - )); + return Err("restore_game_state refused: undo is disabled in multiplayer sessions".into()); } - let restored = decode_and_rehydrate_restored_game_state(json_str) - .map_err(|error| JsValue::from_str(&error))?; + let restored = decode_and_rehydrate_restored_game_state(json_str)?; let mut state = restored.state; // Reseed the skipped `rng` and fast-forward it to the offset captured at // export (issue #5466) so the restored game draws the values that would have @@ -5210,12 +5220,28 @@ mod ai_scoring_rng_bridge_tests { GAME_STATE.with(|cell| cell.set(Some(state))); + // Restore REFUSES without a card database (`rehydrate_restored_state_from_card_db` + // errors on absence alone), and these rows are about the RNG triple, not card + // data: `rehydrate_game_from_card_db` returns `()` and treats an unknown name as + // a no-op, so an EMPTY database satisfies the requirement without inventing card + // rows this module would then have to keep true. `restored_card_db_requirements_tests` + // is the row that pins the requirement itself. + CARD_DB.with(|cell| { + *cell.borrow_mut() = Some( + engine::database::CardDatabase::from_json_str("{}") + .expect("an empty card database must parse"), + ); + }); + // The exact shipped plant: `AiWorkerPool` calls `worker.restoreState(..)` // before every scoring call, and `restore_game_state` rehydrates the full // triple. let json = export_game_state_json().expect("planting must be exportable"); clear_game_state(); - restore_game_state(&json).expect("planting must be restorable"); + // The INNER body, not the `#[wasm_bindgen]` shell: off-wasm32 the shell's + // error path builds a `JsValue` inside a non-unwinding fn and SIGABRTs, so + // calling it here would turn any restore failure into an unreadable abort. + restore_game_state_inner(&json).expect("planting must be restorable"); // Premise 2, measured: the production restore resumed the saved position, // so a zero observed below is this entry point's own policy rather than a diff --git a/crates/engine/src/types/game_state_size.rs b/crates/engine/src/types/game_state_size.rs index a73839ae8e..78fa16d469 100644 --- a/crates/engine/src/types/game_state_size.rs +++ b/crates/engine/src/types/game_state_size.rs @@ -39,7 +39,7 @@ //! //! | Type | before boxing | after | ceiling | //! |---|---:|---:|---:| -//! | `GameState` | 30,112 | 12,800 | 13,056 | +//! | `GameState` | 30,112 | 12,816 | 13,312 | //! | `StackEntry` | 5,336 | 344 | 768 | //! | `PendingCast` | 6,632 | 1,376 | 1,792 | //! | `PendingTrigger` | 6,000 | 744 | 1,024 | @@ -53,7 +53,7 @@ const _: () = { use core::mem::size_of; assert!( - size_of::() <= 13_056, + size_of::() <= 13_312, "GameState grew past its stack budget. It is moved by value through the \ phase-server action + AI path, so an overrun is an uncatchable \ guard-page abort, not a panic. Re-run the -Zprint-type-sizes command in \ diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index f1fe871bc8..94e8b299e6 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -4594,7 +4594,7 @@ fn enum_variants(src: &str, enum_name: &str) -> Vec<(String, Vec)> { /// /// This step used to scan for `pub struct {` at column 0, which is a small fraction of /// the declarations that can hold a `marker`. MEASURED over this walk root: that shape matches -/// **486** declarations, while **649** `pub enum`, **108** `pub(crate) struct` and **12** +/// **491** declarations, while **654** `pub enum`, **108** `pub(crate) struct` and **12** /// generic `pub struct` heads are invisible to it — and a carrier the depth-1 step cannot see /// is scored as a clean GREEN, which is the one failure mode a census must not have. `syn` is /// already a `[dev-dependencies]` entry of this crate and already the instrument @@ -4914,8 +4914,8 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac // ── PLANT 5 — the four holder FORMS the retired string scan could not see, plus a // non-carrier control. Each is a real declaration shape from this walk root: over - // `crates/engine/src` the old `pub struct {` shape matched 486 declarations - // while 649 `pub enum`, 108 `pub(crate) struct` and 12 generic `pub struct` heads were + // `crates/engine/src` the old `pub struct {` shape matched 491 declarations + // while 654 `pub enum`, 108 `pub(crate) struct` and 12 generic `pub struct` heads were // invisible — every one of them a carrier that would have scored a clean GREEN. ── let forms = format!( "pub(crate) struct CrateVisHolder {{\n pub template: Option<{marker}>,\n}}\n\ From 1e6fb080a828d7d04f0b5669bac76bba07a62f19 Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 13:29:57 -0500 Subject: [PATCH 30/44] fix(engine-tests): re-derive the census coordinate for upstream #4155 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto `7127326673` replayed cleanly, and the pinned coordinate was still wrong. #4155 inserts five net lines above the producer this row names (seven for abandoned-cast finalization, less two from its deferred-resume cleanup), so `:12712` moved to `:12717` while every conflict resolution along the way reported success. Located by content digest rather than arithmetic, as every re-derivation of this row has been: the line whose sha256 is `8a544e87…5cc7d63` matches exactly one line under a whole-file scan and sits at the invariant offset 134 from `begin_pending_trigger_target_selection`. `12712 + 5` is the check that agreed with that, not the derivation. Worth stating plainly because it is the point of pinning by digest: a clean rebase is not evidence that a coordinate survived it. Eight conflicts in this file were resolved by digest during the replay and all eight held their offset; this one produced no conflict at all, which is exactly why it needed measuring. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/engine.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 9387d4984d..e3d61b7bbd 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -18738,9 +18738,14 @@ mod stage2_injector_tests { // holds identically: all 5 net inserted lines are `//` comments, so no // `waiting_for = ` or `Ok(Some(` line was added, and the pin below is once more // the ONLY non-comment line in this round's `crates/engine/src` diff. - // ⚠ REBASE #3: `:12713 ⇒ :12712`, located by content digest, offset from - // `begin_pending_trigger_target_selection` unchanged at 134. - "game/engine.rs:12712".to_string(), + // ⚠ RE-REBASE onto upstream `7127326673`: `:12712 ⇒ :12717`, the **+5** that + // upstream #4155 inserts above this producer (seven lines for abandoned-cast + // finalization, less two removed by its deferred-resume cleanup). LOCATED BY + // CONTENT DIGEST, never by arithmetic: the line whose sha256 is + // `8a544e87…5cc7d63` matches exactly ONE line under a whole-file scan and is + // still inside `begin_pending_trigger_target_selection`, at the invariant offset + // 134. `12712 + 5` is the CHECK that agreed, not the derivation. + "game/engine.rs:12717".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ From 512694b6f4f85b5ae3b24dd8c9ed3ff0e3632eef Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 14:43:34 -0500 Subject: [PATCH 31/44] fix(engine-tests): make the censuses measure code, not prose Eight review findings, one shared root: several censuses counted a needle anywhere on an accepted line, so a trailing comment on a real code line was indistinguishable from a construction. Two consequences, both live: prose edits could move a pinned number with no code change, and deleting a construction while mentioning its spelling in a trailing comment held the count -- the exact substitution class those censuses exist to catch. - route the counters through `code_span`, the already-documented fail-closed helper in the battlefield census (a `//` preceded by a `"` stays in the code half, so a URL in a string cannot truncate the line); no new stripper - the CR 603.5 prompt census had NO comment filter at all. It is fixed, and the brace previously deleted from its prose quotation to dodge the bug is restored -- the old counter now reads 39 against its pin of 38, the fixed one reads 38, so the repair is measured on the real tree rather than latent - `c1_every_ring_clear_site` walked two named files while claiming to catch a ninth site anywhere; it now walks `crates/engine/src`. The count is 8 before and after, so a planted clear site in `game/zones.rs` is the only thing that can observe the widening: widened walk fails naming it, the old named-pair walk passes with the same plant present - select the offer-writer census's consumer by role rather than line order, and assert both partition sizes so an unclassifiable shape fails loudly - the inventory keyed 654 enum declarations by bare ident, silently dropping one of two `LayoutKind`s -- and they are not variant-identical (`Omen` vs `Specialize`), so an existence check for the shadowed variant returned a false negative from the discoverability gate itself. Keys are now module-qualified and a duplicate bails; `EnumEntry` keeps a bare `name` because the qualified key alone took a quoted `"LayoutKind"` grep to zero. 653/5310 -> 654/5319 - propagate `WalkDir` errors and sort the walk; a partial inventory no longer reports success, and the survivor of a collision is no longer readdir order - watch `Cargo.lock`, the two manifests that feed this target, and `.cargo/config.toml` (its `RUST_MIN_STACK` gates whether these tests pass); manifest set intersected from `cargo tree` x `cargo metadata`, not guessed - correct two docs that claimed more than their assertions make: the ranking file's false `pub(crate)` reader premise, and natural_balance's drive-wide claim behind a terminal-only check Assisted-by: ClaudeCode:claude-opus-5 --- Tiltfile | 37 +++++++- crates/engine-inventory-gen/src/main.rs | 64 +++++++++++-- crates/engine/src/game/engine.rs | 45 ++++++--- .../battlefield_entry_authority_census.rs | 36 ++++++-- .../fantastic_four_bounded_loop.rs | 49 ++++++++-- .../loop_shortcut_offer_writer_census.rs | 44 ++++++++- .../integration/loop_shortcut_ranking.rs | 13 ++- .../loop_shortcut_seat_pin_census.rs | 91 +++++++++++++++++-- .../tests/integration/natural_balance.rs | 14 ++- 9 files changed, 332 insertions(+), 61 deletions(-) diff --git a/Tiltfile b/Tiltfile index 7e2f430fa4..b3cc7e083f 100644 --- a/Tiltfile +++ b/Tiltfile @@ -450,11 +450,15 @@ local_resource('probe-pin-census', # deliberately NOT ENGINE_SRC + AI_SRC. # ⚠ THE PREDICATE IS STATED BECAUSE THE EARLIER WORD WAS "EVERYTHING", AND THAT WAS FALSE. # Disclosed residual, not a gap that was missed: the target is resolved by building - # `--test integration`, so ANY sibling file under crates/engine/tests/integration/ (plus that - # crate's dev-deps and Cargo.lock) can move the verdict -- by breaking that build, or by adding - # a test that FAILS under the manifest's control. Those are NOT watched, on purpose: watching - # them would re-trigger this pin on every unrelated integration-test edit, and there are over a - # thousand of them. Both failure classes surface as exit 2 with the cause named in the output. + # `--test integration`, so ANY sibling file under crates/engine/tests/integration/ can move the + # verdict -- by breaking that build, or by adding a test that FAILS under the manifest's + # control. Those are NOT watched, on purpose: watching them would re-trigger this pin on every + # unrelated integration-test edit, and there are over a thousand of them. Both failure classes + # surface as exit 2 with the cause named in the output. THE DEV-DEP AND LOCKFILE HALF OF THIS + # RESIDUAL IS NO LONGER RESIDUAL -- it was listed here alongside the thousand siblings and + # inherited their exemption, which was wrong by this file's own admission rule: a lockfile-only + # edit leaves a stale GREEN, and Cargo.lock is one file with a tiny edit surface. It, the two + # manifests that reach this target, and .cargo/config.toml are watched below. # The pin is over exactly what census() reads; ENGINE_SRC is a superset BY DESIGN (its own # comment requires it to stay a superset of the engine cache key: src + data + build.rs + # Cargo.toml), and census() reads none of the extras. The live cost of the wider set is not @@ -469,6 +473,29 @@ local_resource('probe-pin-census', deps = [ 'crates/engine/src/', 'crates/phase-ai/src/', + # THE BUILD INPUTS OF THE TARGET THIS PIN RESOLVES BY BUILDING (`--test integration`). + # A lockfile- or manifest-only edit changes what that target compiles while every watched + # source file stays byte-identical, so without these the resource keeps its PRIOR GREEN + # even when the rebuilt target would fail or move a number. Each is admitted by the rule + # 'main.rs' below is admitted by -- ONE file with a tiny, stable edit surface -- not by the + # "everything that can break the build" reading the comment above already rejects for the + # thousand-odd sibling test files. + # WHICH manifests, MEASURED not shotgunned (`cargo tree -p phase-engine -e normal,dev` + # intersected with the workspace members): phase-engine is the ONLY workspace member in + # this target's graph, so its manifest plus the workspace root's -- [profile.test] and + # [workspace.dependencies], which this target resolves through -- are the whole set. + # Sibling crate manifests are deliberately absent: they cannot reach this build. + # probe-pin's own manifest already rides along inside the 'crates/probe-pin/' dep below. + # Cargo may itself rewrite Cargo.lock when it is stale, which costs at most ONE extra + # settling run, not a loop: the rewrite is idempotent. + 'Cargo.lock', + 'Cargo.toml', + 'crates/engine/Cargo.toml', + # NOT a manifest, watched on the same one-file rule: [env] here sets + # RUST_MIN_STACK = 16777216, which that file records as load-bearing for test threads + # (Debug-formatting the Effect <-> AbilityDefinition recursion overflows a default + # stack). Lowering it turns these tests red with no source edit at all. + '.cargo/config.toml', # The TOOL that renders the block, watched for the same reason 'probe-pin-check' watches # it: a change to block::render or the digest moves the rendered block, and without this # nothing re-triggers the check until an unrelated engine edit happens to fire it. diff --git a/crates/engine-inventory-gen/src/main.rs b/crates/engine-inventory-gen/src/main.rs index 22061dc0af..4f5447bd92 100644 --- a/crates/engine-inventory-gen/src/main.rs +++ b/crates/engine-inventory-gen/src/main.rs @@ -37,6 +37,10 @@ struct Inventory { #[derive(Serialize)] struct EnumEntry { + /// The bare ident. Carried as a field because the map key is module-qualified: without it + /// a quoted grep (`"LayoutKind"`) — a form the discoverability gate is written in — went + /// from 1 hit to 0. MEASURED on this tree before the field was added. + name: String, file: String, line: usize, doc: String, @@ -80,14 +84,23 @@ struct ClusterSmell { /// engine crate rather than a hand-kept subset: `types/` + `analysis/` left 85 of the 654 /// top-level `pub enum`s under `crates/engine/src` structurally invisible to the gate /// (`game/` 61, `ai_support/` 13, `parser/` 7, `database/` 4). One root is also shorter than -/// the list it replaces. +/// the list it replaces. MEASURED by running the generator: 654 enums, 5319 variants — the +/// catalogue now holds one entry per DECLARATION, so `enum_count` and the declaration count +/// are the same number. +/// +/// THE CATALOGUE KEY IS MODULE-QUALIFIED (`types::card::LayoutKind`), because the widened walk +/// makes ident collisions reachable and an ident key drops one side of every collision. Measured +/// on this tree: `LayoutKind` is declared in BOTH `types/card.rs` and `database/synthesis.rs`, +/// and the two are NOT variant-identical — `Omen` exists only in `card.rs`, `Specialize` only in +/// `synthesis.rs`. An ident key therefore answered the discoverability gate with ONE enum's +/// variant list, so an existence check for the shadowed variant returned a FALSE NEGATIVE — the +/// exact outcome CLAUDE.md makes this grep mandatory to prevent. It also made the output +/// irreproducible: which side survived was decided by `readdir` order. /// -/// MEASURED COST, disclosed rather than absorbed: the catalogue is a `BTreeMap` keyed on the -/// enum IDENT, and across the whole crate exactly one ident collides — `LayoutKind`, declared -/// in both `types/card.rs` and `database/synthesis.rs` — so 654 declarations yield 653 entries -/// and the later walk order wins. The gate this feeds is an existence/parameterization lookup -/// by name, which still answers for `LayoutKind`; a module-qualified key is the fix if a -/// second collision ever makes the per-variant listing ambiguous. +/// Grep still works on the qualified key: `LayoutKind` is a substring of +/// `types::card::LayoutKind`, so the skill's `rg "" data/engine-inventory.json` +/// existence check is unaffected. Unique keys additionally make the `BTreeMap` order — and so +/// the emitted JSON — a function of the source tree alone. const TARGET_DIRS: &[&str] = &["crates/engine/src"]; const OUTPUT: &str = "data/engine-inventory.json"; @@ -102,11 +115,16 @@ fn main() -> Result<()> { for dir in TARGET_DIRS { let target = workspace_root.join(dir); - for entry in WalkDir::new(&target).into_iter().filter_map(|e| e.ok()) { + // Sorted so the emitted `sources` list — and the walk itself — does not depend on + // `readdir` order; errors propagate rather than silently yielding a partial inventory + // that still reports success. + for entry in WalkDir::new(&target).sort_by_file_name() { + let entry = entry.with_context(|| format!("walk {}", target.display()))?; let path = entry.path(); if path.extension().is_none_or(|ext| ext != "rs") { continue; } + let module = module_path(path.strip_prefix(&target).unwrap_or(path)); let rel = path.strip_prefix(&workspace_root).unwrap_or(path); sources.push(rel.display().to_string()); @@ -123,7 +141,17 @@ fn main() -> Result<()> { continue; } let entry = build_enum_entry(e, &content, rel, &cr_re); - enums.insert(e.ident.to_string(), entry); + let key = if module.is_empty() { + e.ident.to_string() + } else { + format!("{module}::{}", e.ident) + }; + // Rust cannot declare two same-named top-level enums in one file, so a + // collision here means the key stopped identifying a declaration. Loud, + // because a silent overwrite is the defect this key shape exists to close. + if let Some(prev) = enums.insert(key.clone(), entry) { + anyhow::bail!("duplicate inventory key {key}: already held {}", prev.file); + } } } } @@ -181,6 +209,23 @@ fn find_workspace_root() -> Result { } } +/// The module path of a source file relative to the walk root: `types/card.rs` → `types::card`, +/// `game/mod.rs` → `game`, `lib.rs` → `` (crate root). +/// +/// Derived from the PATH rather than from `syn`, which is exact here because only top-level +/// `file.items` are catalogued — an enum inside an inline `mod` is not walked at all. +fn module_path(rel_to_root: &Path) -> String { + let mut parts: Vec = rel_to_root + .with_extension("") + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + if let Some("mod" | "lib" | "main") = parts.last().map(String::as_str) { + parts.pop(); + } + parts.join("::") +} + fn is_pub(vis: &syn::Visibility) -> bool { matches!(vis, syn::Visibility::Public(_)) } @@ -223,6 +268,7 @@ fn build_enum_entry(e: &ItemEnum, _source: &str, rel_path: &Path, cr_re: &Regex) let sibling_clusters = detect_clusters(&variants); EnumEntry { + name: e.ident.to_string(), file: rel_path.display().to_string(), line, doc, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index e3d61b7bbd..f97ffcc36b 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -17976,6 +17976,23 @@ mod stage2_injector_tests { files.sort(); assert!(files.len() > 100, "reach-guard: the walker found the crate"); + // COMMENT TEXT IS NOT A CENSUS SITE — this instrument counts CODE, and it had no + // comment rule at all until now. The consequence was measured, not theorised: a doc + // comment that quoted the needle verbatim counted ITSELF, reading 39 against a pin of + // 38, and was worked around by deleting the brace from the quotation (`aa313f122`). + // That repaired one sentence and left the counter broken for the next one. The rule is + // `battlefield_entry_authority_census::code_span`'s, restated in its smallest form + // because a `#[cfg(test)]` module in the lib cannot import from the integration binary: + // drop everything from the first `//` NOT preceded by a `"` on that line, so a `//` + // inside a string literal cannot truncate real code away (fail-closed — the residual + // direction is a spurious extra hit, never a missed one). + fn code_of(line: &str) -> &str { + match line.find("//") { + Some(at) if !line[..at].contains('"') => &line[..at], + _ => line, + } + } + // The needle is ASSEMBLED so this row's own source cannot be counted by its own // instrument. `..` excludes multi-line READ destructures whose rest-pattern sits on // a later line — the inflation the raw grep suffers from. @@ -17997,20 +18014,23 @@ mod stage2_injector_tests { .replace('\\', "/"); let test_file = rel.trim_end_matches(".rs").ends_with("_tests"); for (n, line) in lines.iter().enumerate() { - if !line.contains(&needle) || line.contains("..") { + let code = code_of(line); + if !code.contains(&needle) || code.contains("..") { continue; } if test_file || spans.iter().any(|(a, b)| (*a..=*b).contains(&n)) { in_test += 1; - } else if line.contains("waiting_for = ") - || line.contains("Ok(Some(") + } else if code.contains("waiting_for = ") + || code.contains("Ok(Some(") // `install_direct_choice_frame` owns the actual // `state.waiting_for` write. Its typed prompt argument is // still a production mint, not a reader; the call sits - // within this bounded argument expression. + // within this bounded argument expression. Read through + // `code_of` as well, so prose naming the call cannot + // promote a reader to a producer. || lines[n.saturating_sub(32)..n] .iter() - .any(|prior| prior.contains(".install_direct_choice_frame(")) + .any(|prior| code_of(prior).contains(".install_direct_choice_frame(")) { producers.push(format!("{rel}:{}", n + 1)); } else { @@ -18473,12 +18493,15 @@ mod stage2_injector_tests { // // Producer identity re-established rather than assumed: the line at the new // coordinate is byte-identical to the base's `:12004` and to upstream's `:12003` - // (`return Ok(Some(WaitingFor::OptionalEffectChoice`), and it is still inside - // `begin_pending_trigger_target_selection`. The opening brace is dropped from that - // quotation ON PURPOSE: this census has no comment filter, so quoting the needle - // whole makes the sentence count ITSELF as a site. It did — this line was the - // 39th hit against a pin of 38. The test assembles its own needle with `format!` - // for exactly this reason; prose that names the construct owes the same care. + // (`return Ok(Some(WaitingFor::OptionalEffectChoice {`), and it is still inside + // `begin_pending_trigger_target_selection`. THE OPENING BRACE IS QUOTED WHOLE + // AGAIN, and that is the point: it was dropped as a workaround because the census + // had no comment filter and this sentence counted ITSELF as the 39th hit against + // a pin of 38. Mutilating the prose repaired the sentence, not the counter — the + // next whole quotation anywhere in the crate would have broken it again. The + // counter now excludes comment text, so this line is a comment and is not a site; + // restoring the brace is what makes that repair MEASURED on the real tree rather + // than latent. Under the old rule this exact line reds the census at 39. // // TO BE UNAMBIGUOUS FOR THE NEXT READER: the `+1` in `apply_action`'s // `DecideOptionalEffect` arm is a READER, NOT A SIXTH PRODUCER. It destructures the cloned `state.waiting_for` diff --git a/crates/engine/tests/integration/battlefield_entry_authority_census.rs b/crates/engine/tests/integration/battlefield_entry_authority_census.rs index 316d606c14..366ce01a1a 100644 --- a/crates/engine/tests/integration/battlefield_entry_authority_census.rs +++ b/crates/engine/tests/integration/battlefield_entry_authority_census.rs @@ -580,13 +580,22 @@ struct Hit { /// /// The shared walker for BOTH anchors. Keeping one copy is what guarantees the two pins agree on /// the comment rule, on the brace scan, and on the `cfg_test_scoped_lines` scope resolver. +/// +/// The needle is required in the line's CODE half ([`code_span`]), not merely in the line: a +/// whole-line-only exclusion counts a needle written after a trailing `//` as a construction. +/// Strictly more specific — `code_span` only ever removes comment text, and leaves a `//` that +/// follows a `"` in the code half. fn classify_anchor(src: &str, file: &str, needle: &str, keep: impl Fn(&str) -> bool) -> Vec { let scoped = cfg_test_scoped_lines(src); let lines: Vec<&str> = src.lines().collect(); lines .iter() .enumerate() - .filter(|(_, line)| line.contains(needle) && !line.trim_start().starts_with("//")) + .filter(|(_, line)| !line.trim_start().starts_with("//")) + .filter(|(_, line)| { + let (lo, hi) = code_span(line); + line[lo..hi].contains(needle) + }) .filter_map(|(n, _)| { let body = literal_body(&lines, n, needle); keep(&body).then(|| Hit { @@ -606,8 +615,9 @@ fn classify_anchor(src: &str, file: &str, needle: &str, keep: impl Fn(&str) -> b /// branch is what a spelling-based detector misses: `let from = None;` + `from,` constructs exactly /// the same event. /// -/// The comment-line exclusion (`!line.trim_start().starts_with("//")`) is reused verbatim from -/// `loop_shortcut_offer_writer_census::classify`'s measured rule: a comment writes no event, and a +/// The comment exclusion is shared with `loop_shortcut_offer_writer_census::classify` and +/// `loop_shortcut_seat_pin_census::sites_in_source` — all three now route it through +/// [`code_span`], so the rule is whole-line AND trailing: a comment writes no event, and a /// comment-blind anchor makes the tripwire fire on prose. fn classify(src: &str, file: &str) -> Vec { classify_anchor(src, file, &anchor(), |body| { @@ -753,7 +763,13 @@ fn is_ambiguous_mutator(tail: &str) -> bool { } /// The byte range of `line` that is CODE: a LEADING `/* … */` comment and a TRAILING `//` comment -/// are excluded from the container search. +/// are excluded from the search. +/// +/// THE ONE HOME OF THE TRAILING-COMMENT RULE for every source census in this binary — this +/// file's three anchors, `loop_shortcut_offer_writer_census::classify` and +/// `loop_shortcut_seat_pin_census::sites_in_source`. They previously rejected only lines that +/// OPEN with `//`, which counts a needle sitting after a trailing `//` as a real site; a second +/// copy of the corrected rule is a second place for it to drift. /// /// Both exclusions only ever REMOVE text, and each is guarded so that it cannot remove code: /// @@ -771,7 +787,7 @@ fn is_ambiguous_mutator(tail: &str) -> bool { /// scanned, and would ADD a hit. That direction is fail-CLOSED (spurious red, never a missed /// publish); it is listed in this file's residuals. What is closed here are the two shapes this /// change's own prose is most likely to take. -fn code_span(line: &str) -> (usize, usize) { +pub(super) fn code_span(line: &str) -> (usize, usize) { let trimmed = line.trim_start(); let mut lo = 0usize; if trimmed.starts_with("/*") { @@ -909,13 +925,21 @@ const FN_PREFIX_ALLOW_SET: [&str; 5] = [ /// signature's continuation (`) -> Option<…> {`) carries no bare `fn` token, so it is not collected. /// /// `Err` carries the extend-the-allow-set message for an unrecognised prefix. +/// +/// TOKENS COME FROM THE CODE HALF ([`code_span`]), as everywhere else in this binary: a trailing +/// comment naming `fn` on a column-0 code line would otherwise contribute a bare `fn` token and +/// resolve to an unrecognised prefix — loud rather than silent, but still the wrong answer. The +/// COLUMN-0 test deliberately stays on the ORIGINAL line: `code_span` skips a leading +/// `/* … */`, and testing the trimmed remainder for column 0 would drop a real header that +/// happens to carry one. fn top_level_fn_headers(src: &str) -> Result, String> { let mut out = Vec::new(); for (n, line) in src.lines().enumerate() { if line.starts_with([' ', '\t']) || line.trim_start().starts_with("//") { continue; } - let tokens: Vec<&str> = line.split_whitespace().collect(); + let (lo, hi) = code_span(line); + let tokens: Vec<&str> = line[lo..hi].split_whitespace().collect(); let Some(at) = tokens.iter().position(|token| *token == "fn") else { continue; }; diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index 9a4a1f5a2a..5ccc5cedf3 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -2602,8 +2602,19 @@ fn c1_row7c_the_may_journal_does_not_cross_save_load() { /// fails loudly if a NINTH clear site is added without the journal, which is the actual /// regression this guards. /// -/// Discrimination: delete any one `loop_answer_journal = None;` that follows a ring clear ⇒ -/// the pairing count drops and this row reds naming the file and line. +/// THE WALK IS THE WHOLE CRATE, not a named pair of files. A hard-coded +/// `["game/engine.rs", "types/game_state.rs"]` cannot see a ninth site in any THIRD file: such +/// a site is neither paired nor reported, so `paired == 8` still passes while the regression is +/// live. MEASURED on this tree: the recursive walk finds exactly the 8 sites the named pair did +/// (5 in `game/engine.rs`, 3 in `types/game_state.rs`), so THE COUNT ASSERTION IS BLIND TO THE +/// WIDENING — the planted-third-file probe below is the only thing that measures it. +/// +/// Discrimination, BOTH DIRECTIONS, RUN: +/// * delete any one `loop_answer_journal = None;` that follows a ring clear ⇒ the pairing count +/// drops and this row reds naming the file and line; +/// * add an unpaired `state.loop_detect_ring.clear();` to a THIRD file under +/// `crates/engine/src` ⇒ `unpaired` names that file and this row reds. Under the named-pair +/// walk the identical plant left the row GREEN. #[test] fn c1_every_ring_clear_site_also_clears_the_loop_answer_journal() { use std::path::Path; @@ -2611,17 +2622,36 @@ fn c1_every_ring_clear_site_also_clears_the_loop_answer_journal() { let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut unpaired: Vec = Vec::new(); let mut paired = 0usize; - for rel in ["game/engine.rs", "types/game_state.rs"] { - let path = src.join(rel); + // The walker is the sibling census's, not a second copy: one home for "every `.rs` file + // under a root", already shared by the census rows in this binary. + for path in super::loop_shortcut_offer_writer_census::rs_files(&src) { + let rel = path + .strip_prefix(&src) + .expect("walked path is under src") + .to_string_lossy() + .replace('\\', "/"); let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); let lines: Vec<&str> = text.lines().collect(); + // Both halves read the CODE of a line, never its comment: prose neither clears the ring + // nor clears the journal. Whole-line-only exclusion is not enough here and the failure is + // two-sided — a comment naming the clear would be counted as a site, and a comment naming + // `loop_answer_journal = None` inside a window would mark a genuinely UNPAIRED site as + // paired, which is the direction that hides the regression. Shared rule, one home. + let code = |line: &str| { + let (lo, hi) = super::battlefield_entry_authority_census::code_span(line); + line[lo..hi].to_string() + }; for (i, line) in lines.iter().enumerate() { - if !line.contains("loop_detect_ring.clear()") { + if !code(line).contains("loop_detect_ring.clear()") { continue; } // The journal assignment sits within the same block, immediately after the ring // clear (a comment line may separate them). - let window = lines[i + 1..(i + 5).min(lines.len())].join("\n"); + let window = lines[i + 1..(i + 5).min(lines.len())] + .iter() + .map(|l| code(l)) + .collect::>() + .join("\n"); if window.contains("loop_answer_journal = None") { paired += 1; } else { @@ -2637,9 +2667,10 @@ fn c1_every_ring_clear_site_also_clears_the_loop_answer_journal() { ); assert_eq!( paired, 8, - "the ring has EIGHT production clear sites (5 in game/engine.rs, 3 in \ - types/game_state.rs). A different count means a site was added or removed and this \ - census must be re-derived, not re-numbered" + "the ring has EIGHT production clear sites across the whole of `crates/engine/src` \ + (5 in game/engine.rs, 3 in types/game_state.rs; MEASURED by this recursive walk). A \ + different count means a site was added or removed and this census must be re-derived, \ + not re-numbered" ); } diff --git a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs index 3e90759a4e..98a9926889 100644 --- a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs @@ -98,6 +98,8 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use super::battlefield_entry_authority_census::code_span; + /// The bare anchor, ASSEMBLED AT RUNTIME. /// /// This file lives under `crates/engine/tests/`, which the census does not walk, @@ -123,6 +125,9 @@ struct Hit { file: String, line: usize, in_test: bool, + /// The trimmed source line, so a hit can be selected by ROLE (definition vs. consumer) + /// instead of by line ORDER. Mirrors the sibling seat-pin census's `Site::text`. + text: String, } /// CR-neutral source classification: which lines of `src` sit inside a @@ -194,15 +199,28 @@ pub(super) fn cfg_test_scoped_lines(src: &str) -> Vec { /// of 12: that half has since been adjudicated repeatedly, and the assert /// below is the authority for the pair. Prose that repeats a number is prose that /// can go stale — this defers to the assert rather than restating it.) +/// THE COMMENT RULE IS THE CODE HALF OF THE LINE, not the whole line. Rejecting only lines that +/// OPEN with `//` left a needle sitting AFTER a trailing `//` counted as a writer, which breaks +/// the exclusion in both directions: a pure prose edit moves the pinned number with no code +/// change, and deleting a real writer while naming the same spelling in a trailing comment on a +/// surviving line HOLDS the number — the substitution class this census exists to catch. +/// [`super::battlefield_entry_authority_census::code_span`] is the one home of that rule; it is +/// fail-CLOSED (a `//` preceded by a `"` on the same line is left in the code half, so a URL in +/// a string literal cannot hide a real writer behind it). fn classify(src: &str, needle: &str, file: &str) -> Vec { let scoped = cfg_test_scoped_lines(src); src.lines() .enumerate() - .filter(|(_, line)| line.contains(needle) && !line.trim_start().starts_with("//")) - .map(|(n, _)| Hit { + .filter(|(_, line)| !line.trim_start().starts_with("//")) + .filter(|(_, line)| { + let (lo, hi) = code_span(line); + line[lo..hi].contains(needle) + }) + .map(|(n, line)| Hit { file: file.to_string(), line: n + 1, in_test: scoped[n], + text: line.trim().to_string(), }) .collect() } @@ -434,11 +452,27 @@ fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_valid // The one consumer runs the COVERAGE half too, asserted the way the old // per-call-site rule did: a `predictability_gate` hit within two lines. + // + // THE CONSUMER IS SELECTED BY ROLE, NOT BY LINE ORDER. `max_by_key(|h| h.line)` picked the + // consumer only while the definition happened to sit ABOVE it; moving `pub fn validate_pins` + // below `declaration_conforms` — a legal refactor this census has no business objecting to — + // silently made the coverage assertion below check the DEFINITION line instead, i.e. measure + // the wrong thing and report an unrelated failure. The definition is the hit whose text + // declares the fn; the consumer is the other one, and both counts are asserted so a shape + // this rule cannot classify fails loudly instead of defaulting. let gates = census("predictability_gate("); - let consumer = pins_production + let (definitions, consumers): (Vec<&&Hit>, Vec<&&Hit>) = pins_production .iter() - .max_by_key(|h| h.line) - .expect("the assert above proves two hits"); + .partition(|h| h.text.contains("fn validate_pins(")); + assert_eq!( + (definitions.len(), consumers.len()), + (1, 1), + "the two production `validate_pins(` hits must split by ROLE into exactly one \ + DEFINITION (`fn validate_pins(`) and exactly one CONSUMER. Anything else means the \ + role rule stopped classifying this surface and the coverage assertion below would be \ + measuring an unknown hit. definitions: {definitions:?}; consumers: {consumers:?}" + ); + let consumer = consumers[0]; assert!( gates .iter() diff --git a/crates/engine/tests/integration/loop_shortcut_ranking.rs b/crates/engine/tests/integration/loop_shortcut_ranking.rs index 5bb782a8bc..603aca5042 100644 --- a/crates/engine/tests/integration/loop_shortcut_ranking.rs +++ b/crates/engine/tests/integration/loop_shortcut_ranking.rs @@ -284,11 +284,14 @@ pub(super) fn grid_template( /// The contrast this carrier lives inside is "the template survives, the answer journal does /// not". The journal half is asserted by /// `fantastic_four_bounded_loop::r3a_the_accepted_drive_ends_at_the_priority_point_with_the_window_cleared` -/// — with the `> 0` reach-guard that makes it non-vacuous — and NOT here, because -/// `GameState::loop_answer_journal` and its single writer `record_loop_answer` are -/// `pub(crate)`: a board this file can build never populates the journal, so a -/// `loop_answers_recorded() == 0` assertion here would be a vacuous negative with no reachable -/// paired positive. +/// — with the `> 0` reach-guard that makes it non-vacuous — and NOT here. THE REASON IS THE +/// BOARD, NOT VISIBILITY, and the distinction matters to whoever reads this next: the READER +/// accessors are `pub` and reachable from this binary — `natural_balance.rs` calls +/// `runner.state().loop_answers_recorded()` (`:648`) and `runner.state().loop_answer(..)` +/// (`:654`). Only the WRITER, `GameState::record_loop_answer`, is `pub(crate)`, and the journal +/// field with it. What makes an assertion here vacuous is that this file's synthetic board +/// drives no answer through `apply()` at all, so `loop_answers_recorded() == 0` would be a +/// negative with no reachable paired positive — not an unreachable accessor. /// /// # The hostile arm is MULTI-AUTHORITY, and it says so structurally /// diff --git a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs index 0a2cb1896f..e2c5155f94 100644 --- a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs @@ -45,6 +45,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; +use super::battlefield_entry_authority_census::code_span; use super::loop_shortcut_offer_writer_census::{cfg_test_scoped_lines, rs_files}; /// The CHOICE-class needle, ASSEMBLED AT RUNTIME for the same reason the sibling census @@ -86,11 +87,23 @@ struct Site { /// `TargetRef::Object(id) => Some(TargetRef::Object(*id))` match-arm-plus-construction shape a /// producer revert would take. /// -/// COMMENT LINES ARE EXCLUDED, and this is the same deviation the sibling census records: prose -/// writes no pin and reads none, so a doc mentioning a spelling is not a construction site. The -/// doc surface is swept separately (the commit's per-property bucket table); counting it here -/// would make the tripwire fire on prose. `//!`, `///` and `//` are all excluded; a trailing -/// comment on a code line still counts, because the CODE on that line is real. +/// COMMENT TEXT IS EXCLUDED — whole-line AND trailing, which is the half this rule got wrong. +/// Prose writes no pin and reads none, so a doc mentioning a spelling is not a construction site; +/// the doc surface is swept separately (the commit's per-property bucket table). Rejecting only +/// lines that OPEN with `//` still counted every occurrence after a trailing `//`, and that fails +/// in BOTH directions: a pure prose edit moves the pinned multiset with no code change, and +/// deleting one construction while naming the same spelling in a trailing comment on a surviving +/// construction line HOLDS the count — the substitution class conjunct 1 exists to catch. +/// Occurrences are therefore counted in the line's CODE half only, via +/// [`super::battlefield_entry_authority_census::code_span`] (the one home of the rule, shared +/// with both sibling censuses). It is fail-CLOSED: a `//` preceded by a `"` on the same line — +/// `let u = "http://x"; ..` — stays in the code half, so a URL in a string literal cannot hide a +/// real construction behind it, which a naive `split("//")` would. +/// +/// MEASURED at the time of the fix: no line in any walked root carries a needle after a trailing +/// `//`, so this repaired NO pinned number. The repo-scanning rows are therefore blind to it and +/// [`the_seat_pin_census_ignores_a_trailing_comment_mention`] — synthetic input, both directions +/// — is the only thing that measures the rule. fn sites_in_source(src: &str, needle: &str, file: &str) -> Vec { let scoped = cfg_test_scoped_lines(src); let mut out = Vec::new(); @@ -98,7 +111,8 @@ fn sites_in_source(src: &str, needle: &str, file: &str) -> Vec { if line.trim_start().starts_with("//") || scoped[n] { continue; } - for _ in 0..line.matches(needle).count() { + let (lo, hi) = code_span(line); + for _ in 0..line[lo..hi].matches(needle).count() { out.push(Site { file: file.to_string(), line: n + 1, @@ -361,6 +375,71 @@ fn the_seat_pin_census_instrument_reports_both_answers_on_planted_input() { ); } +/// TRAILING-COMMENT ARM — a needle mention after `//` on a REAL CODE LINE neither inflates the +/// count nor masks a deleted construction. +/// +/// SYNTHETIC INPUT BY NECESSITY, not by preference. Measured when this rule was repaired: no line +/// in any of the three walked roots carries a needle after a trailing `//`, so every row that +/// scans the real tree passes byte-identically with the rule and without it. A repo-scanning +/// assertion for this property is vacuous BY CONSTRUCTION; only planted input can separate the +/// two rules. [`the_seat_pin_census_instrument_reports_both_answers_on_planted_input`] plants its +/// prose on its OWN line, which the whole-line filter already rejected, so it never reached this +/// path. +/// +/// # The three arms, and what each one would be under the old rule +/// +/// 1. SUBSTITUTION, the arm that matters and therefore the one asserted FIRST — the construction +/// is DELETED and the trailing-comment mention survives: 0, not 1. Under the old rule the +/// census counted a removed construction as still present, which is exactly the substitution +/// class conjunct 1 exists to catch. MEASURED by revert-probe: restoring +/// `line.matches(needle)` makes this arm fail `left: 1 / right: 0`. +/// 2. INFLATION — one real construction plus a mention of the same spelling in a trailing +/// comment on that same line: 1, not 2. Under the old rule a pure PROSE edit moved the pinned +/// multiset with no code change (revert-probe: `left: 2 / right: 1`). +/// 3. FAIL-CLOSED — a `//` inside a STRING LITERAL preceding a real construction: 1, not 0. A +/// naive `split("//")` truncates there and UNDER-counts, silently dropping a real site; +/// [`code_span`] leaves a `//` that follows a `"` in the code half, so the miss cannot happen. +#[test] +fn the_seat_pin_census_ignores_a_trailing_comment_mention() { + let choice = choice_needle(); + let count = |src: &str| sites_in_source(src, &choice, "planted.rs").len(); + + // FIRST, because it is the arm that matters: a revert-probe must show THIS one failing, not + // merely the cheaper inflation arm that would short-circuit ahead of it. + let substitution = format!("fn f() {{\n let a = 0; // was {choice}PlayerId(0))\n}}\n"); + assert_eq!( + count(&substitution), + 0, + "THE SUBSTITUTION ARM: the construction is gone and only a trailing-comment mention \ + survives, so the count must fall to 0. Counting the whole line makes this 1 — a \ + DELETED producer reported as still present, which is the failure conjunct 1 exists to \ + catch.\nsrc:\n{substitution}" + ); + + let inflation = + format!("fn f() {{\n let a = {choice}PlayerId(0)); // also {choice}PlayerId(9))\n}}\n"); + assert_eq!( + count(&inflation), + 1, + "a needle in a TRAILING comment is prose: the code half of this line holds exactly ONE \ + construction. Counting the whole line makes this 2, and a pure prose edit then moves \ + the pinned multiset with no code change.\nsrc:\n{inflation}" + ); + + let in_string = + format!("fn f() {{\n let u = \"http://x\";\n let a = {choice}PlayerId(0));\n}}\n"); + let in_string_one_line = + format!("fn f() {{\n let u = \"http://x\"; let a = {choice}PlayerId(0));\n}}\n"); + assert_eq!( + (count(&in_string), count(&in_string_one_line)), + (1, 1), + "FAIL-CLOSED: a `//` inside a string literal is not a comment opener. A naive \ + `split(\"//\")` truncates at the URL and reports 0 for the one-line form — a real \ + construction silently dropped, the one direction a census must never fail in.\n\ + src:\n{in_string_one_line}" + ); +} + /// S279 INSTRUMENT — the census counts OCCURRENCES, and the two rules are separated on input /// that distinguishes them. /// diff --git a/crates/engine/tests/integration/natural_balance.rs b/crates/engine/tests/integration/natural_balance.rs index 8a18eba9b7..8fde3b6bce 100644 --- a/crates/engine/tests/integration/natural_balance.rs +++ b/crates/engine/tests/integration/natural_balance.rs @@ -498,8 +498,10 @@ fn natural_balance_collects_two_local_x_searches_before_one_shuffle_each() { /// /// **NOT CLAIMED:** that the seat component changes an OFFER. That additionally requires /// the publisher to publish a `MayChoice` point for this source in a bounded window, which -/// this board does not do — MEASURED here, as the final assertion: no -/// `WaitingFor::LoopShortcut` is minted anywhere in this drive. The offer-level claim is +/// this board does not do. WHAT IS MEASURED IS THE TERMINAL STATE AND ONLY THAT: the final +/// assertion reads `runner.state().waiting_for` once, after the drive, so it says the drive does +/// not END on a `WaitingFor::LoopShortcut`. It CANNOT exclude an offer minted and cleared at an +/// earlier beat — no intermediate beat is sampled. The offer-level claim is /// therefore unmeasured in either direction and this row does not make it. The pair key is /// DEFENSE IN DEPTH PLUS A CODE DELETION, not a live-bug fix: the pin injector already /// aborts a replay whose prompt recipient differs from the template owner, so a @@ -667,10 +669,12 @@ fn natural_balance_two_scoped_seats_journal_one_may_source_under_two_independent Conflicted over the first" ); - // ── the offer-mint non-claim, measured rather than asserted in prose ── + // ── the offer-mint non-claim, measured rather than asserted in prose. TERMINAL STATE ONLY: + // one read of `waiting_for` after the drive. It cannot exclude an offer minted and cleared + // at an earlier beat, and the rustdoc's non-claim is scoped to match. ── assert!( !matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }), - "this board journals two seats but publishes no CR 732.2a offer, which is why this \ - row's claim stops at the journal" + "this board journals two seats and does not END on a CR 732.2a offer, which is why \ + this row's claim stops at the journal" ); } From 3fab647c3a89de94da64c600f0fceeaaacaacb07 Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 14:14:23 -0500 Subject: [PATCH 32/44] feat(client): let the player choose a bounded loop-shortcut's count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine already computed, clamped, published and delivered the count window, the decline permission and the cross-axis preview; nothing rendered any of it. This is the display half. - wire `AmountInput` into `DeclareShortcutModal`, bounded by the engine's published `{min, max, suggested}` — no client-side bound, default or clamp - dispatch the player's chosen count instead of echoing `iteration_count` - render the engine's preview verbatim; the component never recomputes it - honour `allow_decline` instead of rendering Decline unconditionally (BL-1) - reuse `badges.unbounded*` for the preview's family labels rather than minting a parallel 11-key catalog in 7 locales; the two family types are literal-identical (11 = 11, verified both directions) and `tsc` enforces it - 5 new `comboShortcut.*` keys in all 7 locales, in this commit, per the parity gate The module header previously claimed pin-capture was "deferred to an engine-side assembly". That is false: the assembly landed earlier in this lane. It now records the measured limit instead — a `template: null` declaration is refused for a point-carrying schema unless the proposer owns the recorded loop period (`game/engine.rs:6046`, inside the `!schema.points.is_empty()` arm at `:6004`), so point-free drain offers still declare. The end-to-end path for point-carrying offers depends on an engine-side repair that is not in this commit; nothing here asserts the payload is accepted. Assisted-by: ClaudeCode:claude-opus-5 --- client/src/components/hud/HudBadges.tsx | 5 +- .../components/modal/LoopShortcutModal.tsx | 176 ++++++++++++++--- .../__tests__/LoopShortcutModal.test.tsx | 181 +++++++++++++++++- client/src/i18n/locales/de/game.json | 5 + client/src/i18n/locales/en/game.json | 5 + client/src/i18n/locales/es/game.json | 5 + client/src/i18n/locales/fr/game.json | 5 + client/src/i18n/locales/it/game.json | 5 + client/src/i18n/locales/pl/game.json | 5 + client/src/i18n/locales/pt/game.json | 5 + 10 files changed, 366 insertions(+), 31 deletions(-) diff --git a/client/src/components/hud/HudBadges.tsx b/client/src/components/hud/HudBadges.tsx index dd4718dfd0..d829e80a12 100644 --- a/client/src/components/hud/HudBadges.tsx +++ b/client/src/components/hud/HudBadges.tsx @@ -444,7 +444,10 @@ const UNBOUNDED_FAMILY_GLYPH: Record = { triggers: "✴", }; -const UNBOUNDED_FAMILY_LABEL_KEY: Record = { +/** Exported for `LoopShortcutModal`'s preview lines: the engine's + * `InteractionShortcutPreviewFamily` is the same 11 literals as `UnboundedFamily`, so the preview + * reuses these labels instead of minting a parallel 11-key catalog in 7 locales. */ +export const UNBOUNDED_FAMILY_LABEL_KEY: Record = { mana: "badges.unboundedMana", life: "badges.unboundedLife", damage: "badges.unboundedDamage", diff --git a/client/src/components/modal/LoopShortcutModal.tsx b/client/src/components/modal/LoopShortcutModal.tsx index bd86211cec..2c8760ec9f 100644 --- a/client/src/components/modal/LoopShortcutModal.tsx +++ b/client/src/components/modal/LoopShortcutModal.tsx @@ -1,23 +1,51 @@ -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import type { IterationCount, ResourceAxis, WinKind } from "../../adapter/types.ts"; +import type { + InteractionResponseSpec, + InteractionShortcutPreview, + ViewerInteraction, +} from "../../adapter/generated/interaction"; +import type { IterationCount, ResourceAxis, WaitingFor, WinKind } from "../../adapter/types.ts"; import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts"; import { useGameStore } from "../../stores/gameStore.ts"; -import { familyOf, UnboundedBadge } from "../hud/HudBadges.tsx"; +import { familyOf, UNBOUNDED_FAMILY_LABEL_KEY, UnboundedBadge } from "../hud/HudBadges.tsx"; +import { AmountInput, parseAmount } from "../mana/AmountInput.tsx"; import { DialogShell } from "./DialogShell.tsx"; /** * CR 732.2a/b/c: the interactive loop-shortcut declare + accept-or-shorten * modals. Pure display layer — every rendered value is a direct read of an - * engine schema/proposal field; the frontend derives, filters, and computes - * nothing. `DeclareShortcut.template` is always `null` (per-iteration pin-capture - * is deferred to an engine-side assembly), and the engine remains the sole - * legality authority (`predictability_gate` + `validate_pins`). + * engine schema/proposal/response-spec field; the frontend derives, filters, and + * computes nothing. `DeclareShortcut.template` is always `null`: building pins is not a + * client authority, and the engine remains the sole legality authority + * (`predictability_gate` + `validate_pins`). + * + * MEASURED LIMIT, stated rather than assumed — `null` is what the client can honestly send, + * NOT a payload the engine accepts everywhere. `handle_declare_shortcut` + * (`game/engine.rs`, the `!offer.schema.points.is_empty()` block) REFUSES a `template: null` + * declaration unless the proposer controls the recorded loop period, so only the point-free + * drain shape declares successfully at this base. Carrying the engine's own issued + * declaration through the manual declare path is an ENGINE-side repair; a client that + * reconstructed a template would be inventing rules authority it does not have. */ -// CR 732.1b: render the engine-proposed repeat mode. The count is echoed from the -// schema/proposal verbatim — never chosen or computed here. +/** The engine's published shortcut response spec — the count window, `allowDecline`, and the + * engine-computed preview. A lookup into what the engine already sent, never a derivation. */ +type ShortcutSpec = Extract["data"]; + +function shortcutSpec(interaction: ViewerInteraction | null): ShortcutSpec | null { + for (const opportunity of interaction?.opportunities ?? []) { + if (opportunity.response.type !== "schema") continue; + const { spec } = opportunity.response.data; + if (spec.type === "shortcut") return spec.data; + } + return null; +} + +// CR 732.1b: render the engine-proposed repeat mode — the offer's own stated count, echoed +// verbatim. The picker below narrows WITHIN the engine's published window; this line is the +// offer, not the pick. function CountLine({ count }: { count: IterationCount }) { const { t } = useTranslation("game"); return ( @@ -49,6 +77,43 @@ function FamilyBadges({ axes }: { axes: ResourceAxis[] }) { ); } +/** + * CR 732.2a: what the offer's stated count actually DOES, per axis. Every magnitude is read + * straight off the engine's published preview — already multiplied, already signed. The heading + * names `preview.count` because these numbers describe that count and no other, so a player can + * never read them against a different one; the display layer multiplies nothing. + */ +function PreviewLines({ preview }: { preview: InteractionShortcutPreview }) { + const { t } = useTranslation("game"); + if (preview.entries.length === 0) return null; + return ( +
+

+ {t("comboShortcut.previewTitle", { count: preview.count })} +

+ {preview.entries.map((entry, index) => ( +

+ {entry.player === null + ? t("comboShortcut.previewEntry", { + amount: entry.amount, + resource: t(UNBOUNDED_FAMILY_LABEL_KEY[entry.family]), + }) + : t("comboShortcut.previewEntryPlayer", { + amount: entry.amount, + resource: t(UNBOUNDED_FAMILY_LABEL_KEY[entry.family]), + // Seat display numbering, the same +1 formatting `LifeTotal` uses on the engine's + // seat id. Formatting, not derivation. + player: t("lifeTotal.playerLabel", { seat: entry.player + 1 }), + })} +

+ ))} +
+ ); +} + /** * CR 732.2a: the priority holder (the proposer) may declare the shortcut OR decline it — * "the player with priority may suggest a shortcut" is @@ -57,29 +122,63 @@ function FamilyBadges({ axes }: { axes: ResourceAxis[] }) { * `RespondToShortcutModal`. */ export function DeclareShortcutModal() { - const { t } = useTranslation("game"); const canAct = useCanActForWaitingState(); const waitingFor = useGameStore((s) => s.waitingFor); + // `shortcutSpec` returns a reference INTO store state (or null), so the selector is stable. + const spec = useGameStore((s) => shortcutSpec(s.viewerInteraction)); + + if (waitingFor?.type !== "LoopShortcut" || !canAct) return null; + + // The offer body is mounted only while the offer is live, so the picker's entry cannot survive + // into a later offer: this component itself never unmounts (GamePage keeps both modals mounted + // and they self-gate), which is exactly how a stale typed count would otherwise leak. + return ; +} + +function DeclareShortcutOffer({ + data, + spec, +}: { + data: Extract["data"]; + spec: ShortcutSpec | null; +}) { + const { t } = useTranslation("game"); const dispatch = useGameStore((s) => s.dispatch); + const { certificate, schema } = data; + + // CR 732.2a: the count window is ENGINE-OWNED. `null` when this offer publishes no finite + // window (UntilLethal) or when the transport published no interaction projection at all — in + // both cases no picker renders and the offer's own count is declared verbatim, as before. + const countSpec = spec?.count.type === "fixed" ? spec.count.data : null; + // No client-side default: until the player types, the box shows the ENGINE's suggested count. + const [picked, setPicked] = useState(null); + const raw = picked ?? (countSpec === null ? "" : String(countSpec.suggested)); + // `parseAmount` is the shared sanitization authority — it REJECTS out-of-window entries rather + // than clamping, so a count the engine did not offer can never be declared. + const chosen = countSpec === null ? null : parseAmount(raw, countSpec.min, countSpec.max); + const confirmDisabled = countSpec !== null && chosen === null; const handleConfirm = useCallback(() => { - if (waitingFor?.type !== "LoopShortcut") return; - // Echo the engine-proposed iteration_count verbatim; pin-capture is deferred, - // so `template` is always null (matches every live engine + AI declare path). - dispatch({ - type: "DeclareShortcut", - data: { count: waitingFor.data.schema.iteration_count, template: null }, - }); - }, [waitingFor, dispatch]); + // `template: null` is unchanged by C5 (see the module header's measured limit) — the picker + // moves the COUNT only. + if (countSpec === null) { + dispatch({ + type: "DeclareShortcut", + data: { count: schema.iteration_count, template: null }, + }); + return; + } + // Refused entry ⇒ submit nothing. The guard lives here once (AmountInput deliberately does + // not re-guard), and the confirm button is disabled in the same state. + if (chosen === null) return; + dispatch({ type: "DeclareShortcut", data: { count: { Fixed: chosen }, template: null } }); + }, [dispatch, countSpec, chosen, schema.iteration_count]); const handleDecline = useCallback(() => { // CR 732.2a: decline the auto-offer; the engine restores ordinary priority. dispatch({ type: "DeclineShortcut" }); }, [dispatch]); - if (waitingFor?.type !== "LoopShortcut" || !canAct) return null; - - const { certificate, schema } = waitingFor.data; // CR 702.51a: engine-computed count of untapped creatures the engine will auto-tap // for convoke — read directly from the schema (the engine owns the derivation). const convokeTappable = schema.convoke_tappable_count; @@ -88,16 +187,22 @@ export function DeclareShortcutModal() {
- + {/* CR 732.2a: declining is offered only when the engine says this offer may be declined. */} + {(spec?.allowDecline ?? true) && ( + + )}
); @@ -111,6 +216,21 @@ export function DeclareShortcutModal() {
+ {countSpec && ( + + )} + {spec?.preview && } {convokeTappable > 0 && (

diff --git a/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx b/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx index 4bed27913e..84c0bb7ad5 100644 --- a/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx +++ b/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx @@ -1,6 +1,11 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + InteractionId, + InteractionResponseSpec, + ViewerInteraction, +} from "../../../adapter/generated/interaction"; import type { DecisionPoint, GameState, @@ -16,6 +21,41 @@ import { DeclareShortcutModal, RespondToShortcutModal } from "../LoopShortcutMod const dispatchMock = vi.fn(); +type ShortcutSpec = Extract["data"]; + +/** The engine's published shortcut response spec, delivered on `viewerInteraction` exactly as + * `gameStore.legalResultState` assigns it. Defaults mirror the live publisher + * (`game/interaction.rs`): a Fixed window and `allow_decline: true`. */ +function shortcutInteraction(overrides: Partial = {}): ViewerInteraction { + const spec: ShortcutSpec = { + count: { type: "fixed", data: { min: 1, max: 5, suggested: 5 } }, + points: [], + allowDecline: true, + preview: null, + confirm: "explicit", + ...overrides, + }; + return { + waitingForKind: { simultaneous: null, terminal: false, code: "shortcut" }, + authorizedSubmitters: [0], + canSubmit: true, + autoPassRecommended: false, + opportunities: [ + { + interactionId: "session.0.1" as InteractionId, + response: { + type: "schema", + data: { spec: { type: "shortcut", data: spec }, candidates: [] }, + }, + surfaces: [], + progress: { selected: 0, minimum: 1, maximum: 1, aggregate: null, confirmable: false }, + }, + ], + attachmentFans: {}, + availability: { type: "inputRequired" }, + }; +} + // A ConvokeTaps decision-point with two tappable creatures (informational — the // engine auto-taps via select_convoke_taps; the modal renders it read-only). const convokePoint: DecisionPoint = { @@ -23,14 +63,20 @@ const convokePoint: DecisionPoint = { kind: { ConvokeTaps: { tappable: [40, 41] } }, }; -function seed(waitingFor: WaitingFor, overrides: Partial = {}) { +// `viewerInteraction` is ALWAYS written (null by default): `setGameStoreForTest` merges into a +// module-level store, so an unset field would leak a previous test's published spec forward. +function seed( + waitingFor: WaitingFor, + overrides: Partial = {}, + viewerInteraction: ViewerInteraction | null = null, +) { const gameState = buildGameState({ objects: {}, priority_player: 0, waiting_for: waitingFor, ...overrides, }); - setGameStoreForTest({ gameState, waitingFor, dispatch: dispatchMock }); + setGameStoreForTest({ gameState, waitingFor, dispatch: dispatchMock, viewerInteraction }); } describe("LoopShortcutModal", () => { @@ -133,6 +179,137 @@ describe("LoopShortcutModal", () => { expect(dispatchMock).toHaveBeenCalledWith({ type: "DeclineShortcut" }); }); + // C5a: the picker declares the count the PLAYER picked. Discriminating by construction — the + // pre-C5 dispatch echoed `schema.iteration_count` ({Fixed:5}), and 2 is neither that, nor the + // engine's `suggested` (5), nor either window edge (1/5), so no hardcoded value satisfies it. + it("declares the picked count, not the engine's suggestion (C5a)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } }), + {}, + shortcutInteraction(), + ); + render(); + + // Opens on the ENGINE's suggested count — the frontend holds no default. + const box = screen.getByRole("spinbutton"); + expect(box).toHaveValue("5"); + + fireEvent.change(box, { target: { value: "2" } }); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + // COUNT ONLY, deliberately. `template` is asserted nowhere in the C5 rows: the engine refuses + // a `template: null` declaration on a point-carrying schema (module header), so pinning the + // whole payload here would codify a payload the engine does not accept as the end state. + expect(dispatchMock).toHaveBeenCalledWith({ + type: "DeclareShortcut", + data: expect.objectContaining({ count: { Fixed: 2 } }), + }); + }); + + // C5a bounds: the window is engine-owned. The steppers stop at the published max, and an entry + // outside [min,max] declares NOTHING. The final legal entry is the paired positive reach-guard — + // without it "never dispatched" could pass on a modal that renders no working control at all. + it("steps inside the engine window and refuses an entry outside it (C5a bounds)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 3 } } }), + {}, + shortcutInteraction({ count: { type: "fixed", data: { min: 1, max: 3, suggested: 2 } } }), + ); + render(); + + const box = screen.getByRole("spinbutton"); + fireEvent.click(screen.getByRole("button", { name: "Increase amount" })); + expect(box).toHaveValue("3"); + expect(screen.getByRole("button", { name: "Increase amount" })).toBeDisabled(); + + fireEvent.change(box, { target: { value: "9" } }); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + expect(dispatchMock).not.toHaveBeenCalled(); + + fireEvent.change(box, { target: { value: "1" } }); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + expect(dispatchMock).toHaveBeenCalledWith({ + type: "DeclareShortcut", + data: expect.objectContaining({ count: { Fixed: 1 } }), + }); + }); + + // C5a negative: a window absent from the payload renders NO picker and never invents a + // client-chosen count — the offer's own `iteration_count` is declared verbatim. Both absent + // shapes are covered: no interaction projection at all, and an UntilLethal offer. + it("renders no picker without a published window (C5a negative)", () => { + seed(buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } })); + render(); + + expect(screen.queryByRole("spinbutton")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + expect(dispatchMock).toHaveBeenCalledWith({ + type: "DeclareShortcut", + data: expect.objectContaining({ count: { Fixed: 5 } }), + }); + cleanup(); + dispatchMock.mockReset(); + + seed( + buildLoopShortcutWaitingFor(), + {}, + shortcutInteraction({ count: { type: "untilLethal" } }), + ); + render(); + + expect(screen.queryByRole("spinbutton")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Take the shortcut" })); + expect(dispatchMock).toHaveBeenCalledWith({ + type: "DeclareShortcut", + data: expect.objectContaining({ count: "UntilLethal" }), + }); + }); + + // BL-1 (CR 732.2a), BOTH arms: Decline is offered iff the engine's `allowDecline` says so. The + // false arm asserts Confirm is still present, so "no Decline button" cannot pass by the modal + // having failed to render. + it("renders Decline only when the engine allows it (BL-1)", () => { + seed(buildLoopShortcutWaitingFor(), {}, shortcutInteraction({ allowDecline: true })); + render(); + expect(screen.getByRole("button", { name: "Decline the shortcut" })).toBeInTheDocument(); + cleanup(); + + seed(buildLoopShortcutWaitingFor(), {}, shortcutInteraction({ allowDecline: false })); + render(); + expect(screen.queryByRole("button", { name: "Decline the shortcut" })).toBeNull(); + expect(screen.getByRole("button", { name: "Take the shortcut" })).toBeInTheDocument(); + }); + + // C4 render path: the preview's magnitudes are the ENGINE's, already multiplied and signed, and + // headed by the count they describe. The recompute-guard is the discriminator: moving the picker + // to 2 must leave every number untouched — a component that rescaled the preview to the picked + // count (or that recomputed it at all) fails here. + it("renders the engine preview verbatim and never rescales it (C4 render)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 4 } } }), + {}, + shortcutInteraction({ + count: { type: "fixed", data: { min: 1, max: 4, suggested: 4 } }, + preview: { + count: 4, + entries: [ + { family: "life", player: 1, amount: -40 }, + { family: "mana", player: null, amount: 12 }, + ], + }, + }), + ); + render(); + + expect(screen.getByText("Repeating 4 times produces:")).toBeInTheDocument(); + expect(screen.getByText("-40 life — P2")).toBeInTheDocument(); + expect(screen.getByText("12 mana")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("spinbutton"), { target: { value: "2" } }); + expect(screen.getByText("Repeating 4 times produces:")).toBeInTheDocument(); + expect(screen.getByText("-40 life — P2")).toBeInTheDocument(); + expect(screen.getByText("12 mana")).toBeInTheDocument(); + }); + // T4: the respond window renders the proposal and Accept dispatches Accept. it("renders the proposal and dispatches Accept (T4)", () => { seed(buildRespondToShortcutWaitingFor()); diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index f229ed6115..f38d465729 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index 7d956367c5..0024802419 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -26,6 +26,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index d4cffd20d0..3dc2d1c128 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index 04613d4886..8058d01020 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 5b5a940a57..2d5409f1dc 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 78993022ba..1c0ca16756 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 8bf35ad432..576a31dedf 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -20,6 +20,11 @@ "fixedCount_other": "Repeat at most {{count}} times.", "convokeInfo_one": "Auto-taps up to 1 creature for convoke each iteration.", "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", + "countAria": "Number of iterations", + "previewTitle_one": "Repeating once produces:", + "previewTitle_other": "Repeating {{count}} times produces:", + "previewEntry": "{{amount}} {{resource}}", + "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", "winKind": { "LethalDamage": "This loop deals lethal damage.", "PoisonLoss": "This loop gives lethal poison counters.", From 2f325071efc4f811668d92e3ed07d45928a6f058 Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 16:01:08 -0500 Subject: [PATCH 33/44] refactor(engine-tests): one authority for "which part of this line is code" Every guard test that counts needles in Rust source carried its own comment policy. Most skipped a line only when the whole line was `//`, so a needle in a TRAILING comment counted as a construction: prose could move a pinned number with no code change, and deleting a construction while mentioning its spelling in a trailing comment held the count -- the substitution class those censuses exist to catch. `src/source_census.rs` is now the single implementation, `#![cfg(test)]` so it never ships. Both venues compile the same file: `mod source_census;` from the lib, `#[path = "../../src/source_census.rs"]` from the integration target. A second copy for the integration cohort was considered unavoidable and is not -- `#![cfg(test)]` holds in an integration target too, so the earlier `code_span` moved in rather than remaining a sibling. Seven counters routed, not the six first surveyed: `game/engine.rs` holds four, not one. A routing census asserts no counter reopens a private policy. It matches on `source_census::`, not the bare name: `source_census` is an existing unrelated engine identifier (`ability_rw.rs`'s `SourceCensus`, its `source_census` field, and `source_census_overlaps_filter`), so a bare-name marker would have handed those files a false pass -- the census would have reported green over exactly the shape it exists to detect. `call_tail` is deliberately NOT routed, and the measurement is recorded at the site: routing it flipped the battlefield census's arm 5(vi) from (1,1) to (0,0) by reading through a block comment, breaking that arm's fail-closed contract. It classifies a tail and never counts, so the code/comment split is not its question. No pinned number moves: full lib 19019 passed, full integration 4944 passed, clippy --all-targets -D warnings clean, probe-pin check RC 0. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/analysis/resource.rs | 5 +- crates/engine/src/game/engine.rs | 42 +-- .../src/game/engine_resolution_choices.rs | 12 +- crates/engine/src/game/filter.rs | 8 +- crates/engine/src/lib.rs | 6 + crates/engine/src/source_census.rs | 320 ++++++++++++++++++ .../battlefield_entry_authority_census.rs | 78 ++--- .../fantastic_four_bounded_loop.rs | 8 +- .../loop_shortcut_offer_writer_census.rs | 10 +- .../loop_shortcut_seat_pin_census.rs | 11 +- crates/engine/tests/integration/main.rs | 9 + 11 files changed, 396 insertions(+), 113 deletions(-) create mode 100644 crates/engine/src/source_census.rs diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 3e257335c3..73b5bd5f69 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -15141,9 +15141,10 @@ mod tests { "the extractor must return the whole predicate, not a truncated span; got \ {head}-{end}" ); + // The shared comment rule (`crate::source_census::code`), so a trailing comment naming + // one of the tokens below cannot be read as a code site. let code: Vec<(usize, &str)> = (head..=end) - .map(|i| (i, lines[i])) - .filter(|(_, l)| !l.trim_start().starts_with("//")) + .map(|i| (i, crate::source_census::code(lines[i]))) .collect(); let item6_head = code diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index f97ffcc36b..8a9e82b9f3 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -17980,18 +17980,10 @@ mod stage2_injector_tests { // comment rule at all until now. The consequence was measured, not theorised: a doc // comment that quoted the needle verbatim counted ITSELF, reading 39 against a pin of // 38, and was worked around by deleting the brace from the quotation (`aa313f122`). - // That repaired one sentence and left the counter broken for the next one. The rule is - // `battlefield_entry_authority_census::code_span`'s, restated in its smallest form - // because a `#[cfg(test)]` module in the lib cannot import from the integration binary: - // drop everything from the first `//` NOT preceded by a `"` on that line, so a `//` - // inside a string literal cannot truncate real code away (fail-closed — the residual - // direction is a spurious extra hit, never a missed one). - fn code_of(line: &str) -> &str { - match line.find("//") { - Some(at) if !line[..at].contains('"') => &line[..at], - _ => line, - } - } + // That repaired one sentence and left the counter broken for the next one. The rule now + // has ONE home for the whole repository, `crate::source_census`, which the integration + // binary compiles from the same file. + use crate::source_census::code as code_of; // The needle is ASSEMBLED so this row's own source cannot be counted by its own // instrument. `..` excludes multi-line READ destructures whose rest-pattern sits on @@ -18782,15 +18774,15 @@ mod stage2_injector_tests { let effects_src = std::fs::read_to_string(root.join("game/effects/mod.rs")) .expect("readable effects module"); let authority = format!("{}_prompt_player", "optional"); - // CODE LINES ONLY. A whole-file `matches()` also counted PROSE, and this PR's C1 adds a - // doc link to the authority in `upfront_optional_gate`'s comment — a mention that is - // neither a definition nor a call. Excluding `//` lines makes the instrument STRICTLY - // MORE specific to the thing it names (a second CALL) rather than less: the pinned - // count is unchanged at 2, and a real second call still trips it because a call cannot - // live on a comment line. + // CODE ONLY, and now the CODE HALF of each line rather than only non-comment lines: + // a whole-file `matches()` counted PROSE, and this PR's C1 adds a doc link to the + // authority in `upfront_optional_gate`'s comment — a mention that is neither a + // definition nor a call. `crate::source_census::code` is the shared rule; the pinned + // count is unchanged at 2 (re-measured), and a real second call still trips it because + // a call cannot live in comment text. let authority_code_hits = effects_src .lines() - .filter(|l| !l.trim_start().starts_with("//")) + .map(crate::source_census::code) .filter(|l| l.contains(&authority)) .count(); assert_eq!( @@ -20678,11 +20670,13 @@ mod bounded_offer_conjunct_tests { /// Code lines (comments excluded, per R8's ruling: a comment reads nothing) of an extent /// that contain `needle`, as absolute line indices. + /// + /// "Comments excluded" means the shared `crate::source_census::code` rule — whole-line AND + /// trailing — not a private `starts_with("//")` test. #[cfg(test)] fn engine_code_hits(lines: &[&str], extent: (usize, usize), needle: &str) -> Vec { (extent.0..=extent.1) - .filter(|i| !lines[*i].trim_start().starts_with("//")) - .filter(|i| lines[*i].contains(needle)) + .filter(|i| crate::source_census::code(lines[*i]).contains(needle)) .collect() } @@ -21112,9 +21106,9 @@ mod bounded_offer_conjunct_tests { .replace('\\', "/"); let test_file = rel.trim_end_matches(".rs").ends_with("_tests"); for (n, line) in lines.iter().enumerate() { - if line.trim_start().starts_with("//") { - continue; - } + // The shared comment rule, not a private one: comment text declares no + // predicate and calls none. + let line = crate::source_census::code(line); if test_file || spans.iter().any(|(a, b)| (*a..=*b).contains(&n)) { continue; } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 08d81b18bc..581e480b4a 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -10715,14 +10715,10 @@ mod tests { // EXIT-AXIS BINDING — the half the two assertions above cannot supply, because they compare // `possible_hold` against a transcription of itself. Counting unit and decomposition are the // ones stated on `BoundaryHold`: 4 control-flow statements = 1 push + 2 item-level non-push - // + 1 inner per-growth skip. Comment lines are stripped so prose ABOUT `continue`/`return` - // cannot inflate the count. - let code: String = boundary_apply_loop_region() - .lines() - .map(str::trim_start) - .filter(|line| !line.starts_with("//")) - .collect::>() - .join("\n"); + // + 1 inner per-growth skip. `crate::source_census::code_lines` is the shared rule: + // whole-line AND trailing comment text removed, so prose ABOUT `continue`/`return` + // cannot inflate the count from either position. + let code: String = crate::source_census::code_lines(boundary_apply_loop_region()); // The counters read raw text, and a string literal is not a comment, so one carrying the // word `break` (or a `?`) would be counted as control flow — a red no reader could act on. // There are none in the loop today; keep it that way, or teach the counters to skip them. diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index ff13948e27..2f638e1b70 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -14280,11 +14280,11 @@ mod characteristic_read_classification_tests { let mut carriers = Vec::new(); let mut current: Option<&str> = None; for line in body.lines() { + // Doc comments name `ControllerRef` in prose; they declare nothing — and neither + // does a TRAILING comment on a field line, which the shared + // `crate::source_census::code` rule removes too. + let line = crate::source_census::code(line); let trimmed = line.trim_start(); - // Doc comments name `ControllerRef` in prose; they declare nothing. - if trimmed.starts_with("//") { - continue; - } // A variant header is the only thing at one indent level that opens // with an uppercase letter; its fields sit one level deeper. if let Some(header) = line diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 93a368bbe4..b5d2a798ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,12 @@ pub mod util; #[cfg(test)] mod test_support; +// The shared comment rule for every source census in this repository. Also compiled into the +// integration binary through a `#[path]` declaration in `tests/integration/main.rs`, so the two +// venues share ONE implementation rather than `test_support.rs`'s twin-sync PAIR. +#[cfg(test)] +mod source_census; + // Re-export `im` so downstream crates can construct persistent containers // without declaring their own dependency. Keeps the backing-container choice // (im vs rpds vs dashmap) centralized here. diff --git a/crates/engine/src/source_census.rs b/crates/engine/src/source_census.rs new file mode 100644 index 0000000000..4560fff170 --- /dev/null +++ b/crates/engine/src/source_census.rs @@ -0,0 +1,320 @@ +#![cfg(test)] +//! THE SINGLE AUTHORITY ON "WHICH PART OF THIS LINE IS CODE", for every guard test in this +//! repository that counts needles in Rust source. +//! +//! # Why this file exists at all +//! +//! The engine carries a family of source censuses — tests that read `.rs` text and pin how many +//! times a construct is written, so that a producer added or deleted without adjudication is a +//! counted event. Every one of them had its OWN comment policy, and they disagreed: some rejected +//! a line only when the WHOLE line opened with `//`, one had no comment rule at all. Both shapes +//! fail in the direction that matters. A needle written after a trailing `//` was counted as a +//! real site, so: +//! +//! * a pure PROSE edit moved a pinned multiset with no code change (measured: a doc comment that +//! quoted a census's own needle counted ITSELF, reading 39 against a pin of 38); and +//! * deleting a real construction while naming the same spelling in a trailing comment HELD the +//! count — the substitution class those censuses exist to catch. +//! +//! The first repair of that 39-vs-38 failure edited the PROSE (dropping a brace from a +//! quotation) rather than the counter. That fixed one sentence and left the next author to +//! rediscover the bug. Seven counters each carrying a private comment policy IS the defect; this +//! module is the producer-level fix, and +//! [`tests::no_source_reading_file_carries_a_private_comment_policy`] is what stops an eighth +//! private policy from appearing silently. +//! +//! # Venue +//! +//! `src/` unit tests reach this as `crate::source_census`; the integration binary reaches THE +//! SAME FILE through a `#[path]` module declaration in `tests/integration/main.rs`. That is +//! deliberate, and it is why the module is `#![cfg(test)]` rather than `pub`: `cfg(test)` holds +//! in both venues (an integration target is built with `--test`), so one source file serves both +//! without shipping guard infrastructure in a release build. The repo's older answer to this +//! problem is `test_support.rs`'s "TWIN-SYNC: keep the fixture path here in lockstep with +//! `tests/integration/support.rs`" — two copies and a comment asking a human to keep them equal. +//! This module is the shape that does not need the comment. +//! +//! Each venue compiles its own instance, so [`tests`] runs in both. That is a feature, not +//! duplication to remove: the copy is validated where it is used. + +/// The byte range of `line` that is CODE: a LEADING `/* … */` comment and a TRAILING `//` comment +/// are excluded. +/// +/// Both exclusions only ever REMOVE text, and each is guarded so that it cannot remove code: +/// +/// * The leading form fires only when the TRIMMED line starts with `/*`, and drops exactly up to +/// and including the first `*/` — so `/*count=*/ state.last_created_token_ids.push(id)` keeps +/// its call. +/// * The trailing form fires only when no `"` precedes the `//` in the remaining code — so +/// `let u = "http://x"; state.last_created_token_ids.push(id);` keeps its call. A naive +/// `split("//")` truncates at the URL and UNDER-counts, silently dropping a real site, which is +/// the one direction a census must never fail in. +/// +/// Offsets rather than a substring, because a caller may need to index back into the ORIGINAL +/// line (`battlefield_entry_authority_census::classify_container_tails` resolves a call tail from +/// an absolute offset). [`code`] is the substring form and is what most callers want. +/// +/// Whatever survives both guards — a `//` after a quote, a `/* … */` opened mid-line, a string +/// literal quoting the defect, or the interior lines of a multi-line block comment — is still +/// scanned, and would ADD a hit. That direction is fail-CLOSED: spurious red, never a missed +/// site. What is closed here are the two shapes a census's own prose is most likely to take. +pub(crate) fn code_span(line: &str) -> (usize, usize) { + let trimmed = line.trim_start(); + let mut lo = 0usize; + if trimmed.starts_with("/*") { + let start = line.len() - trimmed.len(); + lo = match line[start..].find("*/") { + Some(end) => start + end + 2, + None => line.len(), + }; + } + let mut hi = line.len(); + if let Some(slash) = line[lo..].find("//") { + if !line[lo..lo + slash].contains('"') { + hi = lo + slash; + } + } + (lo, hi) +} + +/// The CODE half of one line — [`code_span`] applied. A whole-line comment yields `""`, so a +/// caller needs no separate `starts_with("//")` rule. +pub(crate) fn code(line: &str) -> &str { + let (lo, hi) = code_span(line); + &line[lo..hi] +} + +/// A whole source text with every line's comment half removed, line structure preserved. +/// +/// For the censuses that count over a REGION rather than per line. Derived from [`code`] so the +/// region form and the line form cannot drift apart. +pub(crate) fn code_lines(src: &str) -> String { + src.lines().map(code).collect::>().join("\n") +} + +#[cfg(test)] +mod tests { + use super::{code, code_lines, code_span}; + + /// THE discrimination arm for the shared rule — one test on the authority, rather than a + /// near-duplicate in each of its callers. + /// + /// SYNTHETIC INPUT BY NECESSITY, not by preference. Measured when this rule was extracted: no + /// line in any walked root carried a needle after a trailing `//`, so every census that scans + /// the real tree passes byte-identically with the rule and without it. A repo-scanning + /// assertion for this property is vacuous BY CONSTRUCTION; only planted input separates the + /// two rules. + /// + /// # The arms, and what each is under the whole-line-only rule this replaced + /// + /// 1. SUBSTITUTION, the arm that matters and therefore asserted FIRST — the construction is + /// DELETED and only a trailing-comment mention survives: 0, not 1. Under the old rule a + /// census counted a removed producer as still present. + /// 2. INFLATION — a real construction plus a mention of the same spelling in a trailing + /// comment on that line: 1, not 2. Under the old rule a pure PROSE edit moved a pinned + /// multiset with no code change. + /// 3. FAIL-CLOSED — a `//` inside a STRING LITERAL preceding a real construction: 1, not 0. + /// A naive `split("//")` truncates there and UNDER-counts. + /// 4. Whole-line and leading-block comments, and the offsets contract [`code_span`] owes its + /// one offset-taking caller. + #[test] + fn the_code_half_rule_separates_a_trailing_comment_from_the_code_beside_it() { + // Assembled, so this test's own source cannot be counted by a census that walks `src/`. + let needle = format!("{}::{}(", "TargetPin", "Player"); + let count = |line: &str| code(line).matches(&needle).count(); + + assert_eq!( + count(&format!(" let a = 0; // was {needle}PlayerId(0))")), + 0, + "SUBSTITUTION: the construction is gone and only a trailing-comment mention \ + survives, so the count must fall to 0. Counting the whole line makes this 1 — a \ + DELETED producer reported as still present." + ); + assert_eq!( + count(&format!( + " let a = {needle}PlayerId(0)); // also {needle}PlayerId(9))" + )), + 1, + "INFLATION: a needle in a trailing comment is prose; the code half holds exactly ONE \ + construction. Counting the whole line makes this 2." + ); + assert_eq!( + count(&format!( + " let u = \"http://x\"; let a = {needle}PlayerId(0));" + )), + 1, + "FAIL-CLOSED: a `//` inside a string literal is not a comment opener. A naive \ + `split(\"//\")` truncates at the URL and reports 0 — a real construction silently \ + dropped." + ); + + assert_eq!( + count(&format!(" // {needle}PlayerId(0))")), + 0, + "whole-line comment" + ); + assert_eq!( + count(&format!(" /// {needle}PlayerId(0))")), + 0, + "doc comment" + ); + assert_eq!( + count(&format!("/*n=*/ let a = {needle}PlayerId(0));")), + 1, + "a LEADING block comment is dropped up to `*/` and the code after it survives" + ); + + // The offsets contract: `code` is exactly the span, and the span indexes the ORIGINAL + // line, which is what the one offset-taking caller relies on. + let line = format!(" let a = {needle}PlayerId(0)); // tail"); + let (lo, hi) = code_span(&line); + assert_eq!(&line[lo..hi], code(&line), "the substring form IS the span"); + assert!(hi < line.len(), "the trailing comment is outside the span"); + + // The region form is the line form, applied line-wise. + assert_eq!( + code_lines(&format!("let a = 1; // {needle}\n// {needle}\nlet b = 2;")), + "let a = 1; \n\nlet b = 2;", + "code_lines preserves line structure and strips both comment shapes" + ); + } + + /// THE PRODUCER GUARD — no file that reads Rust source may carry its own comment policy. + /// + /// Sweeping the seven known counters fixes seven instances and leaves the eighth author to + /// re-invent the bug. This row is what makes the change a producer fix: a NEW file that reads + /// `.rs` text and counts in it must either route through this module or be adjudicated into + /// [`EXEMPT`] with a stated reason. Neither is possible to do silently. + /// + /// The population predicate is mechanical, not a judgement: a file READS RUST SOURCE if it + /// `include_str!`s a `.rs` path, or walks for `.rs` files (`rs_files(` / an `== "rs"` + /// extension test). Every such file must carry a path-qualified `source_census::` call or be + /// EXEMPT. + /// + /// Both halves of the predicate read the file through [`code_lines`] — this census obeys its + /// own rule, so a file that merely QUOTES `include_str!("x.rs")` or the marker in prose is + /// neither pulled into the population nor credited with routing. + /// + /// # Discrimination + /// + /// Delete the `source_census::` import from any routed census ⇒ that file is neither routed + /// nor exempt ⇒ this row reds naming it. Verified by revert-probe on + /// `loop_shortcut_seat_pin_census.rs`. + #[test] + fn no_source_reading_file_carries_a_private_comment_policy() { + /// Files that read Rust source but do NOT count needles in code, each with the measured + /// reason. An entry here is an ADJUDICATION, not a suppression: it says "this file's + /// matching is not the comment-blind counting class", and the reason has to survive a + /// reader who disagrees. + const EXEMPT: &[(&str, &str)] = &[ + ( + "src/bin/rules_audit.rs", + "INVERSE POLARITY BY DESIGN: it scans ONLY comment lines, because CR annotations \ + live in comments. Stripping comments would leave it nothing to read.", + ), + ( + "tests/integration/cr_annotations.rs", + "Same inverse polarity: the asserted subject IS comment text (CR annotations).", + ), + ( + "tests/integration/loop_shortcut.rs", + "Parses with `syn` rather than by substring, so comments are excluded by \ + construction — the stronger instrument this module approximates.", + ), + ( + "src/game/ability_scan.rs", + "SECOND WAVE, disclosed not routed: whole-FILE boolean signals \ + (`src.contains(..)`), not per-line counts. Same comment-blind class, different \ + shape; `code_lines` is the routing tool when it is adjudicated.", + ), + ( + "src/game/turns.rs", + "SECOND WAVE, disclosed not routed: region slices of a source string.", + ), + ( + "src/parser/oracle_condition.rs", + "SECOND WAVE, disclosed not routed: whole-file `body.contains(..)` family probe.", + ), + ( + "tests/integration/interaction_contract.rs", + "SECOND WAVE, disclosed not routed: whole-file signature/arithmetic probes.", + ), + ( + "tests/integration/deterministic_game_state_serde.rs", + "SECOND WAVE, disclosed not routed: parses serde ATTRIBUTE regions, not needles.", + ), + ( + "tests/integration/no_top_level_test_binaries.rs", + "SECOND WAVE, disclosed not routed: collects `mod` registrations from main.rs.", + ), + ]; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut files: Vec = Vec::new(); + let mut stack = vec![root.join("src"), root.join("tests/integration")]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}")) { + let path = entry.expect("dir entry").path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + files.sort(); + assert!(files.len() > 100, "reach-guard: the walk found the crate"); + + // Assembled so this file's own text cannot satisfy the predicate it defines. + let walks_rs = [format!("rs_files{}", '('), format!("== {}rs{}", '"', '"')]; + // THE ROUTING MARKER IS THE PATH-QUALIFIED CALL, `source_census::`, NOT the bare module + // name — measured, after the bare name gave a FALSE PASS. `source_census` is already an + // unrelated engine identifier: `ability_rw.rs` declares a `SourceCensus` type and a + // `source_census` field, and `triggers_ordering_parity_tests.rs` calls + // `source_census_overlaps_filter`. None of those is a comment rule, and a counter added + // to one of those files would have been accepted as routed on the strength of a + // coincidence. The `::` form appears only in a use/call of THIS module. + let marker = format!("source_census{}", "::"); + let mut unrouted: Vec = Vec::new(); + let mut routed = 0usize; + for path in &files { + let rel = path + .strip_prefix(root) + .expect("under the crate root") + .to_string_lossy() + .replace('\\', "/"); + if rel == "src/source_census.rs" { + continue; + } + let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {rel}: {e}")); + let src = super::code_lines(&text); + let reads_rust_source = src.contains(".rs\")") && src.contains("include_str!(") + || walks_rs.iter().any(|w| src.contains(w.as_str())); + if !reads_rust_source { + continue; + } + if src.contains(marker.as_str()) { + routed += 1; + } else if !EXEMPT.iter().any(|(f, _)| *f == rel) { + unrouted.push(rel); + } + } + + assert!( + unrouted.is_empty(), + "these files read Rust source but neither route through `source_census` nor carry an \ + adjudicated EXEMPT reason. A source census that brings its own comment policy is the \ + defect this module exists to remove: a needle written after a trailing `//` counts \ + as a real site, so prose alone can move a pinned number and a deleted producer can \ + hide behind a comment that names it. Route the file through `source_census::code` / \ + `code_lines`, or add it to EXEMPT with the reason it is not a counter.\n\ + unrouted: {unrouted:#?}" + ); + assert!( + routed >= 7, + "reach-guard: the seven repaired counters must still be visible to this census as \ + ROUTED, or the predicate above stopped matching and the assertion is vacuous. \ + routed={routed}" + ); + } +} diff --git a/crates/engine/tests/integration/battlefield_entry_authority_census.rs b/crates/engine/tests/integration/battlefield_entry_authority_census.rs index 366ce01a1a..023a8ba4e1 100644 --- a/crates/engine/tests/integration/battlefield_entry_authority_census.rs +++ b/crates/engine/tests/integration/battlefield_entry_authority_census.rs @@ -245,6 +245,10 @@ use std::path::Path; use super::loop_shortcut_offer_writer_census::{cfg_test_scoped_lines, rs_files}; +// The comment rule is NOT this file's any more: it moved to `src/source_census.rs`, which the +// crate's own unit-test censuses share through a plain `mod` and this binary reaches through a +// `#[path]` declaration in `main.rs`. One implementation, both venues. +use super::source_census::{code, code_span}; /// The bare anchor, ASSEMBLED AT RUNTIME so this file can never count its own text — the doctrine /// `loop_shortcut_offer_writer_census` files against its own superseded round-2 anchor. @@ -591,11 +595,7 @@ fn classify_anchor(src: &str, file: &str, needle: &str, keep: impl Fn(&str) -> b lines .iter() .enumerate() - .filter(|(_, line)| !line.trim_start().starts_with("//")) - .filter(|(_, line)| { - let (lo, hi) = code_span(line); - line[lo..hi].contains(needle) - }) + .filter(|(_, line)| code(line).contains(needle)) .filter_map(|(n, _)| { let body = literal_body(&lines, n, needle); keep(&body).then(|| Hit { @@ -685,6 +685,13 @@ fn call_tail(lines: &[&str], row: usize, from: usize) -> String { let mut row = row; let mut rest: &str = &lines[row][from..]; loop { + // DELIBERATELY NOT ROUTED THROUGH `source_census::code`, measured rather than overlooked. + // Routing it lets `call_tail` read THROUGH a `/* … */` sitting between the field and its + // call, which turns arm 5(vi)'s UNREADABLE tail into a readable `.extend(` and drops that + // arm from `(1, 1)` to `(0, 0)` — RUN, not reasoned. Arm 5(vi)'s contract is "a tail this + // function cannot read is COUNTED", and it is not this change's to redefine. The shared + // rule governs which text is a NEEDLE SITE; this function reads a TAIL, and the two + // questions come apart exactly here. let trimmed = rest.trim_start(); if !trimmed.is_empty() && !trimmed.starts_with("//") { return trimmed.chars().take(24).collect(); @@ -762,50 +769,6 @@ fn is_ambiguous_mutator(tail: &str) -> bool { .any(|verb| tail.starts_with(verb)) } -/// The byte range of `line` that is CODE: a LEADING `/* … */` comment and a TRAILING `//` comment -/// are excluded from the search. -/// -/// THE ONE HOME OF THE TRAILING-COMMENT RULE for every source census in this binary — this -/// file's three anchors, `loop_shortcut_offer_writer_census::classify` and -/// `loop_shortcut_seat_pin_census::sites_in_source`. They previously rejected only lines that -/// OPEN with `//`, which counts a needle sitting after a trailing `//` as a real site; a second -/// copy of the corrected rule is a second place for it to drift. -/// -/// Both exclusions only ever REMOVE text, and each is guarded so that it cannot remove code: -/// -/// * The leading form fires only when the TRIMMED line starts with `/*`, and drops exactly up to -/// and including the first `*/` — so `/*count=*/ state.last_created_token_ids.push(id)` keeps its -/// call (arm 5(viii)). -/// * The trailing form fires only when no `"` precedes the `//` in the remaining code — so -/// `let u = "http://x"; state.last_created_token_ids.push(id);` keeps its call (arm 5(viii)). -/// -/// The offsets are returned rather than a substring because [`call_tail`] indexes back into the -/// ORIGINAL line, and because the `/* … */` tail form of arm 5(vi) must still reach the classifier. -/// -/// Whatever survives both guards — a `//` after a quote, a `/* … */` opened mid-line, a string -/// literal quoting the defect, or the interior lines of a multi-line block comment — is still -/// scanned, and would ADD a hit. That direction is fail-CLOSED (spurious red, never a missed -/// publish); it is listed in this file's residuals. What is closed here are the two shapes this -/// change's own prose is most likely to take. -pub(super) fn code_span(line: &str) -> (usize, usize) { - let trimmed = line.trim_start(); - let mut lo = 0usize; - if trimmed.starts_with("/*") { - let start = line.len() - trimmed.len(); - lo = match line[start..].find("*/") { - Some(end) => start + end + 2, - None => line.len(), - }; - } - let mut hi = line.len(); - if let Some(slash) = line[lo..].find("//") { - if !line[lo..lo + slash].contains('"') { - hi = lo + slash; - } - } - (lo, hi) -} - /// Classify every access to either anaphora container in `src` whose TAIL satisfies `keep`. /// /// The third anchor's walker, parameterised by the tail predicate so that @@ -823,14 +786,14 @@ fn classify_container_tails(src: &str, file: &str, keep: fn(&str) -> bool) -> Ve let lines: Vec<&str> = src.lines().collect(); let mut hits = Vec::new(); for (n, line) in lines.iter().enumerate() { - if line.trim_start().starts_with("//") { - continue; - } + // ONE comment rule, the shared one: a whole-line comment has an EMPTY code span, so the + // `starts_with("//")` test this used to carry beside it was a second policy saying the + // same thing — and a second place to get it wrong. let (lo, hi) = code_span(line); - let code = &line[lo..hi]; + let code_part = &line[lo..hi]; for needle in ANAPHORA_CONTAINERS { let mut from = 0usize; - while let Some(at) = code[from..].find(needle) { + while let Some(at) = code_part[from..].find(needle) { from += at + needle.len(); if keep(&call_tail(&lines, n, lo + from)) { hits.push(Hit { @@ -935,11 +898,12 @@ const FN_PREFIX_ALLOW_SET: [&str; 5] = [ fn top_level_fn_headers(src: &str) -> Result, String> { let mut out = Vec::new(); for (n, line) in src.lines().enumerate() { - if line.starts_with([' ', '\t']) || line.trim_start().starts_with("//") { + if line.starts_with([' ', '\t']) { continue; } - let (lo, hi) = code_span(line); - let tokens: Vec<&str> = line[lo..hi].split_whitespace().collect(); + // A whole-line comment yields an empty code half and therefore no `fn` token, so the + // shared rule subsumes the comment test that used to sit in the condition above. + let tokens: Vec<&str> = code(line).split_whitespace().collect(); let Some(at) = tokens.iter().position(|token| *token == "fn") else { continue; }; diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index 5ccc5cedf3..4a46b88421 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -2636,11 +2636,9 @@ fn c1_every_ring_clear_site_also_clears_the_loop_answer_journal() { // nor clears the journal. Whole-line-only exclusion is not enough here and the failure is // two-sided — a comment naming the clear would be counted as a site, and a comment naming // `loop_answer_journal = None` inside a window would mark a genuinely UNPAIRED site as - // paired, which is the direction that hides the regression. Shared rule, one home. - let code = |line: &str| { - let (lo, hi) = super::battlefield_entry_authority_census::code_span(line); - line[lo..hi].to_string() - }; + // paired, which is the direction that hides the regression. Shared rule, one home: + // `src/source_census.rs`, the same file the crate's own unit-test censuses use. + use super::source_census::code; for (i, line) in lines.iter().enumerate() { if !code(line).contains("loop_detect_ring.clear()") { continue; diff --git a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs index 98a9926889..9bdd6730da 100644 --- a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs @@ -98,7 +98,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use super::battlefield_entry_authority_census::code_span; +use super::source_census::code; /// The bare anchor, ASSEMBLED AT RUNTIME. /// @@ -204,18 +204,14 @@ pub(super) fn cfg_test_scoped_lines(src: &str) -> Vec { /// the exclusion in both directions: a pure prose edit moves the pinned number with no code /// change, and deleting a real writer while naming the same spelling in a trailing comment on a /// surviving line HOLDS the number — the substitution class this census exists to catch. -/// [`super::battlefield_entry_authority_census::code_span`] is the one home of that rule; it is +/// [`super::source_census::code`] is the one home of that rule, shared by every census in /// fail-CLOSED (a `//` preceded by a `"` on the same line is left in the code half, so a URL in /// a string literal cannot hide a real writer behind it). fn classify(src: &str, needle: &str, file: &str) -> Vec { let scoped = cfg_test_scoped_lines(src); src.lines() .enumerate() - .filter(|(_, line)| !line.trim_start().starts_with("//")) - .filter(|(_, line)| { - let (lo, hi) = code_span(line); - line[lo..hi].contains(needle) - }) + .filter(|(_, line)| code(line).contains(needle)) .map(|(n, line)| Hit { file: file.to_string(), line: n + 1, diff --git a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs index e2c5155f94..9e99bdcf79 100644 --- a/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs +++ b/crates/engine/tests/integration/loop_shortcut_seat_pin_census.rs @@ -45,8 +45,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -use super::battlefield_entry_authority_census::code_span; use super::loop_shortcut_offer_writer_census::{cfg_test_scoped_lines, rs_files}; +use super::source_census::code; /// The CHOICE-class needle, ASSEMBLED AT RUNTIME for the same reason the sibling census /// assembles its own: this file lives under `crates/engine/tests/`, which the walk does not @@ -95,7 +95,7 @@ struct Site { /// deleting one construction while naming the same spelling in a trailing comment on a surviving /// construction line HOLDS the count — the substitution class conjunct 1 exists to catch. /// Occurrences are therefore counted in the line's CODE half only, via -/// [`super::battlefield_entry_authority_census::code_span`] (the one home of the rule, shared +/// [`super::source_census::code`] (the one home of the rule, shared /// with both sibling censuses). It is fail-CLOSED: a `//` preceded by a `"` on the same line — /// `let u = "http://x"; ..` — stays in the code half, so a URL in a string literal cannot hide a /// real construction behind it, which a naive `split("//")` would. @@ -108,11 +108,10 @@ fn sites_in_source(src: &str, needle: &str, file: &str) -> Vec { let scoped = cfg_test_scoped_lines(src); let mut out = Vec::new(); for (n, line) in src.lines().enumerate() { - if line.trim_start().starts_with("//") || scoped[n] { + if scoped[n] { continue; } - let (lo, hi) = code_span(line); - for _ in 0..line[lo..hi].matches(needle).count() { + for _ in 0..code(line).matches(needle).count() { out.push(Site { file: file.to_string(), line: n + 1, @@ -398,7 +397,7 @@ fn the_seat_pin_census_instrument_reports_both_answers_on_planted_input() { /// multiset with no code change (revert-probe: `left: 2 / right: 1`). /// 3. FAIL-CLOSED — a `//` inside a STRING LITERAL preceding a real construction: 1, not 0. A /// naive `split("//")` truncates there and UNDER-counts, silently dropping a real site; -/// [`code_span`] leaves a `//` that follows a `"` in the code half, so the miss cannot happen. +/// [`code`] leaves a `//` that follows a `"` in the code half, so the miss cannot happen. #[test] fn the_seat_pin_census_ignores_a_trailing_comment_mention() { let choice = choice_needle(); diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 6238194dd7..fda6125963 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1,3 +1,12 @@ +// THE SHARED COMMENT RULE for every source census in this binary, compiled from the SAME FILE the +// crate's own unit-test censuses use. `#[path]` rather than a copy: `src/source_census.rs` is +// `#![cfg(test)]`, and `cfg(test)` HOLDS in this venue too (an integration target is built with +// `--test`), so one implementation serves both without shipping guard code in a release build. +// MEASURED, not assumed — the alternative already in this tree is `test_support.rs` / `support.rs`, +// twin files kept equal by a comment asking a human to remember. +#[path = "../../src/source_census.rs"] +mod source_census; + mod abigale_integration; mod abundance_optional_draw_replacement; mod ad_nauseam_repeat; From decef86483c95a2c50c28d14efa3437312b26450 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Thu, 13 Aug 2026 17:19:25 -0500 Subject: [PATCH 34/44] fix(engine): resolve template-free shortcut declarations against the published offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `DeclareShortcut` carrying `template: None` was refused by the period-owner arm even when the offer itself published a declaration, so a browser client that sent a bare Confirm fell back to `WaitingFor::Priority` and the proposal was silently discarded. The engine already had the proposer's own suggested sequence in hand; it just never consulted it. `LoopShortcutOffer` now carries the offer's `declaration`, the `DeclareShortcut` arm binds it instead of discarding it, and `handle_declare_shortcut` resolves a `None` template against it. Four lines of production code; the rest is comment and test. Placement is load-bearing: the `or_else` sits above the owner firewall, so a resolved declaration is still subject to `t.owner != offer.proposer`. Relocating it below the firewall was probed and flips a foreign-owner declaration from refused to accepted, so the ordering is covered by a test rather than a comment. CR 732.2a: the published declaration IS the proposer's own suggested sequence of game choices, so resolving `None` against it declares that same sequence rather than a new one. Two existing tests pinned the pre-fix behaviour and are adapted rather than suppressed. `a_template_free_declaration_is_admitted_only_by_the_proposers_own_period` keeps testing the period-owner arm by clearing the offer's declaration, which stays a reachable configuration because the builder yields `None` on a journal miss or kind mismatch and both non-bounded mints hard-code it. `u6_the_generators_own_candidate_opens_the_window_and_the_accepted_shape_is_measured` flips to the accepted shape, since its old assertion encoded the defect. A dynamic sweep at the seam (logging test name, declaration presence and point count) covered 123 hits across 92 unique tests in 9 files: exactly 2 changed path, and they are exactly the 2 that failed. No test changed path while staying green. The period-owner arm remains live on 34 distinct tests. Known red, deliberate: one lib test pins literal source coordinates of prompt producers, and these edits shift one of them. The replacement must be derived against the rebased tree — upstream moves a different element of the same vector — so it is left failing rather than pinned to a value that is stale on arrival. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/engine.rs | 52 +- .../fantastic_four_bounded_loop.rs | 486 +++++++++++++++++- 2 files changed, 512 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 8a9e82b9f3..bb86fd0172 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -5876,6 +5876,10 @@ struct LoopShortcutOffer<'a> { predicted_winner: Option, certificate: &'a crate::analysis::loop_check::LoopCertificate, schema: &'a crate::analysis::decision_template::ShortcutDecisionSchema, + /// The engine's OWN published declaration for this offer, borrowed. Cloned only on the + /// fallback path in `handle_declare_shortcut`, where a `template: None` declaration + /// resolves against it. + declaration: Option<&'a crate::analysis::decision_template::DecisionTemplate>, } /// CR 732.2a + CR 800.4a: reject a @@ -5981,6 +5985,41 @@ fn handle_declare_shortcut( crate::analysis::decision_template::IterationCount::Fixed(_) | crate::analysis::decision_template::IterationCount::UntilLethal => {} } + // A `template: None` declaration is not "no pins" — it is "no OVERRIDE of the pins this + // offer already published". Resolve it against the offer's own engine-issued declaration so + // the manual ingress and `ai_support::candidates` (which reads the identical field) declare + // the SAME proposal against the SAME offer. Until this line existed the engine published a + // declaration on the offer and then discarded it here, so the AI — which sends + // `Some(declaration)` — was accepted while the browser, which sends `None`, was refused on + // one and the same offer. No rules citation is minted here: the block immediately below + // carries this handler's, and `docs/MagicCompRules.txt` is absent from this tree, so an + // unverified restatement would be worse than none. + // + // PLACEMENT IS LOAD-BEARING, AND MEASURED: this sits ABOVE the `template.owner` firewall + // below. `declaration_conforms` is `predictability_gate && validate_pins` and reads no + // `owner` at all — measured: a template differing from a conforming one ONLY in `owner` + // still conforms. That firewall is therefore the SOLE refuser of a foreign-owner + // declaration, and moving this statement one line down would hand the firewall a `None` + // (which passes) and then hand the `Some(t)` arm a foreign-owner template it accepts. + // Pinned by `r3_placement_a_restored_foreign_owner_declaration_is_refused`. + // + // WHAT THIS DOES TO THE `None if …loop_period_controller() != Some(proposer)` ARM BELOW, + // stated because it reads like a loosening and is not: that arm is BYPASSED whenever the + // offer published a declaration, because `&template` then takes the `Some(t)` arm instead. + // That is intended. The arm exists so a PINLESS drive never runs — its own doc says "with + // nothing this proposer can re-derive from, a pin-consuming drive would run with no pins at + // all" — and a resolved declaration supplies exactly those pins. The substitute gate is + // `declaration_conforms`, which is strictly STRONGER for this case: the arm asserts only + // that a re-derivation SOURCE exists, while `declaration_conforms` validates the actual + // pins against the actual schema over the range the accepted count will drive. + // + // THE ARM IS NOT DEAD AFTERWARDS — do not "simplify" it away. It still decides every offer + // that published no declaration, and that set is non-empty by construction: + // `build_bounded_declaration` returns `None` on a journal miss or on a kind/value mismatch + // even with a non-empty schema, both non-bounded mints hard-code `declaration: None`, and a + // restored save may carry `None`. Pinned by + // `a_template_free_declaration_is_admitted_only_by_the_proposers_own_period`. + let template = template.or_else(|| offer.declaration.cloned()); // CR 732.2a + CR 603.5: the declared template's `owner` is CLIENT-SUPPLIED — the // `GameAction::DeclareShortcut { template }` payload arrives here verbatim — and it is // the comparand `inject_pinned_answer` uses to decide WHOSE CR 603.5 choice a pin may @@ -9394,11 +9433,13 @@ fn apply_action( predicted_winner, certificate, schema, - // NOT threaded, deliberately: resolving a `template: None` declaration against - // the offer's own `declaration` is a change to the DECLARE handler's proposal - // shape, with its own hostile-fixture obligations (foreign period, restore - // ingress). `_` rather than a bind so nothing here implies otherwise. - declaration: _, + // Threaded: `handle_declare_shortcut` resolves a `template: None` declaration + // against the offer's own published declaration, so the manual ingress and + // `ai_support::candidates` (which reads this identical field) declare the SAME + // proposal against the SAME offer. The hostile-fixture obligations this bind + // used to defer — foreign period, restore ingress — are discharged by the rows + // named on that handler's `or_else`. + declaration, }, GameAction::DeclareShortcut { count, template }, ) => { @@ -9409,6 +9450,7 @@ fn apply_action( predicted_winner: *predicted_winner, certificate, schema, + declaration: declaration.as_ref(), }, count, template, diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index 4a46b88421..a4f74508b1 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -2822,10 +2822,19 @@ fn d6_the_ai_declare_candidate_carries_the_offers_own_published_declaration() { /// RE-DERIVED from the generator rather than hand-named: the declare candidate opens the window /// and the decline hands priority back. /// -/// **The four one-axis drives below are UNCHANGED and remain the engine-side guards the -/// generator's gate depends on** — in particular `Fixed(max) + None ⇒ Priority`, which is a -/// LIVE fail-closed guard (resolving a `template: None` declaration against the offer's own -/// declaration is a declare-handler change deliberately out of this commit's partition). +/// **The four one-axis drives below still measure the engine-side guards the generator's gate +/// depends on, but one of them has FLIPPED, deliberately.** `Fixed(max) + None` used to be a +/// live fail-closed guard, on the stated grounds that resolving a `template: None` declaration +/// against the offer's own published declaration was a declare-handler change deferred out of +/// that commit's partition. Item-4 C2 IS that change: `handle_declare_shortcut` now resolves a +/// `None` template against `offer.declaration` before the `template.owner` firewall, so on this +/// board — which publishes a declaration — that arm is ACCEPTED and the `None if +/// …loop_period_controller() != Some(proposer)` arm is bypassed rather than reached. The arm is +/// kept, flipped, because it is the one row here that measures the manual ingress agreeing with +/// the AI ingress on one and the same offer. Its fail-closed sibling did not disappear — it +/// moved to the offer shape that still reaches it, which is +/// [`a_template_free_declaration_is_admitted_only_by_the_proposers_own_period`] (offer with +/// `declaration: None`). /// /// Four declarations are driven through `apply()` on the SAME real offer board, differing one /// axis at a time: @@ -2834,12 +2843,12 @@ fn d6_the_ai_declare_candidate_carries_the_offers_own_published_declaration() { /// |---|---| /// | `UntilLethal` + `None` — **the shape the generator emitted before the bounded gate** | REFUSED ⇒ `Priority` | /// | `UntilLethal` + a conformant template | REFUSED ⇒ `Priority` (so the refusal is keyed on the COUNT, not on the pins) | -/// | `Fixed(max)` + `None` | REFUSED ⇒ `Priority` (`template: None` against a non-empty schema fail-closes when `last_loop_action_sequence` is empty — measured empty here) | +/// | `Fixed(max)` + `None` | **ACCEPTED** ⇒ item-4 C2 resolves the `None` against the declaration this offer published, so the browser payload reaches the same window the AI's does | /// | `Fixed(max)` + a conformant template | **ACCEPTED** ⇒ the CR 732.2b APNAP window opens | /// /// The last row is the ANTI-VACUITY control: without it, "everything reaches `Priority`" would /// be satisfied by a board that refuses every declaration for some unrelated reason. With it, -/// the three refusals are proved to be refusals of *those* declarations. +/// the two `UntilLethal` refusals are proved to be refusals of *those* declarations. /// /// ⚠ This row deliberately does NOT assert what the accepted declaration then accomplishes — /// that is [`r2a_an_accepted_declaration_commits_exactly_n_cycles_because_reeds_may_is_announced`]'s @@ -2875,11 +2884,18 @@ fn u6_the_generators_own_candidate_opens_the_window_and_the_accepted_shape_is_me assert!( state.last_loop_action_sequence.is_empty(), - "the measured precondition for the `Fixed` + `None` arm below: with a NON-empty \ - sequence a template-free declaration is legitimately re-derivable and that arm would \ - be measuring something else. len={}", + "the measured precondition that makes the `Fixed` + `None` arm below ATTRIBUTABLE: with \ + no recorded period at all, the `None if …loop_period_controller() != Some(proposer)` \ + arm would refuse this declaration on the pre-C2 engine, so that arm's acceptance is \ + attributable to item-4 C2's `or_else` and to nothing else on this board. len={}", state.last_loop_action_sequence.len() ); + assert!( + offer_declaration(&state).is_some(), + "and the other half of that attribution: the `or_else` can only accept because THIS \ + offer published a declaration to fall back to. An offer publishing `None` still \ + fail-closes — `a_template_free_declaration_is_admitted_only_by_the_proposers_own_period`" + ); // Every AI candidate, driven through the public boundary and dispatched on its own SHAPE, // so the expectation is re-derived from the generator rather than named by hand: a future @@ -2946,10 +2962,13 @@ fn u6_the_generators_own_candidate_opens_the_window_and_the_accepted_shape_is_me ); assert_eq!( outcome(IterationCount::Fixed(max), None), - "Priority", - "and 'just emit `Fixed`' is not a template-free remedy: a `template: None` declaration \ - against a non-empty schema fail-closes unless the recorded driving period belongs to \ - the offer's proposer, and here there is no period at all" + "RespondToShortcut", + "item-4 C2, and this arm FLIPPED with it: `Fixed` + `template: None` is the browser's \ + own payload, and `handle_declare_shortcut` now resolves that `None` against the \ + declaration THIS offer published rather than discarding it. Both reach-guards above \ + are what make the flip attributable — no recorded period (so the pre-C2 engine refused \ + here) and a published declaration (so there is something to resolve against). Revert \ + the `or_else` ⇒ `Priority`" ); // ── ANTI-VACUITY CONTROL: this board DOES accept a declaration ── assert_eq!( @@ -3559,8 +3578,10 @@ fn a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis() { assert_axis_scales("MODE2", "token", tokens_1, tokens_3); } -/// ITEM 2 (CR 732.2a) — the DECLARE seam: a `template: None` declaration is admitted only when -/// the recorded period belongs to the offer's own proposer. +/// ITEM 2 (CR 732.2a) — the DECLARE seam: **on an offer that published no declaration of its +/// own**, a `template: None` declaration is admitted only when the recorded period belongs to +/// the offer's own proposer. The qualifier is item-4 C2's and is load-bearing — see the arm +/// table below. /// /// **WHY THIS FIXTURE AND NOT `loop_shortcut.rs`.** Site F sits under /// `if !offer.schema.points.is_empty()`. The dina bounded offer publishes an EMPTY point set @@ -3581,11 +3602,20 @@ fn a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis() { /// foreign period would take the unvalidated sibling arm and open the CR 732.2b APNAP window on a /// client-supplied declaration. The arm therefore asks whose period it is. /// -/// | arm | sequence | expected `waiting_for` | -/// |---|---|---| -/// | EMPTY-seq | empty | `Priority` (fail-closed) — must-not-flip | -/// | OWN-seq | proposer's | `RespondToShortcut` (the legitimate object-growth route) — must-not-flip | -/// | FOREIGN-seq | an opponent's | `Priority` — **the remedy** | +/// **ALL THREE ARMS RUN ON AN OFFER WHOSE OWN `declaration` IS CLEARED (item-4 C2).** That is +/// the offer shape site F still decides — `handle_declare_shortcut` resolves a `template: None` +/// declaration against `offer.declaration` above the pin block, so an offer that published one +/// bypasses site F entirely. The clearing keeps this row on its own subject instead of silently +/// converting it into a `declaration_conforms` row; the fourth arm below is the paired positive +/// that proves the clearing is the operative axis. See the closure's own comment for why a +/// declaration-free offer is a reachable production shape rather than a contrivance. +/// +/// | arm | offer `declaration` | sequence | expected `waiting_for` | +/// |---|---|---|---| +/// | EMPTY-seq | cleared | empty | `Priority` (fail-closed) — must-not-flip | +/// | OWN-seq | cleared | proposer's | `RespondToShortcut` (the legitimate object-growth route) — must-not-flip | +/// | FOREIGN-seq | cleared | an opponent's | `Priority` — **the remedy** | +/// | RETAINED | **retained** | empty | `RespondToShortcut` — **the C2 paired positive**: one field apart from EMPTY-seq, and it flips | /// /// **TWO-SIDED CONTROL, PER ASSERTION** — no constant implementation passes: /// * **DROP** the proposer test (restore `state.last_loop_action_sequence.is_empty()`) ⇒ @@ -3594,6 +3624,10 @@ fn a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis() { /// instead (the shipped object-growth declarations break — the tree's own doc above this arm /// says keying on `template.is_none()` alone does exactly this). TRIVIALIZE to never-reject ⇒ /// EMPTY-seq returns `RespondToShortcut` ⇒ that assertion fails. +/// * **REVERT item-4 C2** (drop `let template = template.or_else(|| offer.declaration.cloned())` +/// from `handle_declare_shortcut`) ⇒ the RETAINED arm returns `Priority` ⇒ **that** assertion +/// fails, while the three cleared-offer arms are untouched (they have no declaration to +/// resolve against, so the `or_else` was already a no-op for them). /// /// ⚠ **WHAT THIS ROW DELIBERATELY DOES NOT ASSERT — a realized negative, recorded rather than /// re-keyed.** Continuing each ACCEPTED arm through `accept_all_opponents` was measured, and both @@ -3624,6 +3658,13 @@ fn a_template_free_declaration_is_admitted_only_by_the_proposers_own_period() { "REACH-GUARD: the published bound must admit `Fixed(1)`, else the arms are refused for \ a reason that has nothing to do with the period" ); + assert!( + offer_declaration(&state).is_some(), + "REACH-GUARD for the `declaration = None` mutation the closure below applies: the \ + UNTOUCHED offer really does publish a declaration, so that clearing is a genuine \ + one-field mutation rather than a no-op restating the fixture. Paired with the \ + `declaration retained` positive at the end of this row" + ); let opp = state .players @@ -3639,9 +3680,42 @@ fn a_template_free_declaration_is_admitted_only_by_the_proposers_own_period() { .expect("the dump has objects"); // One offer state, one field reassigned per arm, one action applied — nothing else differs. + // + // ⚠ THE OFFER'S OWN `declaration` IS CLEARED, and that is what keeps this row LIVE rather + // than what weakens it (item-4 C2). `handle_declare_shortcut` now resolves a `template: + // None` declaration against `offer.declaration` ABOVE the pin block, so on an offer that + // published one, `&template` takes the `Some(t)` arm and site F is never reached — all + // three arms below would read `RespondToShortcut` and the row would be measuring + // `declaration_conforms` instead of the period test it is named for. Clearing the + // declaration puts the row back on the offer shape site F still decides, which is a + // REACHABLE production shape and not a contrivance: `build_bounded_declaration` returns + // `None` on a journal miss or a kind/value mismatch even with a non-empty schema, both + // non-bounded mints hard-code `declaration: None`, and a restored save may carry `None`. + // Measured across the tracked suite at this tip: 34 distinct tests still reach site F on a + // point-carrying offer that published no declaration. let declare_with = |seq: Vec| { let mut probe = state.clone(); probe.last_loop_action_sequence = seq; + match &mut probe.waiting_for { + WaitingFor::LoopShortcut { declaration, .. } => *declaration = None, + other => panic!("expected the CR 732.2a bounded offer, got {other:?}"), + } + apply( + &mut probe, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: None, + }, + ) + .expect("dispatched — a refusal is a HANDBACK, not an error"); + probe.waiting_for.variant_name() + }; + // The SAME EMPTY-seq call with the declaration RETAINED — one field apart from the first + // assertion below, and the axis is the offer's own `declaration`. + let declare_empty_seq_with_declaration_retained = || { + let mut probe = state.clone(); + probe.last_loop_action_sequence = Vec::new(); apply( &mut probe, proposer, @@ -3683,7 +3757,377 @@ fn a_template_free_declaration_is_admitted_only_by_the_proposers_own_period() { "Priority", "FOREIGN-seq — THE REMEDY. CR 732.2a: an opponent's independent activation is not a \ template this proposer's drive can re-derive from, so admitting it would open the \ - CR 732.2b window on a client-supplied declaration that received ZERO pin validation \ + CR 732.2b window on a client-supplied declaration that received ZERO pin validation. \ + NOTE the paired assertion below: this seat-relative refusal is what site F decides on a \ + declaration-free offer, NOT a blanket refusal of `template: None` \ against a schema with published points" ); + // ── PAIRED POSITIVE, and it is what makes the two refusals above ATTRIBUTABLE ── + assert_eq!( + declare_empty_seq_with_declaration_retained(), + "RespondToShortcut", + "item-4 C2: byte-identical to the EMPTY-seq arm above except that the offer's own \ + `declaration` is RETAINED, and it flips. Two things follow, and neither is provable \ + from the refusals alone. (1) Those refusals are site F's seat-relative period verdict, \ + not this fixture refusing every `template: None` declaration for some unrelated reason \ + — an always-reject engine fails HERE. (2) Site F is REACHED at all on the cleared \ + offer, because the only difference between reaching it and bypassing it is the field \ + this assertion restores. Revert C2's `or_else` ⇒ this arm reads `Priority` and the \ + whole row degenerates into three copies of one verdict" + ); +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// item-4 C2 — the engine-issued declaration is HONOURED on the manual declare path +// +// The defect these rows close is an ACTOR DIVERGENCE on one and the same offer: the engine +// mints a bounded offer carrying its own `declaration` (the proposer's journalled answers), +// `ai_support::candidates` reads that field and declares with `template: Some(declaration)` and +// is accepted, while a browser — which structurally sends `template: null`, because the client +// never constructs a template — was refused. The repair is one `Option::or_else` in +// `handle_declare_shortcut`, placed ABOVE the `template.owner` firewall. +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// The accepted proposal behind a `RespondToShortcut` window. Panics loudly on any other state +/// so a row that meant to assert on a proposal can never silently assert on its absence. +fn accepted_proposal(state: &GameState) -> &engine::analysis::loop_check::ShortcutProposal { + match &state.waiting_for { + WaitingFor::RespondToShortcut { proposal, .. } => proposal, + other => panic!("expected the `RespondToShortcut` accept-or-shorten window, got {other:?}"), + } +} + +/// Declare `Fixed(k)` with the browser's own payload (`template: None`) against the live F4 +/// offer, returning the post-state. +fn declare_template_free(state: &GameState, proposer: PlayerId, k: u32) -> GameState { + let mut probe = state.clone(); + apply( + &mut probe, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(k), + template: None, + }, + ) + .expect("dispatched — a refusal is a HANDBACK, not an error"); + probe +} + +/// **Rows R1 + R1b — THE REPAIR.** A browser `template: null` declaration against the real +/// point-carrying bounded offer reaches the ACCEPTED declaration, at every count the picker +/// makes selectable rather than only at the suggested one. +/// +/// # Why this row exists at all +/// +/// `ai_support::candidates` gates its declare candidate on `declaration.is_some()` and sends +/// that very template, so the AI path was already green +/// ([`d6_the_ai_declare_candidate_carries_the_offers_own_published_declaration`]). The manual +/// arm bound `declaration: _` and threw the field away, so the identical offer answered the two +/// ingresses differently. `template: null` is not "no pins" — it is "no OVERRIDE of the pins you +/// already published", and this row is the measurement of that reading. +/// +/// # The revert-failing assertion, named +/// +/// `proposal.template == Some(offer_declaration(&state))` — VALUE-equal against the field the +/// offer published, never `is_some()`. Delete `let template = template.or_else(|| ...)` from +/// `handle_declare_shortcut` and every arm here lands `Priority`, so `accepted_proposal` panics +/// before any assertion is reached. +/// +/// # R1b: the counts are not the suggested one +/// +/// The picker's whole point is that any count in `[min, max]` may be declared, so a repair that +/// only worked at `suggested` would be no repair. `k = 1` is the window's lower edge and +/// `k = 5` is neither edge nor the suggestion — no implementation that special-cases +/// `max_iterations` (which this board publishes as `suggested`) satisfies the `k = 5` arm. +/// `proposal.count` is asserted per arm, so an engine that accepted the declaration but drove +/// the suggested count anyway fails here rather than silently overriding the player. +/// +/// # Reach-guards, asserted BEFORE the claim +/// +/// The schema publishes points (else the pin block is skipped and the row measures the empty +/// path — that is [`c2_r4b_a_points_empty_offer_is_gated_by_the_owner_firewall_alone`]'s +/// subject), the schema is bounded, the offer really published a declaration (else the +/// `or_else` has nothing to resolve against and every arm would be measuring site F), and the +/// window is wide enough that `k = 5` is genuinely interior. The bound is read from the schema +/// rather than pinned to a literal. +#[test] +fn c2_r1_the_browsers_template_free_declaration_reaches_the_accepted_declaration() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + let (points, bounded, max) = ( + schema.points.len(), + schema.is_bounded(), + schema.max_iterations, + ); + + assert!( + points > 0, + "REACH-GUARD: with an empty point set `handle_declare_shortcut` skips the pin block \ + entirely and this row would measure the owner firewall instead of the repair" + ); + assert!( + bounded, + "REACH-GUARD: an unbounded schema takes the `UntilLethal` arms, not this one" + ); + let published = offer_declaration(&state).expect( + "REACH-GUARD: the `or_else` resolves against THIS field — without it every arm \ + below would be measuring site F's period test, not the repair", + ); + assert!( + max >= 5, + "REACH-GUARD: `k = 5` must be INTERIOR to the declarable window, else R1b's \ + non-suggested arm is refused by the `Fixed(n) > max_iterations` cap for a reason that \ + has nothing to do with the repair. max_iterations={max}" + ); + + // R1 — the suggested count, which is `max` on this board. + let at_max = declare_template_free(&state, proposer, max); + assert_eq!( + accepted_proposal(&at_max).template.as_ref(), + Some(&published), + "item-4 C2: the accepted proposal carries the offer's OWN published declaration, \ + value-equal. `is_some()` would also pass on an engine that fabricated an empty \ + template, which is precisely the wrong implementation \ + `a_template_free_declaration_is_admitted_only_by_the_proposers_own_period` kills" + ); + assert_eq!( + accepted_proposal(&at_max).count, + IterationCount::Fixed(max), + "and the count the player named is the count the proposal carries" + ); + + // R1b — a lower-edge count and a strictly interior one. Neither is `suggested`. + for k in [1u32, 5] { + let post = declare_template_free(&state, proposer, k); + assert_eq!( + accepted_proposal(&post).template.as_ref(), + Some(&published), + "R1b at k={k}: the picker may name ANY count in the window, and the resolved \ + declaration is the same published one at every count — the offer publishes one \ + declaration, not one per count" + ); + assert_eq!( + accepted_proposal(&post).count, + IterationCount::Fixed(k), + "R1b at k={k}: the proposal drives the count the player NAMED. An engine that \ + accepted the declaration and then substituted `suggested` fails here. k=5 is \ + neither window edge (1/{max}) nor the suggestion, so no hard-coded value \ + satisfies this arm" + ); + } +} + +/// **Row R3 — PLACEMENT.** A restored offer whose published declaration carries a FOREIGN owner +/// is refused, because the `or_else` resolves the `None` template ABOVE the `template.owner` +/// firewall rather than below it. +/// +/// # ⚠ What this row does and does not discriminate — read before trusting it +/// +/// **It does NOT discriminate the C2 repair itself: it passes both ways.** Pre-repair the +/// `template: None` never resolves, reaches site F and lands `Priority`; post-repair the +/// resolved `Some(hostile)` reaches the owner firewall and lands `Priority`. Same verdict by two +/// different paths, and the paths are indistinguishable from outside — all six refusal arms call +/// the same `reject_shortcut_declaration`, which writes a byte-identical `WaitingFor::Priority` +/// and pushes zero events (`game/engine.rs`, on the count `match`: *"no row can observe which +/// block refused first"*). No assertion can recover which arm fired, so none is attempted here; +/// an arm-exclusion assert would read as verification while proving nothing. +/// [`c2_r1_the_browsers_template_free_declaration_reaches_the_accepted_declaration`] is what +/// covers the repair. +/// +/// **What it DOES discriminate is the `or_else`'s PLACEMENT**, which is the one thing about C2 +/// that is not self-evident from the diff. Move that statement one line down, below the +/// firewall, and this row flips to `RespondToShortcut`: the firewall would see the unresolved +/// `None` and pass it, then the `Some(t)` arm would judge the hostile template by +/// `declaration_conforms` alone — and `declaration_conforms` is `predictability_gate && +/// validate_pins`, neither of which reads `owner`. The firewall is therefore the SOLE refuser of +/// a foreign-owner declaration, and below it there is nothing left to refuse one. +/// MEASURED, by physically relocating the statement: refused above, ACCEPTED below. +/// +/// # Fixture guard, labelled honestly +/// +/// `offer_declaration(..).is_some()` after the mutation is a FIXTURE guard — it proves the owner +/// rewrite did not erase the declaration — and not a path discriminator. It is true pre-repair +/// as well. +/// +/// # The matched positive is what makes "refused" mean anything +/// +/// The untampered offer, same call, same count, must open APNAP. Without it, `Priority` here is +/// indistinguishable from a fixture that refuses everything. The two differ in exactly one +/// field: `declaration.owner`. +#[test] +fn r3_placement_a_restored_foreign_owner_declaration_is_refused() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + assert!( + !schema.points.is_empty(), + "REACH-GUARD: an empty point set would make the two arms differ for a different reason" + ); + let hostile = state + .players + .iter() + .find(|p| p.id != proposer && !p.is_eliminated) + .map(|p| p.id) + .expect("REACH-GUARD: a living seat other than the proposer must exist on a 4p board"); + + // The RESTORE ingress image: a persisted offer whose published declaration names another + // seat. One field differs from the untampered board. + let mut tampered = state.clone(); + match &mut tampered.waiting_for { + WaitingFor::LoopShortcut { declaration, .. } => { + declaration + .as_mut() + .expect("the untampered offer publishes a declaration") + .owner = hostile; + } + other => panic!("expected the CR 732.2a bounded offer, got {other:?}"), + } + assert_eq!( + offer_declaration(&tampered).map(|d| d.owner), + Some(hostile), + "FIXTURE GUARD (not a path discriminator): the owner rewrite landed and did not erase \ + the declaration. This is equally true before the repair" + ); + + assert_eq!( + declare_template_free(&tampered, proposer, 1) + .waiting_for + .variant_name(), + "Priority", + "PLACEMENT: the resolved declaration meets the `template.owner` firewall BEFORE anything \ + else looks at it. Relocate the `or_else` below that firewall and this reads \ + `RespondToShortcut`, because `declaration_conforms` accepts a template that differs \ + only in `owner`" + ); + // ── MATCHED POSITIVE, one field apart ── + assert_eq!( + declare_template_free(&state, proposer, 1) + .waiting_for + .variant_name(), + "RespondToShortcut", + "the byte-identical offer whose declaration is owned by the PROPOSER opens the APNAP \ + window. Without this arm the refusal above would be indistinguishable from a fixture \ + that refuses every declaration" + ); +} + +/// **Rows R4b + R5 — the points-EMPTY offer, where the owner firewall is the only gate.** +/// +/// `handle_declare_shortcut` runs the pin block only under `!offer.schema.points.is_empty()`, so +/// on a point-free offer neither `declaration_conforms` nor site F ever runs and the resolved +/// template meets the firewall alone. Three arms on one F4-derived fixture, `schema.points` +/// emptied: +/// +/// | arm | offer `declaration` | expected | +/// |---|---|---| +/// | **R5** point-free control | cleared | `RespondToShortcut` — accepts pre- AND post-repair | +/// | **R4b/A** | retained, `owner == proposer` | `RespondToShortcut`, and `proposal.template` carries it | +/// | **R4b/B** | retained, `owner == hostile` | `Priority` — the firewall, alone | +/// +/// # Per-arm discrimination, stated rather than assumed +/// +/// **R5 passes both ways and is labelled a CONTROL.** Its job is to prove this fixture accepts +/// declarations at all once the point set is gone, so R4b/B's refusal is attributable to the +/// owner rather than to the emptied schema. It also pins that the `or_else` is a genuine no-op +/// on the shape §4.3 calls row 4: every production mint publishes `declaration: None` for an +/// empty schema, because `build_bounded_declaration` returns `None` on +/// `schema.points.is_empty()` before doing anything else. +/// +/// **R4b/A discriminates the repair** — pre-repair `proposal.template` is `None` here, so the +/// `Some(..)` assertion fails. **R4b/B discriminates in the OPPOSITE direction** — pre-repair +/// the firewall sees an unresolved `None` and ACCEPTS, so `Priority` is the post-repair verdict +/// only. The pair is the row; neither half alone shows both directions. +/// +/// # The capability R4b/B does not create, recorded because it looks like one +/// +/// A points-empty offer carrying a restored declaration is reachable only through the restore +/// ingress — no production mint emits that pair. The `or_else` is deliberately NOT guarded with +/// `!points.is_empty()`: a live client can already send `template: Some(anything owned by the +/// proposer)` against a points-empty offer today and reach `proposal.template` with the pin +/// block skipped, so the firewall is the only gate on this shape both before and after. A guard +/// for an unreachable case is a special case; the behaviour is pinned here instead. +#[test] +fn c2_r4b_a_points_empty_offer_is_gated_by_the_owner_firewall_alone() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, _schema) = offer_parts(&state); + let hostile = state + .players + .iter() + .find(|p| p.id != proposer && !p.is_eliminated) + .map(|p| p.id) + .expect("REACH-GUARD: a living seat other than the proposer must exist on a 4p board"); + let published = + offer_declaration(&state).expect("the untampered offer publishes a declaration"); + + // One F4 offer, `schema.points` emptied, `declaration` set per arm. Nothing else differs. + let point_free_offer = |declaration: Option| { + let mut probe = state.clone(); + match &mut probe.waiting_for { + WaitingFor::LoopShortcut { + schema, + declaration: decl, + .. + } => { + schema.points.clear(); + *decl = declaration.map(|owner| { + let mut d = published.clone(); + d.owner = owner; + d + }); + } + other => panic!("expected the CR 732.2a bounded offer, got {other:?}"), + } + assert!( + match &probe.waiting_for { + WaitingFor::LoopShortcut { schema, .. } => schema.points.is_empty(), + _ => false, + }, + "REACH-GUARD: the row is about the SKIPPED pin block, so the point set must really \ + be empty — otherwise `declaration_conforms` runs and the firewall is not alone" + ); + probe + }; + + // ── R5, the point-free CONTROL: passes pre- and post-repair ── + assert_eq!( + declare_template_free(&point_free_offer(None), proposer, 1) + .waiting_for + .variant_name(), + "RespondToShortcut", + "R5 CONTROL: a point-free offer publishing no declaration drains exactly as before — \ + the `or_else` resolves `None` to `None` and is a no-op. This arm is what makes R4b/B's \ + refusal below attributable to the OWNER rather than to the emptied schema" + ); + + // ── R4b/A: retained declaration, owner == proposer ── + let honest = declare_template_free(&point_free_offer(Some(proposer)), proposer, 1); + assert_eq!( + honest.waiting_for.variant_name(), + "RespondToShortcut", + "R4b/A: the firewall passes a declaration owned by the proposer" + ); + assert_eq!( + accepted_proposal(&honest) + .template + .as_ref() + .map(|t| t.owner), + Some(proposer), + "R4b/A discriminates the repair: PRE-repair `proposal.template` is `None` here, because \ + the offer's declaration was discarded and the pin block never ran. The resolved \ + template reaching the proposal is the change" + ); + + // ── R4b/B: retained declaration, foreign owner — the OPPOSITE direction ── + assert_eq!( + declare_template_free(&point_free_offer(Some(hostile)), proposer, 1) + .waiting_for + .variant_name(), + "Priority", + "R4b/B discriminates in the opposite direction from R4b/A: PRE-repair this ACCEPTS, \ + because the firewall inspects an unresolved `None` and passes it. Post-repair the \ + resolved foreign-owner declaration meets the firewall and is refused. A row asserting \ + only R4b/A would miss that the repair WIDENS what the firewall inspects" + ); } From 2e8e3ae73ec6e32cff77687570479569dd04d917 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Thu, 13 Aug 2026 17:35:25 -0500 Subject: [PATCH 35/44] test(engine): re-derive the CR 603.5 prompt census coordinate after C2 C2 adds four production lines above `begin_pending_trigger_target_selection`, which shifts the `OptionalEffectChoice` producer the census pins by literal source coordinate. Only the `game/engine.rs` element moved; the vector's `effects/mod.rs` element arrived already correct from upstream #7374, which re-pinned it for a producer shift in that file. Derived, not predicted. The whole-file sha256 content scan finds the producer digest exactly once, at :12759, and once at :12717 on the parent. The nearest preceding fn is unchanged and the invariant offset of 134 holds on both trees. Arithmetic is a check rather than a source: the four hunks above the producer sum to +42, and 12717 + 42 = 12759. A fourth instrument that shares no code with the digest scan agrees -- the census test's own failure named :12759 before anything was edited. The population is unchanged, confirmed by failure shape rather than by inspection: the total and partition asserts both stayed green and only the vector assert fired, which is a coordinate shift and not a producer gained or lost. C2 adds one `Option::or_else`, one struct field and a match-arm binding; none assigns `waiting_for`, so no needle-matching line enters or leaves. A pre-rebase derivation of this same coordinate was discarded unused rather than carried across the rebase, on the census's own principle that a predicted coordinate is exactly what it exists to catch. `cargo test -p phase-engine --lib`: 19029 passed, 0 failed. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/engine.rs | 37 +++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index bb86fd0172..460127e311 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -18802,7 +18802,42 @@ mod stage2_injector_tests { // `8a544e87…5cc7d63` matches exactly ONE line under a whole-file scan and is // still inside `begin_pending_trigger_target_selection`, at the invariant offset // 134. `12712 + 5` is the CHECK that agreed, not the derivation. - "game/engine.rs:12717".to_string(), + // + // ⚠ item-4 C2 (the manual declare path honours the offer's own published + // declaration): `:12717 ⇒ :12759`, `+42`. LOCAL, not upstream. LOCATED BY + // CONTENT DIGEST, never by arithmetic: the line whose sha256 is + // `8a544e87…5cc7d63` — the digest this log has carried since `a6d1a0e62` — + // matches EXACTLY ONE line under a whole-file scan of the new tree, at `:12759`, + // and exactly one in the parent, at `:12717`. It is still inside + // `begin_pending_trigger_target_selection` (`:12625`) with no intervening `fn`, + // at the INVARIANT OFFSET 134 — `12759 - 12625`, and the parent's + // `12717 - 12583`. Arithmetic CHECK afterwards, never as the source: `git diff + // -U0` against the parent shows FOUR hunks, ALL above this producer — `+4` + // (`LoopShortcutOffer`'s new `declaration` field and its doc), `+35` + // (`handle_declare_shortcut`'s `or_else` and the placement rationale above it), + // `+2` net (`apply_action`'s `declaration: _` discharge rewritten as a bind, + // `-5`/`+7`) and `+1` (`declaration: declaration.as_ref(),` in the struct + // literal) — summing to exactly `+42`, and `12717 + 42 = 12759`. + // + // DERIVED TWICE, ACROSS A REBASE, AND THAT IS THE ENTRY'S POINT. This value was + // first measured pre-rebase against `b51e45c59`, then DISCARDED unused and + // re-derived from scratch against the rebased tree rather than carried — the + // discipline the entry six above states as *"a coordinate is a fact about a + // tree, not a property of this commit"*. The two derivations agreeing is a + // result, not a shortcut that was taken. (The rebase moved this file's OTHER + // stale element for us: upstream `d11529d0c` re-pinned + // `game/effects/mod.rs:9922 ⇒ :9932`, which arrived through the rebase already + // correct and is not this commit's to touch.) + // + // SET PRESERVATION: C2 adds ONE production statement (an `Option::or_else`) and + // one struct field, and rewrites a match-arm binding from `declaration: _` to a + // bind. None of the three assigns `state.waiting_for`, so no line matching the + // needle is added or removed and no `OptionalEffectChoice` prompt can be minted. + // Confirmed by the failure shape rather than by inspection alone: the total (38) + // and the partition (5/8/25) both fired GREEN on the run that caught this, and + // the panic was on this third assert alone — which is what makes it a coordinate + // shift rather than a population change. + "game/engine.rs:12759".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ From 2cedd93704a827d1887cfc4a3fba1d20dce40ef5 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Thu, 13 Aug 2026 18:04:30 -0500 Subject: [PATCH 36/44] feat(protocol): bump both wire contracts for the loop-shortcut surface additions Two serialized surfaces gained fields in this PR without a coordinated version move: `WaitingFor::LoopShortcut` now carries `declaration`, and `InteractionResponseSpec::Shortcut` now carries `preview`. Both are absent at the merge-base, so both are genuinely new here. Two counters move, and they are independent rather than coupled. The full-game contract goes 30 -> 31 across the Rust const and its TypeScript twin, which `scripts/check-protocol-version.mjs` binds together. The P2P wire contract goes 20 -> 21; it is TypeScript-only, has no Rust referent, and the gate does not read it. Each moves +1 for the same payload arriving over a different transport. This is a capability bump rather than a parse bump -- both fields are `Option` with serde defaults, so an old peer still parses a new frame. That follows the precedent the lobby-broker header cites for 24, and the changelog says so explicitly rather than implying a parse break. The gate was mutation-probed rather than trusted for exiting 0: a Rust-only bump reports `Rust=31, client=30`; agreeing consts that disagree with the expected value report `must remain 31`; a broken floor expression throws. It fails on exactly the partial-bump mode the review raised, and it is chained into `type-check`. On "green current checks do not exercise an old/new peer pairing": the full-game counter already had refusal coverage on both sides, but every such test derives its version from the const, so all of them pass identically before and after a bump. They prove the refusal mechanism, not the bump. The P2P counter had no adjacent-peer test at all, so this adds one that stamps literals -- v20 refused, v21 admitted -- since a fixture built from the const cannot discriminate. That row was verified by reverting the const to 20 and running it: the refusal half stops throwing, and the accept half reports `host sent v21, this client speaks v20`. The revert was restored and the diff hash confirmed byte-identical. It is not a live two-peer WebRTC handshake; no such harness exists in this repo. Deliberately not written: the plan's prescribed changelog wording states that `preview` became a list, which is false at this tree -- C3 has not landed and `preview` is still `Option`. The prose here describes the shape that exists. Because 31 is unreleased, C3 must extend this entry's wording rather than bump again; a second bump inside one PR would advertise a compatibility generation that never existed on its own. Assisted-by: ClaudeCode:claude-opus-5 --- client/src/adapter/ws-adapter.ts | 9 +++- client/src/network/__tests__/protocol.test.ts | 41 ++++++++++++++----- client/src/network/protocol.ts | 6 ++- crates/lobby-broker/src/protocol.rs | 13 ++++-- crates/server-core/src/protocol.rs | 6 +-- scripts/check-protocol-version.mjs | 2 +- 6 files changed, 57 insertions(+), 20 deletions(-) diff --git a/client/src/adapter/ws-adapter.ts b/client/src/adapter/ws-adapter.ts index 18ffbb4088..0b86aa3a0a 100644 --- a/client/src/adapter/ws-adapter.ts +++ b/client/src/adapter/ws-adapter.ts @@ -203,6 +203,13 @@ export class NativeEngineVersionMismatchError extends Error { * `crates/server-core/src/protocol.rs`. Bump in lockstep when either side * adds, removes, renames, or changes the type of a protocol variant field. * + * 31 — WaitingFor::LoopShortcut publishes the engine-issued declaration, and + * InteractionResponseSpec::Shortcut publishes preview, the per-axis + * consequence of the offered count. Both are optional, so a v30 peer still + * PARSES the frame — a capability bump like 24, not a parse bump. A v31 + * client paired with a v30 server sends the template-free DeclareShortcut + * these fields authorize and has its declaration silently dropped, with no + * parse error to catch it. * 30 — Serialized player-action completion provenance and modal continuations. * 29 — Added requester-correlated ResolveAllRejected response frames. * 28 — Added native ResolveAll request/result frames. @@ -237,7 +244,7 @@ export class NativeEngineVersionMismatchError extends Error { * into a MulliganDecisionPhase::BottomCards sub-phase on * WaitingFor::MulliganDecision. */ -export const PROTOCOL_VERSION = 30; +export const PROTOCOL_VERSION = 31; /** * Lowest server protocol version this client will accept in the handshake. diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index 541a869842..e79d01179d 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -36,8 +36,8 @@ const viewerInteractionWithProducedMana = { } as never; describe("encodeWireMessage / decodeWireMessage", () => { - it("pins the P2P wire protocol to v20", () => { - expect(WIRE_PROTOCOL_VERSION).toBe(20); + it("pins the P2P wire protocol to v21", () => { + expect(WIRE_PROTOCOL_VERSION).toBe(21); }); it("defaults shortcut actions for a legacy payload created before the additive field", () => { @@ -214,17 +214,36 @@ describe("encodeWireMessage / decodeWireMessage", () => { await expect(decodeWireMessage(new Uint8Array())).rejects.toThrow(/empty/); }); + const setupFrameAt = (wireProtocolVersion: number) => ({ + type: "game_setup", + wireProtocolVersion, + assignedPlayerId: 1, + playerToken: "token-123", + state: buildGameState(), + events: [], + legalActions: [], + manaPaymentShortcutActions: [], + }); + it("rejects stale setup wire protocol versions", () => { - expect(() => validateMessage({ + expect(() => validateMessage(setupFrameAt(4))).toThrow(/Wire protocol mismatch/); + }); + + // The ADJACENT-peer pairing, which the far-stale v4 row above cannot exercise: + // 4 is refused whatever this client speaks, so that row proves the mechanism + // and nothing about the version. Both halves here stamp LITERALS — a frame + // built from WIRE_PROTOCOL_VERSION cannot tell a bumped client from an + // unbumped one, which is why every other handshake fixture in the suite is + // useless as an instrument for a bump. Revert 21 → 20 and BOTH halves red: + // the v20 frame stops being refused, and the v21 frame stops being admitted. + // The admitting half is the reach-guard: without it "refuses v20" is also + // satisfied by a client that refuses everything. + it("refuses the previous wire protocol (v20) and admits its own (v21)", () => { + expect(() => validateMessage(setupFrameAt(20))).toThrow(/Wire protocol mismatch/); + expect(validateMessage(setupFrameAt(21))).toMatchObject({ type: "game_setup", - wireProtocolVersion: 4, - assignedPlayerId: 1, - playerToken: "token-123", - state: buildGameState(), - events: [], - legalActions: [], - manaPaymentShortcutActions: [], - })).toThrow(/Wire protocol mismatch/); + wireProtocolVersion: 21, + }); }); // (e) Compressed payload still gates through validateMessage so unknown diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 9f70d390a3..2399511c68 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -80,6 +80,10 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * of silently corrupting state. * * Bumps to date: + * 21 — LegalActionsWire.viewerInteraction carries the loop-shortcut preview, + * and the state snapshot carries WaitingFor::LoopShortcut.declaration. + * Both are optional and parse on a v20 peer; the loss is silent, so the + * handshake is the only place the pairing can be refused. * 20 — Serialized player-action completion provenance and modal continuations. * 19 — Added an action_noop acknowledgement for accepted transport no-ops. * 18 — DebugCardEntries added a serialized, private resolution frame for @@ -112,7 +116,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards * variant was removed */ -export const WIRE_PROTOCOL_VERSION = 20 as const; +export const WIRE_PROTOCOL_VERSION = 21 as const; export type P2PMessage = P2PAuthorityWire & ( | { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string } diff --git a/crates/lobby-broker/src/protocol.rs b/crates/lobby-broker/src/protocol.rs index e00e578a68..38918a9a95 100644 --- a/crates/lobby-broker/src/protocol.rs +++ b/crates/lobby-broker/src/protocol.rs @@ -43,6 +43,13 @@ pub enum ServerErrorCode { /// handshake. When making such changes, plan a deprecation window where /// both the old and new variants coexist, then bump and remove the old. /// +/// 31 — `WaitingFor::LoopShortcut` publishes the engine-issued `declaration`, and +/// `InteractionResponseSpec::Shortcut` publishes `preview`, the per-axis +/// consequence of the offered count. Both are `Option`, so a v30 peer still +/// *parses* the frame — this is a capability bump like 24, not a parse bump. +/// A v31 client paired with a v30 server sends the template-free +/// `DeclareShortcut` these fields authorize and has its declaration silently +/// dropped, with no parse error to catch it. /// 30 — Serialized player-action completion provenance and modal continuations. /// 29 — Added requester-correlated `ResolveAllRejected` response frames. /// 28 — Added native `ResolveAll` request/result frames. @@ -77,7 +84,7 @@ pub enum ServerErrorCode { /// payload; mulligan bottoming folded into a /// `MulliganDecisionPhase::BottomCards` sub-phase on /// `WaitingFor::MulliganDecision`. -pub const PROTOCOL_VERSION: u32 = 30; +pub const PROTOCOL_VERSION: u32 = 31; /// Minimum protocol version accepted by lobby-only brokers at the hello /// handshake. Lobby traffic has a one-version rollout window; full game servers @@ -414,12 +421,12 @@ mod tests { #[test] fn protocol_version_tracks_full_game_wire_additions() { - assert_eq!(PROTOCOL_VERSION, 30); + assert_eq!(PROTOCOL_VERSION, 31); // Lobby keeps its one-version rollout window; full-game servers stay // current-only (`server_core::MIN_SUPPORTED_PROTOCOL == PROTOCOL_VERSION`), // which is what refuses an older full-game peer whose GameState cannot // understand a success acknowledgment the submitting client awaits. - assert_eq!(MIN_SUPPORTED_PROTOCOL, 29); + assert_eq!(MIN_SUPPORTED_PROTOCOL, 30); } #[test] diff --git a/crates/server-core/src/protocol.rs b/crates/server-core/src/protocol.rs index 115af10106..db11691d25 100644 --- a/crates/server-core/src/protocol.rs +++ b/crates/server-core/src/protocol.rs @@ -2324,8 +2324,8 @@ mod tests { } #[test] - fn protocol_version_is_30() { - assert_eq!(PROTOCOL_VERSION, 30); + fn protocol_version_is_31() { + assert_eq!(PROTOCOL_VERSION, 31); } /// The bump alone is inert — a version number nobody enforces prevents no @@ -2335,7 +2335,7 @@ mod tests { /// understand. /// /// REVERT-PROBE: relax to `PROTOCOL_VERSION - 1` — the exact regression - /// this guards — and this test reds while `protocol_version_is_30` stays + /// this guards — and this test reds while `protocol_version_is_31` stays /// green, which is why the two are separate assertions. #[test] fn full_game_floor_is_current_only_not_a_rollout_window() { diff --git a/scripts/check-protocol-version.mjs b/scripts/check-protocol-version.mjs index 3fad9222e1..64ed2758b9 100644 --- a/scripts/check-protocol-version.mjs +++ b/scripts/check-protocol-version.mjs @@ -3,7 +3,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const EXPECTED_PROTOCOL_VERSION = 30; +const EXPECTED_PROTOCOL_VERSION = 31; function extractVersion(source, pattern, label) { const match = source.match(pattern); From 6650d1b0a7e65c9ad018760e32c98a7a96205027 Mon Sep 17 00:00:00 2001 From: Lindsey Gray Date: Thu, 13 Aug 2026 18:37:26 -0500 Subject: [PATCH 37/44] fix: repair four comments that claimed mechanisms nothing enforced Three review findings and one found while fixing them, all the same shape: a comment asserting an invariant the code beside it does not provide. Tiltfile: probe-pin-census ran with an ungated auto_init and no trigger_mode while both siblings carried the Linux gates, so macOS auto-started into a Linux-only path -- every `probe-pin check` reaches `unshare` unconditionally. The resource was written hours before that gate existed and replayed cleanly onto it, which is how a clean rebase produced a platform-policy hole no diff could show. The gates are applied, and the surrounding prose now states a rule over every probe-pin resource rather than a count of them, because a count is what went stale here and a fourth resource would reopen it. Verified by evaluating the Tiltfile twice, once with a `uname` printing Darwin, since that is the only way to make a Linux-only gate discriminating on a Linux host. A comment ten lines away still quoted that resource's old auto_init. Its argument survives -- the hazard it describes is a profile mismatch, and the platform gate only narrows -- so it now names the profiles, which are stable, instead of re-quoting a condition that has a live original in the same file. engine-inventory-gen: the comment promised errors propagate rather than yielding a partial inventory that still reports success, while `syn` parse failures were skipped with `continue`. Worse, `sources.push` ran before the parse, so an unparseable file was listed as scanned while contributing nothing -- the inventory claimed completeness over a file it never read. The parse error now propagates with file context, and the push moved below it. That move is behaviour-neutral today, since any failure aborts before the write; it is worth making because it turns the invariant from one the error handling maintains into one that is true by position. Its stale enum total is re-measured, and the sentence split in two. The `types/`+`analysis/` breakdown was still exactly right; only the total had rotted, because the two had been welded into one sentence and a live total contaminated a standing fact. The breakdown now stands alone, and the total is labelled a dated snapshot pointing at the generator, which prints its own counts on every run. ws-adapter: the v31 entry described a v31 client paired with a v30 server silently dropping its declaration. Both full-game floors are exact-match, so that pairing is refused at handshake and never sends a frame. The lobby window does admit one version, but it cannot carry the action either -- LobbyClientMessage has no Action variant, and the native shell rejects actions outright in lobby-only mode -- and the P2P path a broker matchmakes is gated on build-commit equality, which is tighter still. So the hazard is nowhere, and the entry now says why rather than warning about a pairing that cannot occur. The parse-compatibility half is kept and given the basis it lacked: no `deny_unknown_fields` anywhere, so a v30 peer does parse a v31 frame. Not done, deliberately: six other Tiltfile comments quote a condition verbatim and are all exact today. They share the shape that failed here, but rewriting correct comments on unrelated lines is churn against a hypothesis. Assisted-by: ClaudeCode:claude-opus-5 --- Tiltfile | 61 +++++++++++++++++-------- client/src/adapter/ws-adapter.ts | 21 +++++++-- crates/engine-inventory-gen/src/main.rs | 39 +++++++++++----- 3 files changed, 85 insertions(+), 36 deletions(-) diff --git a/Tiltfile b/Tiltfile index b3cc7e083f..b1733989a2 100644 --- a/Tiltfile +++ b/Tiltfile @@ -24,12 +24,17 @@ TMP_IGNORE = ['**/*.tmp.*'] # probe-pin isolates through `unshare --map-root-user --mount` (util-linux) and runs its target # under `timeout` (GNU coreutils). macOS ships neither, and there is no Darwin equivalent of an -# unprivileged mount namespace to port to — so both probe-pin resources abort on a Darwin host -# no matter what the tree contains. Gate their auto_init rather than let them boot straight into -# a permanent red: a gate that is red on every change teaches everyone to stop reading the -# colour, which costs more than the gate earns. They stay VISIBLE and clickable, like every -# other opt-in resource here, so the refusal is still one click away when someone wants to see -# it. `os.name` is consulted FIRST and short-circuits, so a Windows host never reaches `uname` +# unprivileged mount namespace to port to — so EVERY probe-pin resource in this file aborts on +# a Darwin host no matter what the tree contains: `probe-pin check` reaches `isolate::run` +# unconditionally from `pipeline()` (crates/probe-pin/src/main.rs:145 — the baseline run, before +# any mutant), and that spawns `unshare` (crates/probe-pin/src/isolate.rs:157). Scoped as a rule +# over all of them rather than as a count: a count goes stale the next time one is added, which +# is precisely how a resource below came to sit here ungated. Gate their auto_init rather than +# let them boot straight into a permanent red: a gate that is red on every change teaches +# everyone to stop reading the colour, which costs more than the gate earns. They stay VISIBLE +# and clickable, like every other opt-in resource here, so the refusal is still one click away +# when someone wants to see it. +# `os.name` is consulted FIRST and short-circuits, so a Windows host never reaches `uname` # — which it does not ship, and which would fail Tiltfile LOAD rather than one resource. IS_LINUX = ( os.name == 'posix' @@ -37,9 +42,11 @@ IS_LINUX = ( ) # auto_init alone would NOT be enough: it governs only the STARTUP run, and the default -# TRIGGER_MODE_AUTO re-runs a resource whenever its deps change. Both probe-pin resources watch -# 'crates/probe-pin/', so off Linux the very next edit there would drag them back into the red -# that auto_init just avoided. Off-Linux they must stop watching too, not merely stop booting. +# TRIGGER_MODE_AUTO re-runs a resource whenever its deps change. Every probe-pin resource lists +# 'crates/probe-pin/' in `deps`, so off Linux the very next edit there would drag it back into +# the red that auto_init just avoided. Off-Linux they must stop watching too, not merely stop +# booting — so the two gates travel together: a probe-pin resource carrying only one of them is +# a bug, not a lighter reading of the policy. PROBE_PIN_TRIGGER = TRIGGER_MODE_AUTO if IS_LINUX else TRIGGER_MODE_MANUAL # Must stay a SUPERSET of what `scripts/engine-source-hash.sh` hashes as the engine cache @@ -421,18 +428,33 @@ local_resource('probe-pin-e2e', # Putting CARGO_TARGET_DIR on the `cargo probe-pin` alias instead would push a second, cold engine # build into target/probe-pin-census. What warms the shared tree is whatever else # happened to run; this resource orders itself behind nothing, which is hazard 2. -# NO `resource_deps`. MEASURED, not preferred: 'build-native' is auto_init = 'test' in enabled -# and this resource is auto_init = 'lint' in enabled, so under `tilt up -- lint` it would wait -# forever on a resource that never starts -- merged, green in the file, and enforced NOWHERE, in -# the only venue this manifest has. Every other resource_deps pair in this Tiltfile satisfies -# "dependent auto-inits => dependency auto-inits" (test-engine/test-ai -> build-native, same -# group; test-frontend -> wasm and coverage -> card-data, dependency always inits; caddy -> -# frontend violates it only under `tilt up -- https tauri`, which nothing rejects, so that pair -# is already a violation and this one would be the second -- the first that fires under a profile -# the file itself documents). Price of not depending on it: build-native saves 1.7s +# NO `resource_deps`. MEASURED, not preferred: 'build-native' auto-inits only under the `test` +# profile and this resource only under `lint` (plus the platform gate, which only narrows it +# further), so under `tilt up -- lint` it would wait forever on a resource that never starts -- +# merged, green in the file, and enforced NOWHERE, in the only venue this manifest has. +# The PROFILES are named rather than the two `auto_init` expressions quoted: the argument rests +# only on neither condition implying the other, and a quoted expression is a second copy to keep +# in sync -- which is exactly how this sentence came to misquote the resource below. +# +# Every other resource_deps pair in this Tiltfile satisfies "dependent auto-inits => +# dependency auto-inits" (test-engine/test-ai -> build-native, same group; test-frontend -> +# wasm and coverage -> card-data, dependency always inits; caddy -> frontend violates it only +# under `tilt up -- https tauri`, which nothing rejects, so that pair is already a violation +# and this one would be the second -- the first that fires under a profile the file itself +# documents). Price of not depending on it: build-native saves 1.7s # of 32.9s on the inner resolve WHEN THE TWO RUN IN SEQUENCE (see the numbers above) -- not worth # a venue that silently does not run. The parallel case is hazard 2, and it is the price of this # choice, stated rather than netted out. +# +# PLATFORM: Linux-only, under the file-wide probe-pin policy and for the reason that policy +# states -- this cmd is `probe-pin check`, and that reaches `unshare` unconditionally (see the +# IS_LINUX note at the top of this file for the two coordinates). What the gate BUYS is only +# that a Darwin host does not boot this resource into a red it can never clear; what it does NOT +# buy is any enforcement of the pin off Linux. There is no CI venue to fall back to -- MEASURED: +# `grep -rn probe-pin .github/workflows/` returns nothing, which is the state 'probe-pin-check''s +# note calls a hard stop -- so off Linux this pin's verdict is carried entirely by whatever Linux +# venue last ran it. That is the identical shape 'probe-pin-e2e' states for Tier 2, and it is +# stated here rather than inferred from the flag. local_resource('probe-pin-census', cmd = ['bash', '-c', # Starlark has NO implicit adjacent-string-literal concatenation (a Python rule this @@ -527,7 +549,8 @@ local_resource('probe-pin-census', # this exact pairing and states the reason; watching the crate without it re-imports the # retrigger loop that comment exists to document. ignore = TMP_IGNORE + ['**/tmp/**'], - auto_init = 'lint' in enabled, + auto_init = 'lint' in enabled and IS_LINUX, + trigger_mode = PROBE_PIN_TRIGGER, allow_parallel = True, labels = ['lint'], ) diff --git a/client/src/adapter/ws-adapter.ts b/client/src/adapter/ws-adapter.ts index 0b86aa3a0a..65d339df59 100644 --- a/client/src/adapter/ws-adapter.ts +++ b/client/src/adapter/ws-adapter.ts @@ -205,11 +205,22 @@ export class NativeEngineVersionMismatchError extends Error { * * 31 — WaitingFor::LoopShortcut publishes the engine-issued declaration, and * InteractionResponseSpec::Shortcut publishes preview, the per-axis - * consequence of the offered count. Both are optional, so a v30 peer still - * PARSES the frame — a capability bump like 24, not a parse bump. A v31 - * client paired with a v30 server sends the template-free DeclareShortcut - * these fields authorize and has its declaration silently dropped, with no - * parse error to catch it. + * consequence of the offered count. Both are optional and neither type + * sets deny_unknown_fields, so a v30 peer still PARSES the frame — a + * capability bump like 24, not a parse bump. UNLIKE 24, no pairing is left + * for the capability gap to bite in, so this entry names no silent-drop + * hazard: full-game floors are exact-match on BOTH sides + * (MIN_SUPPORTED_SERVER_PROTOCOL below, and MIN_SUPPORTED_PROTOCOL in + * crates/server-core/src/protocol.rs, each equal to their own + * PROTOCOL_VERSION), so a v31/v30 full-game pair is refused at the + * handshake and never sends an action frame. The one-version window that + * does exist is lobby-only (LOBBY_MIN_SUPPORTED_SERVER_PROTOCOL below / + * MIN_SUPPORTED_PROTOCOL in crates/lobby-broker/src/protocol.rs) and it + * cannot carry this capability either: DeclareShortcut rides + * ClientMessage::Action, which LobbyClientMessage has no variant for at + * all, and which reject_if_disabled in crates/phase-server/src/main.rs + * answers under ServerMode::LobbyOnly with an explicit rejection rather + * than a silent drop. * 30 — Serialized player-action completion provenance and modal continuations. * 29 — Added requester-correlated ResolveAllRejected response frames. * 28 — Added native ResolveAll request/result frames. diff --git a/crates/engine-inventory-gen/src/main.rs b/crates/engine-inventory-gen/src/main.rs index 4f5447bd92..90a220d2cd 100644 --- a/crates/engine-inventory-gen/src/main.rs +++ b/crates/engine-inventory-gen/src/main.rs @@ -81,12 +81,19 @@ struct ClusterSmell { /// Every directory whose `pub enum`s are engine surface a variant proposal must be able to /// discover. CLAUDE.md makes an inventory grep the mandatory discoverability gate before /// proposing a variant and scopes it to "any other engine enum", so the walk is the WHOLE -/// engine crate rather than a hand-kept subset: `types/` + `analysis/` left 85 of the 654 +/// engine crate rather than a hand-kept subset: a `types/` + `analysis/` walk leaves 85 /// top-level `pub enum`s under `crates/engine/src` structurally invisible to the gate -/// (`game/` 61, `ai_support/` 13, `parser/` 7, `database/` 4). One root is also shorter than -/// the list it replaces. MEASURED by running the generator: 654 enums, 5319 variants — the -/// catalogue now holds one entry per DECLARATION, so `enum_count` and the declaration count -/// are the same number. +/// (`game/` 61, `ai_support/` 13, `parser/` 7, `database/` 4). That split is the standing +/// reason for the walk root, and it is re-derivable at any time by grouping the emitted +/// `file` fields. One root is also shorter than the list it replaces. +/// +/// THE TOTALS ARE A SNAPSHOT, NOT A STANDING FACT — deliberately stated apart from the split +/// above, because pinning the two together is what let one stale digit rot the other. Measured +/// 2026-08-13: 655 enums, 5320 variants. If they move, RE-TAKE them rather than re-label; and +/// prefer not to read them here at all, since every run prints its own totals on the success +/// line, which is the only current answer. What must stay true is the invariant those totals +/// are evidence for: the catalogue holds one entry per DECLARATION, so `enum_count` and the +/// declaration count are the same number. /// /// THE CATALOGUE KEY IS MODULE-QUALIFIED (`types::card::LayoutKind`), because the widened walk /// makes ident collisions reachable and an ident key drops one side of every collision. Measured @@ -116,8 +123,14 @@ fn main() -> Result<()> { for dir in TARGET_DIRS { let target = workspace_root.join(dir); // Sorted so the emitted `sources` list — and the walk itself — does not depend on - // `readdir` order; errors propagate rather than silently yielding a partial inventory - // that still reports success. + // `readdir` order. + // + // ALL THREE per-file failures propagate: walk, read, AND parse. The parse arm used to + // `continue` past unparseable files as "likely WIP" while `sources.push` ran BEFORE + // it, so such a file was LISTED as scanned while contributing zero enums — the + // inventory reported success over a file it never read, and the `add-engine-variant` + // existence gate got a FALSE NEGATIVE indistinguishable from a true one. A WIP file is + // a reason to fix the file, not to hand that gate a silent hole. for entry in WalkDir::new(&target).sort_by_file_name() { let entry = entry.with_context(|| format!("walk {}", target.display()))?; let path = entry.path(); @@ -126,14 +139,16 @@ fn main() -> Result<()> { } let module = module_path(path.strip_prefix(&target).unwrap_or(path)); let rel = path.strip_prefix(&workspace_root).unwrap_or(path); - sources.push(rel.display().to_string()); let content = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - let file = match syn::parse_file(&content) { - Ok(f) => f, - Err(_) => continue, // skip unparseable files (likely WIP) - }; + let file = + syn::parse_file(&content).with_context(|| format!("parse {}", path.display()))?; + // AFTER the parse, not before. The ordering is unobservable today (every failure + // above aborts the run and writes nothing), so this is the structural form of the + // fix rather than the fix: `sources` means "files this inventory actually read", + // and position now enforces that instead of the error handling continuing to. + sources.push(rel.display().to_string()); for item in &file.items { if let Item::Enum(e) = item { From 7a1510a9924be898e33ca625a7202b35e368ac17 Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 19:55:59 -0500 Subject: [PATCH 38/44] fix(lobby-broker): repair the v31 changelog twin left behind by its pair The v31 entry claimed a v31 client paired with a v30 server has its declaration "silently dropped, with no parse error to catch it". The TypeScript twin in client/src/adapter/ws-adapter.ts was corrected in 759276ffe; this file was not, so the two changelogs described the same protocol version in contradictory terms. Measured at this tip rather than carried from the twin's round: full-game floors are exact-match on both sides, so a v31/v30 full-game pair is refused at the handshake and never sends an action frame. The one-version window is lobby-only, and DeclareShortcut rides ClientMessage::Action, which LobbyClientMessage has no variant for and which reject_if_disabled answers with an explicit rejection under ServerMode::LobbyOnly. P2P is gated tighter still, on build-commit equality. No pairing is left for the capability gap to bite in. The parse-tolerance conclusion is additionally anchored on the absence of deny_unknown_fields, which holds regardless of preview's eventual type; the "Both are Option" premise is left for the partition that retypes it. Assisted-by: ClaudeCode:claude-opus-5 --- crates/lobby-broker/src/protocol.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/lobby-broker/src/protocol.rs b/crates/lobby-broker/src/protocol.rs index 38918a9a95..bcc48dfb3e 100644 --- a/crates/lobby-broker/src/protocol.rs +++ b/crates/lobby-broker/src/protocol.rs @@ -45,11 +45,21 @@ pub enum ServerErrorCode { /// /// 31 — `WaitingFor::LoopShortcut` publishes the engine-issued `declaration`, and /// `InteractionResponseSpec::Shortcut` publishes `preview`, the per-axis -/// consequence of the offered count. Both are `Option`, so a v30 peer still -/// *parses* the frame — this is a capability bump like 24, not a parse bump. -/// A v31 client paired with a v30 server sends the template-free -/// `DeclareShortcut` these fields authorize and has its declaration silently -/// dropped, with no parse error to catch it. +/// consequence of the offered count. Both are `Option` and neither type sets +/// `deny_unknown_fields`, so a v30 peer still *parses* the frame — this is a +/// capability bump like 24, not a parse bump. UNLIKE 24, no pairing is left to +/// exercise the gap, so this entry names no silent-drop hazard. Full-game floors +/// are exact-match on both sides (`server_core::MIN_SUPPORTED_PROTOCOL == +/// PROTOCOL_VERSION`, and `MIN_SUPPORTED_SERVER_PROTOCOL` in +/// `client/src/adapter/ws-adapter.ts`), so a v31/v30 full-game pair is refused +/// at the handshake and never sends an action frame. The one-version window is +/// this file's `MIN_SUPPORTED_PROTOCOL` below, and it is lobby-only: +/// `DeclareShortcut` rides `ClientMessage::Action`, which `LobbyClientMessage` +/// has no variant for at all, and which `reject_if_disabled` in +/// `crates/phase-server/src/main.rs` answers under `ServerMode::LobbyOnly` with +/// an explicit rejection rather than a silent drop. The P2P games this broker +/// matchmakes are gated tighter still, on build-commit equality +/// (`check_build_commit`), not on a protocol window. /// 30 — Serialized player-action completion provenance and modal continuations. /// 29 — Added requester-correlated `ResolveAllRejected` response frames. /// 28 — Added native `ResolveAll` request/result frames. From cf239c92b4fa8fd3582bb86121142700ebaec292 Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 20:34:12 -0500 Subject: [PATCH 39/44] fix(client): scope the loop-shortcut picker to the engine-issued offer GamePage keeps DeclareShortcutModal mounted and the modal self-gates, so two consecutive offers shared one DeclareShortcutOffer instance and its `picked` state: a count typed into offer A was submitted for a later, differently-suggested offer B. Key the offer body on the engine's interaction id. The id already exists and already reaches the client -- allocate_interaction_ids mints "{session}.{generation}.{serial}" and rebind_interaction_slots_after_action re-mints non-simultaneous Single decisions "including A->A and A->B->A" -- so no engine change is needed. The new selector returns the branded string itself, never a wrapper: zustand 5's useStore is a bare useSyncExternalStore with no equality function, so a selector returning a fresh object literal is the documented infinite-loop shape. The comment that claimed the mount lifecycle already prevented this named only the offer -> other-state -> offer path. The offer -> offer path is covered by the key and only by the key, because the transport may deliver B with no render committed at an intermediate state. Two regression rows, both using rerender() rather than the file's cleanup() + render() idiom, which would remount the child and pass against the live defect. The second is the discriminating one: offer B carries a byte-identical window and differs only in interactionId, so a key derived from the window or any waitingFor.data field passes the first row and fails the second. With the key removed both red, reading offer A's typed count. Also corrects two comments C2 falsified: the phase-ai policy note, which missed that the handler resolves a payload-free template against the published offer before the firewall, and a file-inventory claim that no r3_ row existed where C2 added one. Assisted-by: ClaudeCode:claude-opus-5 --- .../components/modal/LoopShortcutModal.tsx | 42 +++++++++- .../__tests__/LoopShortcutModal.test.tsx | 80 ++++++++++++++++++- .../fantastic_four_bounded_loop.rs | 11 ++- crates/phase-ai/src/policies/loop_shortcut.rs | 9 ++- 4 files changed, 132 insertions(+), 10 deletions(-) diff --git a/client/src/components/modal/LoopShortcutModal.tsx b/client/src/components/modal/LoopShortcutModal.tsx index 2c8760ec9f..1cd9e73ecf 100644 --- a/client/src/components/modal/LoopShortcutModal.tsx +++ b/client/src/components/modal/LoopShortcutModal.tsx @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import type { + InteractionId, InteractionResponseSpec, InteractionShortcutPreview, ViewerInteraction, @@ -43,6 +44,24 @@ function shortcutSpec(interaction: ViewerInteraction | null): ShortcutSpec | nul return null; } +/** The live shortcut offer's interaction id — the identity React keys the offer body on. + * Walks the same list under the same predicate as `shortcutSpec`, so the two agree on which + * opportunity is "the offer" by construction rather than by convention. + * + * Returns the branded string ITSELF, never a wrapper object. `useGameStore` is zustand 5, whose + * `useStore` is a bare `useSyncExternalStore` with no equality function, so React compares + * successive selector results with `Object.is`: a selector returning a fresh object literal is + * the documented infinite-loop shape, not merely an extra render. `InteractionId` is + * `string & { __brand }` — a primitive at runtime — so this is `Object.is`-stable whenever the + * id has not rotated, for the same reason `shortcutSpec`'s store reference is. */ +function shortcutInteractionId(interaction: ViewerInteraction | null): InteractionId | null { + for (const opportunity of interaction?.opportunities ?? []) { + if (opportunity.response.type !== "schema") continue; + if (opportunity.response.data.spec.type === "shortcut") return opportunity.interactionId; + } + return null; +} + // CR 732.1b: render the engine-proposed repeat mode — the offer's own stated count, echoed // verbatim. The picker below narrows WITHIN the engine's published window; this line is the // offer, not the pick. @@ -126,13 +145,28 @@ export function DeclareShortcutModal() { const waitingFor = useGameStore((s) => s.waitingFor); // `shortcutSpec` returns a reference INTO store state (or null), so the selector is stable. const spec = useGameStore((s) => shortcutSpec(s.viewerInteraction)); + // A branded string, not an object literal — same snapshot-stability reason as the line above. + const offerId = useGameStore((s) => shortcutInteractionId(s.viewerInteraction)); if (waitingFor?.type !== "LoopShortcut" || !canAct) return null; - // The offer body is mounted only while the offer is live, so the picker's entry cannot survive - // into a later offer: this component itself never unmounts (GamePage keeps both modals mounted - // and they self-gate), which is exactly how a stale typed count would otherwise leak. - return ; + // A typed count must not survive into a LATER offer, and the two ways one offer can follow + // another need two different mechanisms. Naming only the first is how this comment was wrong: + // - offer -> other-state -> offer: covered by the `return null` above, which unmounts the body + // when the state leaves `LoopShortcut`. This component itself never unmounts (GamePage keeps + // both modals mounted and they self-gate), so that guard is the only unmount there is. + // - offer -> offer: covered by the `key` below and ONLY by it. React reconciles by element type + // and position, so without a key two consecutive offers share one `DeclareShortcutOffer` + // instance and its `picked`. The transport may deliver B without the client ever committing a + // render at an intermediate state, so the first guard is not merely weaker here — it never runs. + // `interactionId` is the identity because it rotates in ENGINE state on every accepted action: + // `LoopShortcut` classifies as a non-simultaneous Single decision, and + // `rebind_interaction_slots_after_action` re-mints those "including A→A and A→B→A". A key built + // from the published window, or from any `waitingFor.data` field, is not distinct between two + // offers that happen to carry equal values — the plausible fix that reads as a fix. + // `offerId` is null only when no shortcut opportunity is published, and then `spec` is null too + // (one predicate, one list), so no picker renders and there is no `picked` to leak. + return ; } function DeclareShortcutOffer({ diff --git a/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx b/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx index 84c0bb7ad5..fb24035bdd 100644 --- a/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx +++ b/client/src/components/modal/__tests__/LoopShortcutModal.test.tsx @@ -26,7 +26,13 @@ type ShortcutSpec = Extract["data /** The engine's published shortcut response spec, delivered on `viewerInteraction` exactly as * `gameStore.legalResultState` assigns it. Defaults mirror the live publisher * (`game/interaction.rs`): a Fixed window and `allow_decline: true`. */ -function shortcutInteraction(overrides: Partial = {}): ViewerInteraction { +function shortcutInteraction( + overrides: Partial = {}, + // The offer's identity. Defaults to the literal every existing row was already written + // against, so parameterizing it changes no existing row; the A→B rows below pass distinct + // ids because a rotating id is precisely what they discriminate on. + interactionId = "session.0.1", +): ViewerInteraction { const spec: ShortcutSpec = { count: { type: "fixed", data: { min: 1, max: 5, suggested: 5 } }, points: [], @@ -42,7 +48,7 @@ function shortcutInteraction(overrides: Partial = {}): ViewerInter autoPassRecommended: false, opportunities: [ { - interactionId: "session.0.1" as InteractionId, + interactionId: interactionId as InteractionId, response: { type: "schema", data: { spec: { type: "shortcut", data: spec }, candidates: [] }, @@ -264,6 +270,76 @@ describe("LoopShortcutModal", () => { }); }); + // C4/§7.5: a count typed into offer A must not survive into offer B. The body is keyed on the + // offer's `interactionId`, which the engine re-mints on every accepted action. + // + // ⚠ The second render MUST be `view.rerender(...)`, never a second `render(...)`. A fresh + // `render` builds a new tree and mounts a new `DeclareShortcutOffer`, which resets `picked` on + // the UNFIXED code too — the row would go green against the defect and prove nothing. The rows + // above use `cleanup()` + `render()` between shapes; that is the opposite of what these need, so + // do not "fix" these into the house idiom. + it("starts offer B from its own suggestion, not the count typed into offer A (C4)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } }), + {}, + shortcutInteraction( + { count: { type: "fixed", data: { min: 1, max: 9, suggested: 5 } } }, + "session.0.1", + ), + ); + const view = render(); + + const box = screen.getByRole("spinbutton"); + // Positive reach-guard: the entry actually landed, so a later "not 2" cannot pass vacuously + // by the picker never having accepted input. `type="text"` + `role="spinbutton"`, so the + // compared value is a STRING. + fireEvent.change(box, { target: { value: "2" } }); + expect(box).toHaveValue("2"); + + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 7 } } }), + {}, + shortcutInteraction( + { count: { type: "fixed", data: { min: 1, max: 9, suggested: 7 } } }, + "session.0.2", + ), + ); + view.rerender(); + + expect(screen.getByRole("spinbutton")).toHaveValue("7"); + }); + + // The hostile sibling, and it is what kills the plausible wrong fix: offer B publishes a + // BYTE-IDENTICAL window to A and differs only in `interactionId`. A key built from the window — + // or from any `waitingFor.data` field — passes the row above and fails this one. + it("resets on a second offer carrying an identical window (C4 hostile)", () => { + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } }), + {}, + shortcutInteraction( + { count: { type: "fixed", data: { min: 1, max: 9, suggested: 5 } } }, + "session.0.1", + ), + ); + const view = render(); + + const box = screen.getByRole("spinbutton"); + fireEvent.change(box, { target: { value: "2" } }); + expect(box).toHaveValue("2"); + + seed( + buildLoopShortcutWaitingFor({ schema: { iteration_count: { Fixed: 5 } } }), + {}, + shortcutInteraction( + { count: { type: "fixed", data: { min: 1, max: 9, suggested: 5 } } }, + "session.0.2", + ), + ); + view.rerender(); + + expect(screen.getByRole("spinbutton")).toHaveValue("5"); + }); + // BL-1 (CR 732.2a), BOTH arms: Decline is offered iff the engine's `allowDecline` says so. The // false arm asserts Confirm is still present, so "no Decline button" cannot pass by the modal // having failed to render. diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index a4f74508b1..b38fec2b04 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -542,8 +542,15 @@ fn panic_message(payload: &Box) -> String { /// fail-closing on. Any surviving cross-reference to `r2` resolves to nothing. /// /// This row keeps the half it always owned — the offer fires, and its bound arithmetic is -/// correct. `r2b`/`r3`/`r4`/`r5` and the interruptibility pair are still unwritten: no `fn r2b_`, -/// `fn r3_`, `fn r4_` or `fn r5_` row exists in this file. +/// correct. `r2b`/`r4`/`r5` and the interruptibility pair are still unwritten: no `fn r2b_`, +/// `fn r4_` or `fn r5_` row exists in this file, and `interruptibility` appears nowhere in it +/// outside this sentence. **`r3` IS written** — +/// `fn r3_placement_a_restored_foreign_owner_declaration_is_refused`, added by the same commit +/// that made a template-free declaration resolve against the offer's published declaration. That +/// commit is why this sentence needed repairing at all: it was measured true when written, and a +/// row added later falsified it silently, because no sweep in this lane reads prose under +/// `crates/engine/`. Locate the row by NAME — this is a measurement of one tree, not a standing +/// property, and the next row added re-opens it. /// /// # What the assertion is bound to, and why it is not `f(x) == f(x)` /// diff --git a/crates/phase-ai/src/policies/loop_shortcut.rs b/crates/phase-ai/src/policies/loop_shortcut.rs index 1d4b84b6ea..01ae2ce7ea 100644 --- a/crates/phase-ai/src/policies/loop_shortcut.rs +++ b/crates/phase-ai/src/policies/loop_shortcut.rs @@ -70,8 +70,13 @@ //! needing no crown — so a reject that ignored the count would be wrong for the class. Today's AI //! candidate generator only ever emits `UntilLethal`, but `Fixed(n)` is reachable through the //! public `GameAction` surface: `handle_declare_shortcut` moves `count` into the proposal with -//! ZERO validation (the fail-closed firewall validates only `template` pins, and is skipped -//! entirely when `template` is `None`). +//! ZERO validation (the fail-closed firewall validates only `template` pins, and it runs against +//! the RESOLVED template rather than the payload's: the handler shadows it with +//! `template.or_else(|| offer.declaration.cloned())` before the `match`, so a payload carrying +//! `None` against an offer that PUBLISHED a declaration reaches the `Some` arm and IS +//! pin-validated by `declaration_conforms`. The firewall is skipped only when the payload carried +//! none AND the offer published none — the arm that still refuses unless the proposer controls +//! the recorded loop period). //! //! ## Why the verdict reads `proposer` from the state, never `ctx.ai_player` //! From 5d4c6807ec8b8157ceae2a918c76b84597b13cf8 Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 20:47:03 -0500 Subject: [PATCH 40/44] test(engine): re-derive the CR 603.5 prompt census coordinate after #7382 Upstream #7382 adds four lines in apply_action (@@ -9867,0 +9868,4 @@), entirely above the begin_pending_trigger_target_selection producer this census pins. The rebase raised the literal as a conflict on two lane commits and then drifted it SILENTLY at the tip, where the pinned line had become a bare `}`. Only the offset-from-enclosing-fn control caught the silent one: it is still 134, which is what re-establishes producer identity, since the same mint text occurs at several coordinates in this crate and the text alone cannot discriminate. The new value is measured in the rebased file, never computed from the shift. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/src/game/engine.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 460127e311..4302cf2322 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -18837,7 +18837,16 @@ mod stage2_injector_tests { // and the partition (5/8/25) both fired GREEN on the run that caught this, and // the panic was on this third assert alone — which is what makes it a coordinate // shift rather than a population change. - "game/engine.rs:12759".to_string(), + // ⚠ REBASE onto upstream `635c51ec4` (#7382, pre-entry opponent controller): + // `:12759 ⇒ :12763`, +4 from a hunk at `apply_action` `@@ -9867,0 +9868,4 @@`, + // entirely above this producer. MEASURED in the rebased file, never computed: the + // offset from `begin_pending_trigger_target_selection` is the control and is STILL + // 134, which is what re-establishes identity — the same mint text occurs at several + // coordinates in this crate, so the offset discriminates where the text cannot. + // This rebase raised the literal as a CONFLICT twice and then drifted it SILENTLY a + // third time at the tip; only the offset control caught the silent one. That is the + // drift class FU-4 (content-hash coordinate anchor) exists to end. + "game/engine.rs:12763".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ From 67f0412f5c9738fcfc72f229e8e8dccfc169d2df Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 20:47:06 -0500 Subject: [PATCH 41/44] test(engine): correct a false universal about this file's board loaders Row 9's rationale claimed "Every row in this file loads through `load_f4`". Measured: of 35 rows, `b5f_` and `m1_` load through `load_mode1`, `a1_` through `load_mode2`, and `c1_` loads no board at all -- it walks source. All four predate this branch, so this is a pre-existing false claim, not one the lane introduced. Restated as loaders rather than as a count. A count would rot on the next row added, which is how the sentence became false in the first place. Assisted-by: ClaudeCode:claude-opus-5 --- .../tests/integration/fantastic_four_bounded_loop.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index b38fec2b04..69ccb7deed 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -390,8 +390,11 @@ fn replay_at_priority(state: &GameState, proposer: PlayerId) -> GameState { /// **Row 9, tracked-loader arm.** The RNG chokepoint gap was never confined to the untracked Dina /// board: this TRACKED dump carries `rng_word_pos = 379` and used to restore with the live stream /// at word 0, so the very next export-time `capture_rng_word_pos` panicked -/// `HighWaterRegression { current: 379, requested: 0 }`. Every row in this file loads through -/// `load_f4`, so the gap sat under all of them. Scope: this row measures the CHOKEPOINT's +/// `HighWaterRegression { current: 379, requested: 0 }`. Every row that loads an F4 board loads +/// through `load_f4`, so the gap sat under all of those. NOT literally every row here: `b5f_` and +/// `m1_` load through `load_mode1`, `a1_` through `load_mode2`, and `c1_` loads no board at all +/// (it walks source). Stated as loaders rather than as a count, because a count rots on the next +/// row added and this sentence has already been false once. Scope: this row measures the CHOKEPOINT's /// postcondition, which is not every shipped ingress's postcondition — `server-core`'s /// `GameSession::from_persisted` re-seeds after the chokepoint and zeroes `rng_word_pos` with it, /// ending at an agreed live-0 / high-water-0 pair rather than at this row's resumed position. From 0e4e223650e0edf68e70a90e6a069c89a18cce1d Mon Sep 17 00:00:00 2001 From: lgray Date: Thu, 13 Aug 2026 21:15:41 -0500 Subject: [PATCH 42/44] test(engine): adjudicate the WaitingFor reach-guard for #7382's new variant Upstream #7382 added `WaitingFor::EntryControllerChoice { player, candidates }` (CR 614.12a), so the variant reach-guard moves 129 -> 130. Adjudicated on the terms this row already set for #7336, not bumped: that variant's body holds no `DecisionTemplate`, so it is not a third carrier, and both the carrier vec and the redaction loop below it are unchanged. Only the reach-guard total moves. The number is read from this assertion's own failure output rather than from a hand-written variant counter -- one was tried and returned 49 while contradicting itself, and a second instrument that disagrees with the syn parse is worth less than no second instrument. This drift produced no merge conflict and could not have, so the reach-guard plus CI were the only things between it and shipping. Assisted-by: ClaudeCode:claude-opus-5 --- crates/engine/tests/integration/loop_shortcut.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 94e8b299e6..fdbfbb4c5c 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -4805,8 +4805,8 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac // ── the classifier's own reach-guard: the enum was actually found ── let total = enum_variants(&enum_src, "WaitingFor").len(); assert_eq!( - total, 129, - "`WaitingFor` has 129 variants at this tip, read off the `syn` parse. This number is \ + total, 130, + "`WaitingFor` has 130 variants at this tip, read off the `syn` parse. This number is \ pinned so a variant REMOVED is as visible as one added; if you added a variant and it \ carries no `DecisionTemplate`, update this number. A wildly different count means the \ reader lost its anchor, and every assertion below would then be measuring an empty enum" @@ -4817,6 +4817,15 @@ fn exactly_two_waiting_for_variants_carry_a_decision_template_and_both_are_redac // its body), so it is not a third carrier and the assertion below is unchanged by it. The // count moved for a reason that does not touch this row's subject — which is exactly the // case this reach-guard exists to make visible rather than silent. + // 129 ⇒ 130 is ADJUDICATED on the same terms: upstream #7382 ("choose pre-entry opponent + // controller") added `EntryControllerChoice { player: PlayerId, candidates: Vec }` + // (CR 614.12a). Measured, not inferred from the diff: that body holds NO `DecisionTemplate`, + // so it is not a third carrier, and both the carrier vec and the redaction loop below are + // unchanged by it. The count itself was read from THIS assertion's own failure (`left: 130`) + // rather than from a hand-written variant counter — one was tried and returned 49 while + // contradicting itself, and a second instrument that disagrees with the `syn` parse is worth + // less than no second instrument. The reach-guard did its whole job here: this drift produced + // no merge conflict and could not have, so CI was the only thing between it and shipping. let carriers = carriers_in_source(&enum_src, "WaitingFor", &corpus, &marker, true); assert_eq!( From 09ed81213b624880f358d1422d9d44e41b402042 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 20:08:56 -0700 Subject: [PATCH 43/44] fix(PR-7375): add Polish preview plural forms --- client/src/i18n/locales/pl/game.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 1c0ca16756..4f1a245f1d 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -22,6 +22,8 @@ "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", "countAria": "Number of iterations", "previewTitle_one": "Repeating once produces:", + "previewTitle_few": "Repeating {{count}} times produces:", + "previewTitle_many": "Repeating {{count}} times produces:", "previewTitle_other": "Repeating {{count}} times produces:", "previewEntry": "{{amount}} {{resource}}", "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}", From beff50c2d22c1416d1089afd7b12d8448bbbce76 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 20:31:26 -0700 Subject: [PATCH 44/44] fix(PR-7375): restore locale key parity --- client/src/i18n/locales/pl/game.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index 4f1a245f1d..1c0ca16756 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -22,8 +22,6 @@ "convokeInfo_other": "Auto-taps up to {{count}} creatures for convoke each iteration.", "countAria": "Number of iterations", "previewTitle_one": "Repeating once produces:", - "previewTitle_few": "Repeating {{count}} times produces:", - "previewTitle_many": "Repeating {{count}} times produces:", "previewTitle_other": "Repeating {{count}} times produces:", "previewEntry": "{{amount}} {{resource}}", "previewEntryPlayer": "{{amount}} {{resource}} — {{player}}",