diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 3af92f7c4a..3b59623880 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -1880,22 +1880,15 @@ fn window_scope_from_cover_frames<'a>( && pb.extra_phases.is_empty()) .then_some(pa.phase); - // (s1) BOTH sequences non-empty — the `(Some, Some)` arm; (s2) one controller - // across BOTH sequences. - let sole_driver = match ( - pa.last_loop_action_sequence.first(), - pb.last_loop_action_sequence.first(), - ) { - (Some(first), Some(_)) => { - let driver = first.controller; - pa.last_loop_action_sequence - .iter() - .chain(pb.last_loop_action_sequence.iter()) - .all(|ctx| ctx.controller == driver) - .then_some(driver) - } - _ => None, - }; + // (s1) BOTH sequences non-empty; (s2) one controller across BOTH sequences. Both conjuncts + // are exactly [`GameState::loop_period_controller`] applied per frame — "whose period is + // this", the single authority every routing site reads — with the two answers required to + // agree. Stating it that way rather than re-deriving `first().controller` + `all()` here is + // the point of hoisting that authority: a two-frame twin of the same question cannot drift + // from the one-frame form it duplicates. + let sole_driver = pa + .loop_period_controller() + .filter(|driver| pb.loop_period_controller() == Some(*driver)); LoopWindowScope { phase_invariant, @@ -2360,7 +2353,25 @@ pub(crate) fn loop_states_cover_modulo_growth_pinned<'a>( /// nothing". `Some(vec![])` would assert the latter and relieve EVERY conditioned /// self-cost static — relief in the forbidden direction. `None` = scan everything. /// Pinned by `empty_loop_action_sequence_proves_nothing_about_casting`. -fn window_cast_card_ids(state: &GameState) -> Option> { +/// +/// FAIL-CLOSED ON A FOREIGN PERIOD, for the same reason one level up (CR 732.2a). A recorded +/// period is evidence about the seat that recorded it and no one else, so when the caller names +/// a `proposer` only THAT seat's own period is proof of what this window casts. Otherwise an +/// opponent's choice of WHICH CARD TO ACTIVATE would select which soundness relief applies to +/// the proposer's certification — the same "relief in the forbidden direction" the emptiness +/// contract above rules out, arriving through a different door. This became reachable when the +/// bounded mint's step (1b) went seat-relative: before that, a bounded offer could not be minted +/// with any sequence present, so the question never arose. +/// +/// `is_some_and`, NOT `is_some`: the proposer-less 2-arg entry +/// [`loop_states_cover_modulo_growth`] builds a `PeriodVerdicts::unproven` container used by the +/// object-growth detection covers in `analysis::loop_check`, which have no proposer to bind. When +/// the container names none, this is byte-identical to the pre-fix behaviour; requiring +/// `Some(proposer)` there would strip relief from that whole class. +fn window_cast_card_ids(state: &GameState, proposer: Option) -> Option> { + if proposer.is_some_and(|p| state.loop_period_controller() != Some(p)) { + return None; + } let ids: Vec = state .last_loop_action_sequence .iter() @@ -2464,7 +2475,12 @@ pub(crate) fn loop_states_cover_modulo_growth_scoped<'a>( // (5) Off-stack fail-closed fire-time condition guard (the second read surface). // CR 601.2f: `cast_ids` is bound BEFORE `projected_scope` so NLL keeps the borrow // live across the call (`LoopWindowScope::cast_card_ids` is `Option<&'a [CardId]>`). - let cast_ids = window_cast_card_ids(current); + // + // SITE E (CR 732.2a): the window's cast-set proof is scoped to the seat this container is + // bound to, so a period recorded by ANOTHER seat cannot select which relief applies here. + // `verdicts.proposer()` is `None` for the proposer-less 2-arg entry, where this stays + // byte-identical to the unscoped read. + let cast_ids = window_cast_card_ids(current, verdicts.proposer()); // All four fields written explicitly — no functional-update base, so there is no // `LoopWindowScope<'static>` -> `LoopWindowScope<'_>` variance question to reason // about, and a future FIFTH field is a compile error that forces a decision rather @@ -11394,7 +11410,7 @@ mod tests { let mut state = GameState::new_two_player(7); assert!(state.last_loop_action_sequence.is_empty()); assert_eq!( - window_cast_card_ids(&state), + window_cast_card_ids(&state, None), None, "(1) an empty driving sequence is NO PROOF — `Some(vec![])` would assert \ `this window casts nothing` and relieve every conditioned self-cost static" @@ -11413,12 +11429,87 @@ mod tests { pins: Vec::new(), }]; assert_eq!( - window_cast_card_ids(&state), + window_cast_card_ids(&state, None), Some(vec![CardId(64)]), "(2) a one-entry sequence yields exactly that card id" ); } + /// X4-5 — [`window_cast_card_ids`]'s PROPOSER SCOPING (CR 732.2a), the sibling contract to + /// X4-4's emptiness one, called DIRECTLY for the same anti-domination reason. + /// + /// A recorded period is evidence about the seat that recorded it. Once the bounded mint's + /// step (1b) went seat-relative, a certification could be taken with a FOREIGN period sitting + /// in state — and an unscoped read would then let an OPPONENT'S choice of which card to + /// activate decide which conditioned self-cost static gets relieved for THIS proposer. + /// + /// THREE-WAY AND EACH ARM IS LOAD-BEARING, so no constant implementation passes: + /// * `None` (the proposer-less 2-arg entry) ⇒ unscoped, byte-identical to pre-fix. Dropping + /// the `Option` guard — the UNCONDITIONAL-MATCH form `if state.loop_period_controller() != + /// proposer { return None; }` — refuses the unbound container and FAILS (1); this is the arm + /// that protects `loop_check`'s object-growth detection covers. (MEASURED, and it corrects + /// this row's own earlier claim: the `is_some`-instead-of-`is_some_and` swap does NOT fail + /// (1) — with `proposer == None` it never returns early — it fails (2), by refusing the + /// seat that DID record the period.) + /// * `Some(owner)` ⇒ proof. An always-`None` implementation FAILS (2), as does the `is_some` + /// swap above. + /// * `Some(other)` ⇒ no proof. The pre-fix unscoped implementation FAILS (3). + /// + /// (4) pins the fail-closed homogeneity clause: a two-seat run is nobody's period, so it is + /// proof for NEITHER seat — an implementation testing only `seq[0].controller` FAILS it. + #[test] + fn a_foreign_driving_period_proves_nothing_about_this_proposers_casting() { + use crate::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + let owner = PlayerId(0); + let other = PlayerId(1); + let step = |controller: PlayerId, card_id: CardId| LoopActionContext { + card_id, + controller, + action: LoopAction::Recast { + from_zone: Zone::Hand, + uses_buyback: BuybackUsage::Used, + }, + convoke: None, + pins: Vec::new(), + }; + + let mut state = GameState::new_two_player(7); + state.last_loop_action_sequence = vec![step(owner, CardId(64))]; + + assert_eq!( + window_cast_card_ids(&state, None), + Some(vec![CardId(64)]), + "(1) an UNBOUND container (the proposer-less 2-arg entry `loop_check` uses) reads \ + the period unscoped — `is_some_and`, not `is_some`, or the object-growth detection \ + covers lose their relief" + ); + assert_eq!( + window_cast_card_ids(&state, Some(owner)), + Some(vec![CardId(64)]), + "(2) the seat that RECORDED the period is proved by it" + ); + assert_eq!( + window_cast_card_ids(&state, Some(other)), + None, + "(3) CR 732.2a: another seat's independent activation describes no sequence THIS \ + proposer takes, so it is no proof about this window's cast set — relieving on it \ + would hand an opponent the choice of which soundness relief applies" + ); + + // (4) the fail-closed homogeneity clause: nobody's period. + state.last_loop_action_sequence = vec![step(owner, CardId(64)), step(other, CardId(90))]; + assert_eq!( + ( + window_cast_card_ids(&state, Some(owner)), + window_cast_card_ids(&state, Some(other)), + ), + (None, None), + "(4) a heterogeneous run belongs to no seat, so it proves nothing for EITHER — \ + reading only `seq[0].controller` would wrongly prove it for the first" + ); + } + /// X4-3 — the REAL 4-player Dina/Conqueror capture (`dina_conqueror_4p.json.gz`), /// loaded through the production restore chokepoint /// `PersistedGameState::into_game_state`. It carries dump-D obj 90 **Mortality diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index bcd4597b7a..6a1dbfed76 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -1408,16 +1408,23 @@ fn reconcile_terminal_result(state: &mut GameState, result: &mut ActionResult) { // stack, so the sampler clears the ring at that beat and the `!stack.is_empty()` bridge // is structurally unreachable for it. Detect it here by driving the captured loop-action // sequence on a clone. Gated identically (opt-in + top-level-only) plus a cheap - // `last_loop_action_sequence` precondition (non-empty only on a buyback-paid token-creating + // `last_loop_action_sequence` precondition (armed only on a buyback-paid token-creating // cast or a multi-activation engine's accumulated beats — so the clone-drive runs ~never for // the recast class; a mana engine arms per mana activation but its drive aborts fast when // unsustainable). INV-2: this OFFERS the interactive shortcut (never auto-resolves — CR 732.2a). + // + // SITE A (CR 732.2a): the precondition asks whose period it is, not merely whether one exists. + // BEHAVIOUR-PRESERVING by construction — `try_offer_object_growth_shortcut` below applies the + // identical whole-period test to its own admission and returns `None` for a foreign period, so + // this conjunct only stops paying for a clone-drive whose answer is already known. It reads the + // same authority as that consumer so the two cannot drift apart. if !matches!(state.waiting_for, WaitingFor::GameOver { .. }) && matches!(state.waiting_for, WaitingFor::Priority { .. }) && state.stack.is_empty() && state.loop_detection.samples() && !in_simulation_probe() - && !state.last_loop_action_sequence.is_empty() + && matches!(state.waiting_for, WaitingFor::Priority { player } + if state.loop_period_controller() == Some(player)) { if let Some((certificate, schema)) = try_offer_object_growth_shortcut(state) { let WaitingFor::Priority { player: proposer } = state.waiting_for else { @@ -1742,9 +1749,13 @@ fn build_cert( pub enum BoundedOfferRefusal { /// (1) Not a `WaitingFor::Priority` beat, so nobody may suggest a shortcut. NotAtPriority, - /// (1b) A non-empty `last_loop_action_sequence` routes an accepted proposal to the - /// object-growth materializer, which commits zero bounded cycles. - DrivingSequenceNotEmpty, + /// (1b) A driving period belonging to the PROPOSER'S OWN seat is accumulating, which routes + /// an accepted proposal to the object-growth materializer — it would commit zero bounded + /// cycles. Another seat's period is not a reason to refuse (CR 732.2a): it describes no + /// sequence this proposer can take, and `try_offer_object_growth_shortcut` will not admit it + /// either. Named for the state that refuses, not for a non-emptiness test the conjunct + /// stopped applying when it went seat-relative. + ProposerHasDrivingPeriod, /// (2) The priority holder is not the active player the ring sampler gates on. ProposerIsNotActivePlayer, /// (4) Neither certification basis matched. @@ -1774,8 +1785,10 @@ pub enum BoundedOfferRefusal { /// * `predicted_winner: None` — this seam never calls `live_mandatory_loop_winner`, so it /// neither consults nor weakens the CR 104.2a crown gate (`loop_check.rs`'s /// `nonfallers.len() != 1`); it routes around it. -/// * an EMPTY `last_loop_action_sequence` (step 1b) — the object-growth producer's class is +/// * no driving period of the PROPOSER'S OWN (step 1b) — the object-growth producer's class is /// the complement, and `materialize_fixed_shortcut` dispatches on that same discriminant. +/// Seat-relative, not merely non-empty: a period recorded by another seat admits no +/// object-growth offer either, so it is not the complement of anything (CR 732.2a). /// /// Returns the offer to write, or the FIRST conjunct that refused. Pure: it reads `state` and /// writes nothing. The refusal is typed rather than a bare `None` because nine fail-closed @@ -1887,16 +1900,29 @@ fn bounded_cycle_offer( return Err(BoundedOfferRefusal::NotAtPriority); }; // (1b) The bounded drain mints nothing, so it is reachable in `materialize_fixed_shortcut` - // ONLY below that function's object-growth dispatch — and that dispatch is an EARLY - // RETURN gated on `!state.last_loop_action_sequence.is_empty()`. An offer minted with a - // non-empty sequence would be accepted and routed to the object-growth materializer, - // committing ZERO bounded cycles and making this whole path silently dead. The two - // conjuncts are not disjoint — a mana activation arms a period and a same-controller - // on-stack activation both appends to it and leaves the stack non-empty, which is the - // bridge's own entry condition — so this guard is load-bearing, not a restatement of an + // ONLY below that function's object-growth dispatch — and that dispatch is an EARLY RETURN + // taken when the recorded period belongs to the accepting proposal's proposer. An offer minted + // while THIS proposer's own period is accumulating would be accepted and routed to the + // object-growth materializer, committing ZERO bounded cycles and making this whole path + // silently dead. The two conjuncts are not disjoint — a mana activation arms a period and a + // same-controller on-stack activation both appends to it and leaves the stack non-empty, which + // is the bridge's own entry condition — so this guard is load-bearing, not a restatement of an // invariant. It converts a silent misroute into an observable refusal. - if !state.last_loop_action_sequence.is_empty() { - return Err(BoundedOfferRefusal::DrivingSequenceNotEmpty); + // + // SITE B (CR 732.2a) — THE SEAT-RELATIVE FORM. The test is whose period is recorded, not + // whether one exists. CR 732.2a describes a shortcut as "a sequence of game choices … that may + // be legally taken based on the current game state and the predictable results of the sequence + // of choices": a period recorded from a DIFFERENT seat's independent activation describes no + // sequence this proposer can take, so it is no reason to refuse their own predictable one. One + // opponent activation used to refuse a proposer's certified bounded offer for the rest of the + // game. `loop_period_controller()` is `None` for a heterogeneous run, which also mints — and + // that is sound in the same direction, because `try_offer_object_growth_shortcut` fail-closes + // on heterogeneity too, so no object-growth offer can exist to be misrouted to. + // + // (CR 732.3's fragmented-loop rule is NOT what this guard ever enforced — the engine + // implements no CR 732.3 gate anywhere; see the contrast note under step (2).) + if state.loop_period_controller() == Some(proposer) { + return Err(BoundedOfferRefusal::ProposerHasDrivingPeriod); } // (2) The ring sampler gates on `Priority{active_player}`, so requiring the proposer to // BE the active player is what establishes they held priority at every sampled frame. @@ -2137,11 +2163,22 @@ fn certified_bounded_cycle_offer<'a>( // So the discriminant is a FIRE-TIME CONDITION READING A PROJECTED AXIS, not the shape of // the resources the loop moves. And the composition worth remembering: gate (5)'s // `scope.cast_card_ids` relief — which exists precisely to excuse a self-cost modifier on - // a card the window provably never casts — CANNOT fire for this class, because step (1b) - // requires `last_loop_action_sequence` to be EMPTY, so `window_cast_card_ids` returns - // `None` (no proof ⇒ scan everything). The requirement that DEFINES the bounded class is - // exactly what disables the relief that would otherwise let cover succeed. Two - // individually-correct constraints composing into a refusal neither intended. + // a card the window provably never casts — CANNOT fire for this class, and the CONCLUSION is + // unchanged by the seat-relative (1b), but the REASON is not the one recorded here before. + // + // ⚠ STALE REASON, CORRECTED. This block used to say the relief cannot fire "because step (1b) + // requires `last_loop_action_sequence` to be EMPTY". Step (1b) no longer requires that: it + // refuses only when the recorded period is the PROPOSER'S OWN, so a bounded offer can now be + // minted with a FOREIGN period sitting in state. What preserves the conclusion is instead + // `window_cast_card_ids`, which is proposer-scoped: when the verdict container names a + // proposer, only that seat's own period is proof of what the window casts, so a foreign period + // yields `None` (no proof ⇒ scan everything) exactly as an empty one does. Without that + // scoping an OPPONENT'S choice of which card to activate would select which soundness relief + // applies to this proposer's certification — relief in the forbidden direction. This block is + // the reasoning record for precisely that (1b) × gate-(5) composition, which is why the reason + // is corrected here rather than left to be re-derived. + // + // Two individually-correct constraints still compose into a refusal neither intended. // // ⚠ NEVER attribute the basis from `frames_per_period`. BOTH bases now MEASURE it — basis A // from the certifying prior's ring index above, basis B from `ring_delta_signature`'s @@ -3026,7 +3063,19 @@ fn apply_until_lethal_shortcut( let period = shortcut_drive_period(proposal.template.as_ref()); // DRIVE one representative cycle to produce the measured post-drive `work` state. - let work: GameState = if !committed.last_loop_action_sequence.is_empty() { + // + // SITE D (CR 732.2a): drive the recorded period ONLY when it is this proposal's proposer's + // own. Under `!is_empty()` a foreign seat's independent activation sitting in state would make + // this branch drive THAT seat's period and measure its delta as if it were the proposal's — + // CR 732.2a binds a shortcut to the choices its proposer can predictably take, and another + // seat's period is not among them. Fails closed on `None` into the ring-boundary branch below, + // which reads no sequence at all. + // + // ⚠ PRE-EXISTING AND INDEPENDENT OF the (1b) fix: Path A (`interactive_loop_bridge`'s + // ring-gated offer) never reads the sequence, so a Path-A `UntilLethal` offer accepted while a + // foreign period sat in state already reached here and drove the wrong seat. No fixture reaches + // that path today; the guard is the one-line root-cause fix through the same authority. + let work: GameState = if committed.loop_period_controller() == Some(proposal.proposer) { // Object-growth loop period (recast buyback+convoke, or a multi-activation mana engine) // declared `UntilLethal` by the AI (which hardcodes it for every optional offer). Drive // one real period on a clone under the re-entrancy guard; an inert Advantage token/mana @@ -3045,7 +3094,7 @@ fn apply_until_lethal_shortcut( match drive_loop_sequence_iteration(&mut w, &seq, 0, &expected_defs) { Ok(()) => w, Err(RecastAbort) => { - return until_lethal_fallback(state, result, committed); + return until_lethal_fallback(state, result, committed, proposal.proposer); } } } else { @@ -3091,12 +3140,12 @@ fn apply_until_lethal_shortcut( }; result.waiting_for = state.waiting_for.clone(); } else { - until_lethal_fallback(state, result, committed); + until_lethal_fallback(state, result, committed, proposal.proposer); } return; } CycleOutcome::Abort => { - return until_lethal_fallback(state, result, committed); + return until_lethal_fallback(state, result, committed, proposal.proposer); } } } @@ -3124,12 +3173,12 @@ fn apply_until_lethal_shortcut( &fallers, ) { - until_lethal_fallback(state, result, committed); + until_lethal_fallback(state, result, committed, proposal.proposer); } else { crown_until_lethal(state, result, proposal, winner); } } - _ => until_lethal_fallback(state, result, committed), + _ => until_lethal_fallback(state, result, committed, proposal.proposer), } } @@ -3178,16 +3227,34 @@ fn crown_until_lethal( /// loop-detect ring so this same `apply()` does not instantly re-offer the (now-declined) /// loop; a later beat re-detects genuinely. Mirrors the `materialize_fixed_shortcut` abort /// tail. -fn until_lethal_fallback(state: &mut GameState, result: &mut ActionResult, committed: GameState) { +/// +/// THE SEQUENCE CLEAR IS OWNERSHIP-SCOPED (CR 732.2a) — the same authority, and for the same +/// reason, as `handle_decline_shortcut`'s. `*state = committed` restores the PRE-DRIVE board, +/// which since the bounded mint's step (1b) went seat-relative can carry a period belonging to a +/// seat other than this proposal's proposer; an unconditional clear then destroyed that seat's +/// accumulating period as a side effect of somebody else's aborted drive. Scoping costs the +/// suppression below nothing, because `try_offer_object_growth_shortcut` returns `None` for every +/// period that is not the priority holder's — so the only period whose survival could re-fire the +/// offer this fallback is walking away from is the proposer's own, which this branch still clears. +fn until_lethal_fallback( + state: &mut GameState, + result: &mut ActionResult, + committed: GameState, + proposer: PlayerId, +) { *state = committed; // CR 732.2c: a declined shortcut must not instantly re-offer the SAME loop in this same // `apply()`. Clear both re-offer signals: the drain offer's `loop_detect_ring` AND the // object-growth offer's `last_loop_action_sequence` routing signal (a non-drain object-growth // loop, e.g. an AI-declared UntilLethal on an inert Advantage recast, would otherwise // re-fire `try_offer_object_growth_shortcut` on the next reconcile and livelock). A later - // real re-cast re-captures the sequence and re-detects genuinely. + // real re-cast re-captures the sequence and re-detects genuinely. The ring is a board-wide + // 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(); - state.last_loop_action_sequence.clear(); + if state.loop_period_controller() == Some(proposer) { + state.last_loop_action_sequence.clear(); + } priority::reset_priority(state); state.waiting_for = WaitingFor::Priority { player: living_priority_seat(state), @@ -3696,7 +3763,17 @@ fn materialize_fixed_shortcut( // plus the `cr733/authority_matrix` census fixture that pins its composition. (Only a // PARALLEL per-item bound VECTOR would be positionally unsyncable across that sort; that // is the shape being rejected here, not per-accept binding as such.) - if !state.last_loop_action_sequence.is_empty() { + // + // SITE C (CR 732.2a) — MANDATORY IN LOCKSTEP WITH SITE B. Route to the object-growth + // materializer only when the recorded period belongs to THIS proposal's proposer, which is + // exactly the admission `try_offer_object_growth_shortcut` required to mint the offer being + // materialized. Under `!is_empty()` the seat-relative (1b) above would let a bounded drain + // offer be accepted while a FOREIGN period sits in state; this early return would then fire + // and commit ZERO bounded cycles — the precise silent misroute (1b)'s own doc block exists to + // prevent, reintroduced through the back door. Fails closed on `None` into the drain path + // below, which is correct because a heterogeneous period cannot have minted an object-growth + // offer in the first place. + if state.loop_period_controller() == Some(proposal.proposer) { let stashed_before = state .pending_unbounded_materialization .get(&proposal.proposer) @@ -4600,7 +4677,14 @@ fn try_offer_object_growth_shortcut( // The whole PERIOD must belong to the priority holder. A multi-controller / interleaved // sequence is fail-closed here; the per-step drive's controller re-find is the runtime // backstop (T-HET). Faithful generalization of the pre-P7 `ctx.controller != caster` check. - if seq.iter().any(|c| c.controller != caster) { + // + // CR 732.2a: this admission test IS `loop_period_controller`, and reads it rather than + // re-implementing it. That is the whole point of the hoist — the ROUTING sites (the bridge + // precondition, the bounded mint's (1b), the two materialize/drive dispatches, the declare + // arm) all route to THIS consumer, so a routing signal coarser than this admission is an + // in-tree contradiction: it sends a foreign period down a path that then refuses it. Sharing + // one authority closes that by construction instead of by parallel edits. + if state.loop_period_controller() != Some(caster) { return None; } // STEP D (CR 104.4b / CR 601.2a / CR 602.2 / CR 605.3a): only OFFER a VOLUNTARILY-repeatable @@ -5198,11 +5282,21 @@ fn handle_declare_shortcut( // is legitimate for exactly one drive shape: the object-growth route, which // re-derives its template from `state.last_loop_action_sequence` (the same routing // discriminant `materialize` dispatches on) and never reads `proposal.template`. - // With an EMPTY sequence there is nothing to re-derive from, so a pin-consuming - // drive would run with no pins at all — fail closed into the same manual-play - // handback the validation failure above uses. Both conjuncts are required: keying - // on `template.is_none()` alone breaks the shipped object-growth declarations. - None if state.last_loop_action_sequence.is_empty() => { + // With nothing this proposer can re-derive from, a pin-consuming drive would run with + // no pins at all — fail closed into the same manual-play handback the validation + // failure above uses. Both conjuncts are required: keying on `template.is_none()` + // alone breaks the shipped object-growth declarations. + // + // SITE F (CR 732.2a) — THE STRICTEST LOCKSTEP CONSTRAINT ON SITE B, because it is the + // one direction in which relaxing (1b) would make the engine LESS safe than before. + // "Re-derivable" means the period is THIS offer's proposer's own — the same test + // `materialize` dispatches on. A merely non-empty test would let a FOREIGN period take + // the sibling `None => {}` arm, which performs ZERO pin validation, and open the APNAP + // window on a client-supplied declaration against a schema with published points. So + // this arm must reject unless the period is the proposer's, not merely unless one + // exists. `None` from a heterogeneous run also rejects, which is the fail-closed + // direction: nothing can be re-derived from a period that is nobody's. + None if state.loop_period_controller() != Some(offer.proposer) => { reject_shortcut_declaration(state, &mut result); return Ok(result); } @@ -5260,16 +5354,31 @@ fn handle_declare_shortcut( /// 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 /// regression excluding `DeclineShortcut` from that allowlist would fail it loudly. -/// - Object-growth (Seam 2, gated by `!last_loop_action_sequence.is_empty()`): the deliberate-action -/// clear does NOT touch `last_loop_action_sequence`, so `state.last_loop_action_sequence.clear()` here -/// is the genuinely load-bearing suppressor — without it the post-return reconcile re-fires -/// `try_offer_object_growth_shortcut` within this same `apply()`. +/// - Object-growth (Seam 2, gated by `loop_period_controller() == Some(caster)` — the whole-period +/// admission test, NOT mere non-emptiness): the deliberate-action clear does NOT touch +/// `last_loop_action_sequence`, so clearing it here is the genuinely load-bearing suppressor — +/// without it the post-return reconcile re-fires `try_offer_object_growth_shortcut` within this +/// same `apply()`. +/// +/// THE SEAM-2 CLEAR IS OWNERSHIP-SCOPED (CR 732.2a). Once the bounded mint's step (1b) went +/// seat-relative, a `WaitingFor::LoopShortcut` can coexist with a period belonging to a DIFFERENT +/// seat — and `DeclineShortcut` dispatches from any `LoopShortcut`, so an unconditional clear would +/// let one seat's decline wipe another seat's accumulating period and suppress THAT seat's own +/// offer until it re-armed. A recorded period is evidence about the seat that recorded it, so only +/// the decliner's own is theirs to discard. Scoping costs the suppression NOTHING, and the reason +/// is `try_offer_object_growth_shortcut`'s own admission test rather than an argument about who +/// receives priority next: that producer returns `None` for every period that is not the priority +/// holder's, so the only period whose survival could re-fire it is the one this branch still +/// clears. A period left in place is one no reconcile in this `apply()` can turn back into an +/// offer for anybody. /// /// A genuine re-recurrence or a fresh re-cast re-arms the offer naturally. Proposer-only /// authorization is enforced upstream by `check_actor_authorization` -/// (`WaitingFor::acting_player` == `LoopShortcut.proposer`), so offer fields are unused here. +/// (`WaitingFor::acting_player` == `LoopShortcut.proposer`), so the offer's other fields are +/// unused here; `proposer` is threaded in solely as the ownership comparand above. fn handle_decline_shortcut( state: &mut GameState, + proposer: PlayerId, events: &mut Vec, ) -> Result { let mut result = ActionResult { @@ -5278,8 +5387,11 @@ fn handle_decline_shortcut( 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: - state.last_loop_action_sequence.clear(); // Seam 2: load-bearing object-growth offer-gate clear (CR 732.2a) + // ring-clear (engine.rs:3006-3011) — 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(); + } priority::reset_priority(state); state.waiting_for = WaitingFor::Priority { player: living_priority_seat(state), @@ -8388,10 +8500,11 @@ fn apply_action( ); } // CR 732.2a: the proposer DECLINES the offered shortcut (suggesting is optional). - // Proposer-only authorization is enforced upstream by `check_actor_authorization`, so - // `proposer`/`certificate`/`schema` are unused here (`..`). - (WaitingFor::LoopShortcut { .. }, GameAction::DeclineShortcut) => { - return handle_decline_shortcut(state, &mut events); + // Proposer-only authorization is enforced upstream by `check_actor_authorization`; + // `certificate`/`schema` stay unused (`..`), but `proposer` is threaded because the + // handler's Seam-2 suppression clear is OWNERSHIP-SCOPED to that seat. + (WaitingFor::LoopShortcut { proposer, .. }, GameAction::DeclineShortcut) => { + return handle_decline_shortcut(state, *proposer, &mut events); } // The finite pre-cast protocol is intentionally isolated from the // legacy generic loop-shortcut handlers above. @@ -15404,7 +15517,9 @@ mod stage2_injector_tests { // this one ran FIRST and both fired GREEN on the run that caught this — // total still **37**, partition still **5/7/25** — and the other two // entries (`scoped_library_search.rs:452`, `engine.rs:11549`) did not move - // at all, both re-read and sha256-confirmed in place. + // at all, both re-read and sha256-confirmed in place. (`engine.rs`'s entry + // has since moved to `:11619` — see the item-2 note on that entry below; + // `scoped_library_search.rs:452` still has not moved.) // // ⚠ THIS ROW FAILS IN CI BEFORE IT FAILS LOCALLY, and that is not a bug in the // row. CI checks out `refs/pull//merge` — this branch merged with CURRENT @@ -15511,7 +15626,32 @@ mod stage2_injector_tests { // `:11549`) to `a6d1a0e62:engine.rs:11549`, and it is still inside // `begin_pending_trigger_target_selection`, which moved by the same +34 // (opens :11400 ⇒ :11434). - "game/engine.rs:11583".to_string(), + // + // ITEM 2 (`loop_period_controller`), REBASED ONTO `fa5fbdfd7`: `:11583 ⇒ :11696`. + // The three predecessor entries above were written against bases that are now + // history, and the rebase resolved a conflict in THIS array on every one of this + // branch's three commits — so the pin was NOT carried from either side of those + // conflicts. It was re-derived at the rebased tip, which is the only tree the + // assertion runs against. + // + // LOCATED BY CONTENT, NOT BY ARITHMETIC. Hashing every line in the file that + // opens this producer's prompt yields exactly ONE whose sha256 is + // `8a544e878d3e77fb` — `:11696`. (The producer's own text is deliberately NOT + // quoted in this comment: a prose copy of it would make the locating grep match + // twice, and a census that finds its instrument's own documentation is the + // stale-coordinate failure wearing a different hat.) A sum-of-hunks figure + // would have been the wrong instrument here regardless: three-way conflict + // resolution is not a line-shift, so `+113` is a description of where the + // producer landed, never the evidence that it is the same producer. Uniqueness of + // the hash IS that evidence, and it is what a stale-coordinate defect (the exact + // failure this census exists to catch) cannot survive. + // + // Still inside `begin_pending_trigger_target_selection` (opens `:11547`). + // SET PRESERVATION: `git diff --stat upstream/main...HEAD` on + // `effects/mod.rs` and `effects/scoped_library_search.rs` is EMPTY, so + // `:6175/:6252/:9456/:452` could not have moved and stand re-read in place. + // Total still **5**. + "game/engine.rs:11696".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 \ @@ -16804,7 +16944,8 @@ mod kilo_interruptibility_tests { /// /// The reviewer measured all three by disabling them on the PRE-ROW tree: step (2) /// `ProposerIsNotActivePlayer` and step (5) `AdvantageOnlyCycle` could each be deleted with the -/// whole suite still green, and only `DrivingSequenceNotEmpty` was asserted by name anywhere. +/// whole suite still green, and only step (1b) (then `DrivingSequenceNotEmpty`, now +/// `ProposerHasDrivingPeriod`) was asserted by name anywhere. /// A conjunct no row can name is a conjunct nobody notices losing. /// /// ⚠ The pass COUNT that used to appear here ("4167 passed / 0 failed") is deleted rather than diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index f94f041fc2..7d87c7646d 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -8878,13 +8878,31 @@ impl GameState { /// captured inside an object-growth shortcut proposal/response window /// (`WaitingFor::LoopShortcut` / `RespondToShortcut`), where the pending accept→materialize /// resolution still re-derives the ∞ pile from it (`current_period_fodder`). In every - /// other loaded state the only consumer is the live detection re-drive - /// (`try_offer_object_growth_shortcut`), which requires `Priority` + an empty stack and is only - /// HARMED by a stale loaded prefix (it re-drives from a pinless `seq[0]` and aborts — the Kilo - /// bug), so dropping is strictly safe. Called from `PersistedGameState::into_game_state`, the - /// single production restore chokepoint for both the server (`GameSession::from_persisted`) and - /// WASM (`decode_restored_game_state`) paths. Applies only at the load boundary, never during - /// live play (where a populated sequence at `Priority` is the legitimate detection signal). + /// other loaded state the field is a ROUTING SIGNAL with SEVEN consumers — the live detection + /// re-drive (`try_offer_object_growth_shortcut`), its own empty-stack bridge precondition, the + /// bounded mint's step (1b), the `materialize_fixed_shortcut` and `apply_until_lethal_shortcut` + /// drive dispatches, `handle_declare_shortcut`'s `template: None` arm, and the certification + /// window's cast-set scoping (`analysis::resource::window_cast_card_ids`). Dropping is still + /// safe, but for a different reason than the one recorded here before: all seven ask the SAME + /// question ([`GameState::loop_period_controller`]) and every one of them fails CLOSED on + /// `None`, so a cleared field routes to the drain/manual path, grants no soundness relief, and + /// never reaches a pin-consuming drive with nothing to re-derive from. (It is also true that a + /// stale loaded prefix only HARMS the re-drive, which re-drives from a pinless `seq[0]` and + /// aborts — the Kilo bug.) + /// + /// ⚠ THE PRIOR REVISION OF THIS DOC CLAIMED the re-drive was "the only consumer". That was + /// FALSE — the other six are not re-drives — and the false premise is precisely why the + /// routing signal went un-audited against its own consumer. The revision after it named five + /// and missed the bridge precondition and the cast-set scoping, i.e. it corrected an + /// undercount with a smaller one. The count above is the enumerated call set of + /// `loop_period_controller` outside `#[cfg(test)]`; the conclusion survives either way. + /// (`handle_decline_shortcut` also reads the accessor, but as a WRITER — it scopes its own + /// clear — so it is not a consumer of the routing signal and is deliberately not counted.) + /// + /// Called from `PersistedGameState::into_game_state`, the single production restore chokepoint + /// for both the server (`GameSession::from_persisted`) and WASM (`decode_restored_game_state`) + /// paths. Applies only at the load boundary, never during live play (where a populated sequence + /// at `Priority` is the legitimate detection signal). pub fn migrate_transient_loop_sequence(&mut self) { if !matches!( self.waiting_for, @@ -8893,6 +8911,31 @@ impl GameState { self.last_loop_action_sequence.clear(); } } + + /// CR 732.2a: the seat whose driving period `last_loop_action_sequence` currently records. + /// + /// CR 732.2a lets "the player with priority … suggest a shortcut by describing 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" — so a recorded period is evidence + /// about ONE seat's predictable continuation and describes nothing another seat can take. + /// `None` when no period is accumulating, or when the recorded steps do not all belong to one + /// seat (fail-closed: a heterogeneous run is nobody's loop). + /// + /// This is the SAME whole-period test `try_offer_object_growth_shortcut` applies to its own + /// admission, hoisted into one authority so the routing signal and the consumer it routes to + /// cannot disagree. Every routing site reads `loop_period_controller() == Some(proposer)`, + /// which is exactly "the object-growth route is live for this seat"; each fails closed on + /// `None`. + /// + /// The homogeneity clause is a backstop, not a live case: `accumulate_loop_action_step` clears + /// the sequence on a controller change, so a heterogeneous run should be unreachable in play. + pub(crate) fn loop_period_controller(&self) -> Option { + let owner = self.last_loop_action_sequence.first()?.controller; + self.last_loop_action_sequence + .iter() + .all(|step| step.controller == owner) + .then_some(owner) + } } /// Decodes both current trusted snapshots and historical raw `GameState` diff --git a/crates/engine/tests/fixtures/dina_conqueror_phase5_no_offer_4p.json.gz b/crates/engine/tests/fixtures/dina_conqueror_phase5_no_offer_4p.json.gz new file mode 100644 index 0000000000..cbf1c9fa00 Binary files /dev/null and b/crates/engine/tests/fixtures/dina_conqueror_phase5_no_offer_4p.json.gz differ diff --git a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs index c1fd404e72..9ee39ca1c9 100644 --- a/crates/engine/tests/integration/fantastic_four_bounded_loop.rs +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -1562,7 +1562,8 @@ fn u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_ 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 when `last_loop_action_sequence` is empty" + 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" ); // ── ANTI-VACUITY CONTROL: this board DOES accept a declaration ── assert_eq!( @@ -2171,3 +2172,132 @@ fn a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis() { assert_axis_scales("MODE2", "The Thing's counters", counters_1, counters_3); 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. +/// +/// **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 +/// (asserted green by that module's acceptance row), so this row would be structurally VACUOUS +/// there. The F4 offer publishes all three of this cycle's per-iteration choices, so the arm is +/// live here and only here. That fixture choice is load-bearing, not incidental. +/// +/// **WHY IT IS A DIFFERENT ROW FROM THE MINT ARMS.** The mint-seam instrument +/// (`try_offer_bounded_cycle_shortcut`) cannot observe `handle_declare_shortcut` at all — +/// different seam, different instrument. Any future change to this routing discriminant needs +/// BOTH a mint-seam row and a declare-seam row; neither covers the other. +/// +/// **THE HAZARD, and it is the one direction in which relaxing step (1b) makes the engine LESS +/// safe than before.** A `template: None` declaration against a non-empty schema skips pin +/// validation entirely — legitimate for exactly one drive shape, the object-growth route, which +/// re-derives its template from `last_loop_action_sequence`. Once (1b) went seat-relative, a +/// bounded offer can be minted with a FOREIGN period in state; under a merely-non-empty test that +/// 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** | +/// +/// **TWO-SIDED CONTROL, PER ASSERTION** — no constant implementation passes: +/// * **DROP** the proposer test (restore `state.last_loop_action_sequence.is_empty()`) ⇒ +/// FOREIGN-seq returns `RespondToShortcut` ⇒ THAT assertion fails, while EMPTY/OWN still pass. +/// * **TRIVIALIZE** to always-reject ⇒ OWN-seq returns `Priority` ⇒ **that** assertion fails +/// 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. +/// +/// ⚠ **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 +/// the legitimate OWN-seq route and the illegitimate FOREIGN-seq one commit `dlife = 0`: a +/// `template: None` declaration carries no pins, so the drive fail-closes on the first uncovered +/// per-iteration choice either way. (The conformant `template: Some(..)` declarations DO commit — +/// that is `r2a`'s subject — but they never reach this arm.) The board's own zero therefore +/// DOMINATES any life-axis discriminator here, so the downstream harm is structurally +/// unobservable on this fixture and is NOT claimed. This row asserts the GATE VERDICT, which is +/// the property that actually fails closed. +#[test] +fn a_template_free_declaration_is_admitted_only_by_the_proposers_own_period() { + use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + let mut state = load_f4(); + let beat = drive_f4_to_offer(&mut state, 400) + .expect("REACH-GUARD: every arm below is vacuous without the engine's own bounded offer"); + let (proposer, _, schema) = offer_parts(&state); + assert!( + !schema.points.is_empty(), + "REACH-GUARD: site F sits under `!offer.schema.points.is_empty()`, so an empty point \ + set makes this whole row unreachable — which is exactly why it is not on the dina \ + fixture (beat {beat})" + ); + let max = schema.max_iterations; + assert!( + max >= 1, + "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" + ); + + let opp = state + .players + .iter() + .map(|p| p.id) + .find(|p| *p != proposer) + .expect("REACH-GUARD: the FOREIGN arm needs a second seat to attribute a period to"); + let card_id = state + .objects + .values() + .next() + .map(|o| o.card_id) + .expect("the dump has objects"); + + // One offer state, one field reassigned per arm, one action applied — nothing else differs. + let declare_with = |seq: Vec| { + let mut probe = state.clone(); + probe.last_loop_action_sequence = seq; + 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() + }; + let step = |controller: PlayerId| LoopActionContext { + card_id, + controller, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::NotUsed, + }, + convoke: None, + pins: Vec::new(), + }; + + assert_eq!( + declare_with(Vec::new()), + "Priority", + "EMPTY-seq must-not-flip — CR 732.2a: with no period at all there is nothing to \ + re-derive a template from, so a pin-consuming drive would run with no pins. Fail closed \ + into the manual-play handback" + ); + assert_eq!( + declare_with(vec![step(proposer)]), + "RespondToShortcut", + "OWN-seq must-not-flip: the proposer's own recorded period IS the object-growth route's \ + re-derivation source, so this is the shipped legitimate acceptance. An always-reject \ + remedy breaks it" + ); + assert_eq!( + declare_with(vec![step(opp)]), + "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 \ + against a schema with published points" + ); +} diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index bcbb132e06..5ef85e2567 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -562,6 +562,128 @@ fn interactive_3p_optional_cascade_apnap_accept_win() { ); } +/// SITE D (CR 732.2a) — the `UntilLethal` drive dispatch: a FOREIGN driving period in state must +/// not divert an accepted Path-A grant into the object-growth drive. +/// +/// **WHY THIS ROW EXISTS NOW AND DID NOT BEFORE.** Site D was reported row-less on the ground that +/// no fixture reaches it with a foreign period. That was a statement about what boards ARRIVE +/// carrying one, not about reachability: `migrate_transient_loop_sequence` clears the field at +/// every load, so no dump-driven row can start from one, and the answer here is the same one the +/// mint and accept rows use — inject into a board the engine itself drove to its offer. +/// +/// **WHY THIS SCENARIO AND NOT A CAPTURE.** Site D is only reachable through a proposal whose count +/// is `UntilLethal`, and `handle_declare_shortcut` rejects `UntilLethal` against any offer that +/// narrowed its bound. Every tracked capture in this repo reaches the BOUNDED mint (asserted on the +/// Dina capture by `the_user_captures_offer_is_reached_with_its_driving_period_cleared`), so the +/// only route in is a Path-A offer — which is exactly what +/// [`interactive_3p_optional_cascade_apnap_accept_win`] directly above raises. This row is that row +/// plus one injected field, so any divergence attributes to the field alone. +/// +/// **THE HAZARD.** Under a merely-non-empty test, `apply_until_lethal_shortcut` would take its +/// object-growth branch and drive the FOREIGN seat's recorded period, measuring that seat's delta +/// as if it were this proposal's. CR 732.2a binds a shortcut to the sequence its proposer can +/// predictably take, and another seat's independent activation is not among them. Pre-existing and +/// independent of the (1b) fix — Path A never read the sequence — but reachable, and fixed through +/// the same authority. +/// +/// **TWO-SIDED CONTROL** (both measured; each direction breaks a DIFFERENT row): +/// * **DROP** the seat test (restore `!committed.last_loop_action_sequence.is_empty()`) ⇒ this row +/// ends at `Priority { player: P0 }` instead of `GameOver { winner: Some(P0) }` — the drive +/// fell into `until_lethal_fallback` — and the injected period is wiped to length 0 by that +/// fallback's unconditional clear. BOTH assertions below flip. +/// * **TRIVIALIZE** to a constant `true` (always drive the recorded period) ⇒ +/// `interactive_3p_optional_cascade_apnap_accept_win` above panics inside the engine at +/// `seq[0]`, because an EMPTY sequence has no step to drive. So no constant implementation of +/// this dispatch passes the pair. +/// +/// ⚠ **A REALIZED NEGATIVE, recorded rather than hidden.** The complementary constant — +/// `false`, i.e. always take the drain branch — was measured against the WHOLE integration suite +/// at this tree (`cargo test -p phase-engine --test integration`, one unit = one libtest row) and +/// **4564 rows passed, 0 failed**. Site D's own-period branch is therefore asserted by no row +/// in this tree, which is a pre-existing coverage gap this change neither creates nor closes: the +/// object-growth `UntilLethal` rows (`object_growth_advantage_untillethal_no_crown`) reach the +/// same `Priority` handback down either branch, because `until_lethal_fallback` rolls the board +/// back to `committed` and the two routes become observationally identical. +#[test] +fn an_accepted_until_lethal_grant_drains_even_with_a_foreign_period_in_state() { + use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + let (mut runner, kickoff) = setup_3p_optional_cascade(LoopDetectionMode::Interactive); + let _ = runner.cast(kickoff).resolve(); + let (_events, wf) = drive_collect(&mut runner, 500); + let WaitingFor::LoopShortcut { + proposer, + predicted_winner, + .. + } = wf.clone() + else { + panic!("REACH-GUARD: every assertion below needs the engine's own offer, got {wf:?}"); + }; + assert_eq!( + predicted_winner, + Some(P0), + "REACH-GUARD: site D is reachable only through a Path-A offer — a bounded one rejects \ + `UntilLethal` at the declare seam and this row would measure that rejection instead" + ); + + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: None, + }) + .expect("the proposer declares UntilLethal on the Path-A offer"); + + // THE INJECTION: an opponent's own recorded period, sitting in state at the moment the last + // acceptance hands the proposal to `apply_until_lethal_shortcut`. + let opp = runner + .state() + .players + .iter() + .map(|p| p.id) + .find(|p| *p != proposer) + .expect("REACH-GUARD: the foreign period needs a second seat to belong to"); + let card_id = runner + .state() + .objects + .values() + .next() + .map(|o| o.card_id) + .expect("the scenario has objects"); + runner.state_mut().last_loop_action_sequence = vec![LoopActionContext { + card_id, + controller: opp, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::NotUsed, + }, + convoke: None, + pins: vec![], + }]; + assert_ne!( + opp, proposer, + "REACH-GUARD: a period injected for the PROPOSER would be the legitimate object-growth \ + route, and this row would assert the opposite of what it means to" + ); + + accept_all_opponents(&mut runner); + + assert_eq!( + runner.state().waiting_for, + WaitingFor::GameOver { winner: Some(P0) }, + "CR 732.2a SITE D: an opponent's recorded activation describes no sequence this proposer \ + can take, so the accepted `UntilLethal` grant must still drive the DRAIN it was certified \ + on. A `Priority` handback here is the defect: the drive took the object-growth branch and \ + measured the wrong seat's period" + ); + assert_eq!( + runner.state().last_loop_action_sequence.len(), + 1, + "and the foreign period is still THERE — the crown was reached with it in state. Under the \ + DROP mutant this reads 0, because `until_lethal_fallback` clears the field \ + unconditionally, so a wrongly-routed drive also destroys the other seat's period" + ); +} + /// CR 732.2a: a shortcut belongs to the player with priority, not necessarily the player /// whose loop will win. P1 starts the proven P0-controlled drain by making P0 gain life on /// P1's turn, so the live bridge must offer P1 the choice while retaining P0 as the measured @@ -8494,14 +8616,14 @@ fn drive_scenario_to_bounded_offer(runner: &mut GameRunner, cap: usize) -> Optio /// `a_cycle_that_does_not_match_the_published_period_is_dropped`, /// `declared_count_above_the_offered_bound_is_handed_back`, /// `until_lethal_against_a_bounded_offer_is_rejected`, -/// `a_nonempty_action_sequence_mints_no_bounded_offer`. +/// `a_proposers_own_driving_period_mints_no_bounded_offer`. /// /// FIXTURE PROVENANCE of those eleven, RE-COUNTED in fix round 5 over the whole set rather /// than asserted of one row (an earlier revision annotated dina alone as "the real 4p dump", /// which reads as an exclusivity it does not have). SIX load the real `dina_conqueror_4p` /// 4-player capture from `tests/fixtures`, through the same gunzip → restore loader: /// `dina_untargeted_drain_4p_offers_at_three_live_opponents`, -/// `a_nonempty_action_sequence_mints_no_bounded_offer`, +/// `a_proposers_own_driving_period_mints_no_bounded_offer`, /// `declared_count_above_the_offered_bound_is_handed_back`, /// `until_lethal_against_a_bounded_offer_is_rejected`, /// `bounded_fixed_count_commits_exactly_n_periods` (which loops that dump AND two @@ -8866,26 +8988,34 @@ fn multiplayer_pure_life_drain_offers_at_three_and_four_players() { } } -/// PR-7 Phase 5b (G1) — the bounded offer must FORBID a non-empty `last_loop_action_sequence`. +/// PR-7 Phase 5b (G1) — the bounded offer must FORBID a driving period of the PROPOSER'S OWN. /// /// PAIRED ARMS ON ONE CERTIFYING STATE, differing in exactly one field, asserting opposite /// outcomes — so no constant implementation passes. /// +/// ⚠ THE NAME THIS ROW USED TO CARRY (`a_proposers_own_driving_period_mints_no_bounded_offer`) +/// ASSERTED A GENERAL PROPERTY THAT IS FALSE AT THIS TREE. Step (1b) is seat-relative: a +/// non-empty sequence recorded by ANOTHER seat mints the offer, which is what +/// [`a_foreign_driving_period_neither_refuses_nor_recertifies_a_bounded_offer`] directly below +/// asserts. The row kept passing only because arm ⓑ happens to use `controller: proposer` — so +/// the file presented two adjacent rows whose names claimed contradictory general properties. +/// Renamed rather than annotated: the name is what a reader takes the row's contract to be. +/// /// WHY THE GUARD IS LOAD-BEARING (measured, not hypothetical): `materialize_fixed_shortcut` -/// EARLY-RETURNS into `materialize_object_growth_shortcut` when the sequence is non-empty, -/// and the bounded drain path begins strictly below that return. An offer minted with a -/// non-empty sequence would be accepted and routed to the object-growth materializer, -/// committing ZERO bounded cycles — the guard converts that silent misroute into an -/// observable refusal. The two conjuncts are NOT disjoint in the tree: the bridge's own gate -/// needs a non-empty STACK, and an on-stack `ActivateAbility` appends to the sequence once a -/// mana activation has armed a period. +/// EARLY-RETURNS into `materialize_object_growth_shortcut` when the recorded period is the +/// accepting proposal's proposer's own, and the bounded drain path begins strictly below that +/// return. An offer minted while THIS proposer's period is accumulating would be accepted and +/// routed to the object-growth materializer, committing ZERO bounded cycles — the guard converts +/// that silent misroute into an observable refusal. The two conjuncts are NOT disjoint in the +/// tree: the bridge's own gate needs a non-empty STACK, and an on-stack `ActivateAbility` appends +/// to the sequence once a mana activation has armed a period. /// /// REVERT-PROBE: delete step (1b) ⇒ arm ⓑ returns `Ok(..)` ⇒ FAILS. The refusal is asserted -/// BY REASON (`DrivingSequenceNotEmpty`), not merely as "no offer": an assertion that only +/// BY REASON (`ProposerHasDrivingPeriod`), not merely as "no offer": an assertion that only /// observed absence would keep passing if some EARLIER conjunct started refusing first, which /// is the domination trap. #[test] -fn a_nonempty_action_sequence_mints_no_bounded_offer() { +fn a_proposers_own_driving_period_mints_no_bounded_offer() { use engine::game::engine::{try_offer_bounded_cycle_shortcut, BoundedOfferRefusal}; use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; @@ -8926,12 +9056,188 @@ fn a_nonempty_action_sequence_mints_no_bounded_offer() { }]; assert_eq!( try_offer_bounded_cycle_shortcut(&state, false), - Err(BoundedOfferRefusal::DrivingSequenceNotEmpty), + Err(BoundedOfferRefusal::ProposerHasDrivingPeriod), "CR 732.2a: a bounded offer minted with a driving sequence would be routed to the \ object-growth materializer and commit zero bounded cycles" ); } +/// ITEM 2 (CR 732.2a) — a bounded offer is refused by the proposer's OWN driving period, and by +/// NOBODY ELSE'S; and a foreign period is certification-NEUTRAL while it sits there. +/// +/// THE BUG THIS ROW PINS. Step (1b) tested `!last_loop_action_sequence.is_empty()`, so a single +/// opponent activation — a period this proposer can neither drive nor benefit from — refused their +/// own certified bounded offer for the rest of the game. CR 732.2a defines a shortcut 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": another seat's independent +/// activation describes no sequence THIS proposer can take, so it is no reason to refuse theirs. +/// The routing signal was strictly coarser than the admission predicate of the consumer it routes +/// to — `try_offer_object_growth_shortcut` already required every step to belong to the priority +/// holder — so a foreign period could not produce an object-growth offer yet still refused the +/// bounded one. Both now read `GameState::loop_period_controller`. +/// +/// FIVE ARMS ON ONE CERTIFYING STATE, differing ONLY in `last_loop_action_sequence`: +/// +/// | arm | sequence | expected | +/// |---|---|---| +/// | ⓐ | empty | `Ok` — REACH-GUARD, and the neutrality reference | +/// | ⓑ | proposer's, any card | `Err(ProposerHasDrivingPeriod)` — must-not-flip | +/// | ⓒ | opponent's, any card | `Ok` — **the fix** | +/// | ⓓ | opponent's, Mortality Spear | `Ok` — card-identity independence | +/// | ⓔ | proposer's, Mortality Spear | `Err(ProposerHasDrivingPeriod)` — must-not-flip | +/// +/// Refusals are asserted BY REASON, never as bare absence: an assertion that only observed "no +/// offer" would keep passing if some EARLIER conjunct started refusing first (the domination trap +/// `BoundedOfferRefusal` exists for). +/// +/// TWO-SIDED CONTROL ON (1b), PER ASSERTION — no constant implementation passes: +/// * **DROP** the proposer comparison (restore `!is_empty()`) ⇒ ⓒ and ⓓ return +/// `Err(ProposerHasDrivingPeriod)` ⇒ THOSE assertions fail, while ⓑ/ⓔ still pass. +/// * **TRIVIALIZE** it constant-refuse (`loop_period_controller().is_some()`) ⇒ ⓒ/ⓓ fail as above. +/// TRIVIALIZE it constant-admit (never refuse) ⇒ ⓑ and ⓔ return `Ok` ⇒ **those** assertions +/// fail instead. Each direction flips a DIFFERENT named assertion. +/// +/// CERTIFICATION NEUTRALITY (site E) is folded onto the same arms because it needs the same +/// expensive drive, and reported through the metered seam so the certifying BASIS is observable +/// rather than just `Ok`/`Err`. ⓒ and ⓓ must publish the same `PeriodCertification` and the same +/// `per_cycle.frames_per_period` as ⓐ. ⓒ and ⓓ differ ONLY in the foreign step's `card_id`, so any +/// basis difference between them can arise ONLY from `window_cast_card_ids` feeding gate (5)'s +/// `scope.cast_card_ids` — that pair is itself the discriminator for which mechanism carries the +/// change. +/// * **DROP** the proposer test from `window_cast_card_ids` ⇒ ⓒ certifies `BoardCovered` while +/// ⓐ/ⓓ certify `ResourceSignatureOnly` ⇒ the equality assertion FAILS. That is the harm in one +/// line: an OPPONENT'S choice of which card to activate would select which soundness relief +/// applies to THIS proposer's certification. +/// * **TRIVIALIZE** it to `None` unconditionally ⇒ relief is stripped from the proposer-less 2-arg +/// entry the object-growth detection covers use ⇒ `analysis::resource`'s X4-5 arm (1) fails. +/// (That is why the scoping is `is_some_and`, not `is_some`.) +#[test] +fn a_foreign_driving_period_neither_refuses_nor_recertifies_a_bounded_offer() { + use engine::game::engine::{ + try_offer_bounded_cycle_shortcut_metered, BoundedOfferRefusal, ProbeCap, + }; + use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_conqueror_4p.json.gz" + ))); + drive_to_bounded_offer(&mut state, 400) + .expect("the paired arms need a state that PROVABLY certifies; see the acceptance row"); + + // The offer beat's `waiting_for` IS the offer, so rewind that one field to the Priority beat + // the offer was raised at — the mint's own entry condition. + let (proposer, _, _) = bounded_offer_parts(&state); + state.waiting_for = WaitingFor::Priority { player: proposer }; + let opp = *engine_live_opponents(&state, proposer) + .first() + .expect("REACH-GUARD: the foreign arms need a living opponent to attribute a period to"); + + // The X4 subject: a conditioned `ModifyCost`/`SelfRef` static sitting in a zone this window + // never casts from. Its gate-(5) relief is precisely what an unscoped cast-set read would let + // an opponent switch on. Looked up BY NAME so the row cannot silently degrade into ⓒ==ⓓ if the + // fixture's ids ever move. + let spear = state + .objects + .values() + .find(|o| o.name == "Mortality Spear") + .map(|o| o.card_id) + .expect("REACH-GUARD: dump-D ships Mortality Spear; ⓒ-vs-ⓓ is vacuous without it"); + let any_card = state + .objects + .values() + .map(|o| o.card_id) + .find(|id| *id != spear) + .expect("REACH-GUARD: ⓒ and ⓓ must differ in card identity"); + + let step = |controller: PlayerId, card_id| LoopActionContext { + card_id, + controller, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::NotUsed, + }, + convoke: None, + pins: vec![], + }; + // One state, one field reassigned per arm — nothing else differs between arms. + let mint = |seq: Vec| { + let mut probe = state.clone(); + probe.last_loop_action_sequence = seq; + let (outcome, meter) = + try_offer_bounded_cycle_shortcut_metered(&probe, false, ProbeCap::Shipped); + let signature = outcome.as_ref().ok().map(|wf| match wf { + WaitingFor::LoopShortcut { certificate, .. } => { + certificate + .per_cycle + .as_ref() + .expect( + "a bounded offer publishes the per-period signature its bound was \ + divided by", + ) + .frames_per_period + } + other => panic!("expected a bounded LoopShortcut offer, got {other:?}"), + }); + (outcome, meter.certification, signature) + }; + + // ── ⓐ REACH-GUARD: the SAME state certifies with no sequence at all. Every arm below is + // vacuous without this, and it is also the reference ⓒ/ⓓ are compared against. ── + let (empty, empty_basis, empty_k) = mint(vec![]); + assert!( + empty.is_ok(), + "REACH-GUARD: ⓑ–ⓔ are vacuous unless this same state certifies with an empty \ + sequence; got {empty:?}" + ); + assert!( + empty_basis.is_some() && empty_k.is_some(), + "REACH-GUARD: the neutrality assertions compare a BASIS and a period length, so the \ + control must publish both; got {empty_basis:?} / {empty_k:?}" + ); + + // ── ⓑ / ⓔ the proposer's OWN period still refuses, on either card. The load-bearing half of + // guard (1b) — the silent-misroute prevention — is untouched by the fix. ── + assert_eq!( + mint(vec![step(proposer, any_card)]).0, + Err(BoundedOfferRefusal::ProposerHasDrivingPeriod), + "ⓑ CR 732.2a: the proposer's OWN accumulating period would route an accepted proposal \ + to the object-growth materializer and commit zero bounded cycles" + ); + assert_eq!( + mint(vec![step(proposer, spear)]).0, + Err(BoundedOfferRefusal::ProposerHasDrivingPeriod), + "ⓔ the refusal is keyed on WHOSE period it is, not on which card the step names" + ); + + // ── ⓒ / ⓓ THE FIX: a foreign period neither refuses nor moves the certification. ── + let (c, c_basis, c_k) = mint(vec![step(opp, any_card)]); + assert!( + c.is_ok(), + "ⓒ CR 732.2a: an OPPONENT'S independent activation describes no sequence this proposer \ + can take, so it must not refuse their own certified bounded offer; got {c:?}" + ); + let (d, d_basis, d_k) = mint(vec![step(opp, spear)]); + assert!( + d.is_ok(), + "ⓓ the admission is keyed on WHOSE period it is, not on which card the foreign step \ + names; got {d:?}" + ); + + assert_eq!( + (c_basis, c_k), + (empty_basis, empty_k), + "ⓒ vs ⓐ — CR 732.2a certification NEUTRALITY: a foreign period must leave the \ + proposer's certification exactly where the empty-sequence control puts it" + ); + assert_eq!( + (d_basis, d_k), + (empty_basis, empty_k), + "ⓓ vs ⓐ — and neutrality must not depend on WHICH card the opponent activated. ⓒ and ⓓ \ + differ only in `card_id`, so a split here could come only from gate (5)'s \ + `scope.cast_card_ids` — i.e. an opponent selecting this proposer's soundness relief" + ); +} + /// PR-7 Phase 5b — a declared count ABOVE the offered bound is handed back fail-closed. /// /// **TEST-ONLY ROW, ZERO NEW PRODUCTION CODE.** The guard already ships @@ -9363,6 +9669,380 @@ fn bounded_fixed_count_commits_exactly_n_periods() { } } +/// ITEM 2 (CR 732.2a) — the ACCEPT side: a foreign driving period in state must not divert an +/// accepted bounded grant into the object-growth materializer. +/// +/// **WHY NO ROW HAS EVER STARTED FROM A BOARD CARRYING ONE.** +/// `GameState::migrate_transient_loop_sequence` clears `last_loop_action_sequence` at every load +/// whose `waiting_for` is not a shortcut window, so every dump-driven row in this file begins +/// from a cleared field. The whole accept-side dispatch on that field is therefore untested — the +/// blindness is in the FIXTURE PIPELINE, not in the rows. The answer is injection into a tracked +/// fixture (as `a_proposers_own_driving_period_mints_no_bounded_offer` already does), not a new +/// tracked dump. +/// +/// **WHY THIS IS THE ACCEPT SEAM AND NOT THE MINT SEAM.** The mint arms establish that a foreign +/// period no longer REFUSES the offer. That relaxation is only safe if the thing subsequently +/// accepted still routes to the DRAIN materializer: `materialize_fixed_shortcut` early-returns +/// into `materialize_object_growth_shortcut` on its routing test, and the bounded drain path +/// begins strictly below that return. A mint-seam row cannot see which side of it the accept +/// lands on. +/// +/// **SITE F IS NOT ON THIS PATH, and that is asserted rather than assumed** — dina's bounded offer +/// publishes an EMPTY point set, so `handle_declare_shortcut`'s +/// `if !offer.schema.points.is_empty()` block is skipped whole and the `template: None` +/// declaration this row makes never reaches the declare-seam arm. Site F's own row lives on the +/// F4 fixture for exactly the complementary reason. +/// +/// **THE PROPERTY**: the committed life delta is exactly `n ×` the published per-period delta — +/// i.e. the drain materializer ran. Positive control on the same fixture and same helper: +/// [`bounded_fixed_count_commits_exactly_n_periods`], whose reach-guards (non-zero δ, ≥ 2 seats +/// moving, bound ≥ 3) are repeated here because without them `n × δ` is satisfied by a drive that +/// committed nothing. +/// +/// **TWO-SIDED CONTROL:** +/// * **DROP** the proposer test at the materialize dispatch (restore `!is_empty()`) ⇒ the accept +/// early-returns into `materialize_object_growth_shortcut` and commits ZERO ⇒ `n × δ` fails for +/// every seat with a non-zero rate. +/// * **TRIVIALIZE** it to always take the drain path ⇒ a genuine object-growth accept commits +/// nothing, which the object-growth siblings of the positive control catch. +#[test] +fn an_accepted_bounded_grant_drains_even_with_a_foreign_period_in_state() { + use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + const N: u32 = 3; + + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_conqueror_4p.json.gz" + ))); + drive_to_bounded_offer(&mut state, 400) + .expect("the bounded offer must fire; see the acceptance row"); + + let (proposer, _, schema) = bounded_offer_parts(&state); + assert!( + schema.points.is_empty(), + "REACH-GUARD / SCOPE: this row isolates the MATERIALIZE dispatch. A non-empty point set \ + would drag `handle_declare_shortcut`'s declare-seam arm into the same measurement and \ + the outcome would no longer attribute to one site; got {:?}", + schema.points + ); + let opp = *engine_live_opponents(&state, proposer) + .first() + .expect("REACH-GUARD: the foreign period needs a living opponent to belong to"); + + // THE INJECTION the load migration hides from every dump-driven row: an opponent's own + // recorded period, sitting in state at the moment the grant is accepted. + state.last_loop_action_sequence = vec![LoopActionContext { + card_id: state + .objects + .values() + .next() + .map(|o| o.card_id) + .expect("the dump has objects"), + controller: opp, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::NotUsed, + }, + convoke: None, + pins: vec![], + }]; + assert_ne!( + opp, proposer, + "REACH-GUARD: a period injected for the PROPOSER would be the legitimate object-growth \ + route, and this row would assert the opposite of what it means to" + ); + + let (committed, per_cycle, bound) = accept_bounded_fixed(&mut state, N); + + // ── reach-guards: without these `n × δ` holds degenerately for a drive that did nothing ── + assert!( + per_cycle.delta != engine::analysis::resource::ResourceVector::default(), + "a zero-delta period makes `n × δ` zero for every `n`, so the equality below would hold \ + for a drive that committed nothing — which is the exact failure mode this row exists to \ + catch" + ); + assert!( + per_cycle.delta.life.values().filter(|v| **v != 0).count() >= 2, + "fewer than two seats with a non-zero life term is a 2-player shape; got {:?}", + per_cycle.delta.life + ); + assert!( + bound >= N, + "`n = {N}` must be WITHIN the offered bound, else the declaration is handed back and \ + this row silently tests the rejection arm; bound = {bound}" + ); + + // ── THE PROPERTY: the DRAIN materializer ran, not the object-growth one ── + for (seat, delta) in &committed { + assert_eq!( + *delta, + i64::from(N) * per_cycle.delta.life.get(seat).copied().unwrap_or(0), + "CR 732.2a: with a FOREIGN period in state the accepted grant must still commit \ + exactly `n` copies of the published per-period delta ({:?}). A zero here is the \ + object-growth misroute: `materialize_fixed_shortcut` early-returned into \ + `materialize_object_growth_shortcut`, which commits no bounded cycles at all. \ + {seat:?} committed {committed:?}", + per_cycle.delta.life + ); + } +} + +/// ITEM 2 ROUND 2 (CR 732.2a) — the DECLINE seam: one seat's decline may discard only its OWN +/// recorded period, never another seat's. +/// +/// **A SHAPE THE PRE-FIX TREE COULD NOT EXPRESS, which is why no existing row can supply it.** +/// While step (1b) refused on mere non-emptiness, no `WaitingFor::LoopShortcut` could coexist with +/// a period belonging to anyone but its proposer — the object-growth producer mints only for the +/// period's own controller, and the bounded producer minted only with the field empty. So +/// `handle_decline_shortcut`'s unconditional `last_loop_action_sequence.clear()` was, by +/// construction, only ever able to clear the decliner's own. The seat-relative (1b) makes the +/// two-seat state reachable, and `DeclineShortcut` dispatches from ANY `LoopShortcut` — it is the +/// AI's only action at a bounded offer — so an unconditional clear became one seat's decline +/// wiping another seat's accumulating period, suppressing THAT seat's offer until it re-armed. +/// +/// **THE TWO ARMS, on one real driven bounded offer, differing ONLY in the injected period's +/// controller** — so no constant implementation passes: +/// +/// | arm | injected period | assertion | +/// |---|---|---| +/// | FOREIGN | an opponent's | SURVIVES the decline (**the fix**) | +/// | OWN | the proposer's | CLEARED by the decline (must-not-flip: the load-bearing Seam-2 suppressor) | +/// +/// **TWO-SIDED CONTROL, PER ASSERTION — each direction flips a DIFFERENT named assertion:** +/// * **DROP** the ownership test (restore the unconditional +/// `state.last_loop_action_sequence.clear()`) ⇒ the FOREIGN arm's survival assertion FAILS, +/// while OWN still passes. +/// * **TRIVIALIZE** it to never clear (delete the clear, or gate it on +/// `loop_period_controller().is_none()`) ⇒ the OWN arm's clear assertion FAILS, while FOREIGN +/// still passes. +/// +/// The decline is driven through the production `apply()` reducer, not by calling the handler, so +/// the post-return reconcile runs too: the OWN arm therefore also proves the clear still suppresses +/// re-offer within the same `apply()` (a re-nag would leave `waiting_for` on a `LoopShortcut`), and +/// the FOREIGN arm proves leaving a foreign period in place does not resurrect one. +#[test] +fn declining_a_shortcut_discards_only_the_decliners_own_driving_period() { + use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + // One real driven bounded offer, re-derived per arm so neither arm inherits the other's board. + let offer_state = || { + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_conqueror_4p.json.gz" + ))); + drive_to_bounded_offer(&mut state, 400) + .expect("the bounded offer must fire; see the acceptance row"); + state + }; + let period_of = |controller: PlayerId, state: &GameState| { + vec![LoopActionContext { + card_id: state + .objects + .values() + .next() + .map(|o| o.card_id) + .expect("the dump has objects"), + controller, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::NotUsed, + }, + convoke: None, + pins: vec![], + }] + }; + + // ── FOREIGN: seat B's period is mid-accumulation when seat A declines ── + let mut state = offer_state(); + let (proposer, _, _) = bounded_offer_parts(&state); + let opp = *engine_live_opponents(&state, proposer) + .first() + .expect("REACH-GUARD: the foreign period needs a living opponent to belong to"); + assert_ne!( + opp, proposer, + "REACH-GUARD: a period injected for the PROPOSER would be the OWN arm, and this arm \ + would assert the opposite of what it means to" + ); + state.last_loop_action_sequence = period_of(opp, &state); + assert_eq!( + state.last_loop_action_sequence.len(), + 1, + "REACH-GUARD: the arm is vacuous unless a period is actually accumulating when the \ + decline lands — nothing survives an empty field" + ); + + apply(&mut state, proposer, GameAction::DeclineShortcut) + .expect("the proposer may always decline their own offer (CR 732.2a)"); + assert_eq!( + state + .last_loop_action_sequence + .iter() + .map(|s| s.controller) + .collect::>(), + vec![opp], + "CR 732.2a: {proposer:?} declining their own offer must leave {opp:?}'s accumulating \ + period intact — a recorded period is evidence about the seat that recorded it, and \ + discarding it here suppresses THAT seat's own offer until it re-arms" + ); + + // ── OWN: the must-not-flip half. The Seam-2 suppressor is load-bearing for the decliner. ── + let mut state = offer_state(); + let (proposer, _, _) = bounded_offer_parts(&state); + state.last_loop_action_sequence = period_of(proposer, &state); + assert_eq!( + state.last_loop_action_sequence.len(), + 1, + "REACH-GUARD: the arm is vacuous unless a period is actually accumulating when the \ + decline lands — an already-empty field is cleared by doing nothing" + ); + + apply(&mut state, proposer, GameAction::DeclineShortcut) + .expect("the proposer may always decline their own offer (CR 732.2a)"); + assert!( + state.last_loop_action_sequence.is_empty(), + "CR 732.2a: the decliner's OWN period must still be discarded — without it the \ + post-return reconcile re-fires `try_offer_object_growth_shortcut` inside this same \ + `apply()` and re-nags the offer just declined. seq = {:?}", + state.last_loop_action_sequence + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "and the declined offer must not have been re-raised within the same `apply()`; got {:?}", + state.waiting_for + ); +} + +/// CR 732.2a — the SECOND unconditional cross-seat clear in the teardown family: +/// `until_lethal_fallback`. An aborted `UntilLethal` drive discards only the PROPOSER'S own period. +/// +/// **WHY THIS ROW EXISTS NOW.** Round 2 found this site and refused it on a reachability argument +/// that ended in "no fixture reaches it". That was a fact about the fixture corpus, not about the +/// engine: `until_lethal_fallback` starts with `*state = committed`, restoring the PRE-DRIVE board +/// — and since step (1b) went seat-relative, that board can carry another seat's period. MEASURED +/// on the shipped tree before the guard landed: with a foreign period injected at the accept, the +/// sprout-swarm `UntilLethal` drive aborts, the fallback runs, and the foreign seat's period comes +/// back length 0. Same defect, same seam family, same one-line authority as +/// [`declining_a_shortcut_discards_only_the_decliners_own_driving_period`] above. +/// +/// **WHY THE OBJECT-GROWTH FIXTURE.** The fallback is reached only when the drive refuses to crown. +/// `object_growth_advantage_untillethal_no_crown` is the tree's own proof that this board does +/// exactly that (an inert Advantage token loop has no faller), so both arms below are the shipped +/// abort path with one field changed — not a synthesized failure. +/// +/// | arm | injected period | assertion | +/// |---|---|---| +/// | FOREIGN | an opponent's | SURVIVES the aborted drive (**the fix**) | +/// | OWN | the proposer's | CLEARED by it (must-not-flip: the anti-livelock suppressor the doc names) | +/// +/// **TWO-SIDED CONTROL, PER ASSERTION — each direction flips a DIFFERENT named assertion:** +/// * **DROP** the ownership test (restore the unconditional `last_loop_action_sequence.clear()`) +/// ⇒ the FOREIGN arm's survival assertion FAILS, while OWN still passes. +/// * **TRIVIALIZE** it to never clear ⇒ the OWN arm's clear assertion FAILS, while FOREIGN passes. +#[test] +fn an_aborted_until_lethal_drive_discards_only_the_proposers_own_driving_period() { + use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + // The shipped abort path, re-derived per arm: cast the recast, take the object-growth offer, + // declare `UntilLethal` (the AI's hardcoded shape), and let every opponent accept. + let offer_state = || { + let (mut runner, sprout, fodder) = sprout_swarm_scenario(4); + let _ = runner + .cast(sprout) + .accept_optional() + .convoke_with(&[fodder[0]]) + .commit() + .resolve(); + let WaitingFor::LoopShortcut { proposer, .. } = runner.state().waiting_for.clone() else { + panic!( + "REACH-GUARD: the object-growth cast must OFFER, got {:?}", + runner.state().waiting_for + ) + }; + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: None, + }) + .expect("the proposer declares UntilLethal on its own object-growth offer"); + (runner, proposer) + }; + let period_of = |controller: PlayerId, runner: &GameRunner| { + vec![LoopActionContext { + card_id: runner + .state() + .objects + .values() + .next() + .map(|o| o.card_id) + .expect("the scenario has objects"), + controller, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::NotUsed, + }, + convoke: None, + pins: vec![], + }] + }; + + // ── FOREIGN: seat B's period is mid-accumulation when seat A's drive aborts ── + let (mut runner, proposer) = offer_state(); + let opp = runner + .state() + .players + .iter() + .map(|p| p.id) + .find(|p| *p != proposer) + .expect("REACH-GUARD: the foreign period needs a second seat to belong to"); + runner.state_mut().last_loop_action_sequence = period_of(opp, &runner); + accept_all_opponents(&mut runner); + assert!( + !matches!(runner.state().waiting_for, WaitingFor::GameOver { .. }), + "REACH-GUARD: this row is about the FALLBACK, so the drive must refuse to crown — a \ + crowned drive never reaches the clear and the assertion below would be vacuous; got {:?}", + runner.state().waiting_for + ); + assert_eq!( + runner + .state() + .last_loop_action_sequence + .iter() + .map(|s| s.controller) + .collect::>(), + vec![opp], + "CR 732.2a: {proposer:?}'s aborted drive must leave {opp:?}'s accumulating period intact. \ + `until_lethal_fallback` rolls the board back to the pre-drive `committed` state, which \ + carries that period, and an unconditional clear then destroys it as a side effect of \ + somebody else's abort" + ); + + // ── OWN: the must-not-flip half. The clear is the anti-livelock suppressor for the proposer. ── + let (mut runner, proposer) = offer_state(); + assert_eq!( + runner + .state() + .last_loop_action_sequence + .iter() + .map(|s| s.controller) + .collect::>(), + vec![proposer], + "REACH-GUARD: the real recast must have armed the PROPOSER'S own period, else this arm \ + tests an empty field that is cleared by doing nothing" + ); + accept_all_opponents(&mut runner); + assert!( + runner.state().last_loop_action_sequence.is_empty(), + "CR 732.2a: the proposer's OWN period must still be discarded — without it the reconcile \ + re-fires `try_offer_object_growth_shortcut` on the loop just abandoned and livelocks. \ + seq = {:?}", + runner.state().last_loop_action_sequence + ); + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "and the abandoned offer must not have been re-raised; got {:?}", + runner.state().waiting_for + ); +} + /// FIX ROUND 2 (MED-2) — the same `n × δ` property on a certification-basis **A** offer, at /// DRIVE level. The row above covers basis **B** on all three of its fixtures; every basis-A /// claim in this lane rested on ONE published-number assertion until this row. diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 82b946b824..63c8399019 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1241,6 +1241,7 @@ mod tromokratis; mod umbra_stalker_graveyard_chroma_4066; mod undying_malice_edict_sacrifice_5942; mod unless_pay_routes_through_authority; +mod user_capture_probes; mod valakut_fireboar_switch_pt_on_attack; mod vanille_meld_optional_cost; mod vannifar_cloak_from_hand; diff --git a/crates/engine/tests/integration/user_capture_probes.rs b/crates/engine/tests/integration/user_capture_probes.rs new file mode 100644 index 0000000000..08ba7806f9 --- /dev/null +++ b/crates/engine/tests/integration/user_capture_probes.rs @@ -0,0 +1,514 @@ +//! USER-CAPTURE ROWS — the user's own 4-player Dina / Bloodthirsty Conqueror capture, driven +//! through the production `apply()` beat policy, asserting where the CR 732.2a bounded offer does +//! and does not appear. +//! +//! **These are TRACKED acceptance rows and run on every CI run.** They were env-gated diagnostics +//! over an untracked bug-report attachment until that capture was derived into +//! `fixtures/dina_conqueror_phase5_no_offer_4p.json.gz` by the lane's recipe — +//! `jq -c '{gameState}' | gzip -9 -n`, `-n` so the archive carries no timestamp and is +//! byte-reproducible. Regenerating it requires re-gzipping the same way. No env var gates anything +//! here: the headline result — the offer firing on the user's own board with a FOREIGN driving +//! period live in state — is reproducible by anyone who can run the suite. +//! +//! # The capture (2026-08-03T19-29-36-888Z) — what it measured +//! +//! A MANDATORY gain/drain trigger chain with no per-iteration choices, at `Priority{1}`, turn 5, +//! life `[48, 31, 36, 36]`. Its distinguishing field is a length-1 `last_loop_action_sequence`: +//! +//! ```text +//! [{ action: Activate { source_id: 268, ability_index: 1 }, controller: 2, pins: [] }] +//! ``` +//! +//! 268 is Currency Converter, controlled by an OPPONENT — an activation unrelated to the drain. +//! Before the seat-relative fix, conjunct (1b) refused the bounded offer on ANY non-empty sequence +//! (it was named `DrivingSequenceNotEmpty` then, `ProposerHasDrivingPeriod` now), so that one +//! foreign step suppressed the proposer's offer for the rest of the game. The already-tracked +//! `dina_conqueror_4p.json.gz` is a DIFFERENT capture of the same deck (life `[46, 37, 33, 38]`, +//! no recorded period) and cannot stand in for it. +//! +//! # Why tracking the dump is not by itself enough +//! +//! `GameState::migrate_transient_loop_sequence` CLEARS the field at every load that is not a +//! shortcut window, so `into_game_state()` wipes the one field this file is about no matter where +//! the bytes came from — tracking alone would give ARM D1 twice. ARM D2 therefore reads the field +//! back out of the fixture's OWN serialized JSON and puts it back: the dump's own bytes, not a +//! synthesized step. **That asymmetry is the whole point of the pair below**: ARM D1 is what every +//! in-process test sees, ARM D2 is what the running game actually held, and they differ in exactly +//! one field. + +use engine::game::engine::apply; +use engine::types::actions::GameAction; +use engine::types::game_state::{GameState, PersistedGameState, WaitingFor}; +use engine::types::player::PlayerId; + +/// The user's capture, `{gameState}`-only and gzipped. Provenance: +/// `dina-conqueror-phase5-no-offer.zip` / `game-state-turn-5-2026-08-03T19-29-36-888Z.json`. +const DINA_PHASE5_GZ: &[u8] = + include_bytes!("../fixtures/dina_conqueror_phase5_no_offer_4p.json.gz"); + +fn wf_label(w: &WaitingFor) -> String { + match w { + WaitingFor::Priority { player } => format!("Priority({})", player.0), + WaitingFor::LoopShortcut { proposer, .. } => format!("LoopShortcut({})", proposer.0), + other => format!("{other:?}") + .split_whitespace() + .next() + .unwrap_or("?") + .trim_end_matches('{') + .to_string(), + } +} + +/// The TYPED refusal of the bounded-offer mint at this exact frame — the discriminating +/// instrument: it names WHICH conjunct refused instead of collapsing to "no offer". That is what +/// makes a per-beat census non-vacuous; a bare "did not fire" could not distinguish this defect +/// from a board that simply never certified. +fn mint_verdict(state: &GameState) -> String { + match engine::game::engine::try_offer_bounded_cycle_shortcut(state, false) { + Ok(_) => "OFFER".to_string(), + Err(e) => format!("{e:?}"), + } +} + +/// The board as PRODUCTION loads it, paired with the driving period the capture actually +/// serialized — which the load migration has by then already dropped from the board. +/// +/// Decodes AS `PersistedGameState` rather than as a bare `GameState`: only the former runs the +/// production restore chokepoint (`reject_legacy_raw_prompt_authority`, +/// `decode_persisted_resolution_state`, `migrate_transient_loop_sequence`) that both the server's +/// `from_persisted` and WASM's `decode_restored_game_state` funnel through. The dump was captured +/// with the detector OFF and every row here is about the CR 732.2a interactive offer, so the mode +/// is set at load — the same thing the user's own toggle does. +fn load_dina_raw() -> (GameState, serde_json::Value) { + use std::io::Read; + let mut json = String::new(); + flate2::read::GzDecoder::new(DINA_PHASE5_GZ) + .read_to_string(&mut json) + .expect("fixture .json.gz must inflate to UTF-8 JSON"); + let envelope: serde_json::Value = serde_json::from_str(&json).expect("dina dump parses"); + let mut state = serde_json::from_value::(envelope["gameState"].clone()) + .expect("dina gameState decodes through the production decoder") + .into_game_state(); + state.loop_detection = engine::types::game_state::LoopDetectionMode::Interactive; + let raw_seq = envelope["gameState"]["last_loop_action_sequence"].clone(); + (state, raw_seq) +} + +/// Mirror of `GameState::loop_period_controller`, which is `pub(crate)` and therefore unreachable +/// from an integration test: the seat every recorded step shares, or `None` for a heterogeneous +/// run (which every routing site fail-closes on). +fn period_controller(state: &GameState) -> Option { + let owner = state.last_loop_action_sequence.first()?.controller; + state + .last_loop_action_sequence + .iter() + .all(|step| step.controller == owner) + .then_some(owner) +} + +/// One driven beat's mint census entry, in the ISOLATED form. +/// +/// `live` is the verdict on the board as driven; `cleared` is the verdict the SAME board returns +/// with `last_loop_action_sequence` emptied and nothing else touched. The mint runs (1b) BEFORE +/// (2), so a `live` refusal reported against (1b) is EVIDENCE about (1b) only where `cleared` +/// differs from it — otherwise a later conjunct refuses at that frame anyway and the attribution +/// is dominated. The round-2 census published a residual (1b) figure without this second column, +/// and the figure did not mean what it said. +struct MintFrame { + beat: u32, + /// The seat proposing at this frame; `None` when the frame is not a `Priority` beat, in which + /// case no seat proposes and neither classification below applies. + proposer: Option, + /// The recorded period belongs to THIS frame's proposer — the one shape (1b) exists to refuse. + own: bool, + live: String, + cleared: String, +} + +/// Generic mandatory-chain beat: pass at `Priority`, otherwise take the first legal action. +/// The Dina chain opens no player choices, so no preference ordering is needed. +fn dina_drive_one_beat(state: &mut GameState) -> Result { + let who = state + .waiting_for + .acting_player() + .or_else(|| state.waiting_for.acting_players().first().copied()) + .ok_or_else(|| format!("no acting player at {:?}", state.waiting_for))?; + let (actions, _costs, _grouped) = engine::ai_support::legal_actions_for_viewer(state, who); + let chosen = if matches!(state.waiting_for, WaitingFor::Priority { .. }) { + actions + .iter() + .find(|a| matches!(a, GameAction::PassPriority)) + .cloned() + } else { + actions + .iter() + .find(|a| !matches!(a, GameAction::PassPriority)) + .or_else(|| actions.first()) + .cloned() + }; + let action = chosen.ok_or_else(|| { + format!( + "no action at {:?}; legal = {:?}", + state.waiting_for, + actions.iter().take(8).collect::>() + ) + })?; + let label = format!("{action:?}"); + apply(state, who, action.clone()) + .map(|_| label) + .map_err(|e| format!("apply err ({action:?}): {e:?}")) +} + +/// The ISOLATED census entry for the board as it stands after one driven beat. +fn mint_frame(state: &GameState, beat: u32) -> MintFrame { + let proposer = match state.waiting_for { + WaitingFor::Priority { player } => Some(player), + _ => None, + }; + let mut cleared_board = state.clone(); + cleared_board.last_loop_action_sequence.clear(); + MintFrame { + beat, + proposer, + own: proposer.is_some() && period_controller(state) == proposer, + live: mint_verdict(state), + cleared: mint_verdict(&cleared_board), + } +} + +/// Drives IN PLACE (`&mut`), so the caller can inspect the board the drive stopped on — the +/// offer's proposer, the ring depth, the surviving driving sequence — instead of only the beat. +/// +/// Returns the beat the drive stopped on and the per-beat ISOLATED mint census. +fn dina_drive_and_report( + state: &mut GameState, + label: &str, + beats: u32, +) -> (Option, Vec) { + eprintln!( + "[{label}] START turn={} active={} wf={} stack={} ring={} seq={} life={:?}", + state.turn_number, + state.active_player.0, + wf_label(&state.waiting_for), + state.stack.len(), + state.loop_detect_ring.len(), + state.last_loop_action_sequence.len(), + state.players.iter().map(|p| p.life).collect::>(), + ); + let mut fired = None; + let mut census: Vec = vec![]; + for beat in 0..beats { + if matches!( + state.waiting_for, + WaitingFor::LoopShortcut { .. } | WaitingFor::GameOver { .. } + ) { + fired = Some(beat); + break; + } + let at_priority = matches!(state.waiting_for, WaitingFor::Priority { .. }); + let before = wf_label(&state.waiting_for); + match dina_drive_one_beat(state) { + Ok(act) => { + let after_priority = matches!(state.waiting_for, WaitingFor::Priority { .. }); + // Same frame selection the published census used, so the two are comparable. + let verdicts = (after_priority || at_priority).then(|| { + let frame = mint_frame(state, beat); + let rendered = format!("{}/cleared={}", frame.live, frame.cleared); + census.push(frame); + rendered + }); + eprintln!( + "[{label}] beat {beat:3} {before:>26} -> {:<26} stack={} ring={} seq={} life={:?} mint={} act={}", + wf_label(&state.waiting_for), + state.stack.len(), + state.loop_detect_ring.len(), + state.last_loop_action_sequence.len(), + state.players.iter().map(|p| p.life).collect::>(), + verdicts.unwrap_or_else(|| "-".to_string()), + &act.chars().take(40).collect::() + ); + } + Err(e) => { + eprintln!("[{label}] beat {beat:3} ABORT at {before}: {e}"); + break; + } + } + } + eprintln!( + "[{label}] END fired={fired:?} wf={} ring={} seq={} life={:?}", + wf_label(&state.waiting_for), + state.loop_detect_ring.len(), + state.last_loop_action_sequence.len(), + state.players.iter().map(|p| p.life).collect::>(), + ); + report_census(label, &census); + (fired, census) +} + +/// The census, reduced and printed in the ISOLATED form: every (1b) refusal is split into the ones +/// that were the SOLE reason this frame raised no offer and the ones a later conjunct refused at +/// anyway. Only the first count is evidence about (1b); the second is dominated and proves nothing +/// about the conjunct it is attributed to. +fn report_census(label: &str, census: &[MintFrame]) { + let mut tally: std::collections::BTreeMap<(&str, &str), usize> = Default::default(); + for f in census { + *tally + .entry((f.live.as_str(), f.cleared.as_str())) + .or_default() += 1; + } + for ((live, cleared), n) in &tally { + eprintln!("[{label}] CENSUS {n:3} x mint={live} cleared={cleared}"); + } + let one_b = |load_bearing: bool| { + census + .iter() + .filter(|f| { + f.live == "ProposerHasDrivingPeriod" && (f.cleared == "OFFER") == load_bearing + }) + .count() + }; + eprintln!( + "[{label}] CENSUS step-(1b) refusals: {} LOAD-BEARING (the cleared twin would have \ + OFFERED) + {} DOMINATED (a later conjunct refuses that frame anyway)", + one_b(true), + one_b(false), + ); +} + +/// (beat the offer fired at, ring depth, life vector) — the axes the two arms are compared on. +fn offer_signature(state: &GameState, fired: Option) -> (Option, usize, Vec) { + ( + fired, + state.loop_detect_ring.len(), + state.players.iter().map(|p| p.life).collect(), + ) +} + +/// ARM D1 — the capture as PRODUCTION loads it: `migrate_transient_loop_sequence` has already +/// dropped the driving sequence, so this is the state every in-process test would see. This is +/// the CONTROL: the offer this board raises with the field cleared is the one ARM D2 must match. +/// +/// It also pins the load migration itself against this fixture — the fixture SERIALIZES a period +/// (asserted here from its own JSON) and the loaded board does not carry it. That is what makes +/// ARM D2's re-injection a restoration rather than an invention. +#[test] +fn the_user_captures_offer_is_reached_with_its_driving_period_cleared() { + let (state, raw_seq) = load_dina_raw(); + eprintln!("[DINA-LOADED] raw serialized sequence = {raw_seq}"); + assert_eq!( + raw_seq.as_array().map(|a| a.len()), + Some(1), + "REACH-GUARD: the tracked fixture must still SERIALIZE the capture's own single recorded \ + step, else ARM D2 has nothing of the user's to put back; got {raw_seq}" + ); + assert!( + state.last_loop_action_sequence.is_empty(), + "reach-guard: the production restore hook must have DROPPED the sequence at load" + ); + assert!( + state.may_trigger_auto_choices.is_empty(), + "reach-guard: the Dina dump carries no may-trigger auto choice (the F4 mechanism \ + cannot apply here)" + ); + let mut state = state; + let (fired, _) = dina_drive_and_report(&mut state, "DINA-LOADED", 140); + eprintln!("[DINA-LOADED] fired={fired:?}"); + assert!( + fired.is_some(), + "REACH-GUARD: the field-cleared control must reach the offer, else ARM D2 has nothing \ + to be identical TO and the pair proves nothing" + ); + + // WHICH SITES THIS FIXTURE CAN AND CANNOT HOST, asserted rather than claimed in prose + // elsewhere. `handle_declare_shortcut`'s `template: None` arm (site F) sits under + // `if !offer.schema.points.is_empty()`, and the `UntilLethal` drive (site D) is reachable only + // through an offer that states NO narrowed bound. This capture's offer publishes neither, so it + // can host neither site — which is why those two rows ride other fixtures. Pinning it here + // means a future capture that DOES publish points reds this line instead of silently making + // the sibling rows' scope claims stale. + let WaitingFor::LoopShortcut { + predicted_winner, + schema, + .. + } = &state.waiting_for + else { + panic!( + "`fired` is Some, so the drive stopped on the offer; got {:?}", + state.waiting_for + ) + }; + assert_eq!( + predicted_winner, &None, + "this capture reaches the BOUNDED mint (CR 732.2a), not Path A's crowned offer — the \ + whole file is about the bounded mint's step (1b)" + ); + assert!( + schema.points.is_empty(), + "SCOPE: this capture's offer publishes no per-iteration choice point, so site F is \ + STRUCTURALLY unreachable from it; got {:?}", + schema.points + ); + assert!( + schema.is_bounded(), + "SCOPE: a bounded offer is exactly what makes `handle_declare_shortcut` reject \ + `UntilLethal`, so site D is unreachable from this capture too" + ); +} + +/// ARM D2 — the same board with the LIVE sequence put back, i.e. what the running game actually +/// held. Only that one field differs from ARM D1. +/// +/// **THE FIX BAR, on the user's own capture.** The recorded step is +/// `Activate { source_id: 268 }` controlled by PlayerId(2) — an OPPONENT'S activation, unrelated +/// to the drain. Before the seat-relative (1b) this board answered `Priority(2)` for all 140 +/// beats with a step-(1b) refusal census; after it, the drive must reach the SAME offer +/// the field-cleared control reaches, at the same beat, with the same ring depth and the same +/// life vector — while the foreign step is still sitting in state (`seq` stays at 1). That is +/// "a foreign period is inert", measured end-to-end rather than argued. +/// +/// The comparison is against ARM D1 re-driven HERE rather than against transcribed numbers: a +/// hardcoded beat/life tuple would decay into a fixture the next drive-policy change reds for a +/// reason that has nothing to do with this defect. +/// +/// **THE PER-FRAME BAR, which the endpoint equality above cannot state.** Two seats propose over +/// this drive, so the recorded period is FOREIGN at some frames and the proposer's OWN at others, +/// and the two shapes carry opposite obligations (CR 732.2a). Each is asserted against the same +/// frame's CLEARED twin — the identical board with only `last_loop_action_sequence` emptied — so +/// no assertion rests on a refusal an earlier or later conjunct would have produced anyway: +/// * FOREIGN frames: the live verdict must EQUAL the cleared verdict. The period changes nothing, +/// which is inertness stated frame by frame rather than only at the endpoint. +/// * OWN frames: the live verdict must be `ProposerHasDrivingPeriod`. That is the load-bearing +/// half of the guard, and it is asserted HERE rather than inferred from a residual count. +/// +/// **TWO-SIDED CONTROL, PER ASSERTION** — each direction flips a DIFFERENT named assertion: +/// * **DROP** the seat test in (1b) (restore `!last_loop_action_sequence.is_empty()`) ⇒ every +/// FOREIGN frame answers `ProposerHasDrivingPeriod` while its cleared twin does not ⇒ the +/// FOREIGN-INERTNESS assertion fails (and so does the endpoint fix bar, which stops firing). +/// * **TRIVIALIZE** (1b) to never refuse ⇒ OWN frames answer `ProposerIsNotActivePlayer` ⇒ the +/// OWN-PERIOD assertion fails while FOREIGN-INERTNESS still passes. +/// +/// ⚠ **WHAT THE CENSUS IS NOT EVIDENCE FOR.** Round 2 published the residual `ProposerHasDrivingPeriod` +/// count as the guard "working as designed". `report_census` now splits that count by its cleared +/// twin, and on this board every one of those frames is DOMINATED — the proposer there is also not +/// the active player, so conjunct (2) refuses the same frame with the field empty. The residual +/// count is therefore not evidence about (1b); the OWN-PERIOD assertion below and the `ⓑ`/`ⓔ` arms +/// of `a_foreign_driving_period_neither_refuses_nor_recertifies_a_bounded_offer` are. +#[test] +fn the_user_captures_offer_is_reached_with_its_own_foreign_period_live() { + let (mut state, raw_seq) = load_dina_raw(); + state.last_loop_action_sequence = + serde_json::from_value(raw_seq.clone()).expect("the dump's own sequence re-parses"); + assert_eq!( + state.last_loop_action_sequence.len(), + 1, + "reach-guard: the re-injected live sequence must be the dump's own single step" + ); + let foreign = state.last_loop_action_sequence[0].controller; + eprintln!("[DINA-LIVE] re-injected {raw_seq}"); + + let (fired, census) = dina_drive_and_report(&mut state, "DINA-LIVE", 140); + eprintln!("[DINA-LIVE] fired={fired:?}"); + + // The CONTROL, re-driven in this process: same board, the one field cleared. + let (mut control, _) = load_dina_raw(); + control.last_loop_action_sequence.clear(); + let (control_fired, _) = dina_drive_and_report(&mut control, "DINA-CONTROL", 140); + + // IN-ROW REACH-GUARD, not inherited from ARM D1's: the equality below compares this arm to a + // control re-driven HERE, so on a board where NEITHER side reaches an offer both sides are + // `(None, (None, ring, life))` and the assertion passes having measured nothing. ARM D1's own + // guard cannot cover that — a `#[test]` that is skipped, filtered, or reds independently + // leaves this row still "green". The control must PROVABLY reach the offer in this process. + assert!( + control_fired.is_some(), + "REACH-GUARD: the field-cleared control re-driven in THIS row must reach the offer, else \ + the fix bar below compares two absences and passes vacuously" + ); + + // ── THE PER-FRAME BAR, asserted BEFORE the endpoint one on purpose: it is the finer + // instrument, and under the DROP mutant the endpoint bar would otherwise panic first and + // leave FOREIGN-INERTNESS unobserved. Reach-guards first: both frame shapes must actually + // occur, and the instrument must demonstrably be able to answer more than one way. ── + let (own, foreign_frames): (Vec<&MintFrame>, Vec<&MintFrame>) = census + .iter() + .filter(|f| f.proposer.is_some()) + .partition(|f| f.own); + let distinct: std::collections::BTreeSet<&str> = + census.iter().map(|f| f.live.as_str()).collect(); + assert!( + distinct.len() >= 2, + "REACH-GUARD: a mint that answered one constant across the whole drive would satisfy both \ + assertions below without discriminating anything; got {distinct:?}" + ); + assert!( + !foreign_frames.is_empty(), + "REACH-GUARD: no beat had a seat OTHER than {foreign:?} proposing, so FOREIGN-INERTNESS \ + below quantifies over an empty set and passes having measured nothing" + ); + assert!( + !own.is_empty(), + "REACH-GUARD: no beat had {foreign:?} — the seat that recorded the period — proposing, so \ + the OWN-PERIOD assertion below quantifies over an empty set. This board reaches both \ + shapes; if it stops doing so the arm must move, not soften" + ); + + let diverged: Vec<_> = foreign_frames + .iter() + .filter(|f| f.live != f.cleared) + .map(|f| (f.beat, f.proposer, f.live.as_str(), f.cleared.as_str())) + .collect(); + assert!( + diverged.is_empty(), + "CR 732.2a FOREIGN-INERTNESS, per frame: at every beat where the recorded period belongs \ + to a seat OTHER than the proposer, the mint must return exactly what it returns with the \ + field empty — a period recorded by another seat describes no sequence this proposer can \ + take, so it may not change their verdict. {} of {} foreign frames diverged: {diverged:?}", + diverged.len(), + foreign_frames.len() + ); + + let leaked: Vec<_> = own + .iter() + .filter(|f| f.live != "ProposerHasDrivingPeriod") + .map(|f| (f.beat, f.proposer, f.live.as_str(), f.cleared.as_str())) + .collect(); + assert!( + leaked.is_empty(), + "CR 732.2a OWN-PERIOD: at every beat where the recorded period is the PROPOSER'S OWN, \ + step (1b) must refuse — an offer minted there would be accepted and routed to the \ + object-growth materializer, committing ZERO bounded cycles. {} of {} own frames did not: \ + {leaked:?}", + leaked.len(), + own.len() + ); + + // ── THE ENDPOINT BAR: the whole trajectory, not one frame. ── + let (proposer, control_proposer) = (offer_proposer(&state), offer_proposer(&control)); + assert_ne!( + Some(foreign), + control_proposer, + "REACH-GUARD: the recorded step must belong to a seat OTHER than the proposer, else \ + this arm is measuring the legitimate own-period case" + ); + assert_eq!( + (proposer, offer_signature(&state, fired)), + (control_proposer, offer_signature(&control, control_fired)), + "CR 732.2a THE FIX BAR: with an opponent's recorded activation sitting in state, the \ + proposer's own bounded offer must be reached at the same beat, with the same ring \ + depth and the same life vector, as the field-cleared control. A `None` on the left is \ + the original defect: one foreign step suppressing the offer for the whole drive" + ); + assert_eq!( + state.last_loop_action_sequence.len(), + 1, + "and the foreign step is still THERE — the offer was reached with it in state, not by \ + the drive quietly clearing it" + ); +} + +fn offer_proposer(state: &GameState) -> Option { + match &state.waiting_for { + WaitingFor::LoopShortcut { proposer, .. } => Some(*proposer), + _ => None, + } +}