diff --git a/client/src/i18n/locales/de/common.json b/client/src/i18n/locales/de/common.json index 7924b92d12..cad40119d6 100644 --- a/client/src/i18n/locales/de/common.json +++ b/client/src/i18n/locales/de/common.json @@ -48,7 +48,7 @@ "updated": "aktualisiert" }, "comboDetector": { - "label": "Combo-Detektor", + "label": "Combo-Detektor (experimentell)", "title": "Unendliche Schleifen erkennen: Pflichtschleifen automatisch beenden und ∞-Ressourcen anzeigen (standardmäßig aus)", "on": "An", "off": "Aus", diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json index 1daa7abb7f..98f2191045 100644 --- a/client/src/i18n/locales/en/common.json +++ b/client/src/i18n/locales/en/common.json @@ -48,7 +48,7 @@ "updated": "updated" }, "comboDetector": { - "label": "Combo Detector", + "label": "Combo Detector (experimental)", "title": "Detect infinite loops: end mandatory loops automatically and show ∞ resources (off by default)", "on": "On", "off": "Off", diff --git a/client/src/i18n/locales/es/common.json b/client/src/i18n/locales/es/common.json index 794faf51cf..8b744fd262 100644 --- a/client/src/i18n/locales/es/common.json +++ b/client/src/i18n/locales/es/common.json @@ -48,7 +48,7 @@ "updated": "actualizado" }, "comboDetector": { - "label": "Detector de combos", + "label": "Detector de combos (experimental)", "title": "Detectar bucles infinitos: terminar bucles obligatorios automáticamente y mostrar recursos ∞ (desactivado por defecto)", "on": "Activado", "off": "Desactivado", diff --git a/client/src/i18n/locales/fr/common.json b/client/src/i18n/locales/fr/common.json index a080ec5723..991d964506 100644 --- a/client/src/i18n/locales/fr/common.json +++ b/client/src/i18n/locales/fr/common.json @@ -48,7 +48,7 @@ "updated": "à jour" }, "comboDetector": { - "label": "Détecteur de combos", + "label": "Détecteur de combos (expérimental)", "title": "Détecter les boucles infinies : terminer automatiquement les boucles obligatoires et afficher les ressources ∞ (désactivé par défaut)", "on": "Activé", "off": "Désactivé", diff --git a/client/src/i18n/locales/it/common.json b/client/src/i18n/locales/it/common.json index 8036ba87e9..6e52f2ac6c 100644 --- a/client/src/i18n/locales/it/common.json +++ b/client/src/i18n/locales/it/common.json @@ -48,7 +48,7 @@ "updated": "aggiornato" }, "comboDetector": { - "label": "Rilevatore di combo", + "label": "Rilevatore di combo (sperimentale)", "title": "Rileva i loop infiniti: termina automaticamente i loop obbligatori e mostra le risorse ∞ (disattivato per impostazione predefinita)", "on": "Attivo", "off": "Disattivato", diff --git a/client/src/i18n/locales/pl/common.json b/client/src/i18n/locales/pl/common.json index 6cf3dd7c21..d354c638d3 100644 --- a/client/src/i18n/locales/pl/common.json +++ b/client/src/i18n/locales/pl/common.json @@ -48,7 +48,7 @@ "updated": "zaktualizowano" }, "comboDetector": { - "label": "Wykrywacz combosów", + "label": "Wykrywacz combosów (eksperymentalne)", "title": "Wykrywaj nieskończone pętle: automatycznie kończ obowiązkowe pętle i pokazuj zasoby ∞ (domyślnie wyłączone)", "on": "Wł.", "off": "Wył.", diff --git a/client/src/i18n/locales/pt/common.json b/client/src/i18n/locales/pt/common.json index 5bb44cb5b4..5a70ce9b41 100644 --- a/client/src/i18n/locales/pt/common.json +++ b/client/src/i18n/locales/pt/common.json @@ -48,7 +48,7 @@ "updated": "atualizado" }, "comboDetector": { - "label": "Detector de combos", + "label": "Detector de combos (experimental)", "title": "Detectar loops infinitos: encerrar loops obrigatórios automaticamente e mostrar recursos ∞ (desativado por padrão)", "on": "Ligado", "off": "Desligado", diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 3b7281ae98..de08c63af5 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -3037,12 +3037,14 @@ pub fn candidate_actions_broad_with_probe( // accepted at `Fixed(0)`). Clamp, or the generator's sole candidate is rejected by // the reducer's `amount > max` guard and the AI has no legal action at this prompt. // - // Unreachable for the AI *today* and deliberately kept correct anyway: the AI's own - // `WaitingFor::LoopShortcut` arm below only ever proposes `IterationCount:: - // UntilLethal`, which routes to `apply_until_lethal_shortcut` and never reaches - // `materialize_fixed_shortcut` — the only path that registers a stash. So no - // AI-declared shortcut currently produces this prompt; a human-declared one in a - // mixed game, or a future bounded AI offer, does. + // AI-reachable since the bounded fast-forward landed, which is what stales the older + // "the arm below only ever proposes `UntilLethal`" note this replaces: the + // `WaitingFor::LoopShortcut` arm below also proposes `Fixed(max_iterations)` against a + // bounded offer that publishes no pins, and only a `Fixed` count routes through + // `materialize_fixed_shortcut` — the single path that registers the stash `turns.rs` + // turns into this prompt. `UntilLethal` still routes to `apply_until_lethal_shortcut` + // and never gets here; it is now also not offered against a bounded offer at all. A + // human-declared shortcut in a mixed game reaches this prompt too. WaitingFor::PayAmountChoice { player, resource: PayableResource::LoopCollapse { .. }, @@ -3232,21 +3234,60 @@ pub fn candidate_actions_broad_with_probe( // policy/search layer, rather than the candidate generator, decides whether an AI // proposer declares or returns to ordinary priority. // (Scored by `phase_ai::policies::loop_shortcut::LoopShortcutPolicy`.) - WaitingFor::LoopShortcut { proposer, .. } => vec![ - candidate( - GameAction::DeclareShortcut { - count: crate::analysis::decision_template::IterationCount::UntilLethal, - template: None, - }, - TacticalClass::Utility, - Some(*proposer), - ), - candidate( + WaitingFor::LoopShortcut { + proposer, schema, .. + } => { + // CR 732.2a: `UntilLethal` names no count, so it is legal ONLY against an offer + // that narrowed no bound. `handle_declare_shortcut` rejects it outright against + // a bounded one (`IterationCount::UntilLethal if offer.schema.is_bounded()` => + // `reject_shortcut_declaration`), and that reject is a SUCCESSFUL, fail-closed + // handback to priority — `Ok(result)`, not an `Err`. So an unconditional + // `UntilLethal` candidate did not merely waste a search node: it handed the + // simulation layer an action the engine ACCEPTS and then silently discards, + // i.e. an illegal quantity choice wearing the shape of a legal one, which the + // policy layer then has to know to score away. + // + // Emit only the quantity choices the offer can actually take. A bounded offer + // gets `Fixed(max_iterations)` below when its pin set permits a `template: None` + // declaration; where neither applies, `DeclineShortcut` really is the only legal + // answer at the node, and representing that honestly is the point. + let mut v = Vec::new(); + if !schema.is_bounded() { + v.push(candidate( + GameAction::DeclareShortcut { + count: crate::analysis::decision_template::IterationCount::UntilLethal, + template: None, + }, + TacticalClass::Utility, + Some(*proposer), + )); + } + // CR 732.2a: a BOUNDED offer states a legal repetition count, and the declare + // handler rejects `UntilLethal` against one outright — so without this candidate + // the AI's only non-declining option at such a node is an answer the engine + // refuses. `ShortcutDecisionSchema::is_bounded()` is the engine's single + // authority for "this producer narrowed the bound"; do NOT re-spell it as a + // comparison against `MAX_SHORTCUT_CYCLES`. Gated on empty `points` because this + // candidate carries `template: None`, which a published pin set fail-closes on. + if schema.points.is_empty() && schema.is_bounded() { + v.push(candidate( + GameAction::DeclareShortcut { + count: crate::analysis::decision_template::IterationCount::Fixed( + schema.max_iterations, + ), + template: None, + }, + TacticalClass::Utility, + Some(*proposer), + )); + } + v.push(candidate( GameAction::DeclineShortcut, TacticalClass::Pass, Some(*proposer), - ), - ], + )); + v + } // CR 732.2b/c: an opponent answers a loop-shortcut offer. PR-7 Phase 4c (LOW-2): // self-preservation via the single-authority `smart_shortcut_response` — Shorten // when the polled player has a meaningful way to break the loop, else Accept. @@ -5234,6 +5275,7 @@ mod tests { win_kind: crate::analysis::loop_check::WinKind::LethalDamage, mandatory: false, residual_board_delta: crate::analysis::resource::BoardDelta::default(), + per_cycle: None, }, schema: crate::analysis::decision_template::ShortcutDecisionSchema::default(), }; diff --git a/crates/engine/src/analysis/corpus_tests.rs b/crates/engine/src/analysis/corpus_tests.rs index bd9d14d72b..7f2c6524bd 100644 --- a/crates/engine/src/analysis/corpus_tests.rs +++ b/crates/engine/src/analysis/corpus_tests.rs @@ -305,6 +305,7 @@ fn classify_status_compares_against_spec_not_rubber_stamp() { win_kind: WinKind::LethalDamage, mandatory: false, residual_board_delta: BoardDelta::default(), + per_cycle: None, }; assert!( matches!( @@ -320,6 +321,7 @@ fn classify_status_compares_against_spec_not_rubber_stamp() { win_kind: WinKind::Advantage, mandatory: false, residual_board_delta: BoardDelta::default(), + per_cycle: None, }; assert!( matches!( @@ -345,6 +347,7 @@ fn classify_status_compares_against_spec_not_rubber_stamp() { win_kind: WinKind::Advantage, mandatory: false, residual_board_delta: BoardDelta::default(), + per_cycle: None, }; assert!( matches!( diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index f8546e9211..fceaeaa110 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -223,7 +223,8 @@ pub struct ShortcutDecisionSchema { /// The single count authority: the declared-count check in `game::engine` rejects a /// `Fixed(n)` above it, and `game::interaction` publishes it as the count picker's /// ceiling. Every offer built before the bounded-offer phase carries - /// `MAX_SHORTCUT_CYCLES`, so those checks are inert until a producer narrows it. + /// `MAX_SHORTCUT_CYCLES`, and those checks were inert until the bounded-cycle producer + /// began narrowing it. /// /// DELIBERATELY NOT MIRRORED in `client/src/adapter/types.ts::ShortcutDecisionSchema`: /// the frontend never reads the raw bound, it reads the already-clamped ceiling the @@ -262,6 +263,23 @@ impl Default for ShortcutDecisionSchema { } } +impl ShortcutDecisionSchema { + /// CR 732.2a: `true` iff this offer's producer NARROWED the repetition bound below the + /// engine-wide safety cap — i.e. it measured a CR 704.5a / CR 704.5c / CR 104.3c + /// threshold inside the loop. A producer that cannot compute a real bound publishes + /// `MAX_SHORTCUT_CYCLES` (see `max_iterations` above), so an unnarrowed offer is NOT + /// bounded in this sense. + /// + /// The SINGLE AUTHORITY for that question, and the reason it is a method rather than + /// an inline comparison repeated at each caller: `MAX_SHORTCUT_CYCLES` is `pub(crate)` + /// to the engine, so `phase-ai`'s declare policy cannot name it and would otherwise + /// hard-code the literal. This predicate crosses the crate boundary; the constant does + /// not. + pub fn is_bounded(&self) -> bool { + self.max_iterations < crate::game::engine::MAX_SHORTCUT_CYCLES + } +} + /// One open decision-point. `slot` is the same [`DecisionSlot`] the frontend echoes on the /// [`PinnedDecision`] it produces; `kind` carries that decision's legal option set. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -308,6 +326,10 @@ pub enum DecisionPointKind { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum TargetPin { ByIdentity(DecisionSource), + /// A CONSTANT target (CR 732.2a): this pin answers EVERY firing of its source within + /// the period with the declared player. A seat is state-independent by construction — + /// it can never denote "the newest copy" — so no iteration can turn the pin into the + /// conditional action CR 732.2a forbids. Player(PlayerId), Scheduled(TargetSchedule), } @@ -411,15 +433,17 @@ pub enum ConcreteTarget { /// `Static` or `Scheduled`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ReplayFailure { - /// CR 608.2b: a TARGET pin (`Targets`'s `ByIdentity`, or a `Scheduled` schedule) no - /// longer resolves to a legal live object (left its zone / ceased to exist). Raised - /// whenever a *target* is illegal-or-absent, in ANY `ReplayMode` — a `Static`-mode - /// `Targets` pin with a removed target yields THIS, not `MissingSource`. ⇒ abort the - /// auto-shortcut, hand back to manual. - IllegalTarget { - slot: DecisionSlot, - source: DecisionSource, - }, + /// CR 608.2b: a TARGET pin no longer resolves to a legal live target (left its zone / + /// ceased to exist / CR 800.4 + CR 102.1 left the game). Raised whenever a *target* is + /// illegal-or-absent, in ANY `ReplayMode` — a `Static`-mode `Targets` pin with a + /// removed target yields THIS, not `MissingSource`. ⇒ abort the auto-shortcut, hand + /// back to manual. + /// + /// Carries the [`TargetPin`] itself rather than a `DecisionSource`: `Player` and + /// `Scheduled` pins are equally capable of going illegal, and a `DecisionSource` can + /// name neither. Parameterizing the existing variant keeps ONE "target went illegal" + /// failure instead of growing a per-pin-kind sibling cluster. + IllegalTarget { slot: DecisionSlot, pin: TargetPin }, /// CR 400.7: an ORDER pin's source (`Order`) is absent from the current battlefield /// ⇒ the ordering template no longer matches ⇒ fall through to a normal manual /// prompt. Raised ONLY for the `Order` pin kind, in any `ReplayMode`. @@ -538,22 +562,70 @@ fn resolve_pin( } } -/// Resolve one target pin. CR 608.2b: a by-identity or scheduled target must still be a -/// legal live object; an absent one is `IllegalTarget`. +/// Resolve one target pin. CR 608.2b: a by-identity, player, or scheduled target must +/// still be a legal live target; an absent or departed one is `IllegalTarget`. fn resolve_target( pin: &TargetPin, slot: &DecisionSlot, iteration: IterationIndex, state: &GameState, ) -> Result { + let illegal = || ReplayFailure::IllegalTarget { + slot: slot.clone(), + pin: pin.clone(), + }; match pin { TargetPin::ByIdentity(source) => resolve_source(source, state) .map(ConcreteTarget::Object) - .ok_or_else(|| ReplayFailure::IllegalTarget { - slot: slot.clone(), - source: source.clone(), - }), - TargetPin::Player(p) => Ok(ConcreteTarget::Player(*p)), + .ok_or_else(illegal), + // CR 115.10a + CR 701.34a: this seam answers "may this seat be CHOSEN", not "may it + // be TARGETED". A proliferate choice is not a target, so the targeting-only + // exclusions (CR 702.11c hexproof / CR 702.18a shroud / CR 702.16b protection) must + // NOT be applied here — doing so would refuse a legal CR 732.2a proposal (the + // over-veto class). Existence is delegated to + // `game::players::player_exists_for_choice` (CR 800.4 + CR 102.1, phasing per the + // CR 702.26b MIRROR). + // + // AND THE OTHER HALF, so nobody "fixes" this back into the over-veto: declare-path + // TARGET legality is enforced by `validate_pins`' `legal_targets.contains(..)` + // against the offer's PUBLISHED set, which `ability_utils::build_target_slots` + // derives through `targeting::find_legal_targets` → + // `static_abilities::player_cannot_be_targeted_by`. It is NOT enforced here, and + // does not need to be. + // + // OPEN RESIDUAL — the object-growth route. Stated in full here, because no shipped + // file states it elsewhere. INVARIANT: a `TargetPin::Player` must never reach + // materialization validated only against a legal set derived from the declared pins + // themselves. `try_offer_object_growth_shortcut` builds its points through + // `pinned_decisions_to_points`, whose legal sets come FROM the pins, so on that + // route the offer would ratify its own pin — and CR 732.2a admits only a sequence + // "that may be legally taken based on the current game state", which a self-derived + // set cannot establish. NOT live today FROM ANY IN-PROCESS PRODUCER — and the scope + // word is load-bearing: the one `record_loop_pin` arm that can push a + // `TargetPin::Player` is the CR 701.34a proliferate-target arm, and a proliferate + // choice is not a target, so THIS call is its correct authority. + // + // PINS ALSO ARRIVE WIRE-SOURCED, and no in-process invariant covers that. + // `LoopActionContext` is `#[serde(from = "LoopActionContextRepr")]`, and that shim's + // `From` impl installs the deserialized vector verbatim (`pins: r.pins`), so a + // restored save can carry a Player pin of UNKNOWN class. + // `GameState::migrate_transient_loop_sequence` keeps a loaded sequence ONLY for a + // save captured in a `LoopShortcut` / `RespondToShortcut` window, and on that route + // the pins are replayed by the accept→materialize drive through + // `build_recast_template` → `decision_template::resolve`, i.e. through THIS call — + // so a wire pin's EXISTENCE half is authority-enforced here too. Same class as the + // wire-sourced `max_iterations` defect `reject_zero_bound_shortcut_offer` closes: a + // load-seam value the in-process producer census cannot see. + // + // The residual opens the moment any producer — IN-PROCESS OR WIRE — puts a + // TARGET-class Player pin into `LoopActionContext.pins`. DAMAGE MODE then: + // `CycleOutcome::Abort` rolls back only the crossing cycle, so cycles `0..k` stay + // committed under a pin no authority ever validated. Deferred fix shape: + // provenance-type the pin so the two classes are distinguishable at this seam — a + // change to a serialized type, hence not this phase. + TargetPin::Player(p) => crate::game::players::player_exists_for_choice(state, *p) + .then_some(ConcreteTarget::Player(*p)) + .ok_or_else(illegal), TargetPin::Scheduled(sched) => evaluate_schedule(sched, slot, iteration, state), } } @@ -621,7 +693,7 @@ fn evaluate_schedule( .map(ConcreteTarget::Object) .ok_or_else(|| ReplayFailure::IllegalTarget { slot: slot.clone(), - source: source.clone(), + pin: TargetPin::Scheduled(sched.clone()), }) } @@ -672,7 +744,8 @@ pub enum PredictabilityViolation { /// CR 732.2a + CR 608.2b: why a declared pin is not a LEGAL answer to the offered decision /// schema. `validate_pins` is the fail-closed VALUE-legality firewall paired with /// [`predictability_gate`]'s COVERAGE check: the gate proves every offered slot is pinned; -/// this proves every pin's VALUE lies inside the slot's offered legal set. Any violation ⇒ +/// this proves every pin's VALUE lies inside the slot's offered legal set at every index the +/// ACCEPTED COUNT will drive. Any violation ⇒ /// the declare handler rejects the shortcut and hands back to manual play (no APNAP, no /// drive, no crown). #[derive(Debug, Clone, PartialEq, Eq)] @@ -714,9 +787,11 @@ pub(crate) fn resolve_target_ref( /// CR 732.2a + CR 608.2b: the fail-closed VALUE-legality firewall for a declared shortcut. /// Verifies every pin in `template` is a LEGAL answer to `schema` — each pin's slot is one /// the offer exposed, and each pin's resolved value lies inside that slot's offered legal -/// set. `period` (the drive count from [`shortcut_drive_period`]) bounds the iteration -/// indices a scheduled target pin is re-resolved for, so a `RoundRobin`/`Piecewise` schedule -/// is validated at EVERY index it will drive. EXHAUSTIVE over [`PinnedDecision`] with no +/// set. `validated_range` bounds the iteration indices a scheduled target pin is re-resolved +/// for: every pin is validated at every index in `0..validated_range`. Supplying a range that +/// COVERS the drive is the CALLER's obligation, discharged by +/// `game::engine::shortcut_validated_range`, which reads the range off the declared count +/// rather than off the schedule's own length. EXHAUSTIVE over [`PinnedDecision`] with no /// wildcard: `Order` (CR 603.3b trigger-ordering) is not a loop-declaration point; /// `ConvokeTaps` must still address an exposed matching point even though its concrete taps are /// re-bound live by `select_convoke_taps`. Runs once at declare (the board is frozen through Accept); the drive's @@ -724,7 +799,7 @@ pub(crate) fn resolve_target_ref( pub fn validate_pins( schema: &ShortcutDecisionSchema, template: &DecisionTemplate, - period: IterationIndex, + validated_range: IterationIndex, state: &GameState, ) -> Result<(), PinValidation> { for pin in &template.decisions { @@ -751,7 +826,20 @@ pub fn validate_pins( // require the concrete value to be an offered legal target. A scheduled pin // that cannot resolve to a live legal object is itself an illegal value. for t in targets { - for i in 0..period.max(1) { + // CR 732.2b + CR 732.2c: NO `.max(1)` FLOOR. A shortened proposal whose + // new ending point is the first deviating choice — CR 732.2b's "that + // place becomes the new ending point" — is a ZERO-repetition accepted + // proposal, and CR 732.2c makes taking it mandatory, so count 0 must be + // representable AND validatable. A floor would validate index 0 of a + // range nothing drives, refusing conforming declarations. Validation is + // NOT disabled at 0: the slot-exposure (`UnexposedSlot`), pin-kind and + // cardinality checks all sit OUTSIDE this loop and still run. + // + // ⚠ SCOPE: this licenses representing and validating count 0. It does NOT + // claim today's Shorten path reaches here with 0 — + // `handle_respond_to_shortcut` realizes Shorten as a real priority window, + // not an auto-applied `Fixed(0)`. + for i in 0..validated_range { let concrete = resolve_target(t, slot, i, state) .map_err(|_| PinValidation::IllegalPinValue { slot: slot.clone() })?; if !legal_targets.contains(&concrete_to_target_ref(concrete)) { @@ -1219,6 +1307,253 @@ mod tests { assert!(resolve(&template, 0, &present).is_ok()); } + /// CR 800.4 + CR 102.1 + CR 608.2b: a `TargetPin::Player` aimed at a seat that has LEFT + /// THE GAME is no longer one of the people in the game, so it is not choosable and the + /// per-iteration re-check must raise `IllegalTarget{pin}`. Both this seam and + /// `game::targeting`'s legal-set enumeration now SHARE that existence authority — + /// `game::players::player_exists_for_choice`, reached here directly and there through + /// `targeting::player_is_legal_target` — so this is one authority consulted twice, not + /// two implementations mirroring each other. (NOT CR 800.4a, which governs a departed + /// player's objects, control effects and priority rather than choice legality.) + /// This row is the sole owner of `IllegalTarget{pin}` for the `Player` kind, and it is + /// reachable by construction. + /// + /// MATCHED PAIR, one variable (`is_eliminated`): the LIVE half resolves, the DEAD half + /// fails. Only the ABSOLUTE expectations discriminate — a parity assertion against the + /// targeting side would be vacuous now that both sides call one function, since + /// deleting a conjunct moves both together. REVERT-PROBE: drop the `!is_eliminated` + /// conjunct inside `player_exists_for_choice` (via `is_alive`) ⇒ the dead half + /// resolves ⇒ FAILS. + #[test] + fn a_dead_player_pin_is_illegal() { + let pin_slot = DecisionSlot { + source: this_obj(70, Some(0)), + index: 0, + }; + let template = |victim: u8| DecisionTemplate { + owner: PlayerId(0), + decisions: vec![PinnedDecision::Targets { + slot: pin_slot.clone(), + targets: vec![TargetPin::Player(PlayerId(victim))], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: tri_key(), + }; + + // LIVE half — the positive reach-guard: nothing upstream of the liveness check + // rejects this pin, so the dead half's failure is attributable to liveness alone. + let live = GameState::new_two_player(7); + assert!(!live.players[1].is_eliminated, "fixture: P1 starts alive"); + assert_eq!( + resolve(&template(1), 0, &live).unwrap(), + vec![ConcreteDecision::Targets { + slot: pin_slot.clone(), + targets: vec![ConcreteTarget::Player(PlayerId(1))], + }], + "a live player pin resolves unchanged" + ); + + // DEAD half — the identical board with exactly one field flipped. + let mut dead = live.clone(); + dead.players[1].is_eliminated = true; + let err = resolve(&template(1), 0, &dead).unwrap_err(); + assert_eq!( + err, + ReplayFailure::IllegalTarget { + slot: pin_slot, + pin: TargetPin::Player(PlayerId(1)), + }, + "CR 800.4 + CR 102.1: a departed seat is no longer one of the people in the \ + game, so it is not choosable — and the failure NAMES the pin, which a \ + `source: DecisionSource` payload could not express" + ); + } + + /// R1 — CR 115.10a + the CR 702.26b MIRROR: a `TargetPin::Player` aimed at a + /// PHASED-OUT seat also fails the per-iteration re-check. At HEAD this seam checked + /// `is_alive` only, so a phased-out seat resolved here and was killed (or not) further + /// downstream; routing it through `game::players::player_exists_for_choice` makes the + /// EXISTENCE half one authority for both halves of "no longer there". + /// + /// MATCHED PAIR, one variable (the phasing transition): the PHASED-IN half resolves, + /// the PHASED-OUT half fails. The phased-in half is the positive reach-guard — it + /// proves nothing upstream of the existence check rejects this pin, so the other + /// half's failure is attributable to phasing alone. The transition itself is asserted + /// (production API return value + the flag) because a setup that silently no-opped + /// would make the second half pass for no reason at all. + /// + /// R1 and `a_dead_player_pin_is_illegal` are the two behaviour changes of one + /// conjunct pair, deliberately kept as separate rows: elimination was already enforced + /// here, phasing was not. + /// + /// REVERT-PROBE: drop the `is_phased_out` conjunct in `player_exists_for_choice` ⇒ the + /// phased-out half resolves ⇒ FAILS (and `a_dead_player_pin_is_illegal` does not move, + /// which is what attributes this row to the phasing conjunct specifically). + #[test] + fn a_phased_out_player_pin_is_illegal() { + let pin_slot = DecisionSlot { + source: this_obj(71, Some(0)), + index: 0, + }; + let template = DecisionTemplate { + owner: PlayerId(0), + decisions: vec![PinnedDecision::Targets { + slot: pin_slot.clone(), + targets: vec![TargetPin::Player(PlayerId(1))], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: tri_key(), + }; + + // PHASED-IN half — the positive reach-guard. + let phased_in = GameState::new_two_player(7); + assert!( + !phased_in.players[1].is_phased_out(), + "fixture: P1 starts phased in" + ); + assert_eq!( + resolve(&template, 0, &phased_in).unwrap(), + vec![ConcreteDecision::Targets { + slot: pin_slot.clone(), + targets: vec![ConcreteTarget::Player(PlayerId(1))], + }], + "a phased-in player pin resolves unchanged" + ); + + // PHASED-OUT half — the same board, transitioned through the PRODUCTION API. + let mut phased_out = phased_in.clone(); + let mut events = Vec::new(); + let transitioned = + crate::game::phasing::phase_out_player(&mut phased_out, PlayerId(1), &mut events); + assert_eq!( + transitioned, + vec![PlayerId(1)], + "setup anti-vacuity: phase_out_player must report the seat it transitioned" + ); + assert!( + phased_out.players[1].is_phased_out(), + "setup anti-vacuity: P1 must read as phased out" + ); + assert!( + !phased_out.players[1].is_eliminated, + "the ONLY variable is phasing — P1 must still be un-eliminated, or this row \ + would be a second copy of `a_dead_player_pin_is_illegal`" + ); + + assert_eq!( + resolve(&template, 0, &phased_out).unwrap_err(), + ReplayFailure::IllegalTarget { + slot: pin_slot, + pin: TargetPin::Player(PlayerId(1)), + }, + "CR 702.26b MIRROR: a phased-out seat is treated as though it does not exist, \ + so it is not choosable here either" + ); + } + + /// R2 — CR 115.10a is a BOUNDARY, and this row is what stops a future "fix" from + /// erasing it: a targeting-only exclusion gates the TARGET seam and must NOT gate the + /// CHOICE seam. + /// + /// One board, one SHROUDED seat (CR 702.18a — shroud blocks every source, including + /// the shrouded player's own, so the assertion does not depend on who the source + /// controller is), and two assertions at two different seams: + /// + /// 1. the EXCLUDE half — `targeting::find_legal_targets` drops the seat. This is the + /// paired POSITIVE: it proves the shroud grant actually took effect, so assertion 2 + /// is about the seam boundary and not about a setup that silently did nothing. + /// 2. the ADMIT half — `resolve_target`'s `TargetPin::Player` arm still resolves it, + /// because a proliferate choice (CR 701.34a) is not a target and refusing it here + /// is the over-veto class this phase exists to remove. + /// + /// REVERT-PROBE: route the `TargetPin::Player` arm through + /// `targeting::player_is_legal_target` instead of `players::player_exists_for_choice` + /// ⇒ assertion 2 FAILS while assertion 1 still passes. + #[test] + fn a_shrouded_seat_is_untargetable_yet_still_choosable_at_the_pin_recheck() { + use crate::types::ability::{ + ControllerRef, StaticDefinition, TargetFilter, TargetRef, TypedFilter, + }; + use crate::types::statics::StaticMode; + + let mut state = GameState::new_two_player(42); + + // P1 gains shroud from a permanent they control ("You have shroud"). Built with + // the production `zones::create_object`, not a raw `objects.insert`: a raw insert + // never joins `state.battlefield`, so `game_functioning_statics` would not see the + // grantor and the shroud would silently never apply. + let grantor = crate::game::zones::create_object( + &mut state, + CardId(90), + PlayerId(1), + "You Have Shroud Source".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&grantor).unwrap().static_definitions = + vec![ + StaticDefinition::new(StaticMode::Shroud).affected(TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::You), + )), + ] + .into(); + crate::game::layers::flush_layers(&mut state); + + // 1. EXCLUDE half, and the setup's positive control: the TARGET seam drops P1. + let source = crate::game::zones::create_object( + &mut state, + CardId(91), + PlayerId(0), + "Targeting Spell".to_string(), + Zone::Battlefield, + ); + let targets = crate::game::targeting::find_legal_targets( + &state, + &TargetFilter::Any, + PlayerId(0), + source, + ); + assert!( + !targets.contains(&TargetRef::Player(PlayerId(1))), + "CR 702.18a: the shroud grant must actually bite at the TARGET seam — if it \ + does not, assertion 2 below proves nothing. Got {targets:?}" + ); + assert!( + targets.contains(&TargetRef::Player(PlayerId(0))), + "the un-shrouded seat is still targetable, so the exclusion above is shroud \ + and not an empty legal set" + ); + + // 2. ADMIT half: the CHOICE seam still resolves the same seat. + let pin_slot = DecisionSlot { + source: this_obj(92, None), + index: 0, + }; + let template = DecisionTemplate { + owner: PlayerId(0), + decisions: vec![PinnedDecision::Targets { + slot: pin_slot.clone(), + targets: vec![TargetPin::Player(PlayerId(1))], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: tri_key(), + }; + assert_eq!( + resolve(&template, 0, &state).unwrap(), + vec![ConcreteDecision::Targets { + slot: pin_slot, + targets: vec![ConcreteTarget::Player(PlayerId(1))], + }], + "CR 115.10a: a CHOSEN seat is not a TARGETED seat — the targeting-only \ + exclusions must not reach this seam" + ); + } + /// T5b (G2 sibling): the SAME `Static` mode with an `Order` pin (different pin kind) /// whose source is removed yields `MissingSource` (CR 400.7), NOT `IllegalTarget`. /// Together T5+T5b prove failure selection is per pin kind, not per mode. diff --git a/crates/engine/src/analysis/loop_check.rs b/crates/engine/src/analysis/loop_check.rs index 977230efc6..f4d1f42a3f 100644 --- a/crates/engine/src/analysis/loop_check.rs +++ b/crates/engine/src/analysis/loop_check.rs @@ -134,6 +134,13 @@ pub struct LoopCertificate { /// paths require an identical battlefield); wired now so an object-growth path /// populates it with no further change. NOT a `ResourceAxis` — concrete permanents. pub residual_board_delta: BoardDelta, + /// CR 732.2a: the measured resource signature of ONE repetition, published only by a + /// producer that narrowed the CR 704 repetition bound (the bounded cycle offer). + /// `None` for every other offer, and for every save written before this field existed + /// — in which case a drive falls back to the recurrence disjunct, i.e. exactly shipped + /// behaviour. `skip_serializing_if` keeps the existing payload byte-identical. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub per_cycle: Option, } impl LoopCertificate { @@ -173,6 +180,11 @@ pub struct ShortcutProposal { /// streams (skip-if-none), so this is a byte-preserving addition. #[serde(default, skip_serializing_if = "Option::is_none")] pub template: Option, + /// CR 732.2a: copied verbatim off the confirmed certificate so the drive reads ONE + /// authority for what a conformant cycle looks like. `None` for every offer whose + /// producer states no per-period signature (see [`LoopCertificate::per_cycle`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub per_cycle: Option, } /// CR 732.2b/c: an opponent's answer to a proposed loop shortcut. `Accept` lets the @@ -271,6 +283,9 @@ pub fn detect_loop( // Invariant pinned by `residual_empty_for_constant_depth` (T12). (No CR // annotation: this is an invariant/plumbing comment, not rule-implementing code.) residual_board_delta: crate::analysis::resource::board_delta(cycle_start, cycle_end), + // CR 732.2a: `detect_loop` states no CR 704 repetition bound, so it publishes no + // per-period signature either. Only the bounded-cycle offer does. + per_cycle: None, }) } @@ -932,6 +947,7 @@ mod tests { win_kind: WinKind::LethalDamage, mandatory: true, residual_board_delta: BoardDelta::default(), + per_cycle: None, }; assert!(cert.covers(&[ResourceAxis::DamageDealt(pid(1))])); assert!(cert.covers(&[ diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index 87125c6421..6bdea35258 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -38,6 +38,322 @@ use crate::types::player::{Player, PlayerId}; use crate::types::replacements::ReplacementEvent; use crate::types::zones::Zone; +/// CR 732.2a: the metered spend the resolution probe charges against. +/// +/// A NESTED module, not a new file, and that nesting is the enforcement: Rust +/// privacy is module-scoped, so a budget defined beside its consumers could be +/// re-constructed by any same-module code and its spend would be invisible to +/// the meter. Defining it one module down lets the constructor be narrowed +/// without also narrowing it away from this module's own wrappers. +mod verdict_memo { + use std::collections::BTreeMap; + + use crate::game::engine::{CapAuthority, EntryPinSlots}; + use crate::game::resolution_prompt::ResolutionChoiceFreedom; + use crate::types::game_state::{GameState, StackEntry}; + use crate::types::identifiers::ObjectId; + use crate::types::player::PlayerId; + + /// The shipped probe cap, per classification run. + /// + /// RE-DERIVED FROM MEASUREMENT. The retired `12` was fitted to per-mint charge + /// counts (dellian 2–4 / F4 1) taken over the CURRENT STACK only, before the + /// verdict door existed. Over the door's derived `(frame, entry)` population the + /// announcement gate mints keys too, and the measured demand at the corpus's one + /// offering beat — dina `beat=19`, `ring=3 stack=10`, driven through `apply()` — + /// is **13 charges** (`asks=13`, one charge per key), i.e. `12` starved the + /// acceptance offer by exactly one. + /// + /// `26` is `2 ×` that measured demand: an offering beat may carry double the + /// observed key population (≈ a 20-entry stack at the measured 1.3 keys/entry) + /// and still certify. The ceiling is not arbitrary either — it stays far below + /// the unexempted sweep the frozen exemption exists to prevent, whose demand + /// measures **96–107** on dellian's `ring=16 stack≈176` beats, so an unexempted + /// full-stack classification still exhausts and still refuses fail-closed. + pub(crate) const PROBE_BUDGET: u32 = 26; + + /// CR 732.2a: cost is a COVERAGE knob, never a soundness knob — an + /// unaffordable probe degrades to honest-red (no certificate, no offer), + /// never to a wrong certificate and never to an unbounded stall. + /// + /// **Derive list pinned to exactly `#[derive(Debug)]`** — no `Default`, no + /// `Clone`/`Copy`. A derived constructor recompiles the construction escape + /// with `new` untouched: the derived value is a zero-cap always-denying + /// budget, which is fail-closed but meter-invisible and relief-disabling. + #[derive(Debug)] + pub(crate) struct ProbeBudget { + remaining: u32, + /// Latched by [`ProbeBudget::try_charge_one`] so an exhaustion can be + /// ATTRIBUTED rather than inferred from a zero remainder. Read in + /// production by [`PeriodVerdicts::denied`], which the metered mint + /// carries out in its `MintMeter`. + denied: bool, + } + + impl ProbeBudget { + /// MODULE-PRIVATE. Every budget is born inside this module, so the + /// container that owns the meter is the only thing that can start a + /// spend — a fresh budget compiled beside a consumer would be a spend + /// the mint's meter never sees. Re-adding a + /// `ProbeBudget::new(PROBE_BUDGET)` call in `analysis::resource` is + /// E0624, which is the closure stated as a compile fact. + fn new(cap: u32) -> Self { + Self { + remaining: cap, + denied: false, + } + } + + /// `false` ⇒ exhausted, and the exhaustion fact is latched so it can be + /// attributed rather than inferred from a zero remainder. + pub(crate) fn try_charge_one(&mut self) -> bool { + if self.remaining == 0 { + self.denied = true; + return false; + } + self.remaining -= 1; + true + } + + /// Did any charge get denied against this budget? + pub(crate) fn denied(&self) -> bool { + self.denied + } + + /// TEST-ONLY constructor. `new` is module-private so no site outside + /// this module can mint a budget whose spend would be invisible to a + /// meter; a `cfg(test)` door cannot widen that, because it does not + /// exist in a production build. + #[cfg(test)] + pub(crate) fn for_test(cap: u32) -> Self { + Self::new(cap) + } + } + + /// A frame's position in ONE container's OWN `frames` table. + /// + /// The field is private and there is no public constructor and no + /// `From`: a `FrameIx` can only be minted by + /// [`PeriodVerdicts::frame_ix`], which resolves a `&GameState` by POINTER + /// IDENTITY against the very table [`PeriodVerdicts::verdict`] indexes. No + /// index arithmetic exists anywhere on the mint path, so the + /// window-relative off-by-`idx` class is unconstructible rather than merely + /// unobserved — forging `FrameIx(3)` one module out is E0603. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] + pub(crate) struct FrameIx(usize); + + /// Everything ONE `(frame, entry)` pair answers, computed on first demand by + /// [`PeriodVerdicts::verdict`] and memoized for the rest of the mint. + /// + /// Every field is produced by a function whose signature takes NO published + /// slots, which is what removes the key-completeness obligation: a slot list + /// is a *window* fact and would make the memo key incomplete, so a fourth + /// field that reads one is a design error rather than an addition. + pub(crate) struct EntryVerdict { + /// THE MINT, CACHED — `entry_publishes_pin_slots(frame, entry, + /// proposer)` with the CONTAINER's bound proposer. A proposer-less + /// (`unproven`) container computes `None` for every key: nothing is + /// published when no offer binds a proposer, which is the mint's own + /// answer rather than an invented one. + pub(crate) published: Option, + /// `stack_entry_resolution_choice_freedom(frame, entry, budget)` on the + /// ability AS IT STANDS. + pub(crate) primary: ResolutionChoiceFreedom, + /// CR 603.5: the optional-cleared re-classification the relief used to + /// compute inline. `None` for an entry whose mint publishes no `may` + /// slot, and for one whose resolution scope cannot bind (CR 603.4). + pub(crate) residual: Option, + } + + /// CR 732.2a: the ONE per-mint verdict door — a TOTAL, `(FrameIx, + /// ObjectId)`-keyed, compute-on-miss memo that OWNS the probe budget. + /// + /// Totality is scoped honestly: [`PeriodVerdicts::verdict`] is total over + /// every `FrameIx` this container minted, and MINTING + /// ([`PeriodVerdicts::frame_ix`]) is the membership question — it returns + /// `Option` and every consumer treats `None` as a refusal, so a frame + /// outside the period costs a certificate, never a wrong one. + pub(crate) struct PeriodVerdicts<'a> { + /// `ring ++ [current]` — PRIVATE and not indexable, so a `FrameIx` + /// cannot be turned back into a board by the parent module. + frames: Vec<&'a GameState>, + memo: BTreeMap<(FrameIx, ObjectId), EntryVerdict>, + /// PRIVATE: the field route to a fresh spend is E0616 from the parent + /// module, and the construction route is shut by `new`'s module + /// privacy above. + budget: ProbeBudget, + /// The cap this container was born at, so `spent()` is a difference + /// rather than a second counter that could drift from the budget. + cap: u32, + /// The THIRD argument of the memoized mint, fixed at construction, so + /// the EFFECTIVE key is `(proposer, FrameIx, ObjectId)` with the first + /// component constant per container. + proposer: Option, + conjunct6_asks: u32, + conjunct6_frozen_skips: u32, + conjunct4_scans: u32, + } + + impl<'a> PeriodVerdicts<'a> { + fn build( + ring: &[&'a GameState], + current: &'a GameState, + proposer: Option, + cap: u32, + ) -> Self { + let mut frames: Vec<&'a GameState> = ring.to_vec(); + frames.push(current); + Self { + frames, + memo: BTreeMap::new(), + budget: ProbeBudget::new(cap), + cap, + proposer, + conjunct6_asks: 0, + conjunct6_frozen_skips: 0, + conjunct4_scans: 0, + } + } + + /// The default-cap constructor, bound to the mint's own proposer. + /// + /// `pub(super)` and not `pub(crate)`: every in-scope construction site + /// lives in `analysis::resource` or a descendant, while an out-of-file + /// fresh container — whose spend the mint's meter would never see — is + /// E0624. The OFFER path constructs through + /// [`PeriodVerdicts::for_period_with_cap`] instead, because + /// `verdict_memo` cannot mint the `CapAuthority` that door demands. + /// + /// Every U3 caller is a `#[cfg(test)]` site, so the plain lib target sees + /// this as dead (U1's `denied()` precedent). Its production reader is the + /// still-unwritten R22 row; keeping the gate `not(test)`-scoped means that + /// row lands with no annotation churn. + #[cfg_attr(not(test), allow(dead_code))] + pub(super) fn for_period( + ring: &[&'a GameState], + current: &'a GameState, + proposer: PlayerId, + ) -> Self { + Self::build(ring, current, Some(proposer), PROBE_BUDGET) + } + + /// The cap-parameterised twin — the ONLY budget-raise/lower channel, + /// and the reason it is safe is CAPABILITY rather than visibility: the + /// final parameter is a token whose tuple constructor is private to + /// `game::engine`, the metered seam's own module, so a fresh + /// arbitrary-cap container built anywhere else is E0603. + /// + /// `for_period` is this function at `cap = PROBE_BUDGET`; both construct + /// through the module-private `ProbeBudget::new`. + pub(crate) fn for_period_with_cap( + ring: &[&'a GameState], + current: &'a GameState, + proposer: PlayerId, + cap: u32, + _auth: CapAuthority, + ) -> Self { + Self::build(ring, current, Some(proposer), cap) + } + + /// Every other path (including the unscoped sibling): frames = + /// `[current]`, NO proposer ⇒ nothing published ⇒ no relief. That is + /// byte-identical to the pre-change unproven behaviour, where relief + /// died on `scope.pinned == None` before any mint call. + pub(super) fn unproven(current: &'a GameState) -> Self { + Self::build(&[], current, None, PROBE_BUDGET) + } + + /// THE ONLY `FrameIx` MINT. Resolves a frame to its position in + /// `self.frames` by `std::ptr::eq` — the same table `verdict` reads, so + /// the returned index is correct BY IDENTITY, not by arithmetic. + /// `rposition`, so the only conceivable duplicate (one pointer appearing + /// twice) resolves newest; a duplicate pointer is the same state, hence + /// the same verdict. + /// + /// `None` ⇒ the frame is not in this period ⇒ the CALLER refuses. That + /// is what makes "the memo's last frame IS the caller's current" + /// structural rather than a `debug_assert` compiled out of release. + pub(crate) fn frame_ix(&self, frame: &GameState) -> Option { + self.frames + .iter() + .rposition(|f| std::ptr::eq(*f, frame)) + .map(FrameIx) + } + + /// THE ONE DOOR. Computes on miss against `self.frames[f.0]` — the memo, + /// never the caller, converts `FrameIx` back to a board — charges the + /// OWNED budget, and memoizes. Total over minted keys: it returns a + /// value, never an `Option`, so there is no miss contract to get wrong. + pub(crate) fn verdict(&mut self, f: FrameIx, entry: &StackEntry) -> &EntryVerdict { + let key = (f, entry.id); + if !self.memo.contains_key(&key) { + let frame = self.frames[f.0]; + let published = self + .proposer + .and_then(|p| crate::game::engine::entry_publishes_pin_slots(frame, entry, p)); + let primary = + super::stack_entry_resolution_choice_freedom(frame, entry, &mut self.budget); + let residual = match published.as_ref().and_then(|p| p.may.as_ref()) { + Some(_) => { + super::optional_cleared_classification(frame, entry, &mut self.budget) + } + None => None, + }; + self.memo.insert( + key, + EntryVerdict { + published, + primary, + residual, + }, + ); + } + &self.memo[&key] + } + + /// Read-only: the bound proposer, for the relief-side agreement guard. + pub(crate) fn proposer(&self) -> Option { + self.proposer + } + + /// THE METER: charges taken against this container's one budget. + pub(crate) fn spent(&self) -> u32 { + self.cap - self.budget.remaining + } + + /// At least one charge was REFUSED — the exhaustion fact, attributed + /// rather than inferred from a zero remainder. + pub(crate) fn denied(&self) -> bool { + self.budget.denied() + } + + /// Item (6) loop bodies that reached the verdict door. + pub(crate) fn conjunct6_asks(&self) -> u32 { + self.conjunct6_asks + } + /// Frozen `continue`s taken in item (6) — the exemption, counted. + pub(crate) fn conjunct6_frozen_skips(&self) -> u32 { + self.conjunct6_frozen_skips + } + /// Calls of `stack_entry_reads_projected_resource` from item (4)'s + /// `.any()`, which is the only body change item (4) takes. + pub(crate) fn conjunct4_scans(&self) -> u32 { + self.conjunct4_scans + } + pub(crate) fn note_conjunct6_ask(&mut self) { + self.conjunct6_asks += 1; + } + pub(crate) fn note_conjunct6_frozen_skip(&mut self) { + self.conjunct6_frozen_skips += 1; + } + pub(crate) fn note_conjunct4_scan(&mut self) { + self.conjunct4_scans += 1; + } + } +} + +pub(crate) use verdict_memo::{FrameIx, PeriodVerdicts, ProbeBudget, PROBE_BUDGET}; + /// WUBRG + colorless, the canonical index order used by [`ResourceVector::mana`]. /// /// Matches `ManaColor::ALL` (WUBRG) with colorless appended, so index `i` of the @@ -157,28 +473,37 @@ pub enum TriggerKind { /// Compare two snapshots with [`ResourceVector::delta`] to get the per-cycle /// change; [`ResourceVector::is_net_progress`] then decides whether the cycle is /// a beneficial (CR 732.2) loop. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +/// +/// `Serialize`/`Deserialize` exist because a per-cycle delta rides +/// [`PeriodicDelta`] on the `WaitingFor::LoopShortcut` / +/// `WaitingFor::RespondToShortcut` wire. Every map whose key is NOT string-like +/// needs an adaptor to get there — see [`map_key_pairs`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ResourceVector { /// CR 106.1: floating mana by color, indexed `[W, U, B, R, G, C]` (see /// [`MANA_INDEX`]). Summed across all players' pools. **State-readable.** pub mana: [i64; 6], /// CR 119.1: per-player life total. **State-readable.** + #[serde(with = "map_key_pairs")] pub life: BTreeMap, /// CR 120.1: cumulative damage *dealt to* each player this analysis window. /// Damage is an event, not a stored total. **Event-fed** (left empty by /// `snapshot`). + #[serde(with = "map_key_pairs")] pub damage_dealt: BTreeMap, /// CR 401: per-player library size, as a signed delta-friendly count. /// Positive = larger library. Mill loops drive this negative. /// **State-readable** (absolute library size at snapshot time). + #[serde(with = "map_key_pairs")] pub library_delta: BTreeMap, /// CR 122.1 + CR 704.5c: poison counters keyed by VICTIM `PlayerId` (10 ⇒ that /// player loses). Per-victim so a multiplayer poison ∞ attributes the loss to the /// afflicted seat, not the loop's controller. **State-readable.** + #[serde(with = "map_key_pairs")] pub poison: BTreeMap, /// CR 111: tokens created this analysis window. **Event-fed.** @@ -223,6 +548,7 @@ pub struct ResourceVector { /// CR 122.1: counters by `(kind, object class)`. Includes +1/+1, loyalty, /// and poison (poison/energy are keyed under [`ObjectClass::Player`]). /// **State-readable.** + #[serde(with = "map_key_pairs")] pub counters: BTreeMap<(CounterClass, ObjectClass), i64>, /// Generic trigger/keyword-action firings by family (proliferate, magecraft, @@ -230,6 +556,104 @@ pub struct ResourceVector { pub generic_triggers: BTreeMap, } +/// Serde adaptor for every [`ResourceVector`] map whose key is not string-like: +/// [`ResourceVector::counters`] (a `(CounterClass, ObjectClass)` TUPLE key) and the four +/// [`PlayerId`]-keyed maps. Ride the wire as a pair SEQUENCE — the shape +/// `ShortcutDecisionSchema.points` already uses — so there is no map key to encode. +/// +/// Not reusing `types::game_state::tuple_key_map` (the repo's other adaptor, which +/// stringifies instead): it is monomorphic over `HashMap<(ObjectId, usize), u32>`. +/// +/// ⚠ **THE `PlayerId` MAPS ARE NOT OPTIONAL, contrary to what this doc claimed before.** +/// The struck form read "the two sibling maps need no adaptor — `PlayerId` is a newtype +/// over an integer … which `serde_json` accepts as keys". That is true of `to_string` / +/// `from_str` and of `to_value` / `from_value` in ISOLATION, which is why the direct +/// `periodic_delta_survives_the_serde_json_wire` arm passed and gave false confidence — +/// but it is FALSE on the production path, for a reason that is about the ENCLOSING type, +/// not this one: +/// +/// * `WaitingFor` is `#[serde(tag = "type", content = "data")]` (ADJACENTLY TAGGED), so +/// its payload is buffered through serde's private `Content` before being handed to the +/// variant. `Content` represents every map key as a STRING. +/// * `PlayerId` is `#[serde(transparent)]` over `u8`, so it asks for a `u8` and gets a +/// string ⇒ `invalid type: string "0", expected u8`. +/// * `PersistedGameState::deserialize` funnels EVERY persisted decode through +/// `serde_json::Value` and then `serde_json::from_value` — including the production +/// WASM restore at `crates/engine-wasm/src/lib.rs`'s `from_str::`. +/// So a saved game whose `RespondToShortcut` proposal carried a populated `per_cycle` +/// would fail to restore. +/// +/// MEASURED, all four combinations, on `serde_json` 1.0.149: bare `BTreeMap` +/// is `Ok` for `from_str`/`from_value` standalone and for `from_str` under an adjacently +/// tagged enum, and `Err("invalid type: string \"0\", expected u8")` for `from_value` under +/// one — the exact error text this repo had already recorded in +/// `tests/integration/loop_shortcut.rs`. With this adaptor all four are `Ok`. +/// +/// [`ResourceVector::generic_triggers`] deliberately keeps its bare map: `TriggerKind` is a +/// unit-variant enum, so its key is genuinely a string and it was measured `Ok` through the +/// same adjacently-tagged `from_value` path that breaks `PlayerId`. +mod map_key_pairs { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::collections::BTreeMap; + + pub(super) fn serialize(map: &BTreeMap, serializer: S) -> Result + where + S: Serializer, + K: Serialize + Ord, + V: Serialize, + { + map.iter().collect::>().serialize(serializer) + } + + pub(super) fn deserialize<'de, D, K, V>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + K: Deserialize<'de> + Ord, + V: Deserialize<'de>, + { + Ok(Vec::<(K, V)>::deserialize(deserializer)? + .into_iter() + .collect()) + } +} + +/// CR 732.2a: the resource signature of ONE repetition of a certified loop — what the +/// offer publishes so a bounded drive can check that each committed cycle actually +/// conformed, and so the CR 704 count bound has a per-period magnitude to divide by. +/// +/// The `Vec` victim term (rather than a `BTreeMap` keyed by [`DecisionSlot`]) is +/// deliberate: a struct map key hits exactly the `serde_json` restriction +/// [`map_key_pairs`] exists for, and the single consumer +/// ([`ResourceVector::elimination_bounds`]) collects at its call site. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PeriodicDelta { + /// How many RETAINED RING FRAMES one repetition spans. DERIVED on both certification + /// bases — from the certifying prior's index in the ring for direct recurrence, and as + /// `k` for a signature derived by [`ring_delta_signature`]. It is NOT "1 for direct + /// recurrence": that was a hardcode until fix round 1, and it was measured wrong (the + /// `interactive_3p_subset_lethal_does_not_crown` fixture's repetition spans TWO frames, + /// a gain-life resolution then a lose-life one). + /// + /// CONSUMER: `game::engine::drive_one_shortcut_cycle` DELIMITS a committed cycle by this + /// count. It has to, for the class [`ring_delta_signature`] certifies: that basis proves + /// a periodic DELTA, not a recurring board, so the drive's board-recurrence predicates are + /// false at every settle beat and `Fixed(n)`'s `n` would otherwise be structurally inert. + /// The count is measured the same way it is minted — the engine's single + /// `record_loop_detect_sample` call site is inside `pass_priority_once_with_pipeline`, + /// which is the function the drive steps. + /// + /// Named for its unit on purpose: `game::engine::shortcut_drive_period` maps a + /// TEMPLATE to a repeat count, which is a different quantity in the same subsystem. + pub frames_per_period: u32, + /// The whole-game resource change across one repetition, measured from the very + /// frame pair that certified it. + pub delta: ResourceVector, + /// CR 704.5a: per published choice slot, the life magnitude one repetition charges + /// to whichever player that slot's pin names. EMPTY for the untargeted class, where + /// the victims are already visible in `delta.life`. + pub victim_slot: Vec<(DecisionSlot, i64)>, +} + impl ResourceVector { /// Snapshot the **state-readable** resource levels directly out of a /// `GameState`: floating mana, per-player life, per-player library size, and @@ -602,10 +1026,30 @@ impl ResourceVector { /// /// Clamped to `MAX_SHORTCUT_CYCLES`. A return of `0` means no legal repetition exists /// and the caller must not offer; callers require `N >= 1`. - // The first production consumer is the bounded offer, which lands in a later phase; the - // bound ships ahead of it so its conventions are pinned by a unit row before any producer - // depends on them. - #[allow(dead_code)] + /// CR 704.5a: the per-period life loss ONE published pin slot may charge to whichever + /// seat its declaration names — the `slot_magnitude` term + /// [`ResourceVector::elimination_bounds`] divides the headroom by. + /// + /// **MAX over seats, not SUM, and not the observed spread.** A pin is a + /// STATE-INDEPENDENT designation (CR 732.2a), so a declaration may aim *every* + /// iteration of a slot at *one* seat. Charging what the observed — unpinned — iteration + /// happened to spread around would UNDER-charge and overstate the bound, which fails + /// OPEN. Charging the sum over seats is not a loss any single seat can suffer from one + /// slot; it over-charges, which only SHRINKS the bound and is the fail-closed direction + /// this repo takes when the two disagree. + /// + /// Life GAINS contribute nothing (`(-n).max(0)`), so a proposer gaining 5 while three + /// opponents lose 1, 2 and 3 yields 3 — never 5, and never 6. + /// + /// Extracted from `game::engine::try_offer_bounded_cycle_shortcut` so the max-vs-sum + /// fork has a callable seam: `victim_slot` is empty on every trajectory that offers + /// today, so this expression's value is dropped in production and no fixture reaches + /// it. `worst_seat_life_loss_is_the_max_seat_never_the_sum` is its only discriminator. + pub(crate) fn worst_seat_life_loss(&self) -> i64 { + self.life.values().map(|&n| (-n).max(0)).max().unwrap_or(0) + } + + // The first production consumer is `game::engine::try_offer_bounded_cycle_shortcut`. pub(crate) fn elimination_bounds( &self, state: &GameState, @@ -824,6 +1268,121 @@ fn map_delta( out } +/// CR 732.2a: the per-period resource signature of the RETAINED RING, derived — the +/// second certification basis for a bounded cycle offer, and the one that consults no +/// **board** predicate (objects / zones / tap-state) at all. +/// +/// Searches `k` in `1..=(frames - 1) / 2`, smallest first, for a period whose consecutive +/// frame-deltas repeat, and certifies only a period it has observed **twice** (`2k` deltas +/// ⇒ `2k + 1` frames). `k` is an OUTPUT, never an input: no period constant exists in this +/// subsystem — `game::engine::shortcut_drive_period` derives its period from the template +/// schedule and `LOOP_DETECT_RING_CAP` merely CAPS how large a derivable `k` can be +/// (16 frames ⇒ `k <= 7`). +/// +/// Fail-closed in FOUR places. Fewer than `2k + 1` frames for every candidate `k` ⇒ `None` +/// (a period seen once is a coincidence, not a signature). A smallest repeating period whose +/// delta is the zero vector ⇒ `None`, because every multiple of it is zero too and a cycle +/// that moves no resource states no CR 704 threshold to bound. A certifying window that is +/// not TURN-POSITION invariant ⇒ `None` (the CR 703.1 conjunct below). Reading the RING only +/// — never the live `state` — keeps the compared frames homogeneous: every ring frame is a +/// `normalize_for_loop` snapshot taken at `WaitingFor::Priority{active_player}` +/// (`game::engine`'s sole `record_loop_detect_sample` call site), while the live state is not +/// normalized. It does not consult a board predicate, but it DOES require the frames it +/// compares to be homogeneous in turn position, which is what "homogeneous" above now means +/// in full. +pub(crate) fn ring_delta_signature(state: &GameState) -> Option<(u32, ResourceVector)> { + let frames = state.loop_detect_ring.len(); + // 2k + 1 with k >= 1. + if frames < 3 { + return None; + } + let snaps: Vec = state + .loop_detect_ring + .iter() + .map(|f| ResourceVector::snapshot(&f.normalized)) + .collect(); + let deltas: Vec = snaps + .windows(2) + .map(|w| ResourceVector::delta(&w[0], &w[1])) + .collect(); + for k in 1..=(frames - 1) / 2 { + // The MOST RECENT 2k deltas: a stale repeat in an older stretch of the ring says + // nothing about the period the loop is running now. + let recent = &deltas[deltas.len() - 2 * k..]; + if recent[..k] != recent[k..] { + continue; + } + let per_period = ResourceVector::delta(&snaps[frames - 1 - k], &snaps[frames - 1]); + // A cycle that moves no resource states no CR 704 threshold to bound, so it + // supplies no per-period magnitude and is refused. + // + // This is deliberately a WHOLE-SEARCH refusal, and the struck justification for + // it was WRONG. It read "smallest repeating period, so every longer one is a + // whole number of copies of this one — a zero here cannot become non-zero at a + // larger `k`". The repetition test above inspects only the most recent `2k` + // deltas, which does NOT establish that the whole ring is periodic with period + // `k`, so a larger `k'` need not be a multiple of `k` and its per-period delta + // can be non-zero. Counter-example over 8 frames (deltas `d1..d7`, oldest + // first): at `k = 1` the last two deltas are equal and zero, so this returns + // `None`; at `k' = 3` the test compares `[d1,d2,d3]` against `[d4,d5,d6]`, and + // `d5 = d6 = 0` forces `d2 = d3 = 0` while leaving `d4` unconstrained, so the + // `k' = 3` period is `d4 + d5 + d6 = d4`, which can be non-zero. + // + // The BEHAVIOUR is still the safe direction — refusing outright costs a missed + // offer, never a wrong one — so this is a comment defect, not a soundness + // defect. It is corrected rather than deleted because the false claim is the + // kind a later reader would lean on to justify widening the search while keeping + // the early return, or to replace the search with a single-`k` probe. If that + // missed class ever needs to certify, `continue` is sound here for the same + // reason the return is safe: each candidate `k` is validated independently. + if per_period == ResourceVector::default() { + return None; + } + // CR 703.1 + CR 703.3: turn-based actions "happen automatically when certain steps + // or phases begin, or when each step and phase ends", and CR 703.2 says they are + // "not controlled by any player". CR 732.2a licenses a shortcut only over "a + // sequence of game choices, for all players" — so a period whose repetition is paved + // by step/phase boundaries is not a sequence that rule can describe. A 2-player + // draw-go board is exactly periodic in `library_delta` and in an upkeep life ticker, + // and without this conjunct that turn structure certifies as a "loop". + // + // Basis A cannot make that mistake: `loop_states_equal` delegates to + // `impl PartialEq for GameState`, which compares `turn_number`, `active_player` and + // `phase`, and neither `normalize_for_loop` nor `project_out_resources` neutralizes + // any of the three — the deliberate, ratified design recorded at + // `types::game_state::WaitingFor::is_forced_cascade_window`'s doc. Basis B compares + // only resource deltas, so it escaped that discipline silently; this restores parity. + // It is NOT a new policy and NOT a claim that shortcuts may not cross turns — + // CR 732.2a says verbatim that a shortcut "may even cross multiple turns". What is + // refused is a cross-turn certification by the BOARD-BLIND basis. + // + // KNOWINGLY ACCEPTED FALSE NEGATIVE, and it is the price of reusing the shipped + // authority instead of forking a second turn-position predicate: + // `window_scope_from_cover_frames` requires `extra_phases.is_empty()` on BOTH frames + // (CR 500.8 — effects can add phases to a turn), not merely equal counts. So a + // legitimate WITHIN-turn loop running while an extra phase is queued (the + // extra-combat class) mints no basis-B offer. That is the fail-closed direction — a + // missed offer, never a wrong one. If that class ever needs to certify, widen + // `window_scope_from_cover_frames` ITSELF, where both suppressing firewall callers + // see the change too; do not add a second local test here. + let window: Vec<&GameState> = state + .loop_detect_ring + .iter() + .skip(frames - (2 * k + 1)) + .map(|f| &f.normalized) + .collect(); + if !window.windows(2).all(|w| { + window_scope_from_cover_frames(w[0], w[1], None, None) + .phase_invariant + .is_some() + }) { + return None; + } + return Some((k as u32, per_period)); + } + None +} + /// CR 732.2a vs CR 104.4b: the **complement** of the engine's strict loop /// equality (`types::game_state::loop_states_equal`). /// @@ -968,6 +1527,199 @@ pub fn board_delta(before: &GameState, after: &GameState) -> BoardDelta { BoardDelta { added, removed } } +/// CR 732.2a: WHICH certificate a window's touch is derived under. +/// +/// The frozen exemption's extrapolation limb needs BOTH a board-level premise +/// that the certified period cannot SHRINK the stack (P2) AND the Karp–Miller +/// read-surface guard that makes the fast-forward the repetition of the observed +/// period (P4). Exactly one certifying disjunct supplies both, so the exemption +/// is keyed to the DISJUNCT rather than to the basis — as a type rather than a +/// call-site convention, because a convention an executor drops compiles and is +/// fail-OPEN. +/// +/// Three variants, two of which behave identically today, and that is +/// deliberate: collapsing the equality disjunct onto [`PeriodCertification:: +/// ResourceSignatureOnly`] would make the type lie about provenance (equality +/// DOES consult a board predicate), and a mislabel is the fail-open re-entry +/// path — the obvious future "fix" for an equality pair tagged +/// `ResourceSignatureOnly` is to retag it `BoardCovered`. +/// `pub` rather than `pub(crate)` for ONE reason, named so a future reader does not +/// widen it further: it is the type of [`crate::game::engine::MintMeter`]'s +/// `certification` field, the only surface on which the certifying disjunct is +/// observable at all. It is not serialized, not a variant on any gated engine enum, +/// and no card-data export reads it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PeriodCertification { + /// Basis A, COVER disjunct — the pair passed + /// [`loop_states_cover_modulo_growth_pinned`]. P2: item (2)'s `stack_covers` + /// ⇒ strictly growing depth. P4: items (4)/(5) ⇒ no current-stack entry and + /// no live fire-time condition reads a still-projected axis. This is the + /// ONLY value under which `frozen_ids` is non-empty. + BoardCovered, + /// Basis A, EQUALITY disjunct — the pair passed + /// [`loop_states_equal_modulo_resources`]. P2 holds (the stack is compared + /// exactly ⇒ constant depth) but P4 does NOT: that predicate has no items + /// (4)/(5), and this crate says so in its own words — the Karp–Miller NOTE + /// above [`loop_states_cover_modulo_growth`] ("makes the SAME extrapolation + /// with NONE of these") and that predicate's own inherited-assumption + /// section. A replacement that arms mid-extrapolation is exactly the route + /// P4 forecloses, so `frozen_ids` is forced EMPTY here. The shipped + /// constant-depth 2p drain detection is unaffected: this value narrows only + /// the NEW subtraction. + BoardEqualOnly, + /// Basis B — [`ring_delta_signature`] only, which by its own doc "does not + /// consult a board predicate". Neither P2 nor P4 ⇒ `frozen_ids` is forced + /// EMPTY and the resolution gate scans every current-stack entry, exactly as + /// before this change. + ResourceSignatureOnly, +} + +/// CR 732.2a + CR 608.1: what ONE certified period actually announced, and which +/// current-stack entries the window proves it never touched. +/// +/// Derived from the retained ring window `[cert_prior .. current]` — never from +/// the offer-beat stack snapshot. Its frames and entries are the ring sample's +/// **LIVE** halves, never `normalize_for_loop()` products: normalization zeroes +/// `next_object_id` and strips trigger identity, so a normalized frame is a +/// CR 104.4b comparand and not an evaluation board. +/// +/// Fail-closed both ways: an id the window cannot prove frozen is TOUCHED +/// (scanned/refused as before), and an entry the window never shows announcing +/// is not enumerable for pins (the offer under-publishes ⇒ the choice gate +/// refuses ⇒ no offer). +#[derive(Debug)] +pub(crate) struct PeriodTouch<'a> { + /// `(carrying_frame, entry)` for every entry that ANNOUNCED inside the + /// window: present on frame `i`'s stack, absent from frame `i-1`'s (by + /// `StackEntry.id`). The carrying frame is the state the entry is evaluated + /// against — CR 603.3d announcement choices are a property of the board the + /// ability was put on the stack against. + pub(crate) announced: Vec<(&'a GameState, &'a StackEntry)>, + /// Entry ids of `current.stack` that the window proves FROZEN: the same id + /// at the same index in EVERY window frame AND in `current`. CR 608.1: only + /// the top of the stack resolves, so an entry that held its `(index, id)` + /// through the whole window neither announced nor resolved in it. + /// + /// NON-EMPTY ONLY UNDER [`PeriodCertification::BoardCovered`]: the + /// subtraction is the one fail-open half of this type, and the other two + /// certificates do not supply the premises it rests on. + pub(crate) frozen_ids: BTreeSet, +} + +/// CR 732.2a: derive one window's [`PeriodTouch`] under the certificate its +/// caller actually holds. +/// +/// `window` is `ring_live[cert_idx..]`, oldest first; the observed frame +/// sequence is `window ++ [current]`. `announced` is IDENTICAL on all three +/// certificate values — widening the announced set is the fail-CLOSED direction +/// — and only the frozen subtraction is keyed. +pub(crate) fn certified_period_touch<'a>( + window: &[&'a GameState], + current: &'a GameState, + cert: PeriodCertification, +) -> PeriodTouch<'a> { + if window.is_empty() { + // CR 608.1 + CR 732.2a: with NO window frame there is no transition to + // observe, so there is no frozen proof and no observed period; the + // honest degenerate reading is "every current entry may announce", + // which is exactly the snapshot mint this function's alias replaces. + return PeriodTouch { + announced: current.stack.iter().map(|e| (current, e)).collect(), + frozen_ids: BTreeSet::new(), + }; + } + + let mut announced: Vec<(&'a GameState, &'a StackEntry)> = Vec::new(); + let mut prev_ids: HashSet = window[0].stack.iter().map(|e| e.id).collect(); + for frame in window + .iter() + .skip(1) + .copied() + .chain(std::iter::once(current)) + { + for entry in &frame.stack { + if !prev_ids.contains(&entry.id) { + announced.push((frame, entry)); + } + } + prev_ids = frame.stack.iter().map(|e| e.id).collect(); + } + + // The fail-open subtraction, and the ONLY thing the certificate keys. Taken + // BEFORE the frozen walk, so a non-cover certificate never pays it either. + if !matches!(cert, PeriodCertification::BoardCovered) { + return PeriodTouch { + announced, + frozen_ids: BTreeSet::new(), + }; + } + + let frozen_ids = current + .stack + .iter() + .enumerate() + .filter(|(index, entry)| { + window + .iter() + .all(|frame| frame.stack.get(*index).map(|e| e.id) == Some(entry.id)) + }) + .map(|(_, entry)| entry.id) + .collect(); + PeriodTouch { + announced, + frozen_ids, + } +} + +/// CR 603.5 + CR 603.4 + CR 608.2k: re-classify ONE entry's ability with its +/// published "may" gate discharged, on the board `resolve_top` would hand the +/// resolver — this entry off the stack, resolution scope bound. +/// +/// The ONE classifier answers the counterfactual; this module never +/// re-implements the six independent reasons the classifier returns `MayPrompt` +/// for. `None` ⇒ not a triggered ability, or the resolution scope cannot bind, +/// both of which are refusals rather than relief. +fn optional_cleared_classification( + frame: &GameState, + entry: &StackEntry, + budget: &mut ProbeBudget, +) -> Option { + let StackEntryKind::TriggeredAbility { ability, .. } = &entry.kind else { + return None; + }; + let mut without_may_gate = (**ability).clone(); + without_may_gate.optional = false; + // CR 732.2a: the same cheap-precondition-before-the-clone rule the primary + // classifier follows. This is the SIBLING site, and it had the identical shape: + // `frame.clone()` is a whole `GameState` copy, and a `may` trigger whose ability is + // rejected on a pure AST gate (a non-allow-listed effect, an `UpTo` count, a modal + // header) used to buy that copy plus a scope binding to reach a verdict that never + // looks at the board — once per ring frame. + // + // EQUIVALENCE, verified rather than assumed. Hoisting changes exactly one case: + // an entry whose scope would have FAILED to bind AND whose chain is gated now + // returns `Some(MayPrompt)` where it previously returned `None`. `residual` has + // exactly one reader (`optional_relief_for`), and it opens + // `match cached.residual.as_ref()?` with a `MayPrompt => None` arm — so `None` and + // `Some(MayPrompt)` produce the identical downstream result. Re-derived here by + // grepping every `.residual` read in this file: one, plus one comment. + if crate::game::resolution_prompt::chain_offers_choice(&without_may_gate) { + return Some(crate::game::resolution_prompt::ResolutionChoiceFreedom::MayPrompt); + } + let mut board = frame.clone(); + board.stack.retain(|e| e.id != entry.id); + if !crate::game::stack::bind_resolution_scope(&mut board, entry, None) { + return None; + } + Some( + crate::game::resolution_prompt::ability_resolution_choice_freedom( + &board, + &without_may_gate, + budget, + ), + ) +} + /// CR 732.2a: the facts a CALLER has PROVED about the loop window it is asking a /// window predicate to certify. Every field is a *proof obligation discharged by the /// caller*, never a request: a caller that has proved nothing passes @@ -977,10 +1729,10 @@ pub fn board_delta(before: &GameState, after: &GameState) -> BoardDelta { /// /// The `_scoped` predicates below stay identity for [`LoopWindowScope::unproven`] /// (asserted by `scoped_wrappers_are_identity`) because every guard that reads a field -/// sits inside an `if let Some(..)` / `is_some_and`. `phase_invariant` and `sole_driver` -/// ARE now read — by the growing-class firewall's CR 510.2 / CR 506.1 and CR 117.1b -/// guards — and `cast_card_ids` by the projected firewall's CR 601.2f cost guard, so the -/// scope is no longer write-only; `pinned_slots` is the remaining unread field. +/// sits inside an `if let Some(..)` / `is_some_and`. EVERY field is now read: +/// `phase_invariant` and `sole_driver` by the growing-class firewall's CR 510.2 / CR 506.1 +/// and CR 117.1b guards, `cast_card_ids` by the projected firewall's CR 601.2f cost guard, +/// and `pinned` by [`loop_states_cover_modulo_growth_scoped`]'s CR 732.2a gates (3) and (6). #[derive(Debug, Clone, Copy)] pub(crate) struct LoopWindowScope<'a> { /// `Some(phase)` iff the caller proved both frames are equal on turn number AND @@ -993,15 +1745,40 @@ pub(crate) struct LoopWindowScope<'a> { /// activate an ability only with priority; CR 732.2c: the shortcut advances to /// the proposed ending point once every player has accepted). sole_driver: Option, - /// CR 732.2a: the per-iteration choice slots the OFFER publishes, which - /// `decision_template::predictability_gate` then FORCES the declaration to pin. - /// A slot listed here is a *specified* choice in CR 732.2a's sense, not a free one. - #[allow(dead_code)] // write-only until the phase that consumes pinned slots. - pinned_slots: &'a [DecisionSlot], + /// `Some(pins)` iff the caller proved an OFFER published exactly these per-iteration + /// choice slots. READ by [`loop_states_cover_modulo_growth_scoped`]'s gates (3)/(6). + pinned: Option>, /// CR 601.2f (cost determination reads static cost modifiers): `Some(ids)` iff the /// caller proved the EXACT set of card ids this window casts — `Some(&[])` for a /// window that provably casts nothing. `None` means NO PROOF, i.e. scan everything. cast_card_ids: Option<&'a [CardId]>, + /// CR 732.2a + CR 608.1: `Some(touch)` iff the caller proved WHICH pairs the + /// certified period announced and which current-stack entries it left + /// frozen. A Copy HANDLE, never the owned value — [`PeriodTouch`] owns a + /// `Vec` and a `BTreeSet`, so an owned field would be E0204 against this + /// struct's shipped `Copy` derive, while `Option<&T>` is `Copy` regardless + /// of `T`. `None` means NO PROOF: nothing is exempt and nothing is + /// enumerable, i.e. the pre-change width. + period: Option<&'a PeriodTouch<'a>>, +} + +/// CR 732.2a: the per-iteration choice slots ONE offer published, carried together with the +/// seat whose offer minted them. +/// +/// The proposer travels WITH the slots because the relief side re-runs the MINT's own +/// per-entry acceptance test (`game::engine::entry_publishes_pin_slots`), whose first +/// conjunct is `entry.controller == proposer` — the EXTENSION POINT's precondition (c), +/// "only the acting player's own choices are pinnable". A bare slot list cannot express +/// that conjunct, and a SECOND scope field could disagree with the first; one field +/// carrying both makes the pair unable to drift. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PinnedChoices<'a> { + /// The offer's proposer. Every slot in `slots` was minted for this seat. + pub(crate) proposer: PlayerId, + /// The published slots, which `decision_template::predictability_gate` then FORCES the + /// declaration to pin. A slot listed here is a *specified* choice in CR 732.2a's sense, + /// not a free one. + pub(crate) slots: &'a [DecisionSlot], } impl LoopWindowScope<'static> { @@ -1011,8 +1788,13 @@ impl LoopWindowScope<'static> { Self { phase_invariant: None, sole_driver: None, - pinned_slots: &[], + pinned: None, cast_card_ids: None, + // DELIBERATE, and it is the whole meaning of this constructor: a + // caller that has proved nothing gets byte-identical pre-change + // behaviour. `Option<&PeriodTouch>` is const-constructible as + // `None`, so this stays a `const fn` on `LoopWindowScope<'static>`. + period: None, } } } @@ -1043,7 +1825,8 @@ impl LoopWindowScope<'static> { fn window_scope_from_cover_frames<'a>( pa: &GameState, pb: &GameState, - pinned_slots: &'a [DecisionSlot], + pinned: Option>, + period: Option<&'a PeriodTouch<'a>>, ) -> LoopWindowScope<'a> { // (p1) same turn, (p2) same step-granular phase, (p3) no pending extra phase in // either frame (CR 500.8). @@ -1073,9 +1856,134 @@ fn window_scope_from_cover_frames<'a>( LoopWindowScope { phase_invariant, sole_driver, - pinned_slots, + pinned, // 2b's axis (the PROJECTED covers), derived at its own call site. cast_card_ids: None, + // From the parameter: the SINGLE scope authority must be able to carry + // the period proof, or the cover disjunct's own caller would have to + // assemble a scope itself — which is exactly what the private fields + // exist to prevent. + period, + } +} + +/// CR 732.2a: is this stack entry's ANNOUNCEMENT-time target choice (gate (3)) already +/// SPECIFIED by a slot the offer published? +/// +/// The acceptance test is NOT re-derived here: it is the mint's own, +/// [`crate::game::engine::entry_publishes_pin_slots`], called for this one entry with the +/// pins' own proposer. That is what keeps the relief predicate from being coarser than the +/// mint predicate — controller (precondition (c)), entry kind, target shape and the +/// CR 400.7 incarnation binding are all one function, so a slot can never be *matched* +/// here on terms it was not *minted* on. +/// +/// SCOPE OF DISCHARGE. The caller's relief is a bare `continue`, so a `true` here skips +/// ALL FOUR facts [`stack_entry_has_no_ordering_input`] rejects on, while the pin answers +/// exactly one of them (the target). Three of the other three are ability facts the mint +/// itself now refuses to publish on (`multi_target` / `distribution` / +/// `target_constraints`). The fourth, `pending_trigger_entry` (CR 603.3c mid-construction), +/// is a property of THIS state rather than of the offer's schema, so it is enforced HERE — +/// the mint is documented a function of the BOARD, never of the PROMPT (it reads many +/// `GameState` fields; what it must never read is a prompt-coupled one), and +/// `pending_trigger_entry` is set exactly while a `TriggerTargetSelection` prompt is up. +/// That makes this predicate strictly NARROWER than the mint's, which +/// is the safe direction; the forbidden direction is coarser. +/// +/// Fail-closed in every branch: no published pins, a non-qualifying entry, a missing source +/// object, or a mid-construction entry ⇒ not pinned ⇒ the gate that called this keeps +/// rejecting. +/// THE BOARD IS THE PAIR'S CARRYING FRAME — the identical `&GameState` the caller +/// handed [`PeriodVerdicts::frame_ix`] to mint `f`, never a second board. That is +/// what makes the two halves agree by construction: the cached `published` is +/// `entry_publishes_pin_slots(frames[f], entry, proposer)`, so the mint half and +/// the CR 603.3c half read ONE board per key. Dropping the board (and with it the +/// CR 603.3c conjunct) would COMPILE and would be fail-OPEN, which is why it is a +/// parameter rather than something a `FrameIx` is expected to recover: +/// `PeriodVerdicts.frames` is private to `verdict_memo`. +fn entry_target_choice_is_pinned( + board: &GameState, + f: FrameIx, + entry: &StackEntry, + verdicts: &mut PeriodVerdicts<'_>, + scope: LoopWindowScope<'_>, +) -> bool { + let Some(pins) = scope.pinned else { + return false; + }; + // A container bound to one proposer can never relieve pins minted for + // another seat: the cached `published` IS the mint's answer for the + // container's own proposer, so consuming it under different pins would be + // reading a verdict minted for someone else. + if verdicts.proposer() != Some(pins.proposer) { + return false; + } + if board.pending_trigger_entry == Some(entry.id) { + return false; + } + // CR 601.2c: a may-only entry (shape (B)) publishes NO target slot, so it is not + // target-relieved here — its announcement freedom comes from + // `stack_entry_has_no_ordering_input`'s own `targets.is_empty()` arm instead. Requiring + // `Some` keeps this predicate strictly narrower than the mint, never coarser. + verdicts + .verdict(f, entry) + .published + .as_ref() + .and_then(|e| e.target.as_ref()) + .is_some_and(|target| pins.slots.contains(target)) +} + +/// CR 732.2a + CR 603.5: is this entry's RESOLUTION-time `MayPrompt` (gate (6)) fully +/// explained by an optional gate the offer published — and if so, what verdict does the +/// entry carry once that one axis is discharged? +/// +/// `Some(residual)` ⇒ relieved, and `residual` is the classification the entry would have +/// had WITHOUT its CR 603.5 gate, which the caller must go on to gate exactly like any +/// unpinned entry's (a pinned "may" says nothing about the CR 616.1 replacement surface +/// for whichever event classes the residual names). `None` ⇒ no relief. +/// +/// ATTRIBUTION is the load-bearing part. `ability_resolution_choice_freedom` returns +/// `MayPrompt` for SIX independent reasons (`game/ability_scan.rs:6534-6560` plus the +/// sub/else effect join), and the offer publishes a `MayChoice` point for exactly ONE of +/// them — `ability.optional`. So relief requires both published slots to be pinned AND the +/// same ability, re-classified with `optional` cleared, to come back choice-free: an +/// `unless_pay`, a resolution-time target chooser, a modal header, a controller-choice +/// repeat, or a CR 701.34a proliferate sub-ability keeps returning `MayPrompt` and gets no +/// relief, because no published pin specifies it. +/// It performs NO classification of its own and calls the mint not at all: both +/// the published slots and the optional-cleared residual are read through the ONE +/// door, which is what keeps the relief from being a second, drifting authority. +fn pinned_may_choice_relief( + f: FrameIx, + entry: &StackEntry, + verdicts: &mut PeriodVerdicts<'_>, + scope: LoopWindowScope<'_>, +) -> Option { + use crate::game::resolution_prompt::ResolutionChoiceFreedom; + let pins = scope.pinned?; + // Fail-closed agreement guard: relief may only consume a verdict minted for + // the seat whose offer published these pins. + if verdicts.proposer() != Some(pins.proposer) { + return None; + } + let cached = verdicts.verdict(f, entry); + let published = cached.published.as_ref()?; + let may = published.may.as_ref()?; + // Strictly the mint's own facts, never coarser (CR 603.5): the `may` slot must be + // pinned, and the target slot must be pinned WHEN THERE IS ONE. Shape (B) publishes + // `target: None` — an entry that announces no choice has none for a pin to leave + // unspecified — so demanding a pinned target there would refuse relief the mint's own + // schema fully describes. + if !pins.slots.contains(may) + || published + .target + .as_ref() + .is_some_and(|target| !pins.slots.contains(target)) + { + return None; + } + match cached.residual.as_ref()? { + ResolutionChoiceFreedom::MayPrompt => None, + residual @ ResolutionChoiceFreedom::FreeUnlessReplacements(_) => Some(residual.clone()), } } @@ -1112,7 +2020,209 @@ fn window_scope_from_cover_frames<'a>( /// choice — either intrinsically or through the life-event replacement /// environment (item 6, CR 732.2a + CR 608.2d). pub(crate) fn loop_states_cover_modulo_growth(prior: &GameState, current: &GameState) -> bool { - loop_states_cover_modulo_growth_scoped(prior, current, LoopWindowScope::unproven()) + // The zero-proof container: frames = `[current]`, no proposer ⇒ nothing published ⇒ no + // relief, which is byte-identically what an `unproven()` scope already meant. The four + // production callers of this 2-arg entry point are therefore untouched. + let mut verdicts = PeriodVerdicts::unproven(current); + loop_states_cover_modulo_growth_scoped( + prior, + current, + LoopWindowScope::unproven(), + &mut verdicts, + ) +} + +/// CR 732.2a "predictable results": is EVERY per-iteration choice this stack can open a +/// SPECIFIED one — published by the offer's pins, or absent altogether? +/// +/// The conjunct a BOUNDED offer needs, and the reason it is not +/// [`loop_states_cover_modulo_growth_scoped`]: that predicate answers whether one frame +/// COVERS another, and its item (1) additionally requires `object_resource_axes_match` +/// STRICTLY — an axis [`loop_states_equal_modulo_resources`] deliberately projects OUT. An +/// offer certified by exact recurrence would therefore be refused by an unrelated BOARD fact +/// while its choice surface was never examined. Cover is the authority for cover; this is the +/// authority for choices. +/// +/// SINGLE AUTHORITY nonetheless, shared verbatim with that predicate's gates (3) and (6) — +/// the same [`stack_entry_has_no_ordering_input`] (CR 601.2c announcement-time input, reached +/// for a triggered ability via CR 603.3d), the same +/// [`stack_entry_resolution_choice_freedom`] (CR 608.2d resolution-time prompts), the same +/// [`entry_target_choice_is_pinned`] / [`pinned_may_choice_relief`] pin relief, and the same +/// CR 616.1 [`proposed_event_prompt_cause`] environmental guard the +/// `FreeUnlessReplacements` verdict's own contract requires. Nothing is re-derived here. +/// +/// THE WIDTH IS THE CERTIFIED PERIOD'S, NOT THE OFFER-BEAT STACK'S. Both loops range over +/// `touch.announced` ∪ (`state.stack` \ `touch.frozen_ids`), each pair evaluated against its +/// own carrying frame. That is WIDER than the offer-beat stack on the announced half — the +/// majority population on both measured dumps, 157/161 (F4) and 19/23 (dellian) beats carry +/// off-stack announced pairs — and NARROWER only on the proven-frozen half. +/// +/// ⚠ THE JUSTIFICATION THIS DOC USED TO CARRY FOR SCANNING EVERY CURRENT-STACK ENTRY WAS +/// CORRECT FOR A FUNCTION WITH NO WINDOW, AND IS NOW SUPERSEDED BY ONE. It read: "a frozen +/// entry is one the window has NO evidence about, which is precisely the entry a grown-only +/// scan would skip. The width is right." Its PREMISE survives — without a window parameter +/// "no evidence" was all this predicate could say. With one, absence of evidence about +/// RESOLUTION becomes positive evidence about POSITION: a frozen id is PROVEN to hold the +/// same (index, id) in every certified frame and in `state`, under a certificate that forbids +/// the stack shrinking. Measured on the `dellian` 4p fixture, a growing cascade over a frozen +/// bottom prefix: up to 152 of 156 current-stack entries are exempt at the certified beat. +/// +/// WHAT LICENSES THE EXEMPTION HERE, since this function establishes none of the cover +/// predicate's premises itself (its whole body is the two loops plus the CR 616.1 tail): a +/// non-empty `frozen_ids` can only have been built under [`PeriodCertification::BoardCovered`] +/// ⟺ the offer's basis-A `else if` matched ⟺ [`loop_states_cover_modulo_growth_pinned`] +/// returned `true` ⇒ that predicate's items (2)/(4)/(5) all passed UNEXEMPTED, and those run +/// strictly before this conjunct. The premises are inherited as discharged facts about the +/// same `(prior, current)` pair, never assumed. When `frozen_ids` is empty — every basis-B +/// path, every equality-certified path, the degenerate alias — every current-stack entry is +/// scanned exactly as before this change. +pub(crate) fn stack_choices_are_all_specified<'a>( + state: &'a GameState, + proposer: PlayerId, + slots: &[DecisionSlot], + touch: Option<&PeriodTouch<'a>>, + verdicts: &mut PeriodVerdicts<'a>, +) -> bool { + // Only `pinned` and `period` are read below; the other three proofs belong to the cover + // axes and this predicate makes no claim about them. Written out in full so a future SIXTH + // field is a compile error that forces a decision rather than a silent default. + let scope = LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned: Some(PinnedChoices { proposer, slots }), + cast_card_ids: None, + period: touch, + }; + // CR 732.2a: the described sequence is EVERY choice the shortcut makes, not the subset + // that happens to sit on the stack at the offer beat. The mint's own domain is + // `touch.announced`, so both loops range over the announced pairs UNION the current-stack + // entries the window did not prove frozen — each pair evaluated against ITS OWN carrying + // frame, never against the live board (a target legal on the frame but gone from `current` + // would collapse the assignment to "forced" and relieve a choice that is not forced). + let mut pairs: Vec<(&'a GameState, &'a StackEntry)> = Vec::new(); + if let Some(t) = touch { + pairs.extend(t.announced.iter().copied()); + } + for entry in &state.stack { + // CR 608.1: an entry the window proves held its (index, id) through every certified + // frame neither announced nor resolved in the described sequence. + if touch.is_some_and(|t| t.frozen_ids.contains(&entry.id)) { + verdicts.note_conjunct6_frozen_skip(); + continue; + } + pairs.push((state, entry)); + } + + // Announcement-time (gate (3)'s fact), CR 603.3d. + for (frame, entry) in &pairs { + // Fail-closed: a frame outside this container's period cannot be asked about. + let Some(f) = verdicts.frame_ix(frame) else { + return false; + }; + if !(entry_target_choice_is_pinned(frame, f, entry, verdicts, scope) + || stack_entry_has_no_ordering_input(frame, entry)) + { + return false; + } + } + // Resolution-time (gate (6)'s fact), including its paired CR 616.1 obligation. + // The obligation is discharged PER ENTRY against the pipeline's own candidate + // authority, on the same board the entry's events were derived on. + for (frame, entry) in &pairs { + let Some(f) = verdicts.frame_ix(frame) else { + return false; + }; + verdicts.note_conjunct6_ask(); + let primary = verdicts.verdict(f, entry).primary.clone(); + let verdict = match primary { + crate::game::resolution_prompt::ResolutionChoiceFreedom::MayPrompt => { + match pinned_may_choice_relief(f, entry, verdicts, scope) { + Some(residual) => residual, + None => return false, + } + } + free => free, + }; + if !resolution_events_are_discharged(frame, verdict) { + return false; + } + } + true +} + +/// CR 614.1 + CR 616.1: discharge one entry's resolution verdict against the +/// replacement pipeline's own candidate authority, on the board its events were +/// derived on. +/// +/// `board` is the frame carrying the resolution. BOTH halves of this discharge +/// are frame-sensitive: the events are the EVENT half and +/// `proposed_event_prompt_cause`'s first argument is the CANDIDATE-AUTHORITY +/// half — it runs `find_applicable_replacements` over that board's replacement +/// population, so handing it a different board would check one frame's events +/// against another frame's candidates. +fn resolution_events_are_discharged( + board: &GameState, + verdict: crate::game::resolution_prompt::ResolutionChoiceFreedom, +) -> bool { + use crate::game::resolution_prompt::ResolutionChoiceFreedom; + match verdict { + ResolutionChoiceFreedom::MayPrompt => false, + ResolutionChoiceFreedom::FreeUnlessReplacements(events) => { + // CR 616.1: FAIL CLOSED ON AN EMPTY DERIVATION, IN EVERY BUILD. + // + // This was a `debug_assert!(!events.is_empty(), ..)` resting on the contract + // that `probe_resolution` returns `Prompted` for an empty derivation. That + // contract is real but it lives in ANOTHER module and is not enforceable from + // here — and `debug_assert!` compiles out of release, where `any()` over an + // empty slice is `false`, so `!any(..)` would return `true` and discharge the + // obligation having inspected NOTHING. Fail-open is the single direction this + // predicate exists to prevent. + // + // A REFUSAL, not a panic, is the right shape: every other seam in this module + // answers an unclassifiable input with "no certificate, no offer", and the + // refusal is what a test can pin. A `debug_assert!` here could not be covered + // at all — it aborts the very build tests run in. + if events.is_empty() { + return false; + } + !events.iter().any(|ev| { + !crate::game::replacement::proposed_event_prompt_cause( + board, + ev, + crate::game::replacement::replacement_registry(), + ) + .is_empty() + }) + } + } +} + +/// CR 732.2a: [`loop_states_cover_modulo_growth`] with an OFFER's published pin slots in +/// scope — the entry point `game::engine`'s bounded-cycle offer uses for both its +/// certification disjunct and its pin-coverage conjunct. +/// +/// This wrapper exists so the pins reach the gates through the SINGLE AUTHORITY +/// [`window_scope_from_cover_frames`] rather than through a [`LoopWindowScope`] a caller +/// in another module assembled itself; the scope's fields are private for exactly that +/// reason. Passing `slots` empty is NOT the same as passing no proof: `Some(PinnedChoices +/// { slots: &[] })` still names a proposer whose entries the relief tests, and every such +/// test fails on an empty slot list, so an empty publication is byte-identically as strict +/// as [`LoopWindowScope::unproven`]. +pub(crate) fn loop_states_cover_modulo_growth_pinned<'a>( + prior: &GameState, + current: &'a GameState, + proposer: PlayerId, + slots: &[DecisionSlot], + touch: &PeriodTouch<'_>, + verdicts: &mut PeriodVerdicts<'a>, +) -> bool { + let scope = window_scope_from_cover_frames( + prior, + current, + Some(PinnedChoices { proposer, slots }), + Some(touch), + ); + loop_states_cover_modulo_growth_scoped(prior, current, scope, verdicts) } /// CR 601.2f + CR 601.2a: the set of card ids this loop window's recorded driving @@ -1140,15 +2250,27 @@ fn window_cast_card_ids(state: &GameState) -> Option> { } /// Scoped sibling of [`loop_states_cover_modulo_growth`] — see [`LoopWindowScope`]. -/// The `_scope` PARAMETER is still unread: this body's own axis is the PROJECTED -/// firewall, and the scope parameter is the seam for the SIBLING covers. The projected -/// scope conjunct (5) passes downstream is therefore derived LOCALLY, from `current`'s -/// own driving sequence ([`window_cast_card_ids`]) — so behaviour does move through -/// this body even though the parameter does not carry it. -pub(crate) fn loop_states_cover_modulo_growth_scoped( +/// The `scope` parameter now carries CR 732.2a pin proofs INTO this body: gate (3) +/// skips an entry whose published target [`DecisionSlot`] the offer pinned +/// ([`entry_target_choice_is_pinned`]) and gate (6) discharges a `MayPrompt` that is +/// wholly attributable to a published CR 603.5 gate ([`pinned_may_choice_relief`]), per +/// the EXTENSION POINT's three preconditions. That is this body's own use of the +/// parameter. +/// +/// The parameter is ALSO the seam for the SIBLING covers, which build their scope +/// through the single authority [`window_scope_from_cover_frames`] — its third +/// argument is `pinned`, passed `None` at both live sibling call sites today. +/// +/// Not carried by the parameter: the PROJECTED conjunct (5) this body passes +/// downstream is derived LOCALLY from `current`'s own driving sequence +/// ([`window_cast_card_ids`]), and the `projected_scope` built for that call +/// deliberately holds `pinned: None` — the projected firewall is a different +/// axis and must not inherit the caller's pins. +pub(crate) fn loop_states_cover_modulo_growth_scoped<'a>( prior: &GameState, - current: &GameState, - _scope: LoopWindowScope<'_>, + current: &'a GameState, + scope: LoopWindowScope<'_>, + verdicts: &mut PeriodVerdicts<'a>, ) -> bool { // (1) Board equal modulo the NARROWED projection AND modulo the stack, with the // object resource axes STRICT-COMPARED (R5-B1). Project both, clear both stacks @@ -1179,7 +2301,21 @@ pub(crate) fn loop_states_cover_modulo_growth_scoped( // (3) Every grown place is a mandatory, no-ordering-input triggered ability. // Iterate the ORIGINAL current-stack entries (so the mid-construction firewall // sees real stack-entry ids) and check each whose normalized kind strictly grew. + // Fail-closed: this predicate only answers about a `current` the container holds. + let Some(f_current) = verdicts.frame_ix(current) else { + return false; + }; for (orig, norm) in current.stack.iter().zip(cur_stack.iter()) { + // CR 732.2a EXTENSION POINT (see item 6's block): a slot the OFFER publishes is + // a SPECIFIED choice, not a free one, so its announcement-time target input is + // no longer player ordering input. + // + // Deliberately NOT frozen-filtered: the PLACEMENT RULE keeps the frozen skip in + // item (6) alone, because items (4)/(5) are what establish the premise that skip + // consumes — reading it here would consume a premise not yet proved. + if entry_target_choice_is_pinned(current, f_current, orig, verdicts, scope) { + continue; + } let cn = cur_stack.iter().filter(|e| *e == norm).count(); let pn = prior_stack.iter().filter(|e| *e == norm).count(); if cn > pn && !stack_entry_has_no_ordering_input(current, orig) { @@ -1190,11 +2326,14 @@ pub(crate) fn loop_states_cover_modulo_growth_scoped( // (4) On-stack fail-closed resource-read guard: NO entry on `current`'s stack may // carry an AST that reads a still-projected axis (player monotone resources + // journals). Object-axis readers pass — their drift breaks gate (1) instead. - if current - .stack - .iter() - .any(stack_entry_reads_projected_resource) - { + // The closure is the ONLY body change item (4) takes: it counts the scans so the + // PLACEMENT RULE ("the frozen skip lives in item (6) and nowhere earlier") has an + // assertable surface instead of an argued one. The population is the UNEXEMPTED + // current stack — `frozen_ids` is deliberately not read here. + if current.stack.iter().any(|e| { + verdicts.note_conjunct4_scan(); + stack_entry_reads_projected_resource(e) + }) { return false; } @@ -1210,8 +2349,11 @@ pub(crate) fn loop_states_cover_modulo_growth_scoped( let projected_scope = LoopWindowScope { phase_invariant: None, sole_driver: None, - pinned_slots: &[], + pinned: None, cast_card_ids: cast_ids.as_deref(), + // DELIBERATE, same rationale as the `pinned: None` above: the projected firewall is + // a different axis and must not inherit the caller's period proof. + period: None, }; if fire_time_conditions_read_projected_resource_scoped(current, projected_scope) { return false; @@ -1224,11 +2366,11 @@ pub(crate) fn loop_states_cover_modulo_growth_scoped( // eligibility over player counters, CR 701.34a) can open a prompt that the // AST-level item-4 scan cannot see. Verdicts come from the ability_scan // classifier (pure fact-producers — rejection is decided ONLY here); - // FreeUnlessLifeReplacements additionally requires the CR 616.1 environmental - // guard below. THIS block is the single gate seam for resolution-choice - // rejection (item 3 is untouched and gates a different fact — announcement-time - // ordering input). Perf: O(stack × AST) + O(objects × defs) via the guard — - // same order as items (4)/(5). + // FreeUnlessReplacements additionally requires the CR 616.1 environmental + // guard below, for exactly the event classes its payload names. THIS block is the + // single gate seam for resolution-choice rejection (item 3 is untouched and gates + // a different fact — announcement-time ordering input). Perf: O(stack × AST) + + // O(objects × defs) via the guard — same order as items (4)/(5). // // EXTENSION POINT — pinned fixed choices (CR 732.2a): a shortcut proposal MAY // pre-specify choices in advance ("always choose permanent P"); only @@ -1241,18 +2383,50 @@ pub(crate) fn loop_states_cover_modulo_growth_scoped( // option preserves the certificate (the win stays forced per the // CR 104.2a-grounded winner predicate). Plug pins in at THIS seam as an // additional input; do not rewire the classifiers or spread the decision. - let mut needs_life_guard = false; + // + // PINS ARE PLUGGED IN HERE (`scope.pinned`, minted by the single authority + // `game::engine::bounded_cycle_pin_slots`). Precondition (a) holds by construction: + // the pins that channel carries are `TargetPin::Player` / `MayChoice` designations, + // both state-independent (never "the newest copy"). Precondition (c) is NOT taken on + // trust from the mint site: [`pinned_may_choice_relief`] re-runs the mint's own + // per-entry acceptance test — controller conjunct included — for THIS entry, so the + // relief predicate is the mint predicate rather than a coarser sibling of it. + // Precondition (b) is why relief is not a `continue`: the entry's RESIDUAL verdict + // (its classification with the published CR 603.5 gate discharged) re-enters the same + // gating an unpinned entry gets, so `FreeUnlessReplacements` still arms the + // CR 616.1 environmental guard below for the classes it names — a pinned target/"may" + // says nothing about whose life- or draw-event replacements might prompt. + // + // CR 608.1 + CR 732.2a: an entry the certified window proves FROZEN — same id at the + // same index in every window frame and in `current` — neither announced nor resolved in + // the described sequence, so it makes no choice there. THE FROZEN SKIP LIVES HERE AND + // NOWHERE EARLIER: items (2)/(4)/(5) are what establish the premises it consumes, and + // each of them returns on failure strictly above this loop, so by the time control + // arrives the premises are discharged facts about `(prior, current)` rather than + // assumptions about this predicate's own eventual answer. for entry in ¤t.stack { - match stack_entry_resolution_choice_freedom(entry) { - crate::game::ability_scan::ResolutionChoiceFreedom::MayPrompt => return false, - crate::game::ability_scan::ResolutionChoiceFreedom::FreeUnlessLifeReplacements => { - needs_life_guard = true + if scope + .period + .is_some_and(|t| t.frozen_ids.contains(&entry.id)) + { + verdicts.note_conjunct6_frozen_skip(); + continue; + } + verdicts.note_conjunct6_ask(); + let primary = verdicts.verdict(f_current, entry).primary.clone(); + let verdict = match primary { + crate::game::resolution_prompt::ResolutionChoiceFreedom::MayPrompt => { + match pinned_may_choice_relief(f_current, entry, verdicts, scope) { + Some(residual) => residual, + None => return false, + } } + free => free, + }; + if !resolution_events_are_discharged(current, verdict) { + return false; } } - if needs_life_guard && life_event_replacements_may_prompt(current) { - return false; - } true } @@ -1381,7 +2555,7 @@ pub(crate) fn loop_states_cover_modulo_object_growth( if fire_time_conditions_read_growing_class_scoped( &cf, None, - window_scope_from_cover_frames(&pa, &pb, &[]), + window_scope_from_cover_frames(&pa, &pb, None, None), ) { return false; } @@ -1589,7 +2763,7 @@ pub(crate) fn loop_states_cover_modulo_fodder_growth( if fire_time_conditions_read_growing_class_scoped( &cf, Some(&class_members), - window_scope_from_cover_frames(&pa, &pb, &[]), + window_scope_from_cover_frames(&pa, &pb, None, None), ) { return false; } @@ -3038,8 +4212,14 @@ fn stack_entry_has_no_ordering_input(state: &GameState, entry: &StackEntry) -> b } // CR 603.3d + CR 608.2b + CR 732.2a: a non-empty target list is NOT player // ordering input when exactly one legal assignment exists — the choice is - // FORCED, so the shortcut stays deterministic. Re-derived per-iteration against - // the live state (the SOLE caller iterates the grown current-stack entries). + // FORCED, so the shortcut stays deterministic. Re-derived per call against the + // board the CALLER passes, and that board is load-bearing rather than incidental: + // there are THREE call sites and none of them is "the live state" by construction + // — the announced loop passes the pair's own CARRYING FRAME (a retained ring + // sample), the current-stack loops pass `current`. The verdict is a function of + // the board's legal-target population, so handing a retained pair the live board + // is fail-OPEN: a target legal on the frame but gone from `current` collapses the + // assignment to "forced" and relieves a choice that is not forced. forced_unique_targeting(state, ability) } @@ -3116,12 +4296,59 @@ fn stack_entry_reads_projected_resource(entry: &StackEntry) -> bool { /// a real fixture needs it.) The trigger-level `condition` (intervening-if /// re-check, CR 603.4) is pure evaluation and contributes no prompt. fn stack_entry_resolution_choice_freedom( + state: &GameState, entry: &StackEntry, -) -> crate::game::ability_scan::ResolutionChoiceFreedom { - use crate::game::ability_scan::ResolutionChoiceFreedom; + budget: &mut ProbeBudget, +) -> crate::game::resolution_prompt::ResolutionChoiceFreedom { + use crate::game::resolution_prompt::ResolutionChoiceFreedom; match &entry.kind { StackEntryKind::TriggeredAbility { ability, .. } => { - crate::game::ability_scan::ability_resolution_choice_freedom(ability) + // CR 603.4 + CR 608.2k: the classifier probes a RESOLUTION, so it + // must be handed the board `resolve_top` would hand + // `resolve_ability_chain` — this entry off the stack, with + // resolution scope bound. Handing it the raw pre-resolution board + // resolves every `EventContextAmount` / `Triggering*` reference + // against an absent context and is FAIL-OPEN for the `> 0`-gated + // virtual replacement arms. + // + // The entry is removed BY ID, never by `pop`: the callers walk the + // stack at arbitrary depth, so "pop the top" is wrong for every + // non-top entry, while "this entry is the one resolving" is the + // counterfactual being asked. The clone lands only on the probe — + // `resolve_top` calls the same shared binding on its own + // `&mut GameState` and pays nothing. + // CR 732.2a: THE CHEAP BINDING PRECONDITION RUNS BEFORE THE CLONE. + // + // The whole-`GameState` clone below is the dominant per-entry work, and it + // used to be paid unconditionally — including for entries the classifier was + // about to reject on a pure AST gate (`optional`, `unless_pay`, a modal + // header, an `UpTo` count, a non-allow-listed effect). Those entries bought + // a full board copy and a scope binding to reach a verdict that never looked + // at the board. + // + // `chain_offers_choice` is that verdict's AST half, so asking it here costs + // nothing and removes the clone entirely for every gated entry. + // + // DELIBERATELY NOT a `try_charge_one` in this position, which was the other + // remedy on offer: the meter's charge sits BELOW the `optional` gate by + // design, and `r16_the_f4_offering_beats_probe_demand_is_exactly_measured` + // pins `spent == conjunct6_asks` for exactly that reason — its own message + // predicts that hoisting the charge above the gate makes every optional ask + // charge twice. Measured: adding a charge here does make that row fail. The + // precondition form bounds the same dominant work without moving the meter. + if crate::game::resolution_prompt::chain_offers_choice(ability) { + return ResolutionChoiceFreedom::MayPrompt; + } + let mut board = state.clone(); + board.stack.retain(|e| e.id != entry.id); + if !crate::game::stack::bind_resolution_scope(&mut board, entry, None) { + // CR 603.4 false ⇒ the live resolution proposes nothing, and an + // empty derivation is never "safe". + return ResolutionChoiceFreedom::MayPrompt; + } + crate::game::resolution_prompt::ability_resolution_choice_freedom( + &board, ability, budget, + ) } StackEntryKind::Spell { .. } | StackEntryKind::ActivatedAbility { .. } @@ -3445,151 +4672,6 @@ pub(crate) fn life_growth_is_observed(state: &GameState) -> bool { ) } -/// The proposed-event class a life-affecting `ReplacementEvent` watches. CR 616.1 -/// material-ordering competition is counted PER proposed-event class, because a -/// single `ProposedEvent::LifeLoss` draws candidates from every LifeLoss-matching -/// registry key at once (`LoseLife` + `LifeReduced` + `PayLife`). -#[derive(Clone, Copy, PartialEq, Eq)] -enum LifeEventClass { - /// Matches `ProposedEvent::LifeGain`. - LifeGain, - /// Matches `ProposedEvent::LifeLoss`. - LifeLoss, -} - -/// CR 614.1a: is this replacement event in the LIFE class — i.e. does its -/// registry matcher match `ProposedEvent::LifeGain` or `ProposedEvent::LifeLoss`? -/// Compiler-exhaustive over ALL `ReplacementEvent` variants (no wildcard) so a -/// NEW variant fails to compile until classified against the coupling rule. -/// -/// COUPLING RULE (grep-enforced when the set is edited): life-class ⇔ the event's -/// registry matcher (`crate::game::replacement`) matches a life `ProposedEvent`. -/// Measured (`rg -n 'ProposedEvent::Life(Gain|Loss)'` over the matcher fns): -/// `gain_life_matcher` (GainLife → LifeGain), `lose_life_matcher` (LoseLife → -/// LifeLoss), `life_reduced_matcher` (LifeReduced → LifeLoss), `pay_life_matcher` -/// (PayLife → LifeLoss). Classify by the MATCHER, not the name — a hand-picked -/// set had already missed `PayLife` and `LifeReduced`. -fn replacement_event_matches_life(event: &ReplacementEvent) -> Option { - match event { - ReplacementEvent::GainLife => Some(LifeEventClass::LifeGain), - ReplacementEvent::LoseLife | ReplacementEvent::LifeReduced | ReplacementEvent::PayLife => { - Some(LifeEventClass::LifeLoss) - } - // Non-life events (explicitly listed ⇒ None, so a new variant must be - // classified against the coupling rule before it compiles). - ReplacementEvent::DamageDone - | ReplacementEvent::Destroy - | ReplacementEvent::Discard - | ReplacementEvent::Draw - | ReplacementEvent::TurnFaceUp - | ReplacementEvent::Counter - | ReplacementEvent::ChangeZone - | ReplacementEvent::Moved - | ReplacementEvent::AddCounter - | ReplacementEvent::RemoveCounter - | ReplacementEvent::CreateToken - | ReplacementEvent::Tap - | ReplacementEvent::Untap - | ReplacementEvent::DealtDamage - | ReplacementEvent::Mill - | ReplacementEvent::Attached - | ReplacementEvent::SearchFound - | ReplacementEvent::DrawCards - | ReplacementEvent::ProduceMana - | ReplacementEvent::Scry - | ReplacementEvent::CoinFlip - | ReplacementEvent::Transform - | ReplacementEvent::Explore - | ReplacementEvent::Connive - | ReplacementEvent::AssembleContraption - | ReplacementEvent::BeginPhase - | ReplacementEvent::BeginTurn - | ReplacementEvent::Cascade - | ReplacementEvent::CopySpell - | ReplacementEvent::DeclareBlocker - | ReplacementEvent::GameLoss - | ReplacementEvent::GameWin - | ReplacementEvent::Learn - | ReplacementEvent::LoseMana - | ReplacementEvent::PlanarDiceResult - | ReplacementEvent::Planeswalk - | ReplacementEvent::Proliferate - | ReplacementEvent::Other(_) => None, - } -} - -/// §2.2 item 6 environmental guard (CR 616.1 + CR 614.1a): can the current -/// life-event replacement environment open a resolution-time prompt on an -/// allow-listed `GainLife`/`LoseLife` resolution? Paired obligation of -/// `ResolutionChoiceFreedom::FreeUnlessLifeReplacements`. -/// -/// Over-approximates `find_applicable_replacements` fail-closed: conditions, -/// `valid_player` scopes, and amounts are deliberately ignored (over-count ⇒ -/// over-reject ⇒ fail-safe). Def sources = object-attached defs -/// (`active_replacements`, item 5's authority) CHAINED with the game-state-level -/// floating store `state.pending_damage_replacements` (sentinel `ObjectId(0)`, -/// scanned by `find_applicable_replacements` replacement.rs:4838-4862; skip -/// `is_consumed`, mirroring :4859-4861). `pending_step_end_mana_handlers` is a -/// different type gated behind `ProposedEvent::EmptyManaPool` -/// (replacement.rs:4971-4980) that structurally cannot produce a life-class -/// candidate ⇒ excluded. There are NO virtual life candidates in -/// `find_applicable_replacements` (measured — the only `ProposedEvent::LifeGain` -/// there is a `valid_player` filter, not a candidate creator, replacement.rs:4674). -/// -/// Rejects when a life-class def is: -/// (a) OPTIONAL — a single optional candidate prompts (replacement.rs:6221-6247); -/// (b) carries a body continuation (`execute`/`runtime_execute`) — a MANDATORY -/// body is stashed as `PostReplacementContinuation::Resolved` -/// (replacement.rs:5511-5524) and drained via -/// `apply_pending_post_replacement_effect` (engine_replacement.rs:1159), -/// which runs an arbitrary `ResolvedAbility` and can set a non-priority -/// `waiting_for` (e.g. a Sacrifice body ⇒ EffectZoneChoice). `execute` is -/// also rejected by item 5 (resource.rs:1058-1060); re-checked here so the -/// guard does not depend on item ordering, and `runtime_execute` is NOT -/// otherwise covered (item 5 scans it only for projected reads, -/// resource.rs:976-981); -/// (c) one of ≥2 defs competing for the SAME proposed-event class — CR 616.1 -/// material-ordering prompt (replacement.rs:6263-6279). A single mandatory -/// quantity-mod def with no body (Bloodletter / Rhox Faithmender class) -/// trips NONE of these and resolves deterministically (replacement.rs:6250-6261). -fn life_event_replacements_may_prompt(state: &GameState) -> bool { - // CR 614.1 / CR 113.6: `active_replacements` is all-zones (its callers restrict). - // `find_applicable_replacements` — the real pipeline this over-approximates — - // scans [Battlefield, Command] (plus the entering/discarded card, irrelevant to a - // life event). A life-class replacement on a card in the library / hand / - // graveyard cannot apply during the loop; scanning it is the same all-zones - // false-reject class as the observer firewalls, so match the pipeline's scope. - let object_defs = crate::game::functioning_abilities::active_replacements(state) - .filter(|(_, obj, _)| matches!(obj.zone, Zone::Battlefield | Zone::Command)) - .map(|(_, _, def)| def); - let floating_defs = state - .pending_damage_replacements - .iter() - .filter(|def| !def.is_consumed); - - let mut gain_defs = 0usize; - let mut loss_defs = 0usize; - for def in object_defs.chain(floating_defs) { - let Some(class) = replacement_event_matches_life(&def.event) else { - continue; - }; - // (a) single optional candidate prompts. - if crate::game::replacement::replacement_mode_is_optional(&def.mode) { - return true; - } - // (b) mandatory body-continuation drain is prompt-capable. - if def.execute.is_some() || def.runtime_execute.is_some() { - return true; - } - match class { - LifeEventClass::LifeGain => gain_defs += 1, - LifeEventClass::LifeLoss => loss_defs += 1, - } - } - // (c) ≥2 defs competing for one proposed-event class ⇒ CR 616.1 ordering prompt. - gain_defs >= 2 || loss_defs >= 2 -} - /// CR 614.1a: a replacement's BODY (not its `condition`) can read a projected /// player resource. `QuantityModification` variants are all fixed constants (no /// read). `DamageModification::LifeFloor` caps against a player's live life total @@ -4124,11 +5206,148 @@ mod tests { oid } - fn test_trigger_ref(state: &GameState, object_id: ObjectId) -> TriggerDefinitionRef { - let object = &state.objects[&object_id]; - TriggerDefinitionRef { - source: crate::types::identifiers::ObjectIncarnationRef::from_object(object), - occurrence: crate::types::ability::TriggerDefinitionOccurrenceRef::Printed { + /// Inflate one of the real 4p dumps through the PRODUCTION decoder — the same + /// chokepoint the server's `from_persisted` and WASM's `decode_restored_game_state` + /// funnel through, never a bare `GameState` decode. + fn dump_state(gz: &[u8]) -> GameState { + use std::io::Read; + let mut json = String::new(); + flate2::read::GzDecoder::new(gz) + .read_to_string(&mut json) + .expect("fixture .json.gz must inflate to UTF-8 JSON"); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + serde_json::from_value::( + envelope["gameState"].clone(), + ) + .expect("gameState deserializes through the production decoder") + .into_game_state() + } + + /// One beat of the shared dump drive policy (`tests/integration/loop_shortcut.rs`'s + /// `dump_drive_one_beat`): at `Priority` always pass — the mandatory triggers resolve + /// and re-trigger, which IS the loop when there is one — otherwise take the first + /// legal non-terminal action. Every beat crosses `apply()`. + fn dump_drive_one_beat(state: &mut GameState) -> Result<(), String> { + use crate::types::actions::GameAction; + use crate::types::game_state::WaitingFor; + + let actor = state + .waiting_for + .acting_player() + .into_iter() + .chain(state.players.iter().map(|p| p.id)) + .find_map(|p| { + let (actions, _costs, _grouped) = + crate::ai_support::legal_actions_for_viewer(state, p); + (!actions.is_empty()).then_some((p, actions)) + }); + let Some((who, actions)) = actor else { + return Err(format!("no legal actor at {:?}", state.waiting_for)); + }; + let forbidden = + |a: &GameAction| matches!(a, GameAction::Concede { .. } | GameAction::Debug(_)); + let chosen = if matches!(state.waiting_for, WaitingFor::Priority { .. }) { + actions + .iter() + .find(|a| matches!(a, GameAction::PassPriority)) + } else { + actions + .iter() + .find(|a| !matches!(a, GameAction::PassPriority) && !forbidden(a)) + .or_else(|| actions.iter().find(|a| !forbidden(a))) + }; + let Some(action) = chosen.cloned() else { + return Err(format!("empty action list at {:?}", state.waiting_for)); + }; + crate::game::engine::apply(state, who, action.clone()) + .map(|_| ()) + .map_err(|e| format!("apply err ({action:?}): {e:?}")) + } + + /// A frozen-set LOWER BOUND computed INDEPENDENTLY of the function under test: the + /// longest common prefix, by object id, of every window frame's stack and `current`'s. + /// + /// Sound because it is a strictly weaker computation than the predicate it bounds — + /// `certified_period_touch` freezes an id iff it sits at the SAME INDEX in every window + /// frame, and every position inside a common prefix satisfies that by construction. It + /// is a prefix scan that stops at the first disagreement, not a per-index filter over + /// the whole stack, so it cannot degenerate into `f(x) == f(x)` against the callee. + fn frozen_lower_bound(window: &[&GameState], current: &GameState) -> usize { + (0..current.stack.len()) + .take_while(|&i| { + let id = current.stack[i].id; + window + .iter() + .all(|f| f.stack.get(i).map(|e| e.id) == Some(id)) + }) + .count() + } + + /// The two 4p dumps every row in this module drives with the GENERIC policy + /// ([`dump_drive_one_beat`]: pass at `Priority`, else the first legal non-terminal action), + /// as `(name, gzip)` pairs. + /// + /// `fantastic_four_bounded_loop_4p.json.gz` is now tracked too (5d U5), and is deliberately + /// NOT listed here: MEASURED, the generic policy never reaches its loop at all — that + /// helper's victim preference matches `GameAction::SelectTargets` while the F4 dump raises + /// `GameAction::ChooseTarget`, and its fallback answers Invisible Woman's CR 603.5 "may" + /// with whichever `DecideOptionalEffect` is enumerated first, which breaks the chain to + /// Mister Fantastic. Adding it here would add a dump on which these rows measure nothing. + /// Its rows live in `tests/integration/fantastic_four_bounded_loop.rs`, with the drive + /// policy that dump requires. + const TRACKED_DUMPS: [(&str, &[u8]); 2] = [ + ( + "dina", + include_bytes!("../../tests/fixtures/dina_conqueror_4p.json.gz"), + ), + ( + "dellian", + include_bytes!("../../tests/fixtures/dellian_emblem_conqueror_4p.json.gz"), + ), + ]; + + /// Drive one dump through `apply()` until `pred` accepts the board, returning the beat + /// index and that board. + /// + /// The beat is SEARCHED by its construction requirements, never hardcoded — a hardcoded + /// index is a fixture that drifts silently when the drive policy moves. `None` ⇒ no beat + /// within `max_beats` satisfied them, which every caller turns into a loud failure rather + /// than a vacuous pass. + fn drive_dump_until( + gz: &[u8], + max_beats: usize, + pred: impl Fn(&GameState) -> bool, + ) -> Option<(usize, GameState)> { + let mut state = dump_state(gz); + for beat in 0..max_beats { + if pred(&state) { + return Some((beat, state)); + } + if dump_drive_one_beat(&mut state).is_err() { + return None; + } + } + None + } + + /// The construction requirement shared by every row that needs a REAL certified window + /// carrying a non-empty observed-frozen prefix: a usable ring AND a newest candidate pair + /// (`span == 1`, the shape §3 D2's walk reaches first) whose common prefix is + /// index-stable. + fn has_frozen_window(state: &GameState) -> bool { + if state.loop_detect_ring.len() < 2 { + return false; + } + let live: Vec<&GameState> = state.loop_detect_ring.iter().map(|f| &f.live).collect(); + frozen_lower_bound(&live[live.len() - 2..], state) > 0 + } + + fn test_trigger_ref(state: &GameState, object_id: ObjectId) -> TriggerDefinitionRef { + let object = &state.objects[&object_id]; + TriggerDefinitionRef { + source: crate::types::identifiers::ObjectIncarnationRef::from_object(object), + occurrence: crate::types::ability::TriggerDefinitionOccurrenceRef::Printed { base_set: object.trigger_base_set_instance, printed_index: 0, }, @@ -5100,7 +6319,13 @@ mod tests { trigger_event: None, description: None, source_name: String::new(), - subject_match_count: None, + // CR 603.2c: the batched-subject count these entries' `event_amount()` + // drains resolve "that many" against. `bind_resolution_scope` lifts it + // into resolution scope; with it absent the drain's amount resolves to + // ZERO, the resolver proposes nothing, and gate (6)'s probe is + // fail-closed on the EMPTY derivation — a different fact from the ones + // these rows test. + subject_match_count: Some(1), die_result: None, }, } @@ -5283,11 +6508,22 @@ mod tests { } fn bf_object(state: &mut GameState, id: u64) -> ObjectId { + bf_object_owned_by(state, id, PlayerId(1)) + } + + /// CR 614.1: a replacement definition's applicability is scoped to ITS + /// controller's events, so a fixture that installs a def to be DRAWN as a + /// candidate must put it on a permanent controlled by the player whose + /// event it is meant to replace. The event-derived discharge asks the + /// pipeline's own `find_applicable_replacements`, which honours that scope; + /// the def-scan it replaced deliberately ignored it (over-count ⇒ + /// over-reject), so a P1-controlled def used to reject a P0 life gain. + fn bf_object_owned_by(state: &mut GameState, id: u64, owner: PlayerId) -> ObjectId { let oid = ObjectId(id); let object = crate::game::game_object::GameObject::new( oid, CardId(7), - PlayerId(1), + owner, "Test Board Permanent".to_string(), Zone::Battlefield, ); @@ -5470,120 +6706,937 @@ mod tests { ); } - /// CONSTRAINT-3 ORTHOGONALITY: an item-3-passing, item-4-clean forced-unique - /// drain that ALSO carries a `Proliferate` sub_ability (CR 701.34a resolution - /// choice ⇒ `MayPrompt`) is vetoed by item-6. Revert-probe: dropping the - /// Proliferate sub (choice-free) flips this TRUE (= the positive fixture). + /// CR 601.2c (reached for a triggered ability via CR 603.3d): the mint must ask the + /// ANNOUNCEMENT authority how many choices announcing an entry requires — never a proxy + /// for it. `Effect::target_filter()` answers a DIFFERENT question ("is there a + /// player-target filter on the head effect?"); `ability_utils::build_target_slots` + /// answers this one, and is the same function the relief's own `forced_unique_targeting` + /// rebuilds slots with. Three rows, each a shape where the two answers DIVERGE, plus a + /// positive control so the conjunct cannot be constant-false. + /// + /// MEASURED REVERT-PROBE (delete the `build_target_slots` conjunct in + /// `entry_publishes_pin_slots`), on row (a)'s board: + /// `mint_publishes` false→TRUE, `bounded_cycle_pin_slots(..).len()` 0→1 (ONE point with + /// `min/max_targets: 1` for a TWO-choice announcement), and the pinned cover false→TRUE. + /// That last flip is the fail-open: gate (3)'s bare `continue` discharges a slot no pin + /// specifies. Rows (b) and (c) flip `mint_publishes` false→TRUE on the same probe. #[test] - fn item6_still_vetoes_under_forced_unique_targets() { - let drain_prolif = |id| { + fn bounded_cycle_pin_slots_requires_a_single_mandatory_announcement_slot() { + use crate::game::ability_utils::build_target_slots; + use crate::game::engine::{bounded_cycle_pin_slots, entry_publishes_pin_slots}; + + // ── (a) TWO announcement choices: a chained sub-ability that also drains + // `target opponent`. CR 601.2c: "if the spell uses the word `target` in multiple + // places, the same object or player can be chosen once for each instance." + let (prior, current) = grown_window(3, |id| { let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); ability.targets = vec![TargetRef::Player(PlayerId(1))]; - ability.sub_ability = Some(Box::new(ResolvedAbility::new( - Effect::Proliferate, - vec![], - ObjectId(CHURN_SRC), - PlayerId(0), + ability.sub_ability = Some(Box::new(lose_life_targeting( + event_amount(), + opp_typed(vec![]), ))); churn_entry(id, 0, ability, None) - }; - let mut prior = drain_state(2); - prior.stack.push_back(drain_prolif(10)); - prior.stack.push_back(drain_prolif(11)); - let mut current = prior.clone(); - current.stack.clear(); - current.stack.push_back(drain_prolif(20)); - current.stack.push_back(drain_prolif(21)); - current.stack.push_back(drain_prolif(22)); - - // Reach-guard (mandate 4 anti-vacuity): item-3 AND item-4 PASS for this entry, - // so the FALSE below is ATTRIBUTABLE to item-6's Proliferate veto — not an - // upstream conjunct short-circuiting first. + }); let ability = current.stack[2].ability().unwrap(); + assert_eq!( + build_target_slots(¤t, ability).map(|s| s.len()).ok(), + Some(2), + "reach-guard: `collect_target_slots_inner` recurses into `sub_ability`, so the \ + runtime announcement carries two independent CR 601.2c choices" + ); assert!( - forced_unique_targeting(¤t, ability), - "item-3 passes (single forced-unique opponent) even with the Proliferate sub" + ability.effect.target_filter().is_some(), + "reach-guard: the PROXY still reports a single head-effect filter — that is the \ + whole divergence" + ); + // Reach-guards that gate (3) is the SOLE rejector here (gates (4)/(6) clean), so the + // cover assertions below are attributable to the pin relief and nothing else. + assert!( + !forced_unique_targeting(¤t, ability), + "reach-guard: three opponents ⇒ not forced-unique ⇒ gate (3) rejects" ); assert!( !crate::game::ability_scan::ability_reads_projected_resource(ability), - "item-4 passes (Proliferate sub scans NONE; pure-controller Typed target)" + "reach-guard: gate (4) passes" + ); + assert!( + entry_publishes_pin_slots(¤t, ¤t.stack[2], PlayerId(0)).is_none(), + "a published point says `min/max_targets: 1`; this announcement has TWO choices" + ); + assert!( + bounded_cycle_pin_slots(¤t, PlayerId(0)).is_empty(), + "and the mint therefore publishes nothing rather than under-describing it" ); + let slot = churn_src_slot(¤t, 0); + for pinned in [&[][..], std::slice::from_ref(&slot)] { + assert!( + !loop_states_cover_modulo_growth_scoped( + &prior, + ¤t, + pinned_scope(pinned), + &mut PeriodVerdicts::for_period(&[], ¤t, PlayerId(0)) + ), + "gate (3)'s relief is a bare `continue`: an unpublished second target choice \ + must keep rejecting ({} pinned slot(s))", + pinned.len() + ); + } + + // ── (b) ZERO announcement choices, two ways. `Effect::target_filter()` returns + // `Some` for both, but the SLOT BUILDER surfaces no stack slot: CR 701.21a + // `Sacrifice` is carved out of `triggers::extract_target_filter_from_effect` (the + // accessor the builder actually uses), and a CR 601.2c choice made at resolution + // (`TargetChoiceTiming::Resolution`) is not announced at all. + let zero_slot_cases: [(&str, ResolvedAbility); 2] = [ + ("CR 701.21a Sacrifice — not a target", { + ResolvedAbility::new( + Effect::Sacrifice { + target: opp_typed(vec![]), + count: QuantityExpr::Fixed { value: 1 }, + min_count: 0, + }, + vec![], + ObjectId(CHURN_SRC), + PlayerId(0), + ) + }), + ("CR 601.2c — chosen at resolution, not announcement", { + let mut a = lose_life_targeting(event_amount(), opp_typed(vec![])); + a.targets = vec![TargetRef::Player(PlayerId(1))]; + a.target_choice_timing = crate::types::ability::TargetChoiceTiming::Resolution; + a + }), + ]; + for (label, ability) in zero_slot_cases { + let (_p, c) = grown_window(3, |id| churn_entry(id, 0, ability.clone(), None)); + let live = c.stack[2].ability().unwrap(); + assert!( + live.effect.target_filter().is_some(), + "reach-guard: the PROXY says `Some` ({label})" + ); + assert_eq!( + build_target_slots(&c, live).map(|s| s.len()).ok(), + Some(0), + "reach-guard: the ANNOUNCEMENT authority says zero ({label})" + ); + assert!( + entry_publishes_pin_slots(&c, &c.stack[2], PlayerId(0)).is_none(), + "no announcement choice ⇒ no published point ({label})" + ); + } + // ── (c) an "up to one target" announcement: CR 601.2c makes the real minimum ZERO, + // and the slot may legally carry an EMPTY legal set, so `min_targets: 1` overstates + // it. One slot — the count half of the conjunct does NOT catch this; `!optional` does. + let (_p_opt, c_opt) = grown_window(3, |id| { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.targets = vec![TargetRef::Player(PlayerId(1))]; + ability.optional_targeting = true; + churn_entry(id, 0, ability, None) + }); + assert_eq!( + build_target_slots(&c_opt, c_opt.stack[2].ability().unwrap()) + .map(|s| s.iter().map(|slot| slot.optional).collect::>()) + .ok(), + Some(vec![true]), + "reach-guard: exactly ONE slot, and it is OPTIONAL — so only the `!optional` \ + half of the conjunct can reject this row" + ); assert!( - !loop_states_cover_modulo_growth(&prior, ¤t), - "item-6 vetoes the resolution-choice-bearing drain even when item-3/4 pass" + entry_publishes_pin_slots(&c_opt, &c_opt.stack[2], PlayerId(0)).is_none(), + "CR 601.2c: an `up to one target` announcement's minimum is 0, not the \ + published 1" + ); + + // ── positive control: the shipped drain still publishes, so none of the above is a + // constant-false conjunct. Same board as arm 1 of + // `a_pinned_slot_skips_gate_three_and_six`. + let (_p_ok, c_ok) = grown_window(3, |id| drain_entry(id, vec![])); + assert_eq!( + build_target_slots(&c_ok, c_ok.stack[2].ability().unwrap()) + .map(|s| s.len()) + .ok(), + Some(1), + "control: one mandatory announcement choice" + ); + assert_eq!( + bounded_cycle_pin_slots(&c_ok, PlayerId(0)).len(), + 1, + "control: the instrument still returns a NON-zero point set" ); } - /// (c) the grown entry is a SPELL ⇒ false (not a mandatory trigger). Isolates - /// item 3's `TriggeredAbility`-only requirement. + /// CR 115.2 + CR 601.2c (via CR 603.3d): the mint must take *which* choice it publishes + /// from the ANNOUNCEMENT authority too — not just *how many*. + /// + /// The sibling row above closed the cardinality axis. This is the same divergence on the + /// legal-SET axis, and it is the shape the cardinality conjunct cannot see: the head + /// effect declares the CR 115.2 player filter but announces NOTHING + /// (`TargetChoiceTiming::Resolution` ⇒ 0 slots), while a chained sub-ability announces + /// the one mandatory slot — over CREATURES. `Effect::target_filter()` answers + /// `Typed{[], Opponent, []}`; `build_target_slots` answers "one mandatory slot, + /// `[Object(500), Object(901), Object(902)]`" (measured, both). + /// + /// MEASURED REVERT-PROBE (drop the all-`Player` conjunct in + /// `entry_publishes_pin_slots`): `entry_publishes_pin_slots(..).is_some()` false→TRUE and + /// `bounded_cycle_pin_slots(..)` 0→1 point, whose `legal_targets` before the fix was the + /// re-derived `[Player(1), Player(2)]` — a published point claiming a PLAYER set for a + /// three-OBJECT announcement, which no `TargetPin::Player` can specify and which gate + /// (3)'s bare `continue` would discharge anyway. + /// + /// The positive control asserts the published set EQUALS the builder's slot rather than + /// a literal, so it fails if the mint ever re-derives the set from the accessor again. #[test] - fn n1_c_grown_entry_spell_false() { - let spell = |id| StackEntry { - id: ObjectId(id), - source_id: ObjectId(CHURN_SRC), - controller: PlayerId(0), - kind: StackEntryKind::Spell { - card_id: CardId(1), - ability: None, - casting_variant: crate::types::game_state::CastingVariant::Normal, - actual_mana_spent: 0, + fn bounded_cycle_pin_slots_legal_set_comes_from_the_announcement_authority() { + use crate::game::ability_utils::build_target_slots; + use crate::game::engine::{bounded_cycle_pin_slots, entry_publishes_pin_slots}; + use crate::types::ability::{TargetChoiceTiming, TypeFilter}; + + // A chained sub-ability targeting CREATURES: the one slot the announcement really + // surfaces. `controller: None` so the set spans all three seats' creatures. + let creature_filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: None, + properties: vec![], + }); + let (mut prior, mut current) = grown_window(3, |id| { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.targets = vec![TargetRef::Player(PlayerId(1))]; + // CR 601.2c: chosen at RESOLUTION ⇒ the head contributes no announcement slot. + ability.target_choice_timing = TargetChoiceTiming::Resolution; + ability.sub_ability = Some(Box::new(lose_life_targeting( + event_amount(), + creature_filter.clone(), + ))); + churn_entry(id, 0, ability, None) + }); + // Added to BOTH windows: the object axes are STRICT-compared, so a creature present + // only in `current` would make the cover false for an unrelated reason. + for state in [&mut prior, &mut current] { + for (id, controller) in [(901u64, 1u8), (902, 2)] { + let oid = ObjectId(id); + let mut obj = GameObject::new( + oid, + CardId(9), + PlayerId(controller), + format!("Bystander {id}"), + Zone::Battlefield, + ); + obj.card_types.core_types.push(CoreType::Creature); + state.objects.insert(oid, obj); + state.battlefield.push_back(oid); + } + } + + let ability = current.stack[2].ability().unwrap(); + assert!( + ability.effect.target_filter().is_some(), + "reach-guard: the PROXY still reports the head effect's player filter — that is \ + the whole divergence" + ); + let announced = build_target_slots(¤t, ability).expect("one announcement slot"); + assert_eq!( + announced + .iter() + .map(|slot| (slot.optional, slot.legal_targets.clone())) + .collect::>(), + vec![( + false, + vec![ + TargetRef::Object(ObjectId(CHURN_SRC)), + TargetRef::Object(ObjectId(901)), + TargetRef::Object(ObjectId(902)), + ] + )], + "reach-guard: the CARDINALITY conjunct passes here (exactly one MANDATORY slot), \ + so the all-`Player` conjunct is the sole rejector — and the announced set is \ + three OBJECTS, not the head filter's players" + ); + assert!( + !forced_unique_targeting(¤t, ability), + "reach-guard: three legal creatures ⇒ not forced-unique ⇒ gate (3) rejects" + ); + assert!( + !crate::game::ability_scan::ability_reads_projected_resource(ability), + "reach-guard: gate (4) passes, so the cover rows below are gate (3)'s" + ); + + assert!( + entry_publishes_pin_slots(¤t, ¤t.stack[2], PlayerId(0)).is_none(), + "the announced choice is among OBJECTS; a `TargetPin::Player` cannot specify it, \ + so nothing may be published" + ); + assert!( + bounded_cycle_pin_slots(¤t, PlayerId(0)).is_empty(), + "and the mint publishes no point rather than one describing a different choice" + ); + let slot = churn_src_slot(¤t, 0); + for pinned in [&[][..], std::slice::from_ref(&slot)] { + assert!( + !loop_states_cover_modulo_growth_scoped( + &prior, + ¤t, + pinned_scope(pinned), + &mut PeriodVerdicts::for_period(&[], ¤t, PlayerId(0)) + ), + "gate (3)'s relief is a bare `continue`: an object-valued announcement choice \ + must keep rejecting ({} pinned slot(s))", + pinned.len() + ); + } + + // ── the RESIDUAL divergence the all-`Player` conjunct alone does NOT close: a + // chained sub-ability that announces a choice among ALL players (CR 115.2 "target + // player") under the same "target opponent" head. Every conjunct passes — one + // mandatory slot, every candidate a player, head shape accepted — so the mint + // publishes, and the ONLY thing that keeps the point honest is that the set is + // carried through from the builder instead of re-derived from the head filter. + let (_p_any, any_player) = grown_window(3, |id| { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.targets = vec![TargetRef::Player(PlayerId(1))]; + ability.target_choice_timing = TargetChoiceTiming::Resolution; + ability.sub_ability = Some(Box::new(lose_life_targeting( + event_amount(), + TargetFilter::Typed(TypedFilter { + type_filters: vec![], + controller: None, + properties: vec![], + }), + ))); + churn_entry(id, 0, ability, None) + }); + let all_seats = vec![ + TargetRef::Player(PlayerId(0)), + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)), + ]; + assert_eq!( + build_target_slots(&any_player, any_player.stack[2].ability().unwrap()) + .map(|slots| slots + .iter() + .map(|s| (s.optional, s.legal_targets.clone())) + .collect::>()) + .ok(), + Some(vec![(false, all_seats.clone())]), + "reach-guard: ONE mandatory slot whose candidates are all PLAYERS — so every \ + acceptance conjunct passes and only the publication path can still be wrong" + ); + let any_points = bounded_cycle_pin_slots(&any_player, PlayerId(0)); + assert_eq!( + any_points.len(), + 1, + "reach-guard: the mint accepts this entry" + ); + assert_eq!( + any_points[0].kind, + crate::analysis::decision_template::DecisionPointKind::Targets { + legal_targets: all_seats, + min_targets: 1, + max_targets: 1, + ordered: false, }, - }; - let mut prior = GameState::new_two_player(7); - prior.stack.push_back(spell(10)); - prior.stack.push_back(spell(11)); - let mut current = prior.clone(); - current.stack.clear(); - current.stack.push_back(spell(20)); - current.stack.push_back(spell(21)); - current.stack.push_back(spell(22)); - assert!(!loop_states_cover_modulo_growth(&prior, ¤t)); + "the point describes the choice the ANNOUNCEMENT offers (three seats); \ + re-deriving from the head filter would publish the two opponents" + ); + + // ── positive control + EQUIVALENCE: the shipped drain publishes, and the published + // legal set is the BUILDER's, asserted against it rather than against a literal. + let (_p_ok, c_ok) = grown_window(3, |id| drain_entry(id, vec![])); + let mut control_slots = + build_target_slots(&c_ok, c_ok.stack[2].ability().unwrap()).expect("control announces"); + assert_eq!(control_slots.len(), 1, "control: one announcement slot"); + let builder_set = control_slots.swap_remove(0).legal_targets; + assert_eq!( + builder_set, + vec![ + TargetRef::Player(PlayerId(1)), + TargetRef::Player(PlayerId(2)) + ], + "control: the builder enumerates the two opponents" + ); + let published = bounded_cycle_pin_slots(&c_ok, PlayerId(0)); + assert_eq!( + published.len(), + 1, + "control: the instrument returns non-zero" + ); + assert_eq!( + published[0].kind, + crate::analysis::decision_template::DecisionPointKind::Targets { + legal_targets: builder_set, + min_targets: 1, + max_targets: 1, + ordered: false, + }, + "the published legal set IS the announcement authority's slot" + ); } - /// (d) a prior entry-kind absent from `current` ⇒ false (embedding fails). - /// prior `[G, B]`, current `[G, G]` — B (controller 1) never matches. - #[test] - fn n1_d_embedding_missing_kind_false() { - let b = |id| churn_entry(id, 1, gain_ability(1), None); - let mut prior = GameState::new_two_player(7); - prior.stack.push_back(g(10)); - prior.stack.push_back(b(11)); - let mut current = GameState::new_two_player(7); - current.stack.push_back(g(20)); - current.stack.push_back(g(21)); - assert!(!loop_states_cover_modulo_growth(&prior, ¤t)); + /// A slot an OFFER would publish for `CHURN_SRC`'s entries — built through the same + /// authority the gates rebuild it with, so the rows prove the KEY matches rather than + /// asserting a hand-written literal. `index: 0` is the CR 115.2 target choice, + /// `index: 1` the CR 603.5 "may" gate. + fn churn_src_slot(state: &GameState, index: u8) -> DecisionSlot { + DecisionSlot { + source: crate::game::engine::object_decision_source(state, ObjectId(CHURN_SRC)) + .expect("fixture: the churn source is on the battlefield"), + index, + } } - /// (e) equal stacks, no strict growth ⇒ false (that is the equality case). - #[test] - fn n1_e_no_growth_false() { - let mut prior = GameState::new_two_player(7); - prior.stack.push_back(g(10)); - prior.stack.push_back(g(11)); - let current = prior.clone(); - assert!(!loop_states_cover_modulo_growth(&prior, ¤t)); + /// The published-pin channel as a P0 offer would carry it. + fn pinned_scope(slots: &[DecisionSlot]) -> LoopWindowScope<'_> { + LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned: Some(PinnedChoices { + proposer: PlayerId(0), + slots, + }), + cast_card_ids: None, + period: None, + } } - /// (f) WIPE-PENDING (R1-B1): a distinct mandatory no-input trigger kind absent - /// from `prior` grows 0→1 at an UNOCCUPIED place ⇒ false. `W` reads no projected - /// resource, so removing the prior-occupancy guard (2b) flips this true — the - /// false win fires. - #[test] - fn n1_f_wipe_pending_unoccupied_growth_false() { - // W = a distinct-kind mandatory no-input trigger (GainLife 7, no read). - let w = |id| churn_entry(id, 0, gain_ability(7), None); - let (mut prior, mut current) = cover_base(); // [G,G] / [G,G,G] - // Rebuild current as [G,G,W]: G did not grow, W is the 0→1 new kind. - current.stack.clear(); - current.stack.push_back(g(20)); - current.stack.push_back(g(21)); - current.stack.push_back(w(22)); - let _ = &mut prior; - assert!(!loop_states_cover_modulo_growth(&prior, ¤t)); + /// An optional (CR 603.5 "may") forced-unique drain — `MayPrompt` at + /// `ability_scan.rs:6534` for exactly ONE reason, the one the offer publishes a + /// `MayChoice` point for. + fn optional_drain(id: u64) -> StackEntry { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.targets = vec![TargetRef::Player(PlayerId(1))]; + ability.optional = true; + churn_entry(id, 0, ability, None) } - /// (g) PERMUTATION (R1-M3): prior `[B,A]`, current `[A,B,B]` ⇒ false (no + /// The same drain whose `MayPrompt` ALSO has a second, unpublished cause: a + /// CR 701.34a proliferate sub-ability. + fn proliferate_drain(id: u64, optional: bool) -> StackEntry { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.targets = vec![TargetRef::Player(PlayerId(1))]; + ability.optional = optional; + ability.sub_ability = Some(Box::new(ResolvedAbility::new( + Effect::Proliferate, + vec![], + ObjectId(CHURN_SRC), + PlayerId(0), + ))); + churn_entry(id, 0, ability, None) + } + + /// Grow a 2-entry window to 3 of the same kind: `[e(10),e(11)] → [e(20),e(21),e(22)]`. + fn grown_window(players: u8, entry: impl Fn(u64) -> StackEntry) -> (GameState, GameState) { + let mut prior = drain_state(players); + prior.stack.push_back(entry(10)); + prior.stack.push_back(entry(11)); + let mut current = prior.clone(); + current.stack.clear(); + current.stack.push_back(entry(20)); + current.stack.push_back(entry(21)); + current.stack.push_back(entry(22)); + (prior, current) + } + + /// CR 732.2a EXTENSION POINT: a per-iteration choice the OFFER publishes is a + /// *specified* choice, not a free one, so gates (3) and (6) must stop rejecting on it — + /// and ONLY on it. Every relief runs the MINT's own per-entry acceptance test + /// (`game::engine::entry_publishes_pin_slots`), so the relief predicate cannot be + /// coarser than the mint predicate. + /// + /// SIX ARMS. Arms 1–2 are matched pairs whose only variable is `scope.pinned`; arms + /// 3–6 are the over-match controls that a coarser relief would fail. + /// * arm 1 — gate (3), 3p open targeting (two legal opponents ⇒ `auto_select => + /// Ok(None)` ⇒ NOT forced-unique). Same board as + /// `n1_open_target_growing_still_rejected`. + /// * arm 2 — gate (6), an OPTIONAL drain: `MayPrompt` caused solely by CR 603.5 + /// `optional`, the one axis the offer publishes a `MayChoice` point for. + /// * arm 3 — gate (6) NON-ATTRIBUTION: a CR 701.34a proliferate choice is NOT relieved, + /// with or without an optional gate pinned on top of it. The proliferate board is + /// `item6_still_vetoes_under_forced_unique_targets`' board. + /// * arm 4 — precondition (c): an entry the PROPOSER does not control is never relieved + /// by the proposer's pin, even when it shares the pinned source. + /// * arm 5 — gate (3) SCOPE: the relief is a `continue`, so it discharges the whole + /// item-3 predicate — but a slot answers ONE target. `multi_target` (CR 601.2c), + /// `distribution` (CR 601.2d) and `target_constraints` (CR 601.2c) are separate + /// announcement-time facts no published slot specifies, each isolated as the sole + /// rejector on a forced-unique board. Its tail row covers the fourth such fact, + /// `pending_trigger_entry` (CR 603.3c), through the cover as well, with its own + /// gate-(1) control (see there). + /// * arm 6 — gate (6) RESIDUAL: arm 2's relieved board plus an optional life + /// replacement still rejects, because a discharged CR 603.5 gate does not discharge + /// the CR 616.1 environmental surface. + /// + /// REVERT-PROBES (each measured; see the impl report): + /// * drop the `entry_target_choice_is_pinned` guard at gate (3) ⇒ arm 1's PINNED half + /// stays `false` ⇒ FAILS. + /// * drop the `pinned_may_choice_relief` arm at gate (6) ⇒ arm 2's PINNED half ⇒ FAILS. + /// * drop the residual re-classification in `pinned_may_choice_relief` (relieve whenever + /// the may slot is pinned) ⇒ arm 3's optional+proliferate half ⇒ FAILS. + /// * drop the `entry.controller != proposer` conjunct in `entry_publishes_pin_slots` + /// ⇒ arm 4 ⇒ FAILS. + /// * drop the ordering-input block in `entry_publishes_pin_slots` ⇒ arm 5's PINNED + /// half covers ⇒ FAILS (once per mutated field). + /// * turn gate (6)'s `needs_life_guard = true` re-arm back into a `continue` ⇒ arm 6 + /// covers ⇒ FAILS. + /// + /// Non-vacuity: every arm asserts BOTH directions (or pairs its negative with arm 1/2's + /// positive on the same predicate), so neither a constant-`true` nor a constant-`false` + /// relief survives. + #[test] + fn a_pinned_slot_skips_gate_three_and_six() { + // ── arm 1: gate (3), open (≥2-legal) targeting ── + let (prior, current) = grown_window(3, |id| drain_entry(id, vec![])); + + // Reach-guard: the rejector really is gate (3) on this board. + assert!( + !forced_unique_targeting(¤t, current.stack[2].ability().unwrap()), + "reach-guard: two opponents ⇒ not forced-unique ⇒ gate (3) is the rejector" + ); + assert!( + !loop_states_cover_modulo_growth_scoped( + &prior, + ¤t, + pinned_scope(&[]), + &mut PeriodVerdicts::for_period(&[], ¤t, PlayerId(0)) + ), + "UNPINNED: an open per-opponent target choice is a free choice ⇒ reject" + ); + let target_slot = churn_src_slot(¤t, 0); + assert!( + loop_states_cover_modulo_growth_scoped( + &prior, + ¤t, + pinned_scope(std::slice::from_ref(&target_slot)), + &mut PeriodVerdicts::for_period(&[], ¤t, PlayerId(0)) + ), + "PINNED: the offer published this slot ⇒ CR 732.2a specified choice ⇒ cover" + ); + + // ── arm 2: gate (6), a CR 603.5 "may" gate — the published resolution choice ── + let (p_may, c_may) = grown_window(2, optional_drain); + let may_ability = c_may.stack[2].ability().unwrap(); + // Reach-guards: gates (3) and (4) PASS here, so gate (6) is the rejector, and its + // MayPrompt is the `optional` one. + assert!( + forced_unique_targeting(&c_may, may_ability), + "reach-guard: the single opponent is forced-unique ⇒ gate (3) passes" + ); + assert!( + !crate::game::ability_scan::ability_reads_projected_resource(may_ability), + "reach-guard: gate (4) passes ⇒ gate (6) is the rejector" + ); + assert_eq!( + crate::game::resolution_prompt::ability_resolution_choice_freedom( + &c_may, + may_ability, + &mut ProbeBudget::for_test(PROBE_BUDGET) + ), + crate::game::resolution_prompt::ResolutionChoiceFreedom::MayPrompt, + "reach-guard: CR 603.5 `optional` is what makes this entry MayPrompt" + ); + assert!( + !loop_states_cover_modulo_growth_scoped( + &p_may, + &c_may, + pinned_scope(&[]), + &mut PeriodVerdicts::for_period(&[], &c_may, PlayerId(0)) + ), + "UNPINNED: an optional trigger's take/decline is a free choice ⇒ reject" + ); + let may_slots = [churn_src_slot(&c_may, 0), churn_src_slot(&c_may, 1)]; + assert!( + loop_states_cover_modulo_growth_scoped( + &p_may, + &c_may, + pinned_scope(&may_slots), + &mut PeriodVerdicts::for_period(&[], &c_may, PlayerId(0)) + ), + "PINNED: the published CR 603.5 gate specifies that choice ⇒ cover" + ); + // The MayChoice point is load-bearing on its own: pinning only the target slot + // leaves the resolution choice unspecified. + assert!( + !loop_states_cover_modulo_growth_scoped( + &p_may, + &c_may, + pinned_scope(&may_slots[..1]), + &mut PeriodVerdicts::for_period(&[], &c_may, PlayerId(0)) + ), + "a pinned TARGET does not specify the CR 603.5 take/decline choice" + ); + + // ── arm 3: gate (6) NON-ATTRIBUTION — CR 701.34a proliferate is never relieved ── + for optional in [false, true] { + let (p6, c6) = grown_window(2, |id| proliferate_drain(id, optional)); + let ability = c6.stack[2].ability().unwrap(); + assert!( + forced_unique_targeting(&c6, ability), + "reach-guard: gate (3) passes ⇒ gate (6) is the rejector (optional={optional})" + ); + assert!( + !crate::game::ability_scan::ability_reads_projected_resource(ability), + "reach-guard: gate (4) passes (optional={optional})" + ); + // Publish EVERYTHING this entry could publish — target slot and, when the + // ability is optional, its CR 603.5 gate. The proliferate choice still has no + // published pin, so no relief may be granted. + let slots = [churn_src_slot(&c6, 0), churn_src_slot(&c6, 1)]; + for pinned in [&slots[..0], &slots[..1], &slots[..]] { + assert!( + !loop_states_cover_modulo_growth_scoped( + &p6, + &c6, + pinned_scope(pinned), + &mut PeriodVerdicts::for_period(&[], &c6, PlayerId(0)) + ), + "CR 701.34a proliferate is a resolution-time choice NO published slot \ + specifies (optional={optional}, {} pinned slot(s))", + pinned.len() + ); + } + } + + // ── arm 4: precondition (c) — a non-proposer entry sharing the pinned source ── + let foreign_drain = |id| { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.controller = PlayerId(1); + ability.targets = vec![TargetRef::Player(PlayerId(0))]; + churn_entry(id, 1, ability, None) + }; + let (p_foreign, c_foreign) = grown_window(3, foreign_drain); + assert!( + !forced_unique_targeting(&c_foreign, c_foreign.stack[2].ability().unwrap()), + "reach-guard: P1 has two opponents ⇒ not forced-unique ⇒ gate (3) rejects" + ); + assert_eq!( + churn_src_slot(&c_foreign, 0), + target_slot, + "the foreign entry's source is BYTE-IDENTICAL to arm 1's pinned slot — the pin \ + list cannot discriminate it, only the controller conjunct can" + ); + assert!( + !loop_states_cover_modulo_growth_scoped( + &p_foreign, + &c_foreign, + pinned_scope(std::slice::from_ref(&target_slot)), + &mut PeriodVerdicts::for_period(&[], &c_foreign, PlayerId(0)) + ), + "CR 732.2a precondition (c): P0's offer specifies none of P1's choices" + ); + + // ── arm 5: gate (3) SCOPE OF DISCHARGE ── + // The relief is a bare `continue`, so it skips the WHOLE item-3 predicate — but a + // published slot answers ONE target. Each row mutates exactly one of the other + // announcement-time facts `stack_entry_has_no_ordering_input` rejects on, on a 2p + // board where the target fact PASSES, so the mutated field is the sole rejector. + { + use crate::types::ability::MultiTargetSpec; + use crate::types::game_state::TargetSelectionConstraint; + type Mutate = fn(&mut ResolvedAbility); + let mutations: [(&str, Mutate); 3] = [ + ("multi_target — CR 601.2c variable target count", |a| { + a.multi_target = Some(MultiTargetSpec::fixed(1, 2)) + }), + ("distribution — CR 601.2d divide-among", |a| { + a.distribution = Some(vec![(TargetRef::Player(PlayerId(1)), 1)]) + }), + ("target_constraints — CR 601.2c cross-target", |a| { + a.target_constraints = vec![TargetSelectionConstraint::DifferentTargetPlayers] + }), + ]; + for (label, mutate) in mutations { + let (p5, c5) = grown_window(2, |id| { + let mut e = drain_entry(id, vec![]); + let StackEntryKind::TriggeredAbility { ability, .. } = &mut e.kind else { + unreachable!("drain_entry builds a TriggeredAbility") + }; + mutate(ability.as_mut()); + e + }); + assert!( + forced_unique_targeting(&c5, c5.stack[2].ability().unwrap()), + "reach-guard: the single opponent is forced-unique ⇒ the TARGET fact is \ + not what rejects ({label})" + ); + assert!( + !stack_entry_has_no_ordering_input(&c5, &c5.stack[2]), + "reach-guard: {label} is therefore item 3's SOLE rejector" + ); + let slot = churn_src_slot(&c5, 0); + for pinned in [&[][..], std::slice::from_ref(&slot)] { + assert!( + !loop_states_cover_modulo_growth_scoped( + &p5, + &c5, + pinned_scope(pinned), + &mut PeriodVerdicts::for_period(&[], &c5, PlayerId(0)) + ), + "a published slot specifies ONE target; {label} is announcement-time \ + ordering input no slot specifies ({} pinned)", + pinned.len() + ); + } + } + + // The FOURTH fact, `pending_trigger_entry` (CR 603.3c mid-construction), is + // state-dependent, so it lives on the relief predicate rather than in the pure + // mint — and it is REACHABLE through the cover, so the row is written there. + // Measured: `normalize_for_loop` leaves the field AS-IS, so a `current` carrying + // one that `prior` lacks does fail gate (1) — but when BOTH frames carry it, + // gate (1) passes and gate (3) is the sole rejector. Both controls below. + // (Both production call sites compare states at `WaitingFor::Priority`, where + // the field is `None`; that bounds the exposure, it does not make it + // unreachable.) + let (p_pend, c_pend) = grown_window(2, |id| drain_entry(id, vec![])); + let pend_slot = churn_src_slot(&c_pend, 0); + assert!( + loop_states_cover_modulo_growth_scoped( + &p_pend, + &c_pend, + pinned_scope(std::slice::from_ref(&pend_slot)), + &mut PeriodVerdicts::for_period(&[], &c_pend, PlayerId(0)) + ), + "positive control: this entry's published slot IS otherwise matched" + ); + let (mut p_mid, mut c_mid) = (p_pend.clone(), c_pend.clone()); + let mid = c_mid.stack[2].id; + for s in [&mut p_mid, &mut c_mid] { + s.pending_trigger_entry = Some(mid); + } + assert!( + !loop_states_cover_modulo_growth_scoped( + &p_mid, + &c_mid, + pinned_scope(std::slice::from_ref(&pend_slot)), + &mut PeriodVerdicts::for_period(&[], &c_mid, PlayerId(0)) + ), + "CR 603.3c: a mid-construction announcement is not specified by any \ + published slot — the relief must not discharge item 3's firewall" + ); + // The gate-(1) control for the row above: the SAME both-frames shape naming no + // live entry still covers, so the rejection there is gate (3), not the mere + // presence of a non-`None` field. + let (mut p_other, mut c_other) = (p_pend, c_pend); + for s in [&mut p_other, &mut c_other] { + s.pending_trigger_entry = Some(ObjectId(u64::MAX)); + } + assert!( + loop_states_cover_modulo_growth_scoped( + &p_other, + &c_other, + pinned_scope(std::slice::from_ref(&pend_slot)), + &mut PeriodVerdicts::for_period(&[], &c_other, PlayerId(0)) + ), + "gate-(1) control: a `pending_trigger_entry` naming no live entry is not \ + what rejects — CR 603.3c's firewall is entry-scoped" + ); + } + + // ── arm 6: gate (6) RESIDUAL re-arms the CR 616.1 environmental guard ── + // Arm 2's board (relief GRANTED there) plus one optional life replacement. A + // pinned CR 603.5 gate says nothing about whose life-event replacements prompt. + { + use crate::types::ability::ReplacementMode; + let (mut p_life, mut c_life) = grown_window(2, optional_drain); + let mut def = ReplacementDefinition::new(ReplacementEvent::LoseLife); + def.mode = ReplacementMode::Optional { decline: None }; + // CR 614.1a: a `valid_player`-less player-event replacement applies only + // to ITS controller's events (`replacement_source_player`). The residual + // this arm must re-arm is the drain's own `LifeLoss` on the OPPONENT + // (P1), so the def has to sit on a P1-controlled permanent to be drawn + // as a candidate at all. Measured: on a P0 permanent the same def draws + // ZERO candidates for `LifeLoss{P1}` and the arm would pass vacuously. + for state in [&mut p_life, &mut c_life] { + let oid = bf_object_owned_by(state, 812, PlayerId(1)); + state + .objects + .get_mut(&oid) + .unwrap() + .replacement_definitions + .push(def.clone()); + } + let life_loss = crate::types::proposed_event::ProposedEvent::LifeLoss { + player_id: PlayerId(1), + amount: 1, + applied: Default::default(), + }; + assert!( + crate::game::replacement::proposed_event_prompt_cause( + &c_life, + &life_loss, + crate::game::replacement::replacement_registry(), + ) + .contains(crate::game::replacement::ReplacementPromptCause::OptionalCandidate), + "reach-guard: the installed optional def makes the CR 614.1a surface \ + prompt-capable for a LifeLoss event (arm 2's bare board does not)" + ); + let slots = [churn_src_slot(&c_life, 0), churn_src_slot(&c_life, 1)]; + assert!( + !loop_states_cover_modulo_growth_scoped( + &p_life, + &c_life, + pinned_scope(&slots), + &mut PeriodVerdicts::for_period(&[], &c_life, PlayerId(0)) + ), + "the discharged `may` leaves a FreeUnlessReplacements(LIFE) RESIDUAL that must \ + re-arm the CR 616.1 guard — relief is not a `continue`" + ); + } + + // ── non-widening control: an unrelated slot relieves nothing ── + let unrelated = DecisionSlot { + source: crate::types::game_state::YieldTarget::ThisObject { + source_id: ObjectId(CHURN_SRC + 1), + incarnation: Some(0), + trigger_description: None, + }, + index: 0, + }; + for (p, c, label) in [(&prior, ¤t, "gate (3)"), (&p_may, &c_may, "gate (6)")] { + assert!( + !loop_states_cover_modulo_growth_scoped( + p, + c, + pinned_scope(std::slice::from_ref(&unrelated)), + &mut PeriodVerdicts::for_period(&[], c, PlayerId(0)) + ), + "{label}: a pin on a DIFFERENT source must not relieve this entry" + ); + } + + // ── CR 400.7 control: a stale incarnation is a different slot ── + let stale = DecisionSlot { + source: crate::types::game_state::YieldTarget::ThisObject { + source_id: ObjectId(CHURN_SRC), + incarnation: Some(u64::MAX), + trigger_description: None, + }, + index: 0, + }; + assert!( + !loop_states_cover_modulo_growth_scoped( + &prior, + ¤t, + pinned_scope(std::slice::from_ref(&stale)), + &mut PeriodVerdicts::for_period(&[], ¤t, PlayerId(0)) + ), + "CR 400.7: a pin latched to a stale incarnation does not match the live source" + ); + } + + /// CONSTRAINT-3 ORTHOGONALITY: an item-3-passing, item-4-clean forced-unique + /// drain that ALSO carries a `Proliferate` sub_ability (CR 701.34a resolution + /// choice ⇒ `MayPrompt`) is vetoed by item-6. Revert-probe: dropping the + /// Proliferate sub (choice-free) flips this TRUE (= the positive fixture). + #[test] + fn item6_still_vetoes_under_forced_unique_targets() { + let drain_prolif = |id| { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.targets = vec![TargetRef::Player(PlayerId(1))]; + ability.sub_ability = Some(Box::new(ResolvedAbility::new( + Effect::Proliferate, + vec![], + ObjectId(CHURN_SRC), + PlayerId(0), + ))); + churn_entry(id, 0, ability, None) + }; + let mut prior = drain_state(2); + prior.stack.push_back(drain_prolif(10)); + prior.stack.push_back(drain_prolif(11)); + let mut current = prior.clone(); + current.stack.clear(); + current.stack.push_back(drain_prolif(20)); + current.stack.push_back(drain_prolif(21)); + current.stack.push_back(drain_prolif(22)); + + // Reach-guard (mandate 4 anti-vacuity): item-3 AND item-4 PASS for this entry, + // so the FALSE below is ATTRIBUTABLE to item-6's Proliferate veto — not an + // upstream conjunct short-circuiting first. + let ability = current.stack[2].ability().unwrap(); + assert!( + forced_unique_targeting(¤t, ability), + "item-3 passes (single forced-unique opponent) even with the Proliferate sub" + ); + assert!( + !crate::game::ability_scan::ability_reads_projected_resource(ability), + "item-4 passes (Proliferate sub scans NONE; pure-controller Typed target)" + ); + + assert!( + !loop_states_cover_modulo_growth(&prior, ¤t), + "item-6 vetoes the resolution-choice-bearing drain even when item-3/4 pass" + ); + } + + /// (c) the grown entry is a SPELL ⇒ false (not a mandatory trigger). Isolates + /// item 3's `TriggeredAbility`-only requirement. + #[test] + fn n1_c_grown_entry_spell_false() { + let spell = |id| StackEntry { + id: ObjectId(id), + source_id: ObjectId(CHURN_SRC), + controller: PlayerId(0), + kind: StackEntryKind::Spell { + card_id: CardId(1), + ability: None, + casting_variant: crate::types::game_state::CastingVariant::Normal, + actual_mana_spent: 0, + }, + }; + let mut prior = GameState::new_two_player(7); + prior.stack.push_back(spell(10)); + prior.stack.push_back(spell(11)); + let mut current = prior.clone(); + current.stack.clear(); + current.stack.push_back(spell(20)); + current.stack.push_back(spell(21)); + current.stack.push_back(spell(22)); + assert!(!loop_states_cover_modulo_growth(&prior, ¤t)); + } + + /// (d) a prior entry-kind absent from `current` ⇒ false (embedding fails). + /// prior `[G, B]`, current `[G, G]` — B (controller 1) never matches. + #[test] + fn n1_d_embedding_missing_kind_false() { + let b = |id| churn_entry(id, 1, gain_ability(1), None); + let mut prior = GameState::new_two_player(7); + prior.stack.push_back(g(10)); + prior.stack.push_back(b(11)); + let mut current = GameState::new_two_player(7); + current.stack.push_back(g(20)); + current.stack.push_back(g(21)); + assert!(!loop_states_cover_modulo_growth(&prior, ¤t)); + } + + /// (e) equal stacks, no strict growth ⇒ false (that is the equality case). + #[test] + fn n1_e_no_growth_false() { + let mut prior = GameState::new_two_player(7); + prior.stack.push_back(g(10)); + prior.stack.push_back(g(11)); + let current = prior.clone(); + assert!(!loop_states_cover_modulo_growth(&prior, ¤t)); + } + + /// (f) WIPE-PENDING (R1-B1): a distinct mandatory no-input trigger kind absent + /// from `prior` grows 0→1 at an UNOCCUPIED place ⇒ false. `W` reads no projected + /// resource, so removing the prior-occupancy guard (2b) flips this true — the + /// false win fires. + #[test] + fn n1_f_wipe_pending_unoccupied_growth_false() { + // W = a distinct-kind mandatory no-input trigger (GainLife 7, no read). + let w = |id| churn_entry(id, 0, gain_ability(7), None); + let (mut prior, mut current) = cover_base(); // [G,G] / [G,G,G] + // Rebuild current as [G,G,W]: G did not grow, W is the 0→1 new kind. + current.stack.clear(); + current.stack.push_back(g(20)); + current.stack.push_back(g(21)); + current.stack.push_back(w(22)); + let _ = &mut prior; + assert!(!loop_states_cover_modulo_growth(&prior, ¤t)); + } + + /// (g) PERMUTATION (R1-M3): prior `[B,A]`, current `[A,B,B]` ⇒ false (no /// bottom-up embedding: no A after the first B match). Revert-fail for replacing /// embedding with order-blind multiset containment. #[test] @@ -5863,7 +7916,7 @@ mod tests { } /// Fixed-amount `LoseLife` churner — allow-listed - /// (`FreeUnlessLifeReplacements`), reads no projected resource. Distinct + /// (`FreeUnlessReplacements(LIFE)`), reads no projected resource. Distinct /// normalized kind from `gain_ability`. fn lose_ability(amount: i32) -> ResolvedAbility { ResolvedAbility::new( @@ -5884,7 +7937,7 @@ mod tests { /// STRUCTURAL, not observational (the projected poison axis, CR 701.34a, can /// inhabit the option surface mid-extrapolation). Item 4 does NOT mask this: /// `scan_effect(Proliferate)` is `Axes::NONE`. Revert-fail: delete the item-6 - /// loop, or classify `Proliferate` ⇒ `FreeUnlessLifeReplacements`. + /// loop, or classify `Proliferate` ⇒ `FreeUnlessReplacements`. #[test] fn n1_o_grown_choice_opening_proliferate_false() { let p = |id| churn_entry(id, 0, proliferate_ability(), None); @@ -5955,7 +8008,8 @@ mod tests { fn with_object_def(def: ReplacementDefinition) -> (GameState, GameState) { let (mut prior, mut current) = cover_base(); for state in [&mut prior, &mut current] { - let oid = bf_object(state, 810); + // Owned by P0 — the player whose life gain these defs replace. + let oid = bf_object_owned_by(state, 810, PlayerId(0)); state .objects .get_mut(&oid) @@ -5981,12 +8035,21 @@ mod tests { { let (mut prior, mut current) = cover_base(); for state in [&mut prior, &mut current] { - let oid = bf_object(state, 811); + let oid = bf_object_owned_by(state, 811, PlayerId(0)); let obj = state.objects.get_mut(&oid).unwrap(); - obj.replacement_definitions - .push(ReplacementDefinition::new(ReplacementEvent::GainLife)); - obj.replacement_definitions - .push(ReplacementDefinition::new(ReplacementEvent::GainLife)); + // CR 616.1: ordering is a choice only when it is MATERIAL. Two + // no-op definitions COMMUTE, so the fixture must carry two + // modifications whose composition order changes the result — + // `+1` then `×2` is 4, `×2` then `+1` is 3. The def-scan this + // replaces counted definitions instead of asking the pipeline. + let mut plus = ReplacementDefinition::new(ReplacementEvent::GainLife); + plus.quantity_modification = + Some(crate::types::ability::QuantityModification::Plus { value: 1 }); + let mut times = ReplacementDefinition::new(ReplacementEvent::GainLife); + times.quantity_modification = + Some(crate::types::ability::QuantityModification::Times { factor: 2 }); + obj.replacement_definitions.push(plus); + obj.replacement_definitions.push(times); } assert!( !loop_states_cover_modulo_growth(&prior, ¤t), @@ -6009,7 +8072,7 @@ mod tests { current.stack.push_back(l(21)); current.stack.push_back(l(22)); for state in [&mut prior, &mut current] { - let oid = bf_object(state, 812); + let oid = bf_object_owned_by(state, 812, PlayerId(0)); let mut def = ReplacementDefinition::new(ReplacementEvent::PayLife); def.mode = ReplacementMode::Optional { decline: None }; state @@ -6884,8 +8947,9 @@ mod tests { let driver_scope = LoopWindowScope { phase_invariant: None, sole_driver: Some(PlayerId(0)), - pinned_slots: &[], + pinned: None, cast_card_ids: None, + period: None, }; let (subject, observer) = build(AbilityKind::Spell); @@ -6959,8 +9023,9 @@ mod tests { let driver_scope = LoopWindowScope { phase_invariant: None, sole_driver: Some(PlayerId(0)), - pinned_slots: &[], + pinned: None, cast_card_ids: None, + period: None, }; let (subject, observer) = build(Some(PlayerFilter::All)); @@ -7804,7 +9869,12 @@ mod tests { let plain = loop_states_cover_modulo_growth(prior, current); assert_eq!( plain, - loop_states_cover_modulo_growth_scoped(prior, current, LoopWindowScope::unproven()), + loop_states_cover_modulo_growth_scoped( + prior, + current, + LoopWindowScope::unproven(), + &mut PeriodVerdicts::unproven(current) + ), "loop_states_cover_modulo_growth must be its _scoped sibling at unproven()" ); plain @@ -8472,27 +10542,119 @@ mod tests { .collect() } - /// CR 704.5a / CR 704.5c / CR 104.3c + CR 121.4 + CR 732.2a: the bound's conventions, - /// case by case. Every case names the WRONG implementation it kills, so this row is a - /// battery of discriminators rather than one assertion repeated. + /// PR-7 Phase 5b (PA-2A(e)) — CR 704.5a: the MAX-vs-SUM fork in `victim_slot`'s magnitude + /// derivation, which is otherwise UNTESTED and whose wrong answer surfaces in playtesting + /// as a wrong elimination bound rather than as a failure. /// - /// P-A: the four real fixture bounds (dump B/C/D/F4) are deliberately NOT asserted here. - /// They are shipped-state values while a real `max_iterations` is computed at the OFFER - /// beat, dozens of beats later, where the lives differ — a literal measured in a - /// different state than the one under test. This row asserts the PURE FUNCTION against - /// hand-supplied lives, which is exactly what a unit row is for; every fixture row - /// computes its expectation in-test from the offer-beat state. + /// WHY IT NEEDS ITS OWN ROW: `victim_slot` is EMPTY on every trajectory that offers today + /// — dina, the ≥3p life drain and the F4 predicate all publish `points == 0` — so in + /// production `worst_seat_life_loss` is evaluated only where its value is collected into + /// an empty `Vec` and dropped. No fixture reaches the fork. Stated as a coverage hole in + /// the PR body; 5d's targeted class is its first production-path consumer. + /// + /// O4 DERIVE conformance — all THREE legs, not one: + /// 1. **DERIVED, never compared to a literal.** `m` is bound from the return value and + /// asserted only against structural invariants of the input map. No expected magnitude + /// is written in arm ⓐ, and the function is CALLED, never re-derived. + /// 2. **NON-ZERO POPULATION + a fork reach-guard.** `losses` must be non-empty (else every + /// ∀ below is vacuously true over an empty seat set) and must hold ≥2 STRICTLY POSITIVE + /// entries — which is what makes the sum strictly exceed every single seat's loss. A + /// single-seat fixture would make max and sum COINCIDE and the row would pass under + /// both derivations, i.e. the degenerate-fixture trap this leg exists to catch. + /// 3. **POSITIVE CONTROL on the same instrument.** Arms ⓑ and ⓒ assert the OPPOSITE + /// outcome (`0`) on the same function, so no constant implementation passes: ⓐ alone + /// would be satisfied by anything that always returns a large number, and ⓑ/ⓒ alone by + /// anything that always returns zero. + /// + /// MAX-vs-SUM DISCRIMINATION, structural rather than numeric. `m` is asserted to be + /// (i) an UPPER BOUND on every seat's clamped loss and (ii) ATTAINED by a seat that + /// actually LOST life. Together those two are the definition of `max` with no number + /// named. Switching the derivation to `sum` keeps (i) and breaks (ii) — no single seat + /// suffers the total of an asymmetric map (it reports 6; the seats lose 1, 2 and 3). + /// `first`/`last`/`min` break (i), because arm ⓐ's map is keyed so the first entry is the + /// proposer's GAIN and clamps to 0. `abs()` breaks (ii) in ⓐ (it reports the proposer's 5, + /// which no seat lost) and breaks ⓑ outright (it reports 7 where the contract says 0). + /// Arms ⓑ/ⓒ do NOT kill `sum` — a gain-only map sums to 0 either way; killing `sum` is + /// arm ⓐ's job alone, which is why ⓐ's asymmetry reach-guard is load-bearing. #[test] - fn elimination_bounds_conventions() { - let no_slots: BTreeMap = BTreeMap::new(); + fn worst_seat_life_loss_is_the_max_seat_never_the_sum() { + // ⓐ asymmetric multi-seat losses PLUS a proposer GAIN larger than any of them. + let mut v = life_loss_delta(&[(1, 1), (2, 3), (3, 2)]); + v.life.insert(pid(0), 5); - // (a) life 40, Δ2 ⇒ 19. Kills `floor(life / Δ)` (= 20): at 20 cycles the victim is - // at exactly 0 and CR 704.5a has already removed them mid-proposal. - // THE ONLY CASE THAT KILLS `floor(life/Δ)` — never drop it. - assert_eq!( - life_loss_delta(&[(1, 2)]).elimination_bounds(&bound_board(&[40, 40]), &[], &no_slots), - 19 - ); + let m = v.worst_seat_life_loss(); + + // O4(2): non-zero population, and the fixture really does separate max from sum. + let losses: Vec = v.life.values().map(|&n| (-n).max(0)).collect(); + assert!( + losses.iter().filter(|&&l| l > 0).count() >= 2, + "reach-guard: with fewer than two seats LOSING life the sum equals the max and \ + this row passes under either derivation, proving nothing; got {losses:?}" + ); + + // (i) UPPER BOUND — kills `first`, `last`, `min`, and any non-largest per-seat pick. + assert!( + v.life.values().all(|&n| (-n).max(0) <= m), + "CR 704.5a: a slot aimed at ANY one seat must be charged at least what that seat \ + loses per period, else the bound overstates the legal repetition count and the \ + drive can cross a threshold inside the proposal. m = {m}, losses = {losses:?}" + ); + // (ii) ATTAINED BY A LOSER — kills `sum` (no seat suffers the total) and kills + // `abs()`/gain-inclusive forms (the proposer's +5 is not a loss anyone suffers). + assert!( + v.life.iter().any(|(_, &n)| n < 0 && -n == m), + "the magnitude must be a loss some single seat actually took: `sum` reports a \ + total no seat suffers, and a gains-inclusive derivation reports the proposer's \ + own gain. m = {m}, life = {:?}", + v.life + ); + + // ⓑ POSITIVE CONTROL — the refusing value IS reachable on a NON-EMPTY map, which is + // what proves the `(-n).max(0)` clamp is doing the work rather than `unwrap_or(0)`. + let mut gains_only = ResourceVector::default(); + gains_only.life.insert(pid(0), 7); + gains_only.life.insert(pid(1), 2); + assert!( + !gains_only.life.is_empty(), + "reach-guard: an EMPTY map returns 0 through `unwrap_or`, a different arm; this \ + control is about the clamp" + ); + assert_eq!( + gains_only.worst_seat_life_loss(), + 0, + "a period in which nobody LOSES life charges nothing — 0 is the contract's \ + no-repetition sentinel, not a fixture number" + ); + + // ⓒ the empty arm, the other way to reach 0 (`max()` yields None). + assert_eq!( + ResourceVector::default().worst_seat_life_loss(), + 0, + "a delta with no life term charges nothing" + ); + } + + /// CR 704.5a / CR 704.5c / CR 104.3c + CR 121.4 + CR 732.2a: the bound's conventions, + /// case by case. Every case names the WRONG implementation it kills, so this row is a + /// battery of discriminators rather than one assertion repeated. + /// + /// P-A: the four real fixture bounds (dump B/C/D/F4) are deliberately NOT asserted here. + /// They are shipped-state values while a real `max_iterations` is computed at the OFFER + /// beat, dozens of beats later, where the lives differ — a literal measured in a + /// different state than the one under test. This row asserts the PURE FUNCTION against + /// hand-supplied lives, which is exactly what a unit row is for; every fixture row + /// computes its expectation in-test from the offer-beat state. + #[test] + fn elimination_bounds_conventions() { + let no_slots: BTreeMap = BTreeMap::new(); + + // (a) life 40, Δ2 ⇒ 19. Kills `floor(life / Δ)` (= 20): at 20 cycles the victim is + // at exactly 0 and CR 704.5a has already removed them mid-proposal. + // THE ONLY CASE THAT KILLS `floor(life/Δ)` — never drop it. + assert_eq!( + life_loss_delta(&[(1, 2)]).elimination_bounds(&bound_board(&[40, 40]), &[], &no_slots), + 19 + ); // (b) life 39, Δ2 ⇒ 19. Kills `ceil`: 38/2 = 19 exactly, so a ceiling would say 20. assert_eq!( life_loss_delta(&[(1, 2)]).elimination_bounds(&bound_board(&[40, 39]), &[], &no_slots), @@ -8806,8 +10968,9 @@ mod tests { let scope = LoopWindowScope { phase_invariant: None, sole_driver: None, - pinned_slots: &[], + pinned: None, cast_card_ids: Some(&never_cast), + period: None, }; assert!( !fire_time_conditions_read_projected_resource_scoped(&state, scope), @@ -8828,8 +10991,9 @@ mod tests { let scope = LoopWindowScope { phase_invariant: None, sole_driver: None, - pinned_slots: &[], + pinned: None, cast_card_ids: Some(&never_cast), + period: None, }; assert!( fire_time_conditions_read_projected_resource_scoped(¬_modify_cost, scope), @@ -8863,8 +11027,9 @@ mod tests { let scope = LoopWindowScope { phase_invariant: None, sole_driver: None, - pinned_slots: &[], + pinned: None, cast_card_ids: Some(&recast), + period: None, }; assert!( fire_time_conditions_read_projected_resource_scoped(&state, scope), @@ -8878,8 +11043,9 @@ mod tests { let relieved_scope = LoopWindowScope { phase_invariant: None, sole_driver: None, - pinned_slots: &[], + pinned: None, cast_card_ids: Some(&other), + period: None, }; assert!( !fire_time_conditions_read_projected_resource_scoped(&state, relieved_scope), @@ -9078,10 +11244,16 @@ mod tests { .expect("fixture .json.gz must inflate to UTF-8 JSON"); let envelope: serde_json::Value = serde_json::from_str(&json).expect("dump envelope parses as JSON"); + // Cross the dump through the PRODUCTION decoder rather than a bare `GameState` + // decode wrapped in `Raw`: `PersistedGameState`'s own `Deserialize` runs + // `reject_legacy_raw_prompt_authority` and `decode_persisted_resolution_state` + // first, so this row exercises the chokepoint the server's `from_persisted` and + // WASM's `decode_restored_game_state` actually funnel through. + // `.expect(..)`, not `?`: `into_game_state` returns `GameState`, not `Result`. let state = serde_json::from_value::( envelope["gameState"].clone(), ) - .expect("the real 4p gameState restores through the persisted ingress") + .expect("gameState deserializes through the production decoder") .into_game_state(); // ── reach-guards: the X4 subject really is present, in a never-cast-from zone ── @@ -9129,8 +11301,9 @@ mod tests { let scope = LoopWindowScope { phase_invariant: None, sole_driver: None, - pinned_slots: &[], + pinned: None, cast_card_ids: Some(&cast), + period: None, }; assert!( !fire_time_conditions_read_projected_resource_scoped(&state, scope), @@ -9147,8 +11320,9 @@ mod tests { let recast_scope = LoopWindowScope { phase_invariant: None, sole_driver: None, - pinned_slots: &[], + pinned: None, cast_card_ids: Some(&recast), + period: None, }; assert!( fire_time_conditions_read_projected_resource_scoped(&state, recast_scope), @@ -9835,7 +12009,7 @@ mod tests { // ── `sole_driver` — CR 117.1 ── let (pa, pb) = (base(), base()); assert_eq!( - window_scope_from_cover_frames(&pa, &pb, &[]).sole_driver, + window_scope_from_cover_frames(&pa, &pb, None, None).sole_driver, Some(PlayerId(0)), "PAIRED POSITIVE: a homogeneous single-driver window proves CR 117.1's premise" ); @@ -9845,7 +12019,7 @@ mod tests { let mut pb_other = base(); pb_other.last_loop_action_sequence = vec![ctx(1)]; assert_eq!( - window_scope_from_cover_frames(&pa, &pb_other, &[]).sole_driver, + window_scope_from_cover_frames(&pa, &pb_other, None, None).sole_driver, None, "(s2) a two-controller window proves nothing about who holds priority" ); @@ -9854,7 +12028,7 @@ mod tests { let mut pa_mixed = base(); pa_mixed.last_loop_action_sequence = vec![ctx(0), ctx(1)]; assert_eq!( - window_scope_from_cover_frames(&pa_mixed, &pb, &[]).sole_driver, + window_scope_from_cover_frames(&pa_mixed, &pb, None, None).sole_driver, None, "(s2) an interleaved sequence is fail-closed" ); @@ -9863,14 +12037,14 @@ mod tests { let mut pb_empty = base(); pb_empty.last_loop_action_sequence.clear(); assert_eq!( - window_scope_from_cover_frames(&pa, &pb_empty, &[]).sole_driver, + window_scope_from_cover_frames(&pa, &pb_empty, None, None).sole_driver, None, "(s1) an empty driving sequence is NO PROOF, so it cannot relieve anything" ); // ── `phase_invariant` — CR 500.1 / CR 506.1 / CR 500.8 ── assert_eq!( - window_scope_from_cover_frames(&pa, &pb, &[]).phase_invariant, + window_scope_from_cover_frames(&pa, &pb, None, None).phase_invariant, Some(Phase::PreCombatMain), "PAIRED POSITIVE: agreeing frames with no extra phase prove the window's phase" ); @@ -9887,7 +12061,7 @@ mod tests { attacker_restriction_source: None, }); assert_eq!( - window_scope_from_cover_frames(&pa, &pb_extra, &[]).phase_invariant, + window_scope_from_cover_frames(&pa, &pb_extra, None, None).phase_invariant, None, "(p3) CR 500.8: a pending extra phase breaks `equal phase ⇒ never left it`" ); @@ -9896,7 +12070,7 @@ mod tests { let mut pb_turn = base(); pb_turn.turn_number = 14; assert_eq!( - window_scope_from_cover_frames(&pa, &pb_turn, &[]).phase_invariant, + window_scope_from_cover_frames(&pa, &pb_turn, None, None).phase_invariant, None, "(p1) frames from different turns bound nothing about one window's phase" ); @@ -9905,9 +12079,4216 @@ mod tests { let mut pb_phase = base(); pb_phase.phase = Phase::PostCombatMain; assert_eq!( - window_scope_from_cover_frames(&pa, &pb_phase, &[]).phase_invariant, + window_scope_from_cover_frames(&pa, &pb_phase, None, None).phase_invariant, None, "(p2) a window that crosses a phase boundary is not phase-invariant" ); } + /// CR 732.2a — `ring_delta_signature`'s "seen TWICE" contract, at the building-block + /// level: five arms over synthetically-built rings, so every input shape the function can + /// meet is exercised rather than whichever one a fixture happens to produce. + /// + /// `k` is an OUTPUT here, exactly as O4 requires of production: no period constant exists + /// in the function, and the only numbers in this row are the periods THIS TEST + /// CONSTRUCTED. Comparing a derived period against a constructed one is the discriminator + /// for "smallest repeating period"; it is not fixture-brittleness, because the input is + /// built two lines above the assertion. + /// + /// PRODUCTION-PATH COMPANIONS, both in `tests/integration/loop_shortcut.rs`: + /// `bounded_offer_on_a_within_turn_draw_drain_is_basis_b`, where the ENGINE writes a + /// basis-B offer whose published `frames_per_period != 1` on a within-turn draw↔drain + /// cascade; and `dina_untargeted_drain_4p_offers_at_three_live_opponents`, measured to + /// certify through this function with a derived `k == 1` on a REAL 4-player dump (see that + /// row's doc for the two measurements, and for why a `k == 1` basis-B offer is + /// indistinguishable from a basis-A one in the published payload). + /// + /// RETRACTION, kept on the record: this doc previously named + /// `fantastic_four_bounded_loop_4p` as that companion, "a DERIVED `k == 2` and a bound of + /// 35". Both numbers reproduce, and the reading was wrong — measured, F4's δ carries + /// `library -1` for ALL FOUR players and its certifying frames sit at turns + /// `[5, 9, 9, 13, 13]`, so the "period" is one 4-player TURN CYCLE (CR 703.4d: only the + /// active player draws in a draw step) and the "35" is 35 turn cycles, not 35 loop + /// iterations. That certificate is the same artifact as the drawgo false positive, which + /// is why `ring_delta_signature` now refuses it outright. + /// + /// REVERT-PROBES, each flipping a DIFFERENT arm so none dominates another: + /// * accept a period seen ONCE (search `k` up to `frames - 1` and compare only one + /// window) ⇒ arm ⓐ's `None` at 2 frames becomes `Some` ⇒ FAILS. + /// * drop the zero-delta refusal ⇒ arm ⓓ returns `Some` ⇒ FAILS. + /// * scan the OLDEST `2k` deltas instead of the most recent ⇒ arm ⓔ (a ring whose old + /// stretch repeats and whose recent stretch does not) returns `Some` ⇒ FAILS. + /// * take the LARGEST repeating `k` instead of the smallest ⇒ arm ⓑ returns `k = 2` for a + /// constant-delta ring ⇒ FAILS. + /// * delete the CR 703.1 turn-position conjunct ⇒ arm ⓕ returns `Some` ⇒ FAILS. + #[test] + fn ring_delta_signature_certifies_only_a_period_seen_twice() { + /// A ring whose frames carry the given life totals for P1, in order. Everything else + /// is held identical, so the frame-deltas are exactly the successive differences. + fn ring_of(lives: &[i32]) -> GameState { + let mut state = GameState::new_two_player(0); + for &life in lives { + let mut frame = GameState::new_two_player(0); + frame.players[1].life = life; + // Both halves built exactly as `record_loop_detect_sample` builds them, so + // the fixture cannot diverge from production's construction. + state.loop_detect_ring.push_back(std::sync::Arc::new( + crate::types::LoopDetectSample { + normalized: frame.normalize_for_loop(), + live: frame.loop_detect_live_sample(), + }, + )); + } + state + } + + // ⓐ REFUSING: 2 frames is one delta — a period seen ONCE. `2k + 1 = 3` is the + // threshold at the smallest possible `k`, and it is an EXPRESSION, never a literal + // in the function. + let short = ring_of(&[40, 39]); + assert_eq!( + short.loop_detect_ring.len(), + 2, + "reach-guard on the built ring" + ); + assert_eq!( + ring_delta_signature(&short), + None, + "a period observed once is a coincidence; certifying it would let an offer be \ + minted off a single frame pair" + ); + + // ⓑ POSITIVE CONTROL, same shape one frame longer: the instrument provably returns + // `Some`, so ⓐ is not a function that always refuses. + let steady = ring_of(&[40, 39, 38]); + let (k, delta) = ring_delta_signature(&steady) + .expect("three frames observe a 1-frame period TWICE, which is the contract"); + assert_eq!( + k, 1, + "the SMALLEST repeating period, derived — a constant per-frame delta has period 1" + ); + // Bound to a local rather than inlined: `2k + 1` is the CONTRACT expression (2k deltas + // ⇒ the period was observed twice), and clippy's `int_plus_one` would otherwise push it + // to a `> 2k` that no longer reads as the rule. + let frames_needed = 2 * k as usize + 1; + assert!( + steady.loop_detect_ring.len() >= frames_needed, + "the structural invariant every certified period satisfies: 2k+1 frames" + ); + assert_ne!( + delta, + ResourceVector::default(), + "a certified period moves some resource" + ); + assert_eq!( + delta.life.get(&PlayerId(1)).copied(), + Some(-1), + "and the published delta is the one measured across ONE period" + ); + + // ⓒ a 2-frame period, seen twice: 5 frames. The derived `k` must be the constructed + // one, and the delta must span the WHOLE period, not one frame of it. + let period_two = ring_of(&[40, 39, 36, 35, 32]); + let (k2, delta2) = ring_delta_signature(&period_two) + .expect("(-1, -3) repeated twice over 5 frames is a period seen twice"); + assert_eq!( + k2, 2, + "derived from the ring, and equal to the period this test BUILT two lines above" + ); + assert_eq!( + delta2.life.get(&PlayerId(1)).copied(), + Some(-4), + "one whole period is -1 + -3, not either half" + ); + + // ⓓ a ring that repeats perfectly but moves NOTHING: no CR 704 threshold to bound, so + // no signature. Without this the offer's bound would be the safety cap. + let flat = ring_of(&[40, 40, 40, 40, 40]); + assert_eq!( + ring_delta_signature(&flat), + None, + "a zero-delta cycle states no threshold; every multiple of a zero period is zero \ + too, which is why this refuses outright rather than searching on" + ); + + // ⓕ the CR 703.1 turn-position conjunct, at the building-block level: the SAME ring as + // ⓑ, with only the newest frame's turn number moved on. Nothing about the deltas + // changes (`ResourceVector::snapshot` never reads `turn_number` or `phase`), so a + // `None` here is attributable to the conjunct alone. + let mut turn_crossing = ring_of(&[40, 39, 38]); + { + let last = turn_crossing + .loop_detect_ring + .back_mut() + .expect("the ring was just built with three frames"); + // `.normalized` is the half the subject reads (`ring_delta_signature` → + // `window_scope_from_cover_frames`). Retargeting this to `.live` makes the + // subject see no turn crossing ⇒ `Some` ⇒ the `assert_eq!(.., None, ..)` below + // FAILS LOUDLY. That is the arm working, not a reason to weaken the assertion. + std::sync::Arc::make_mut(last).normalized.turn_number += 1; + } + assert_eq!( + ring_delta_signature(&turn_crossing), + None, + "a period paved by a turn boundary is the game advancing, not a CR 732.2a loop — \ + the board-blind basis must refuse it (the ⓑ ring differs from this one in \ + `turn_number` and nothing else)" + ); + + // ⓔ the OLD stretch repeats, the RECENT one does not. A scan anchored at the oldest + // deltas would certify a period the loop is no longer running. + let stale = ring_of(&[40, 39, 38, 37, 30]); + assert_eq!( + ring_delta_signature(&stale), + None, + "the certified period must be the one the loop is running NOW: the most recent \ + 2k deltas are (-1, -7) at k=1 and (-1,-1),(-1,-7) at k=2, neither of which repeats" + ); + } + + /// CR 703.1 / CR 732.2a — the PRODUCTION-DRIVEN half of the turn-position conjunct: on a + /// board with NO loop at all, the game's own turn structure is exactly periodic in the + /// resource axes basis B reads, and this row asserts the board-blind basis derives no + /// signature from it at any beat. + /// + /// HOME. This lives in `resource.rs`'s unit module and NOT in + /// `tests/integration/loop_shortcut.rs` because `ring_delta_signature` is `pub(crate)`: + /// an integration test is a separate crate and cannot name it. The only alternative — a + /// re-derivation of the period search inside the test — is prohibited: it would be a + /// second copy of the very algorithm this row exists to pin, and it would stay green + /// while production drifted. What is copied here is a test HARNESS (the drawgo fixture + /// builder from `drawgo_ring_spans_turns_but_never_offers` and a beat driver), never the + /// thing under test, which is CALLED. + /// + /// FIXTURE, loop-free by construction: P0 has an upkeep ticker, a drain cleric and a + /// "may draw" scribe. Each of P0's upkeeps runs a FINITE 3-deep cascade — nothing + /// re-triggers the ticker — yet the per-turn shape is drain-like, which is exactly what a + /// board-blind periodicity test mistakes for a loop. MEASURED, on this tree: with the + /// conjunct absent the engine minted offers on this board (the sibling integration row + /// `drawgo_ring_spans_turns_but_never_offers` failed on its `offer_at.is_none()` + /// assertion); with it, that row is green and a seam probe over 400 beats counts ZERO + /// engine offers. + /// + /// THE FLATTEN ARM discharges two obligations at once, on the SAME trajectory and the + /// SAME ring, with exactly ONE axis neutralized: + /// * NON-ZERO POPULATION — a `∀ beats: is_none()` over an empty beat set is vacuously + /// true, so the row must prove its quantifier ranged over beats where a signature was + /// actually derivable. `flattened_some > 0` is that proof. + /// * SAME-TRAJECTORY POSITIVE CONTROL — the instrument is shown returning the + /// non-refusing value on drawgo's own data, so the `None`s above are a measured refusal + /// rather than an inert instrument. + /// * ATTRIBUTION — `ResourceVector::snapshot` reads life / library / poison / energy / + /// mana / battlefield counters / `combat_phases_started_this_turn` / `extra_phases`, and + /// never `turn_number` or `phase`, so δ and the derived `k` are unchanged by the + /// flattening and the `None` → `Some` flip is attributable to the turn-position + /// conjunct alone. + /// + /// MEASURED on this tree: 253 of the 300 driven beats hold >= 3 frames, and the flatten + /// arm derives a signature at **225** of them, over 22 turns. + /// + /// REVERT-PROBE (must FLIP): delete the CR 703.1 conjunct from `ring_delta_signature` ⇒ + /// the `is_none()` assertion fires on the first signature-bearing beat. + #[test] + fn drawgo_turn_structure_yields_no_basis_b_signature() { + use crate::game::scenario::GameScenario; + use crate::types::actions::GameAction; + use crate::types::game_state::{LoopDetectionMode, WaitingFor}; + + /// One beat of the shared dump drive policy (`tests/integration/loop_shortcut.rs`'s + /// `dump_drive_one_beat`): at `Priority` always pass — the mandatory triggers resolve + /// and re-trigger, which IS the loop when there is one — and otherwise take the first + /// legal non-terminal action. + fn drive_one_beat(state: &mut GameState) -> Result<(), String> { + let actor = state + .waiting_for + .acting_player() + .into_iter() + .chain(state.players.iter().map(|p| p.id)) + .find_map(|p| { + let (actions, _costs, _grouped) = + crate::ai_support::legal_actions_for_viewer(state, p); + (!actions.is_empty()).then_some((p, actions)) + }); + let Some((who, actions)) = actor else { + return Err(format!("no legal actor at {:?}", state.waiting_for)); + }; + let forbidden = + |a: &GameAction| matches!(a, GameAction::Concede { .. } | GameAction::Debug(_)); + let chosen = if matches!(state.waiting_for, WaitingFor::Priority { .. }) { + actions + .iter() + .find(|a| matches!(a, GameAction::PassPriority)) + } else { + actions + .iter() + .find(|a| !matches!(a, GameAction::PassPriority) && !forbidden(a)) + .or_else(|| actions.iter().find(|a| !forbidden(a))) + }; + let Some(action) = chosen.cloned() else { + return Err(format!("empty action list at {:?}", state.waiting_for)); + }; + crate::game::engine::apply(state, who, action.clone()) + .map(|_| ()) + .map_err(|e| format!("apply err ({action:?}): {e:?}")) + } + + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(PlayerId(0), 20); + scenario.with_life(PlayerId(1), 20); + scenario.add_creature_from_oracle( + PlayerId(0), + "Test Upkeep Ticker", + 2, + 2, + "At the beginning of your upkeep, you gain 1 life.", + ); + scenario.add_creature_from_oracle( + PlayerId(0), + "Test Drain Cleric", + 2, + 2, + "Whenever you gain life, each opponent loses 1 life.", + ); + scenario.add_creature_from_oracle( + PlayerId(0), + "Test May Scribe", + 2, + 2, + "Whenever an opponent loses life, you may draw a card.", + ); + // CR 504.1: both players draw every turn, so the libraries must outlast the drive — a + // deck-out would end the game and silently truncate every assertion below. + let names: Vec = (0..60).map(|i| format!("Filler {i}")).collect(); + let refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); + scenario.with_library_top(PlayerId(0), &refs); + scenario.with_library_top(PlayerId(1), &refs); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + let mut state = runner.state().clone(); + + assert!( + state.loop_detection.samples(), + "reach-guard: a non-sampling mode never populates the ring, which would make the \ + per-beat `is_none()` below vacuous; got {:?}", + state.loop_detection + ); + assert_eq!( + state.loop_detect_ring.len(), + 0, + "reach-guard: every frame the assertions below read was accumulated by THIS drive" + ); + + let mut long_ring_beats = 0usize; + let mut flattened_some = 0usize; + let mut turns_seen: Vec = Vec::new(); + for beat in 0..300usize { + if !turns_seen.contains(&state.turn_number) { + turns_seen.push(state.turn_number); + } + // 2k + 1 at the smallest derivable k: below three frames the refusal is the + // short-ring one, which says nothing about turn position. + if state.loop_detect_ring.len() >= 3 { + long_ring_beats += 1; + assert_eq!( + ring_delta_signature(&state), + None, + "beat {beat} (turn {}, {} frames): this board has no loop — each upkeep \ + runs a FINITE cascade and nothing re-triggers the ticker — so the \ + board-blind basis must derive no period from the game's own turn \ + structure", + state.turn_number, + state.loop_detect_ring.len() + ); + + // Same trajectory, same ring, ONE axis neutralized. + let mut flat = state.clone(); + for frame in flat.loop_detect_ring.iter_mut() { + // `.normalized` is the half the subject reads. Writing `.live` instead + // leaves the axis un-flattened ⇒ `flattened_some == 0` ⇒ the shipped + // `assert!(flattened_some > 0, ..)` below FAILS LOUDLY. + let f = &mut std::sync::Arc::make_mut(frame).normalized; + f.turn_number = 1; + f.phase = Phase::Upkeep; + } + if ring_delta_signature(&flat).is_some() { + flattened_some += 1; + } + } + if drive_one_beat(&mut state).is_err() { + break; + } + } + + assert!( + turns_seen.len() >= 3, + "reach-guard: the drive must cross at least two full turn boundaries for a \ + turn-position claim to mean anything; saw turns {turns_seen:?}" + ); + assert!( + long_ring_beats > 0, + "reach-guard: the ∀ above ranged over ZERO beats holding 2k+1 frames, so it was \ + vacuously true" + ); + assert!( + flattened_some > 0, + "O4(2) + O4(3): with `turn_number` and `phase` flattened on a clone of THIS \ + trajectory's own ring — and nothing else changed — a signature must appear at \ + some beat. It appeared at {flattened_some} of {long_ring_beats} long-ring beats. \ + A zero here would mean the `None`s above are attributable to a short ring or a \ + non-repeating delta rather than to the CR 703.1 conjunct, and the row would not \ + be sound" + ); + } + + /// CR 732.2a — `PeriodicDelta` rides `WaitingFor::LoopShortcut` over the wire, so the + /// whole payload must survive `serde_json`. TWO map-key hazards, both real: a + /// `ResourceVector.counters` key is the `(CounterClass, ObjectClass)` TUPLE, and a + /// `BTreeMap` keyed by `DecisionSlot` (a struct) would be the same failure — which is + /// why `victim_slot` is a `Vec` of pairs and not a map at all. + /// + /// The runtime symptom of getting this wrong is NOT a soft failure: + /// `crates/engine-wasm/src/lib.rs`'s serializer `panic!`s on the error, i.e. a browser + /// crash. + /// + /// ARM (ii) ALONE WOULD PASS AGAINST A BROKEN MAP — an empty map serializes fine + /// whatever its key type. Arm (i) is what discriminates, and both are asserted here. + /// + /// ⚠ **THIS ROW IS NOT SUFFICIENT ON ITS OWN, and its green was once read as if it + /// were.** It uses `to_string`/`from_str` throughout, and that combination was measured + /// `Ok` even against an UNADAPTED `PlayerId`-keyed map. The production persistence path + /// degrades to `serde_json::Value` + `from_value` inside `PersistedGameState`, where + /// serde's `Content` buffering stringifies map keys and a `PlayerId` key breaks. + /// `a_populated_per_cycle_proposal_survives_the_production_persistence_boundary` is the + /// row that covers that; keep BOTH, they discriminate different failures. + /// + /// REVERT-PROBES: drop `#[serde(with = "map_key_pairs")]` from + /// `ResourceVector.counters` ⇒ arm (i) and the payload arm both fail with "key must be a + /// string"; change `PeriodicDelta.victim_slot` to a `BTreeMap` ⇒ same. + /// MUST-NOT-FLIP: a `LoopShortcut` payload with `per_cycle: None` stays byte-identical + /// (`skip_serializing_if`), asserted in the third block. + #[test] + fn periodic_delta_survives_the_serde_json_wire() { + use crate::analysis::decision_template::{DecisionSlot, ShortcutDecisionSchema}; + use crate::analysis::loop_check::{LoopCertificate, WinKind}; + use crate::types::game_state::{WaitingFor, YieldTarget}; + + let slot = DecisionSlot { + source: YieldTarget::ThisObject { + source_id: ObjectId(403), + incarnation: Some(7), + trigger_description: None, + }, + index: 0, + }; + + // (i) POPULATED — a non-empty tuple-keyed `counters` map is the discriminating input. + let mut delta = ResourceVector::default(); + delta.life.insert(PlayerId(1), -3); + delta.life.insert(PlayerId(0), 3); + delta + .counters + .insert((CounterClass::Plus1Plus1, ObjectClass::Creature), 2); + delta + .counters + .insert((CounterClass::Poison, ObjectClass::Player), 1); + delta.generic_triggers.insert(TriggerKind::Proliferate, 4); + assert!( + !delta.counters.is_empty() && !delta.life.is_empty(), + "reach-guard: arm (i) is only discriminating while `counters` is NON-EMPTY — an \ + empty map round-trips whatever the key type" + ); + let populated = PeriodicDelta { + frames_per_period: 2, + delta, + victim_slot: vec![(slot.clone(), 1)], + }; + let json = serde_json::to_string(&populated) + .expect("a populated PeriodicDelta must serialize (engine-wasm PANICS otherwise)"); + assert_eq!( + serde_json::from_str::(&json).expect("and round-trip"), + populated + ); + + // (ii) EMPTY — the degenerate arm, kept only so the pair is visible. + let empty = PeriodicDelta::default(); + let empty_json = serde_json::to_string(&empty).expect("an empty PeriodicDelta too"); + assert_eq!( + serde_json::from_str::(&empty_json).expect("and round-trip"), + empty + ); + + // The ACTUAL wire payload: the `WaitingFor` variant that carries it. + let cert = LoopCertificate { + unbounded: vec![], + win_kind: WinKind::LethalDamage, + mandatory: false, + residual_board_delta: BoardDelta::default(), + per_cycle: Some(populated), + }; + let offer = WaitingFor::LoopShortcut { + proposer: PlayerId(0), + predicted_winner: None, + certificate: cert.clone(), + schema: ShortcutDecisionSchema::default(), + }; + let offer_json = + serde_json::to_string(&offer).expect("the LoopShortcut payload carrying it must too"); + assert_eq!( + serde_json::from_str::(&offer_json).expect("and round-trip"), + offer + ); + + // MUST-NOT-FLIP: `skip_serializing_if` keeps the shipped payload byte-identical — + // `per_cycle` appears nowhere in the JSON of an offer that states none. + let shipped = WaitingFor::LoopShortcut { + proposer: PlayerId(0), + predicted_winner: None, + certificate: LoopCertificate { + per_cycle: None, + ..cert + }, + schema: ShortcutDecisionSchema::default(), + }; + let shipped_json = serde_json::to_string(&shipped).expect("serializes"); + assert!( + !shipped_json.contains("per_cycle"), + "an offer stating no per-period signature must be byte-identical to BASE; got \ + {shipped_json}" + ); + } + + /// CR 616.1 — an EMPTY derivation must not discharge the replacement obligation. + /// + /// `resolution_events_are_discharged` answers `FreeUnlessReplacements(events)` with + /// `!events.iter().any(..)`, and `any()` over an empty slice is `false`, so `!any(..)` + /// is `true`: an empty vector certified the entry having inspected NOTHING. The only + /// thing standing against that was a `debug_assert!`, which compiles out of release — + /// so the fail-open case was live in exactly the build that ships, and was untestable + /// besides (a `debug_assert!` aborts the build tests run in). + /// + /// MATCHED PAIR: + /// * EMPTY ⇒ `false` (refuse). This is the arm the fix adds. + /// * NON-EMPTY, no applicable replacement ⇒ `true` (discharge). Without it the row + /// would pass against a predicate that simply returned `false` always. + /// + /// `MayPrompt ⇒ false` is asserted alongside so all three arms of the match are pinned. + /// + /// REVERT-PROBE (run, recorded): delete the `if events.is_empty() { return false; }` + /// arm ⇒ the EMPTY case FLIPS TO `true` and this row FAILS, while the non-empty arms + /// stay green. + #[test] + fn an_empty_derivation_does_not_vacuously_discharge_the_cr_616_1_obligation() { + use crate::game::resolution_prompt::ResolutionChoiceFreedom; + + let board = GameState::new_two_player(7); + + assert!( + !resolution_events_are_discharged( + &board, + ResolutionChoiceFreedom::FreeUnlessReplacements(Vec::new()) + ), + "an EMPTY derivation proves nothing about CR 616.1 replacements and must \ + REFUSE — `!any()` over an empty slice is `true`, which is the fail-open \ + direction this predicate exists to prevent" + ); + + // POSITIVE CONTROL — a real event on a board with no applicable replacement still + // discharges, so the refusal above is about EMPTINESS and not a blanket `false`. + let non_empty = vec![crate::types::proposed_event::ProposedEvent::LifeLoss { + player_id: PlayerId(0), + amount: 1, + applied: Default::default(), + }]; + assert!( + crate::game::replacement::proposed_event_prompt_cause( + &board, + &non_empty[0], + crate::game::replacement::replacement_registry(), + ) + .is_empty(), + "reach-guard: the control event must have NO applicable replacement on this \ + board, or it would refuse for the wrong reason" + ); + assert!( + resolution_events_are_discharged( + &board, + ResolutionChoiceFreedom::FreeUnlessReplacements(non_empty) + ), + "a NON-EMPTY derivation with no applicable replacement must still discharge" + ); + + // The third arm, pinned so the match stays exhaustively covered. + assert!( + !resolution_events_are_discharged(&board, ResolutionChoiceFreedom::MayPrompt), + "MayPrompt is never discharged" + ); + } + + /// CR 732.2a — the PRODUCTION persistence path for a proposal that carries a per-cycle + /// signature. The row above is NOT a substitute and its green was FALSE CONFIDENCE: it + /// round-trips with `to_string`/`from_str`, and that combination was measured `Ok` even + /// against the unadapted map. The failure needs the enclosing shape: + /// + /// * `WaitingFor` is `#[serde(tag = "type", content = "data")]`, so serde buffers the + /// payload through its private `Content`, which stringifies every map KEY; + /// * `PlayerId` is `#[serde(transparent)]` over `u8`, so it then reads a string where it + /// wants an integer; + /// * `PersistedGameState::deserialize` routes EVERY decode through `serde_json::Value` + /// and `from_value` — including the production WASM restore, whose outer call is + /// `from_str::`. So `from_str` at the boundary does NOT save it. + /// + /// This row therefore drives `to_value`/`from_value` and the real + /// `PersistedGameState` boundary, with a POPULATED `PlayerId`-keyed map, which is the + /// only combination that discriminates. + /// + /// REVERT-PROBE (run, recorded): delete `#[serde(with = "map_key_pairs")]` from + /// `ResourceVector::life` ⇒ arms (i) and (ii) FAIL with + /// `invalid type: string "0", expected u8`, the exact text + /// `tests/integration/loop_shortcut.rs` had recorded as a standing limitation. + /// REACH-GUARD: the map is asserted non-empty before the round trip — an empty map + /// round-trips whatever its key type, so a populated one is what makes this row real. + #[test] + fn a_populated_per_cycle_proposal_survives_the_production_persistence_boundary() { + use crate::analysis::decision_template::IterationCount; + use crate::analysis::loop_check::{ShortcutProposal, WinKind}; + use crate::types::game_state::{PersistedGameState, WaitingFor}; + + let mut delta = ResourceVector::default(); + delta.life.insert(PlayerId(0), 3); + delta.life.insert(PlayerId(1), -3); + delta.damage_dealt.insert(PlayerId(1), 3); + delta.library_delta.insert(PlayerId(0), -1); + delta.poison.insert(PlayerId(1), 1); + delta + .counters + .insert((CounterClass::Plus1Plus1, ObjectClass::Creature), 2); + delta.generic_triggers.insert(TriggerKind::Proliferate, 4); + assert!( + !delta.life.is_empty() + && !delta.damage_dealt.is_empty() + && !delta.library_delta.is_empty() + && !delta.poison.is_empty(), + "reach-guard: all four `PlayerId`-keyed maps must be NON-EMPTY, or this row \ + passes against a broken key type" + ); + let proposal = ShortcutProposal { + proposer: PlayerId(0), + predicted_winner: Some(PlayerId(0)), + count: IterationCount::UntilLethal, + unbounded: vec![], + win_kind: WinKind::LethalDamage, + template: None, + per_cycle: Some(PeriodicDelta { + frames_per_period: 2, + delta, + victim_slot: vec![], + }), + }; + let wait = WaitingFor::RespondToShortcut { + player: PlayerId(1), + remaining_players: vec![], + proposal: proposal.clone(), + }; + + // (i) The precise mechanism: adjacently-tagged `WaitingFor` through `Value`. + let value = serde_json::to_value(&wait).expect("the wait serializes"); + assert_eq!( + serde_json::from_value::(value).expect( + "a populated per-cycle proposal must survive `from_value` — this is the \ + combination `Content` key-stringification breaks" + ), + wait + ); + + // (ii) The PRODUCTION boundary: whole state through `PersistedGameState`, both the + // outer API the WASM bridge uses (`from_str`) and the `Value` form it degrades to. + let mut state = GameState::new_two_player(7); + state.waiting_for = wait.clone(); + let raw = serde_json::to_value(&state).expect("the state serializes"); + let restored = serde_json::from_value::(raw.clone()) + .expect("decodes through the production persistence boundary") + .into_game_state(); + assert_eq!( + restored.waiting_for, wait, + "the restored wait must carry the SAME per-cycle signature, not a dropped or \ + emptied one" + ); + let text = serde_json::to_string(&raw).expect("serializes to text"); + let via_str = serde_json::from_str::(&text) + .expect("and through the WASM bridge's own `from_str::`") + .into_game_state(); + assert_eq!(via_str.waiting_for, wait); + + // (iii) MUST-NOT-FLIP: a proposal stating no signature stays absent from the wire. + let none = ShortcutProposal { + per_cycle: None, + ..proposal + }; + let none_json = serde_json::to_string(&none).expect("serializes"); + assert!( + !none_json.contains("per_cycle"), + "`skip_serializing_if` must keep a signature-free proposal byte-identical to \ + BASE; got {none_json}" + ); + } + + // ── PR-7 Phase 5c: the DRAW verdict's paired CR 616.1 obligation ── + + /// A mandatory, non-"up to" `Effect::Draw` trigger — the starved shape the + /// `FreeUnlessReplacements(DRAW)` arm claims. + fn draw_entry(id: u64) -> StackEntry { + churn_entry( + id, + 0, + ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + ObjectId(CHURN_SRC), + PlayerId(0), + ), + None, + ) + } + + fn with_replacements(entry: StackEntry, defs: &[ReplacementDefinition]) -> GameState { + let mut state = GameState::new_two_player(7); + state.stack.push_back(entry); + // Owned by P0 — the player whose draw these defs are meant to replace + // (CR 614.1 scopes a def to its controller's events). + let oid = bf_object_owned_by(&mut state, 900, PlayerId(0)); + let object = state.objects.get_mut(&oid).expect("just inserted"); + for def in defs { + object.replacement_definitions.push(def.clone()); + } + state + } + + fn repl(event: ReplacementEvent, optional: bool) -> ReplacementDefinition { + let is_draw = matches!(event, ReplacementEvent::Draw); + let mut def = ReplacementDefinition::new(event); + if optional { + def.mode = crate::types::ability::ReplacementMode::Optional { decline: None }; + } + // CR 121.2: a Draw definition MUST declare whether it modifies the + // instruction's count or replaces one individual draw. The pipeline + // debug-asserts on a definition that declares neither; the def-scan this + // replaces never ran the pipeline, so the fixture could omit it. + if is_draw { + def.draw_scope = Some(crate::types::ability::DrawReplacementScope::IndividualDraw); + } + // CR 616.1: ordering is a player choice only when it is MATERIAL. Two + // no-op definitions commute, so a fixture built to exercise the ordering + // prompt must carry a modification whose composition order matters. The + // def-scan this replaces counted definitions instead of asking. + def.quantity_modification = + Some(crate::types::ability::QuantityModification::Plus { value: 1 }); + def + } + + /// The board is bound ONCE and handed to both the predicate and the container it is + /// asked through. That is not a style choice: `PeriodVerdicts::frame_ix` resolves a + /// frame by POINTER IDENTITY, so building the container from a second, equal-valued + /// `GameState` would be a frame the container does not hold and the predicate would + /// fail closed — which is exactly the production guard doing its job, and exactly the + /// vacuity a copy-pasted expression would introduce here. + fn specified_on(board: &GameState) -> bool { + stack_choices_are_all_specified( + board, + PlayerId(0), + &[], + None, + &mut PeriodVerdicts::for_period(&[], board, PlayerId(0)), + ) + } + + /// CR 616.1 + CR 121.1: the draw verdict's environmental obligation is REAL (it can + /// reject) and CLASS-SCOPED (it rejects only on its own event class). + /// + /// Every arm is paired with the control that makes it non-vacuous: the bare board + /// arm proves a mandatory draw entry reaches and PASSES step (6) at all — without it + /// the three `false` arms would be indistinguishable from "draws are still refused + /// upstream" — and the cross-class arms prove the parameterization is load-bearing + /// rather than a rename of a guard that scans everything. + #[test] + fn draw_verdict_obligation_is_real_and_class_scoped() { + // (i) REACH-GUARD: a bare board with a mandatory draw on the stack PASSES. This + // is the assertion that flips if `Effect::Draw` goes back to `MayPrompt`. + assert!( + specified_on(&with_replacements(draw_entry(10), &[])), + "a mandatory non-`up to` draw is starved: no replacement environment, no prompt" + ); + + // (ii) CR 702.52a dredge-class: a single OPTIONAL draw replacement prompts. + assert!( + !specified_on(&with_replacements( + draw_entry(10), + &[repl(ReplacementEvent::Draw, true)] + )), + "an optional draw replacement is a genuine CR 608.2d resolution-time choice" + ); + + // (iii) CR 616.1 material ordering: two MANDATORY draw replacements compete. + // The second one multiplies where the first adds — measured, `Plus` and + // `Times` are different `CommuteClass`es, so their composition order changes + // the drawn count and the affected player must order them. Two `Plus` defs + // COMMUTE and the pipeline correctly opens no prompt for them (measured + // `replacement_ordering_is_material == false`), which is why this arm cannot + // be built from two copies of `repl(..)`. + let material_pair = { + let mut doubler = repl(ReplacementEvent::Draw, false); + doubler.quantity_modification = + Some(crate::types::ability::QuantityModification::Times { factor: 2 }); + [repl(ReplacementEvent::Draw, false), doubler] + }; + { + let board = with_replacements(draw_entry(10), &material_pair); + let drawn_event = crate::types::proposed_event::ProposedEvent::Draw { + player_id: PlayerId(0), + count: 1, + applied: Default::default(), + }; + let candidates = crate::game::replacement::find_applicable_replacements( + &board, + &drawn_event, + crate::game::replacement::replacement_registry(), + ); + assert_eq!( + candidates.len(), + 2, + "reach-guard: the live candidate authority draws BOTH defs for the \ + event this entry's resolution proposes" + ); + assert!( + crate::game::replacement::replacement_ordering_is_material( + &board, + &candidates, + &drawn_event + ), + "reach-guard: those two candidates really are CR 616.1 order-material" + ); + } + assert!( + !specified_on(&with_replacements(draw_entry(10), &material_pair)), + "CR 616.1: the affected player orders two competing mandatory replacements" + ); + + // (iv) ACCEPT-SIDE control: ONE mandatory, body-less draw replacement (Teferi's + // Ageless Insight class) resolves deterministically — the guard is not a blanket + // "any draw replacement rejects". + assert!( + specified_on(&with_replacements( + draw_entry(10), + &[repl(ReplacementEvent::Draw, false)] + )), + "a lone mandatory quantity-mod replacement applies without a prompt" + ); + + // (v) CLASS SCOPING, both directions. A LIFE replacement says nothing about a + // DRAW-only stack and vice versa; an unparameterized guard would reject both. + assert!( + specified_on(&with_replacements( + draw_entry(10), + &[repl(ReplacementEvent::GainLife, true)] + )), + "an optional LIFE replacement cannot prompt on a draw-only stack" + ); + assert!( + specified_on(&with_replacements( + churn_entry(11, 0, lose_ability(1), None), + &[repl(ReplacementEvent::Draw, true)] + )), + "an optional DRAW replacement cannot prompt on a life-only stack" + ); + // …and the same optional LIFE replacement DOES reject a life stack, proving the + // arm above passes because of scoping and not because the def is inert. + assert!( + !specified_on(&with_replacements( + churn_entry(11, 0, lose_ability(1), None), + &[repl(ReplacementEvent::LoseLife, true)] + )), + "positive control: the life guard still rejects its own class" + ); + } + + // ── §6 R24: the probe resolves on `resolve_top`'s board ── + + /// A drain entry whose `EventContextAmount` resolves against a batched + /// subject count of `match_count` (CR 603.2c) rather than `churn_entry`'s + /// fixed `Some(1)` — a distinctive amount is what makes R24(a)'s equality + /// non-degenerate. + fn scoped_drain_entry( + id: u64, + match_count: Option, + condition: Option, + ) -> StackEntry { + let mut ability = lose_life_targeting(event_amount(), opp_typed(vec![])); + ability.targets = vec![TargetRef::Player(PlayerId(1))]; + StackEntry { + id: ObjectId(id), + source_id: ObjectId(CHURN_SRC), + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: ObjectId(CHURN_SRC), + ability: Box::new(ability), + condition, + trigger_event: None, + description: None, + source_name: String::new(), + subject_match_count: match_count, + die_result: None, + }, + } + } + + /// **§6 R24 — THE PROBE RESOLVES ON `resolve_top`'s BOARD: SCOPE BOUND, + /// ENTRY OFF THE STACK, CR 603.4 RE-CHECKED.** + /// + /// Three arms, each keyed to one thing the classifier gets wrong when it is + /// handed the raw pre-resolution board instead of the one + /// `resolve_top` hands `resolve_ability_chain`. + /// + /// * **(a) THE EVENT-CONTEXT AXIS — the FAIL-OPEN closer.** A + /// `LoseLife { amount: Ref(EventContextAmount) }` drain (the Sanguine Bond + /// shape) resolves "that many" against the entry's batched subject count + /// (CR 603.2c), which only `bind_resolution_scope` lifts. The derived + /// `ProposedEvent::LifeLoss.amount` must equal the amount the LIVE + /// resolution proposes, and must be non-zero. Without the lift the derived + /// amount is 0 — a `> 0`-gated virtual candidate is then never drawn and + /// the probe certifies a resolution the live pipeline would prompt on. + /// REVERT-PROBE (RUN): hand `probe_resolution` the raw board (drop the + /// `bind_resolution_scope` call from `stack_entry_resolution_choice_freedom`) + /// ⇒ derived `0` vs live `7` ⇒ FLIPS. + /// * **(b) CR 603.4.** The same entry with a FALSE intervening-if ⇒ + /// `bind_resolution_scope` returns `false` ⇒ `MayPrompt`; matched against + /// the TRUE twin, which classifies. Direction note, so the arm is not + /// over-claimed: skipping the re-check is fail-CLOSED (a superset of + /// events draws a superset of candidates), so (b) is a FIDELITY arm — + /// (a) is the fail-open closer. + /// * **(c) AMOUNT-INSENSITIVITY, and the zero arm RE-KEYED ON A + /// MEASUREMENT.** On one board with a single in-class (Compleated, + /// CR 702.150a) virtual candidate, sweeping the ability's resolved count + /// over `{1, 2, 7, 99}` yields an IDENTICAL candidate set — candidate + /// selection is amount-insensitive ABOVE zero. The `0` arm does NOT reach + /// the zero-payload accounting guard the plan predicted: measured, a + /// zero-count resolution proposes no event at all (as do + /// `DealDamage { amount: 0 }` and `Draw { count: 0 }`), so the refusal is + /// the `is_empty` arm and the plan's stated revert-probe for this arm + /// cannot reproduce. Both facts are asserted in place, with the + /// guard's own classification pinned separately on the partition. See the + /// block comment at the arm. + /// + /// REACH-GUARDS on every arm: `bind_resolution_scope` is asserted to have + /// returned `true` and the probe to have returned `Events(..)` on each + /// positive arm, so a board that refuses for an unrelated reason fails + /// LOUDLY instead of passing a negative vacuously. + #[test] + fn the_probe_resolves_on_resolve_tops_board_with_scope_bound_and_603_4_rechecked() { + use crate::game::resolution_prompt::ResolutionChoiceFreedom; + use crate::types::proposed_event::ProposedEvent; + + // ── (a) the event-context axis ── + const MATCHES: u32 = 7; + let mut state = drain_state(2); + let entry = scoped_drain_entry(20, Some(MATCHES), None); + state.stack.push_back(entry.clone()); + + // Reach-guard: the binding this arm is about actually succeeds. + let mut board = state.clone(); + board.stack.retain(|e| e.id != entry.id); + assert!( + crate::game::stack::bind_resolution_scope(&mut board, &entry, None), + "reach-guard: no CR 603.4 condition on this entry ⇒ the scope binds" + ); + + let freedom = stack_entry_resolution_choice_freedom( + &state, + &entry, + &mut ProbeBudget::for_test(PROBE_BUDGET), + ); + let ResolutionChoiceFreedom::FreeUnlessReplacements(derived) = freedom else { + panic!("reach-guard: the probe must return Events(..) on this board, got {freedom:?}"); + }; + let derived_amount = derived + .iter() + .find_map(|event| match event { + ProposedEvent::LifeLoss { + player_id, amount, .. + } if *player_id == PlayerId(1) => Some(*amount), + _ => None, + }) + .unwrap_or_else(|| panic!("no LifeLoss on P1 in the derived set: {derived:?}")); + + let mut live = state.clone(); + let before = live.players[1].life; + let mut events = Vec::new(); + crate::game::stack::resolve_top(&mut live, &mut events); + let live_amount = before - live.players[1].life; + assert_eq!( + i64::from(derived_amount), + i64::from(live_amount), + "CR 603.2c + CR 608.2k: the derived amount must equal the one the LIVE \ + resolution proposes — an unbound scope resolves EventContextAmount \ + against an absent context" + ); + assert_eq!( + derived_amount, MATCHES, + "non-degeneracy: the amount is the lifted batched subject count, not zero \ + and not a coincidental 1" + ); + + // ── (b) CR 603.4 intervening-if re-check ── + // `drain_state` builds a standard-format board (20 starting life; the `7` + // it passes is the RNG seed), so `LifeTotalGE 6` is TRUE and `LifeTotalGE 30` + // FALSE for the entry's controller. + for (label, condition, binds) in [ + ("TRUE", TriggerCondition::LifeTotalGE { minimum: 6 }, true), + ( + "FALSE", + TriggerCondition::LifeTotalGE { minimum: 30 }, + false, + ), + ] { + let mut s = drain_state(2); + let e = scoped_drain_entry(21, Some(MATCHES), Some(condition)); + s.stack.push_back(e.clone()); + + let mut b = s.clone(); + b.stack.retain(|x| x.id != e.id); + assert_eq!( + crate::game::stack::bind_resolution_scope(&mut b, &e, None), + binds, + "reach-guard: the CR 603.4 re-check is what decides arm ({label})" + ); + + let verdict = stack_entry_resolution_choice_freedom( + &s, + &e, + &mut ProbeBudget::for_test(PROBE_BUDGET), + ); + if binds { + assert!( + matches!(verdict, ResolutionChoiceFreedom::FreeUnlessReplacements(_)), + "the condition-TRUE twin classifies ({label}); got {verdict:?}" + ); + } else { + assert_eq!( + verdict, + ResolutionChoiceFreedom::MayPrompt, + "CR 603.4: a FALSE intervening-if means the live resolution proposes \ + NOTHING, and an empty derivation is never safe ({label})" + ); + } + } + + // ── (c) amount-insensitivity above zero, and the zero-payload guard ── + let mut counter_state = drain_state(2); + { + // CR 702.150a: the Compleated virtual AddCounter candidate is drawn + // only for a loyalty placement on a source whose Phyrexian life was paid. + let src = counter_state + .objects + .get_mut(&ObjectId(CHURN_SRC)) + .expect("fixture: the churn source exists"); + src.phyrexian_life_paid = 2; + src.keywords + .push(crate::types::keywords::Keyword::Compleated); + } + let loyalty_ability = |count: i32| { + ResolvedAbility::new( + Effect::PutCounter { + target: TargetFilter::SelfRef, + counter_type: crate::types::counter::CounterType::Loyalty, + count: QuantityExpr::Fixed { value: count }, + }, + vec![], + ObjectId(CHURN_SRC), + PlayerId(0), + ) + }; + let counter_entry = |count: i32| { + let mut e = scoped_drain_entry(22, Some(MATCHES), None); + let StackEntryKind::TriggeredAbility { ability, .. } = &mut e.kind else { + unreachable!("scoped_drain_entry builds a TriggeredAbility") + }; + **ability = loyalty_ability(count); + e + }; + + let mut candidate_sets = Vec::new(); + for count in [1, 2, 7, 99] { + let e = counter_entry(count); + let mut s = counter_state.clone(); + s.stack.push_back(e.clone()); + let verdict = stack_entry_resolution_choice_freedom( + &s, + &e, + &mut ProbeBudget::for_test(PROBE_BUDGET), + ); + let ResolutionChoiceFreedom::FreeUnlessReplacements(events) = verdict else { + panic!("reach-guard: count {count} must probe to Events(..), got {verdict:?}"); + }; + let add = events + .iter() + .find(|event| matches!(event, ProposedEvent::AddCounter { .. })) + .unwrap_or_else(|| panic!("count {count} derived no AddCounter: {events:?}")); + candidate_sets.push(crate::game::replacement::find_applicable_replacements( + &s, + add, + crate::game::replacement::replacement_registry(), + )); + } + assert!( + !candidate_sets[0].is_empty(), + "reach-guard: the CR 702.150a Compleated virtual candidate IS drawn above \ + zero — without it the sweep would compare four empty sets" + ); + assert!( + candidate_sets.windows(2).all(|pair| pair[0] == pair[1]), + "CR 614.1a: candidate SELECTION is amount-insensitive above zero; got \ + {candidate_sets:?}" + ); + + // The `0` arm, RE-KEYED ON A MEASUREMENT that contradicts the plan's + // stated mechanism — recorded here rather than papered over. + // + // The plan expects a zero-count `PutCounter` to DERIVE an + // `AddCounter { count: 0 }` which the zero-payload guard then classifies + // Unaccounted (arm 4). Measured on this board: the zero-count resolution + // proposes NOTHING AT ALL — and so do `DealDamage { amount: 0 }` and + // `Draw { count: 0 }`, the other two zero-payload classes. Every counter/ + // damage/draw resolver short-circuits above the pipeline at zero. So no + // zero-payload `ProposedEvent` is reachable through the six allow-listed + // classes, the refusal below is arm 3 (`is_empty`), and the plan's + // (c) revert-probe (delete the `AddCounter { count: 0 }` guard ⇒ the + // derivation certifies) CANNOT REPRODUCE — there is no derivation to + // certify. DIRECTION: fail-CLOSED either way, so this is a coverage fact, + // not a hole. The guards remain correct defence-in-depth for events + // proposed by non-allow-listed routes and are pinned directly on the + // partition in BOTH directions by + // `resolution_prompt::tests::an_unaccounted_derived_event_is_prompted_in_the_resolver`. + let zero_entry = counter_entry(0); + let mut zero_state = counter_state.clone(); + zero_state.stack.push_back(zero_entry.clone()); + let mut zero_board = zero_state.clone(); + zero_board.stack.retain(|e| e.id != zero_entry.id); + assert!( + crate::game::stack::bind_resolution_scope(&mut zero_board, &zero_entry, None), + "reach-guard: the zero arm's refusal is not the CR 603.4 arm" + ); + let zero_events = crate::game::replacement::record_proposed_events(|| { + let mut work = zero_board.clone(); + let mut sink = Vec::new(); + let _ = crate::game::effects::resolve_ability_chain( + &mut work, + &loyalty_ability(0), + &mut sink, + 0, + ); + }); + assert!( + zero_events.is_empty(), + "MEASURED, and the reason the arm is re-keyed: a zero count proposes no \ + event at all; recorded {zero_events:?}. If this ever becomes non-empty the \ + accounting arm becomes the reachable one and this arm must be re-keyed \ + back onto it." + ); + assert!( + !crate::game::replacement::event_is_accounted(&ProposedEvent::AddCounter { + placement: crate::types::proposed_event::CounterPlacement::Object { + actor: PlayerId(0), + object_id: ObjectId(CHURN_SRC), + counter_type: crate::types::counter::CounterType::Loyalty, + }, + count: 0, + applied: Default::default(), + }), + "the CR 702.150a zero-payload guard still classifies the event Unaccounted \ + — it is simply not reachable from an allow-listed resolution" + ); + assert_eq!( + stack_entry_resolution_choice_freedom( + &zero_state, + &zero_entry, + &mut ProbeBudget::for_test(PROBE_BUDGET) + ), + ResolutionChoiceFreedom::MayPrompt, + "CR 732.2a: at zero the live pipeline draws no candidate (`count > 0`) and \ + the resolution proposes nothing, so the probe refuses fail-CLOSED rather \ + than certifying an empty derivation" + ); + } + + /// CR 732.2a: `BUDGET-EXCEEDED ⇒ Prompted` is what keeps the probe's cost a + /// COVERAGE knob rather than an unbounded player-facing stall, so the budget + /// must actually stop granting and must LATCH the denial (an exhaustion that + /// is only inferable from a zero remainder cannot be attributed). + /// + /// Both directions asserted so neither can go vacuous: exactly + /// [`PROBE_BUDGET`] charges are granted with `denied()` still `false`, and + /// only the charge AFTER that flips it. + #[test] + fn probe_budget_grants_exactly_its_cap_then_latches_the_denial() { + let mut budget = ProbeBudget::for_test(PROBE_BUDGET); + for i in 0..PROBE_BUDGET { + assert!( + budget.try_charge_one(), + "charge {i} of {PROBE_BUDGET} must be granted" + ); + assert!( + !budget.denied(), + "no denial may be latched while charges are still granted (after {i})" + ); + } + assert!( + !budget.try_charge_one(), + "the charge past the cap must be refused" + ); + assert!( + budget.denied(), + "the exhaustion fact must be latched, not inferred from the remainder" + ); + // A zero-cap budget denies its FIRST charge — the shape a lowered cap + // takes, and the reason exhaustion is fail-closed rather than silent. + let mut starved = ProbeBudget::for_test(0); + assert!(!starved.try_charge_one()); + assert!(starved.denied()); + } + // ───────────────── 5d U2 — the shape-(B) mint's relief-side rows ───────────────── + + /// A 3-seat board with one battlefield source, shared by the two U2 relief rows. + fn u2_relief_board() -> (GameState, ObjectId) { + use crate::game::scenario::GameScenario; + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + let src = ObjectId(970); + let mut obj = crate::game::game_object::GameObject::new( + src, + crate::types::identifiers::CardId(0), + PlayerId(0), + "U2 Source".to_string(), + crate::types::zones::Zone::Battlefield, + ); + obj.incarnation = 3; + state.objects.insert(src, obj); + (state, src) + } + + /// Shape (B): a proposer-controlled OPTIONAL, NO-TARGET triggered ability. + /// + /// Shape (B) is reached whenever `build_target_slots` yields ZERO slots. There are two + /// routes to that, and the difference matters HERE and not at the mint: an effect whose + /// head filter announces nothing (`Draw { target: Controller }`) leaves the residual + /// classification choice-FREE, while `TargetChoiceTiming::Resolution` (the Braids + /// per-player-upkeep shape the mint rows use) is itself one of the six `MayPrompt` + /// reasons — such an entry mints but can never be RELIEVED, so its offer is refused at + /// conjunct (6). That is the fail-closed direction, and it is why the relief rows below + /// take the announce-nothing route rather than the resolution-timing one. + fn u2_shape_b_entry( + src: ObjectId, + id: u64, + effect: crate::types::ability::Effect, + mutate: impl FnOnce(&mut crate::types::ability::ResolvedAbility), + ) -> StackEntry { + let mut ability = + crate::types::ability::ResolvedAbility::new(effect, vec![], src, PlayerId(0)); + ability.optional = true; + mutate(&mut ability); + StackEntry { + id: ObjectId(id), + source_id: src, + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: src, + ability: Box::new(ability), + condition: None, + trigger_event: None, + description: None, + source_name: String::new(), + subject_match_count: None, + die_result: None, + }, + } + } + + fn u2_draw_effect() -> crate::types::ability::Effect { + crate::types::ability::Effect::Draw { + count: crate::types::ability::QuantityExpr::Fixed { value: 1 }, + target: crate::types::ability::TargetFilter::Controller, + } + } + + /// [`u2_scope`] with the proposer as a PARAMETER — the seat whose offer published the pins, + /// which is not always the seat the consuming container is bound to (R22 conjunct (4)). + fn u3_scope_for(proposer: PlayerId, slots: &[DecisionSlot]) -> LoopWindowScope<'_> { + LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned: Some(PinnedChoices { proposer, slots }), + cast_card_ids: None, + period: None, + } + } + + /// The `LoopWindowScope` an offer that published exactly `slots` hands the relief. + fn u2_scope(slots: &[DecisionSlot]) -> LoopWindowScope<'_> { + LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned: Some(PinnedChoices { + proposer: PlayerId(0), + slots, + }), + cast_card_ids: None, + period: None, + } + } + + /// R6 — **an optional trigger carrying an additional unpublishable axis still refuses, + /// and the RELIEF is the layer that refuses it.** + /// + /// `ability_resolution_choice_freedom` returns `MayPrompt` for six independent reasons and + /// the offer publishes a `MayChoice` point for exactly ONE of them (`ability.optional`). + /// `pinned_may_choice_relief` therefore re-classifies the ability with `optional` cleared: + /// an `unless_pay` (CR 118.12) keeps coming back `MayPrompt` and gets no relief, because + /// no published pin specifies it. + /// + /// **(a′) THE MATCHED POSITIVE (ROUND-42 M15), byte-identical except the axis.** Without + /// it this row is a dominated negative: `entry_publishes_pin_slots` returns `None` from + /// four conjuncts that all sit ABOVE R6's axis (`entry.controller != proposer`, the + /// `TriggeredAbility` let-else, and the `multi_target`/`distribution`/`target_constraints` + /// block), so a fixture tripping any of them would refuse for a reason that has nothing to + /// do with the residual re-classification. The positive proves the fixture is + /// proposer-controlled, is a triggered ability, carries none of those three, and reaches + /// the mint — and this row additionally asserts the negative fixture MINTS, so the refusal + /// is attributable to the relief and to nothing upstream of it. + /// + /// SCOPE, so this row is not read as covering the cardinality axis: `unless_pay` and a + /// modal header are axes the relief catches. A `repeat_for`-driven multi-prompt ability + /// does NOT fail that way — it can re-classify choice-free while the resolution still + /// opens N prompts — so it is caught one layer earlier, at the mint, by + /// `one_published_may_slot_stands_for_exactly_one_cr_603_5_prompt`. + /// + /// REVERT-PROBE: make `pinned_may_choice_relief` return the residual without + /// re-classifying (drop the `without_may_gate` round-trip and return + /// `FreeUnlessReplacements` unconditionally) ⇒ the `unless_pay` arm is relieved ⇒ FLIPS. + #[test] + fn an_unpublishable_residual_axis_is_refused_by_the_relief_not_by_the_mint() { + use crate::game::engine::entry_publishes_pin_slots; + use crate::game::resolution_prompt::ResolutionChoiceFreedom; + + let (state, src) = u2_relief_board(); + + // ── (a′) matched positive: no residual axis ── + let clean = u2_shape_b_entry(src, 980, u2_draw_effect(), |_| {}); + let published = entry_publishes_pin_slots(&state, &clean, PlayerId(0)) + .expect("(a′) reach-guard: the clean fixture must reach the mint"); + let may = published + .may + .expect("(a′) the clean fixture publishes its CR 603.5 gate"); + assert!( + published.target.is_none(), + "(a′) shape (B): no announcement choice, so no target slot" + ); + let slots = vec![may.clone()]; + // The relief reads the mint and the residual through the ONE door, so the row drives + // a real container bound to the same proposer the pins carry; `frame_ix` is the only + // `FrameIx` mint and its `None` would be a refusal, so the `expect` is a reach-guard. + let mut verdicts = PeriodVerdicts::for_period(&[], &state, PlayerId(0)); + let f = verdicts + .frame_ix(&state) + .expect("(a′) reach-guard: the container holds the board the relief is asked about"); + assert!( + matches!( + pinned_may_choice_relief(f, &clean, &mut verdicts, u2_scope(&slots)), + Some(ResolutionChoiceFreedom::FreeUnlessReplacements(_)) + ), + "(a′) with the may pinned and no other axis, the residual classification is \ + choice-free and the entry is relieved" + ); + + // ── (a) the negative: one CR 118.12 `unless_pay` axis, nothing else changed ── + let gated = u2_shape_b_entry(src, 981, u2_draw_effect(), |ability| { + ability.unless_pay = Some(crate::types::ability::UnlessPayModifier { + cost: crate::types::ability::AbilityCost::Mana { + cost: crate::types::mana::ManaCost::Cost { + shards: vec![], + generic: 2, + }, + }, + payer: crate::types::ability::TargetFilter::Controller, + }); + }); + let gated_published = entry_publishes_pin_slots(&state, &gated, PlayerId(0)) + .expect("(a) reach-guard: the MINT still publishes — it does not read `unless_pay`"); + assert_eq!( + gated_published.may.as_ref(), + Some(&may), + "(a) reach-guard: the same slot is published, so the two arms differ ONLY in the \ + residual axis and the refusal below cannot come from the mint" + ); + assert!( + pinned_may_choice_relief(f, &gated, &mut verdicts, u2_scope(&slots)).is_none(), + "(a) CR 118.12: an `unless_pay` is a SECOND resolution-time choice no published \ + pin specifies, so the residual re-classification still returns `MayPrompt` and \ + the entry gets no relief" + ); + } + + /// R31 — **the `may` mint's recipient conjunct may read the board, but it can never move + /// a published offer.** + /// + /// Conjunct (a) calls `optional_prompt_player`, whose sole state-touching callee is + /// `targeting::resolve_effect_player_ref`, reaching eleven distinct `GameState` fields + /// (`players`, `seat_order`, `format_config`, `objects`, `lki_cache`, `stack`, + /// `current_trigger_event`, `last_created_token_ids`, `last_revealed_ids`, + /// `last_zone_changed_ids`, `resolution_stack`). Every one of the three branches that + /// reach it is gated on an `Effect` that `effect_resolution_choice_freedom` puts in its + /// fail-closed grouped arm — so conjunct (6) refuses any offer carrying such an entry. + /// The reads happen; they cannot bear on a published result. + /// + /// NO PRODUCTION DELTA: this row pins an ARGUMENT, which is why it needs a revert-probe + /// that edits code rather than deletes a guard. + /// + /// THREE ARMS, and the first two exist so the third cannot pass vacuously: + /// * **(a) the branch is REACHED** — `Effect::PayCost { payer: Controller }` routes + /// through `resolve_effect_player_ref`'s `Controller` arm and returns the proposer ⇒ a + /// `may` slot IS published. + /// * **(a′) matched negative, differing in exactly the payer filter** — `payer: Opponent` + /// resolves through `players::is_opponent`/`opponents` to a seat ≠ proposer ⇒ no slot. + /// The pair proves the verdict is decided BY the callee's return value, which is why no + /// function-level inertness is claimed anywhere. + /// * **(b) the closure** — the offer is refused anyway, repeated for all three + /// state-reading branches (`PayCost`, `Sacrifice`, `SearchLibrary`) so the arm covers + /// the closure's whole population rather than one member of it. + /// + /// REVERT-PROBE (symbol-anchored, so it survives file moves): in + /// `game/resolution_prompt.rs`, move `Effect::PayCost { .. }` out of + /// `effect_resolution_choice_freedom`'s fail-closed grouped arm into a + /// `FreeUnlessReplacements(vec![])` arm ⇒ **(b) FLIPS to `true`** for the `PayCost` case + /// while (a)/(a′) stay green — proving the closure rests on the scope filter and not on + /// the fixture. + /// + /// The COMPLETENESS arm ("every minted pair is scanned") ships in U3: its probe names + /// `touch.announced`, which does not exist until the announcement loop gains its window. + #[test] + fn the_recipient_conjunct_reads_the_board_but_can_never_move_a_published_offer() { + use crate::game::engine::entry_publishes_pin_slots; + use crate::types::ability::{AbilityCost, Effect, QuantityExpr, TargetFilter}; + + let (state, src) = u2_relief_board(); + let mana = || AbilityCost::Mana { + cost: crate::types::mana::ManaCost::Cost { + shards: vec![], + generic: 1, + }, + }; + let pay_cost = |payer: TargetFilter| Effect::PayCost { + cost: mana(), + scale: None, + payer, + }; + + // ── (a) the state-reading branch is REACHED and returns the proposer ── + let reached = u2_shape_b_entry(src, 990, pay_cost(TargetFilter::Controller), |_| {}); + let StackEntryKind::TriggeredAbility { ability, .. } = &reached.kind else { + panic!("the fixture is a triggered ability"); + }; + assert_eq!( + crate::game::effects::optional_prompt_player(&state, ability), + PlayerId(0), + "(a) reach-guard: the `PayCost` branch really routes through \ + `resolve_effect_player_ref`'s `Controller` arm and returns the proposer" + ); + let published = entry_publishes_pin_slots(&state, &reached, PlayerId(0)) + .expect("(a) the state-reading branch publishes"); + let may = published + .may + .expect("(a) a `may` slot IS minted through the state-reading branch"); + + // ── (a′) matched negative: exactly the payer filter differs ── + let opposed = u2_shape_b_entry(src, 991, pay_cost(TargetFilter::Opponent), |_| {}); + let StackEntryKind::TriggeredAbility { ability, .. } = &opposed.kind else { + panic!("the fixture is a triggered ability"); + }; + assert_ne!( + crate::game::effects::optional_prompt_player(&state, ability), + PlayerId(0), + "(a′) reach-guard: the `Opponent` arm resolves to a seat that is NOT the proposer" + ); + assert!( + entry_publishes_pin_slots(&state, &opposed, PlayerId(0)).is_none(), + "(a′) the mint's verdict is decided by `resolve_effect_player_ref`'s RETURN value, \ + not by the effect's shape — so the state reads really are result-bearing" + ); + + // ── (b) the closure: the offer is refused anyway, on all three branches ── + let branches: Vec<(&str, Effect)> = vec![ + ("PayCost", pay_cost(TargetFilter::Controller)), + ( + "Sacrifice", + Effect::Sacrifice { + target: TargetFilter::ParentTargetController, + count: QuantityExpr::Fixed { value: 1 }, + min_count: 1, + }, + ), + ( + "SearchLibrary", + Effect::SearchLibrary { + source_zones: vec![crate::types::zones::Zone::Library], + filter: TargetFilter::Controller, + count: QuantityExpr::Fixed { value: 1 }, + reveal: false, + target_player: Some(TargetFilter::ParentTargetController), + selection_constraint: Default::default(), + split: None, + }, + ), + ]; + for (label, effect) in branches { + let mut board = state.clone(); + let entry = u2_shape_b_entry(src, 992, effect, |_| {}); + // Reach-guard for the ANNOUNCEMENT loop: without this, a `false` below could come + // from gate (3) instead of from gate (6) and the closure claim would be vacuous. + assert!( + stack_entry_has_no_ordering_input(&board, &entry), + "{label}: reach-guard — shape (B) announces no choice, so the announcement \ + loop must PASS and the refusal below is attributable to gate (6)" + ); + board.stack.push_back(entry); + assert!( + !stack_choices_are_all_specified( + &board, + PlayerId(0), + std::slice::from_ref(&may), + None, + &mut PeriodVerdicts::for_period(&[], &board, PlayerId(0)) + ), + "{label}: CR 732.2a conjunct (6) refuses the offer — the effect sits in \ + `effect_resolution_choice_freedom`'s fail-closed grouped arm, so a `may` \ + slot minted through a state-reading branch can never reach a published offer" + ); + } + } + + /// R33 arms (a) / (b) / (a′1) / (c) — THE FROZEN EXEMPTION IS KEYED TO THE CERTIFYING + /// DISJUNCT, AT THE CONSTRUCTOR **AND** AT THE CONSUMER, ON A REAL DRIVEN WINDOW. + /// + /// CR 732.2a + CR 608.1. The exemption's limb (*observed-frozen ⇒ frozen across the + /// fast-forward*) needs P2 (the period cannot SHRINK the stack) and P4 (the fast-forward + /// IS the repetition of the observed period). Exactly one certifying disjunct supplies + /// both, so `frozen_ids` is non-empty under `BoardCovered` and EMPTY under the other two. + /// + /// The board is the dellian dump DRIVEN through `apply()` — the dump ships with an empty + /// ring, so every frame the window is built from was accumulated by this drive. The beat + /// is SEARCHED FOR by its construction requirements rather than hardcoded, because a + /// hardcoded beat index is a fixture that drifts silently when the drive policy moves. + /// + /// (a) the window really freezes something — asserted against an INDEPENDENT lower bound + /// ([`frozen_lower_bound`]), never against the callee's own answer. (b)/(a′1) the two + /// matched negatives, differing from (a) in EXACTLY ONE ARGUMENT. (c) the consumer half: + /// the same two touches through `stack_choices_are_all_specified`, where the boolean flip + /// is the load-bearing assertion and the counters are its attribution. + /// + /// REVERT-PROBE 1: delete `certified_period_touch`'s pre-walk early return so `frozen_ids` + /// is computed unconditionally ⇒ (b) and (a′1) FLIP from empty to the full set and (c)'s + /// two arms collapse to equal populations. + #[test] + fn r33_frozen_exemption_is_keyed_to_the_certificate_on_the_real_dellian_window() { + let mut state = dump_state(include_bytes!( + "../../tests/fixtures/dellian_emblem_conqueror_4p.json.gz" + )); + assert_eq!( + state.loop_detect_ring.len(), + 0, + "reach-guard: the dump must ship with an EMPTY ring — every frame the window \ + below is built from is accumulated by THIS drive, not restored" + ); + + // Drive until a beat satisfies the row's construction requirements: a usable ring + // AND a window that genuinely freezes something. Both are checked BEFORE the board + // is captured, so the arms below cannot run on a beat that does not carry them. + let mut hit: Option<(usize, GameState)> = None; + for beat in 0..80usize { + if state.loop_detect_ring.len() >= 2 { + let live: Vec<&GameState> = + state.loop_detect_ring.iter().map(|f| &f.live).collect(); + let window = &live[live.len() - 2..]; + if frozen_lower_bound(window, &state) > 0 { + drop(live); + hit = Some((beat, state.clone())); + break; + } + } + if dump_drive_one_beat(&mut state).is_err() { + break; + } + } + let (beat, board) = hit.expect( + "REACH-GUARD: no driven beat carried a ring >= 2 frames AND a window with a \ + non-empty observed-frozen prefix. Every arm below would be vacuous on such a \ + beat, so the row FAILS rather than passing over a window that freezes nothing", + ); + + let live: Vec<&GameState> = board.loop_detect_ring.iter().map(|f| &f.live).collect(); + // The newest candidate pair, i.e. `span == 1` — the shape §3 D2's walk reaches first. + let window = &live[live.len() - 2..]; + let bound = frozen_lower_bound(window, &board); + let proposer = board.active_player; + + // ── (a) the exemption is genuinely AVAILABLE on this window ────────────────────── + let cover = certified_period_touch(window, &board, PeriodCertification::BoardCovered); + assert!( + cover.frozen_ids.len() >= bound && bound > 0, + "(a) beat {beat}: the cover certificate must freeze at least the {bound} entries \ + the independent common-prefix bound proves are index-stable across every window \ + frame; got {}", + cover.frozen_ids.len() + ); + + // ── (b) / (a′1) the two matched negatives, ONE argument different ──────────────── + let sig = + certified_period_touch(window, &board, PeriodCertification::ResourceSignatureOnly); + assert!( + sig.frozen_ids.is_empty(), + "(b) beat {beat}: basis B consults no board predicate, so it supplies neither P2 \ + nor P4 and the subtraction is withdrawn; got {} frozen", + sig.frozen_ids.len() + ); + let eq = certified_period_touch(window, &board, PeriodCertification::BoardEqualOnly); + assert!( + eq.frozen_ids.is_empty(), + "(a′1) beat {beat}: the equality disjunct supplies P2 but NOT P4 — it has no \ + items (4)/(5) — so it is as fail-closed as basis B; got {} frozen", + eq.frozen_ids.len() + ); + + // ANTI-OVER-NARROWING: only the SUBTRACTION is keyed. A certificate change must not + // zero the whole touch — that would under-publish the mint instead of un-exempting. + let ids = |t: &PeriodTouch<'_>| -> Vec { + t.announced.iter().map(|(_, e)| e.id).collect() + }; + assert_eq!( + ids(&sig), + ids(&cover), + "the certificate keys the frozen subtraction ONLY: `announced` must be \ + element-for-element identical under every value" + ); + assert_eq!(ids(&eq), ids(&cover), "same, for the equality value"); + + // ── (c) THE CONSUMER HALF: the same two touches, through conjunct (6) ──────────── + // Fresh containers per arm — a shared one would carry a warm memo and a part-spent + // budget into the second arm, making the counters incomparable. + let mut v_cover = PeriodVerdicts::for_period(&live, &board, proposer); + let c1 = stack_choices_are_all_specified(&board, proposer, &[], Some(&cover), &mut v_cover); + let mut v_sig = PeriodVerdicts::for_period(&live, &board, proposer); + let c2 = stack_choices_are_all_specified(&board, proposer, &[], Some(&sig), &mut v_sig); + + // The BOOLEAN FLIP is the load-bearing assertion; the counters are its attribution. + assert!( + c1 && !c2, + "(c) beat {beat}: conjunct (6) must ACCEPT under the exempting certificate and \ + REFUSE under the non-exempting one. Measured c1={c1} (asks={}, skips={}, \ + spent={}, denied={}) c2={c2} (asks={}, skips={}, spent={}, denied={}), stack={}", + v_cover.conjunct6_asks(), + v_cover.conjunct6_frozen_skips(), + v_cover.spent(), + v_cover.denied(), + v_sig.conjunct6_asks(), + v_sig.conjunct6_frozen_skips(), + v_sig.spent(), + v_sig.denied(), + board.stack.len() + ); + assert!( + v_cover.conjunct6_frozen_skips() as usize >= bound, + "(c1) the accepted arm must have SKIPPED the frozen ids rather than classified \ + them; skips={} bound={bound}", + v_cover.conjunct6_frozen_skips() + ); + assert!( + !v_cover.denied() && v_cover.spent() <= PROBE_BUDGET, + "(c1) the exempted classification must fit the cap: spent={} denied={}", + v_cover.spent(), + v_cover.denied() + ); + assert_eq!( + v_sig.conjunct6_frozen_skips(), + 0, + "(c2) the non-exempting certificate must skip NOTHING" + ); + assert!( + v_sig.denied() && v_sig.spent() == PROBE_BUDGET, + "(c2) the unexempted sweep must exhaust the cap and refuse fail-closed — its \ + measured demand is far above it. spent={} denied={} cap={PROBE_BUDGET}", + v_sig.spent(), + v_sig.denied() + ); + } + + /// R21 (b) + (b-unproven) — THE EXEMPTION NARROWS CONJUNCT (6)'s POPULATION BY EXACTLY + /// THE FROZEN SET, AND "NO PROOF" NARROWS IT BY NOTHING. + /// + /// CR 732.2a + CR 608.1. Both TRACKED dumps, driven through `apply()` to the first beat + /// carrying a real certified window with a non-empty observed-frozen prefix. + /// + /// ⚠ THE PLAN'S FIGURES FOR THIS ROW ARE HEAD-ERA AND ARE RE-MEASURED HERE, NOT COPIED. + /// It expects *"`conjunct6_asks()` = 2–4, `conjunct6_frozen_skips()` = 152, their sum = + /// `current.stack.len()` = 154–156"*. Measured on the driven tree (`dellian` beat 14, + /// `ring=2 stack=154 announced=2 frozen=152`): `asks=4`, `skips=152`, **sum = 156**, and + /// `156 = announced + stack`, NOT `stack` — post-U3 the predicate's domain is + /// `touch.announced ∪ (stack \ frozen)`, so the announced pairs are asks the HEAD-era + /// figure could not include. The SUM IDENTITY is asserted in the corrected form; it is + /// what fails if a future edit exempts an entry without counting it, which a bare + /// `asks == 2..4` would not see. (`dina` beat 10: `stack=8 announced=3 frozen=5`, + /// `asks=6 skips=5`, sum `11 = 3 + 8`.) + /// + /// (b-unproven): the shipped meaning of NO PROOF is `touch == None`, and it must exempt + /// NOTHING — `skips == 0` on both dumps, and where the sweep completes, the ask count is + /// the UNCONDITIONED `current.stack`. Without this arm the exemption could be made + /// unconditional and nothing would fail. + /// + /// REVERT-PROBE: delete the `frozen_ids` skip from `stack_choices_are_all_specified`'s + /// current-stack loop ⇒ `skips` drops to 0 while `frozen_ids` stays non-empty ⇒ the + /// `skips == frozen_ids.len()` arm FLIPS on both dumps. + #[test] + fn r21_b_the_exemption_narrows_conjunct_six_by_exactly_the_frozen_set() { + // Set by whichever dump's UNPROVEN sweep runs to completion, so the population claim + // below is asserted against a COMPLETE count and never against a truncated one. + let mut unproven_population_witness: Option<(&str, usize, usize)> = None; + + for (name, gz) in TRACKED_DUMPS { + let (beat, board) = drive_dump_until(gz, 80, has_frozen_window).unwrap_or_else(|| { + panic!( + "REACH-GUARD [{name}]: no driven beat carried a ring >= 2 frames AND a \ + window with a non-empty observed-frozen prefix; every arm below would be \ + vacuous on such a beat" + ) + }); + let live: Vec<&GameState> = board.loop_detect_ring.iter().map(|f| &f.live).collect(); + let window = &live[live.len() - 2..]; + let proposer = board.active_player; + let cover = certified_period_touch(window, &board, PeriodCertification::BoardCovered); + + // ── reach-guards: neither half of the domain may be empty ──────────────────── + assert!( + !cover.frozen_ids.is_empty(), + "[{name}] beat {beat}: the exemption must have something to remove" + ); + assert!( + !cover.announced.is_empty(), + "[{name}] beat {beat}: the announced half must be non-empty, else the sum \ + identity below degenerates into a statement about `current.stack` alone" + ); + assert!( + cover.frozen_ids.len() < board.stack.len(), + "[{name}] beat {beat}: a fully-frozen stack would make `asks` zero and the \ + narrowing unobservable; frozen {} of {}", + cover.frozen_ids.len(), + board.stack.len() + ); + + let measure = |touch: Option<&PeriodTouch<'_>>| { + let mut v = PeriodVerdicts::for_period(&live, &board, proposer); + let r = stack_choices_are_all_specified(&board, proposer, &[], touch, &mut v); + ( + r, + v.conjunct6_asks() as usize, + v.conjunct6_frozen_skips() as usize, + v.spent(), + v.denied(), + ) + }; + + // ── (b) the exempting certificate ──────────────────────────────────────────── + let (r_cov, asks_cov, skips_cov, spent_cov, denied_cov) = measure(Some(&cover)); + assert!( + r_cov, + "[{name}] beat {beat}: the exempted sweep must RUN TO COMPLETION, else every \ + count below is a truncation rather than a population. asks={asks_cov} \ + skips={skips_cov} spent={spent_cov} denied={denied_cov}" + ); + assert_eq!( + skips_cov, + cover.frozen_ids.len(), + "[{name}] beat {beat}: conjunct (6) must skip EXACTLY the proven-frozen ids — \ + no more (an over-skip is the fail-open direction) and no fewer" + ); + assert_eq!( + asks_cov + skips_cov, + cover.announced.len() + board.stack.len(), + "[{name}] beat {beat}: THE SUM IDENTITY. Every member of the derived domain \ + `announced ∪ current.stack` is either ASKED or COUNTED as exempt; an entry \ + exempted without being counted would land here. asks={asks_cov} \ + skips={skips_cov} announced={} stack={}", + cover.announced.len(), + board.stack.len() + ); + + // ── (b-unproven) NO PROOF exempts NOTHING ──────────────────────────────────── + let (r_none, asks_none, skips_none, spent_none, denied_none) = measure(None); + assert_eq!( + skips_none, 0, + "[{name}] beat {beat}: (b-unproven) a caller that proved no period gets \ + byte-identical pre-change behaviour — the subtraction is not merely smaller, \ + it is WITHDRAWN. asks={asks_none} spent={spent_none} denied={denied_none}" + ); + if r_none { + unproven_population_witness = Some((name, asks_none, board.stack.len())); + } + assert!( + skips_cov > 0, + "[{name}] beat {beat}: the two arms must actually DIFFER on this board" + ); + } + + let (name, asks_none, stack_len) = unproven_population_witness.expect( + "REACH-GUARD: neither dump's UNPROVEN sweep ran to completion, so the population \ + claim below would be asserted against a budget-truncated count. The row FAILS \ + rather than silently weakening to `skips == 0`", + ); + assert_eq!( + asks_none, stack_len, + "[{name}] (b-unproven) POPULATION: with no period proof the resolution gate scans \ + the UNCONDITIONED `current.stack`, element for element" + ); + } + + /// R21 (b-placement-S) — THE FROZEN SKIP IS READ AT EXACTLY ONE SITE, AND THAT SITE IS + /// BELOW ITEM (6)'s LOOP HEAD. + /// + /// CR 732.2a. §3 D2 step 3's replacement argument for the exemption is an ITEM-ORDERING + /// argument: items (2)/(4)/(5) are what establish the premises the skip consumes, and each + /// of them `return false`s strictly before it. Its sole precondition is that the skip lives + /// in item (6) and nowhere earlier — a source-level fact, asserted here rather than argued. + /// + /// This arm covers item (5) as well, and better than a scan count would: item (5) is inside + /// the extent, so a skip placed there raises the count to 2. + /// + /// COMMENT LINES ARE EXCLUDED, per R8's own ruling: a comment reads nothing, and counting + /// one would make the tripwire fire on prose. (The extent carries exactly one such line — + /// item (4)'s "`frozen_ids` is deliberately not read here".) + /// + /// REVERT-PROBE: add a `frozen_ids.contains(&e.id)` skip to item (4)'s closure ⇒ the read + /// count goes 1 → 2 ⇒ FLIPS. + #[test] + fn r21_b_placement_s_the_frozen_skip_is_read_once_and_only_below_item_six() { + let src = include_str!("resource.rs"); + let lines: Vec<&str> = src.lines().collect(); + + // Symbol-anchored extent, the §6 R8 self-census discipline: column-0 signature line + // to the first column-0 `}`. + let extent = |signature: &str| -> (usize, usize) { + let head = lines + .iter() + .position(|l| l.starts_with(signature)) + .unwrap_or_else(|| panic!("extractor found no column-0 `{signature}`")); + let end = lines[head..] + .iter() + .position(|l| *l == "}") + .map(|i| head + i) + .unwrap_or_else(|| panic!("`{signature}` has no column-0 closing brace")); + (head, end) + }; + // Needles ASSEMBLED at runtime so this test's own source cannot be counted by its own + // instrument (R13's hardening, applied here by construction). + let frozen_token = format!("frozen{}ids", '_'); + let scan_token = format!("note{}conjunct4{}scan", '_', '_'); + + let (head, end) = extent("pub(crate) fn loop_states_cover_modulo_growth_scoped"); + assert!( + end - head > 100, + "the extractor must return the whole predicate, not a truncated span; got \ + {head}-{end}" + ); + let code: Vec<(usize, &str)> = (head..=end) + .map(|i| (i, lines[i])) + .filter(|(_, l)| !l.trim_start().starts_with("//")) + .collect(); + + let item6_head = code + .iter() + .find(|(_, l)| l.contains("for entry in ¤t.stack")) + .map(|(i, _)| *i) + .expect("item (6)'s loop head must be inside the extent"); + let reads: Vec = code + .iter() + .filter(|(_, l)| l.contains(&frozen_token)) + .map(|(i, _)| *i) + .collect(); + + assert_eq!( + reads.len(), + 1, + "R21(b-placement-S): the frozen subtraction must be read at EXACTLY ONE site in \ + {head}-{end}; a second read is a premise consumed before it is proved. Found at \ + lines {:?} (1-based)", + reads.iter().map(|i| i + 1).collect::>() + ); + assert!( + reads[0] > item6_head, + "R21(b-placement-S): the one read (line {}) must sit BELOW item (6)'s loop head \ + (line {}) — items (2)/(4)/(5) establish the premises it consumes and each \ + returns strictly above it", + reads[0] + 1, + item6_head + 1 + ); + + // POSITIVE CONTROL AGAINST A DEAD GREP, same extractor and same filter: a token known + // to be present in the SAME extent must be found, and one known to be absent must not. + // One instrument, two values, one input. + assert_eq!( + code.iter().filter(|(_, l)| l.contains(&scan_token)).count(), + 1, + "the instrument must be able to find a token that IS there — item (4)'s scan \ + notifier lives inside {head}-{end}" + ); + assert_eq!( + code.iter() + .filter(|(_, l)| l.contains("ring_delta_signature")) + .count(), + 0, + "…and must not find one that is not" + ); + } + + /// R21 (b-placement-B) — THE BEHAVIOURAL HALF, ON THE REAL DELLIAN WINDOW: ITEM (4) SCANS + /// THE UNEXEMPTED STACK WHILE CONJUNCT (6) SKIPS IT. + /// + /// CR 732.2a. The matched pair is on ONE board and ONE touch, one predicate apart, so the + /// difference is attributable to the placement and to nothing else. + /// + /// ⚠ THE PLAN'S STATED PAIR IS FALSIFIED BY MEASUREMENT AND IS RE-KEYED. It asks for + /// *"the mutated board is REFUSED … the unmutated board still OFFERS (B-pos, row D1's + /// beat)"*. Measured on the driven tree, the unmutated dellian beat-14 board does NOT + /// offer — the mint returns `NoCertification` (`spent=26 scans=36 cert=None`), because + /// item (4) already trips on a projected-resource reader at stack index 35 and basis B's + /// `ring_delta_signature` finds no signature at `ring=2`. A pair whose two arms both + /// refuse discriminates nothing. And no mutation is needed to make the point: the entry + /// item (4) trips on IS ITSELF a frozen one, so the unmutated board already witnesses + /// that item (4) does not consult the exemption. + /// + /// REVERT-PROBE: add a `frozen_ids` skip to item (4)'s closure ⇒ the scan can no longer + /// reach index 35 and `conjunct4_scans` collapses to at most the non-exempt population + /// (2 on this board) ⇒ FLIPS. + #[test] + fn r21_b_placement_b_item_four_scans_frozen_entries_that_conjunct_six_skips() { + let (beat, board) = drive_dump_until(TRACKED_DUMPS[1].1, 80, has_frozen_window) + .expect("REACH-GUARD: the dellian drive must reach a window with a frozen prefix"); + let live: Vec<&GameState> = board.loop_detect_ring.iter().map(|f| &f.live).collect(); + let window = &live[live.len() - 2..]; + let prior = window[0]; + let proposer = board.active_player; + let cover = certified_period_touch(window, &board, PeriodCertification::BoardCovered); + let non_exempt = board.stack.len() - cover.frozen_ids.len(); + assert!( + cover.frozen_ids.len() > non_exempt, + "REACH-GUARD beat {beat}: the frozen prefix must DOMINATE the stack, else \ + `scans > non_exempt` is satisfiable without ever touching a frozen entry; \ + frozen {} non-exempt {non_exempt}", + cover.frozen_ids.len() + ); + + // ── ITEM (4): the scan population is the UNEXEMPTED current stack ──────────────── + let mut v4 = PeriodVerdicts::for_period(&live, &board, proposer); + let covered = + loop_states_cover_modulo_growth_pinned(prior, &board, proposer, &[], &cover, &mut v4); + let scans = v4.conjunct4_scans() as usize; + assert!( + scans > non_exempt, + "R21(b-placement-B) beat {beat}: item (4) scanned {scans} entries, which must \ + EXCEED the {non_exempt} non-exempt ones — a scan that consulted `frozen_ids` \ + could never get past them" + ); + assert!( + scans > 0 && scans < board.stack.len(), + "attribution: item (4)'s `.any()` must have SHORT-CIRCUITED inside the stack \ + ({scans} of {}), which is what makes it the refuser rather than a later item", + board.stack.len() + ); + assert!( + cover.frozen_ids.contains(&board.stack[scans - 1].id), + "R21(b-placement-B): the entry item (4) refused on (stack index {}) must itself \ + be PROVEN FROZEN — that is the whole content of `the skip lives in item (6) and \ + nowhere earlier`", + scans - 1 + ); + assert!( + !covered, + "attribution: with a projected-resource reader inside the scanned population the \ + cover disjunct must fail; a `true` here would mean the scan found nothing and \ + the index assertion above was about the wrong entry" + ); + assert_eq!( + v4.conjunct6_frozen_skips(), + 0, + "the cover predicate returned at item (4), so item (6) never ran and can have \ + taken no exemption — the two counters below come from the OTHER arm" + ); + + // ── MATCHED PAIR: the SAME touch, at the gate the skip actually lives in ───────── + let mut v6 = PeriodVerdicts::for_period(&live, &board, proposer); + let specified = + stack_choices_are_all_specified(&board, proposer, &[], Some(&cover), &mut v6); + assert!( + specified, + "REACH-GUARD: the resolution gate must run to completion under the exempting \ + certificate, else its skip count is a truncation" + ); + assert_eq!( + v6.conjunct6_frozen_skips() as usize, + cover.frozen_ids.len(), + "R21(b-placement-B) matched pair: the SAME {} proven-frozen ids item (4) scanned \ + are the ones the resolution gate exempts", + cover.frozen_ids.len() + ); + } + + /// R17 — ID FRESHNESS ON THE DRIVEN DUMPS, AND `normalize_for_loop` PRESERVES EVERY + /// `StackEntry.id`. + /// + /// CR 608.1 + CR 104.4b. The frozen-prefix exemption is the ONE place 5d makes the + /// resolution gate strictly NARROWER than HEAD, and its soundness rests on a fixture that + /// is structurally unconstructible: *a window whose prefix is identity-stable across every + /// sampled frame while an exempted entry DOES announce or resolve in the driven period*. + /// Both disjuncts die on one fact — an entry that RESOLVED inside the window has RETIRED + /// its `StackEntry.id` (ids come from the monotone `next_object_id`), and an entry that + /// ANNOUNCED inside the window was absent from the oldest window frame, which the + /// exemption requires presence in. The second is definitional; the first is the invariant + /// this row asserts, since a fixture cannot. + /// + /// ARM 3 is the cross-frame comparison's unstated dependency, stated: `certified_period_touch` + /// compares `StackEntry.id` across frames, and `normalize_for_loop`'s products still feed + /// `loop_states_equal` / `loop_states_cover_modulo_growth*` (CR 104.4b equality), so an id + /// rewrite inside a function whose stated job is zeroing volatile monotone fields is + /// exactly the plausible future regression. + /// + /// REVERT-PROBE (arm 3, EXECUTED IN-TEST as an instrument-liveness control): zero one id in + /// a constructed normalized clone ⇒ the comparison must report a mismatch. REVERT-PROBE + /// (arms 1/2): inject a synthetic re-push of a retired id into the observed sequence ⇒ the + /// revival assertion FLIPS. + /// + /// ⚠ SCOPE: "both dumps" is the two TRACKED dumps. F4 is untracked until §5 U5. + #[test] + fn r17_a_retired_stack_entry_id_never_returns_and_normalization_preserves_it() { + use std::collections::HashSet; + + for (name, gz) in TRACKED_DUMPS { + let mut state = dump_state(gz); + let mut retired: HashSet = HashSet::new(); + let mut prev: HashSet = HashSet::new(); + let mut announcements = 0usize; + let mut resolutions = 0usize; + let mut revivals: Vec<(usize, ObjectId)> = Vec::new(); + let mut normalization_checks = 0usize; + let mut beats = 0usize; + + for beat in 0..40usize { + beats = beat; + let cur: HashSet = state.stack.iter().map(|e| e.id).collect(); + for id in cur.difference(&prev) { + announcements += 1; + if retired.contains(id) { + revivals.push((beat, *id)); + } + } + for id in prev.difference(&cur) { + resolutions += 1; + retired.insert(*id); + } + prev = cur; + + // ── ARM 3, on every beat carrying a stack ──────────────────────────────── + if !state.stack.is_empty() { + let ids = + |s: &GameState| -> Vec { s.stack.iter().map(|e| e.id).collect() }; + let before = ids(&state); + let normalized = state.normalize_for_loop(); + assert_eq!( + ids(&normalized), + before, + "[{name}] beat {beat}: ARM 3 — `normalize_for_loop` zeroes \ + `next_object_id` and clears trigger identity; rewriting a \ + `StackEntry.id` would break both `certified_period_touch`'s \ + cross-frame comparison and CR 104.4b equality" + ); + // INSTRUMENT-LIVENESS CONTROL: the comparison must be able to SEE a + // rewrite. Without it, `ids(..) == before` proves nothing about the + // detector, only about this board. + let mut rewritten = normalized; + rewritten.stack[0].id = ObjectId(u64::MAX); + assert_ne!( + ids(&rewritten), + before, + "[{name}] beat {beat}: the arm-3 comparison must FLIP on a rewritten id" + ); + normalization_checks += 1; + } + + if announcements >= 3 && resolutions >= 3 && normalization_checks >= 3 { + break; + } + if dump_drive_one_beat(&mut state).is_err() { + break; + } + } + + // ── PAIRED POSITIVE REACH-GUARD: the population is non-empty in BOTH directions + assert!( + announcements >= 3 && resolutions >= 3, + "[{name}] REACH-GUARD: the invariant must be checked over a population that \ + really announces AND really resolves, else it passes over nothing. \ + {announcements} announcements / {resolutions} resolutions in {beats} beats" + ); + assert!( + normalization_checks >= 3, + "[{name}] REACH-GUARD: arm 3 must have run against a NON-EMPTY stack; \ + {normalization_checks} checks" + ); + assert!( + revivals.is_empty(), + "[{name}] ARMS 1/2 — CR 608.1: an id that left the stack must never reappear \ + on it. `StackEntry.id` is drawn from the monotone `next_object_id`, whose \ + only two plain (non-`+= 1`) production assignments write THROWAWAY CLONES \ + (`effects/prepare.rs`'s simulated clone and `normalize_for_loop`'s comparand). \ + Revived: {revivals:?}" + ); + } + } + + /// R27 (a2) — THE SEAM: EVERY BOARD `certified_period_touch` HANDS THE CLASSIFIER IS AN + /// UN-NORMALIZED EVALUATION BOARD. + /// + /// CR 732.2a + CR 104.4b. Announcement and resolution are evaluated against the frame that + /// CARRIES the pair; a `normalize_for_loop` product zeroes `next_object_id`, so a + /// resolution evaluated against one allocates `ObjectId(0)` over a live object. The window + /// carrier is therefore the sample's `live` half, never its `normalized` half. + /// + /// The matched pair is the two halves of the SAME ring, one argument apart — the + /// `.normalized` arm is rounds 13–33's carrier, executed here as an instrument-liveness + /// control, so the `!= 0` assertion cannot be true for want of a board that could fail it. + /// + /// ⚠ WHAT THIS ARM DOES NOT COVER: the mint's own carrier CHOICE (`ring_live` in + /// `game::engine::bounded_cycle_offer`) is pinned structurally by + /// `game::engine`'s `the_period_touch_window_is_carried_by_the_live_half`, because a test + /// that builds its own window cannot flip on an edit to the mint's. + #[test] + fn r27_a2_every_announced_pair_carries_an_unnormalized_evaluation_board() { + for (name, gz) in TRACKED_DUMPS { + let (beat, board) = drive_dump_until(gz, 80, has_frozen_window).unwrap_or_else(|| { + panic!("REACH-GUARD [{name}]: no driven beat carried a usable window") + }); + assert_ne!( + board.next_object_id, 0, + "[{name}] beat {beat}: the LIVE board's allocator cursor must be non-zero, \ + else the axis this row is keyed to cannot discriminate" + ); + + let live: Vec<&GameState> = board.loop_detect_ring.iter().map(|f| &f.live).collect(); + let norm: Vec<&GameState> = board + .loop_detect_ring + .iter() + .map(|f| &f.normalized) + .collect(); + let touch = certified_period_touch( + &live[live.len() - 2..], + &board, + PeriodCertification::BoardCovered, + ); + assert!( + !touch.announced.is_empty(), + "[{name}] beat {beat}: REACH-GUARD — the announced half must be non-empty, \ + else the universal below quantifies over nothing" + ); + assert!( + touch + .announced + .iter() + .all(|(frame, _)| frame.next_object_id != 0), + "[{name}] beat {beat}: (a2) every carrying frame must be an EVALUATION board. \ + {} of {} announced pairs carry a zeroed allocator cursor", + touch + .announced + .iter() + .filter(|(f, _)| f.next_object_id == 0) + .count(), + touch.announced.len() + ); + + // ── INSTRUMENT-LIVENESS CONTROL: rounds 13–33's carrier, one argument apart ── + let control = certified_period_touch( + &norm[norm.len() - 2..], + &board, + PeriodCertification::BoardCovered, + ); + assert_eq!( + control.announced.len(), + touch.announced.len(), + "[{name}] the control must observe the SAME announcements — normalization \ + preserves every `StackEntry.id` (R17 arm 3), so only the CARRIER differs" + ); + assert!( + control + .announced + .iter() + .filter(|(_, e)| board.stack.iter().all(|s| s.id != e.id)) + .all(|(frame, _)| frame.next_object_id == 0), + "[{name}] the control arm must really carry ZEROED boards for the pairs whose \ + carrying frame is a RING frame; if it did not, the (a2) assertion above \ + would be true of any board and would prove nothing" + ); + } + } + + /// R16 (ii-b) — THE MAX SPEND ACROSS MINTABLE BEATS SATURATES THE CAP, AND IT DOES SO + /// AWAY FROM THE BEAT THE CORPUS OFFERS ON. + /// + /// CR 732.2a. (ii-a)'s companion, and the reason the two are SPLIT: exhaustion at a + /// non-offering beat is a fail-closed refusal on a beat that was refusing anyway — the + /// budget's stall-bounding job — while exhaustion at an OFFERING beat is a starved + /// acceptance and a defect. This arm records the first half on a real driven board. + /// + /// MEASURED, not predicted: at dellian beat 14 (`ring=2 stack=154 frozen=152`) the mint + /// spends the FULL cap and refuses `NoCertification`; the corpus's one offering beat + /// (dina, integration row `r16_the_offering_beats_probe_demand_is_exactly_measured`) + /// spends 13 and is NOT denied. Same seam, same cap, opposite sides of the budget. + /// + /// REVERT-PROBE: raise `PROBE_BUDGET` above dellian's unexempted demand (measured 96–107 + /// at these beats) ⇒ `denied` goes false ⇒ FLIPS. Lowering it cannot flip this arm, which + /// is exactly why the offering-beat row is a separate one. + #[test] + fn r16_ii_b_a_non_offering_mintable_beat_saturates_the_probe_budget() { + use crate::game::engine::{try_offer_bounded_cycle_shortcut_metered, ProbeCap}; + use crate::types::game_state::WaitingFor; + + let (beat, board) = drive_dump_until(TRACKED_DUMPS[1].1, 80, has_frozen_window) + .expect("REACH-GUARD: the dellian drive must reach a mintable beat"); + let proposer = board.active_player; + let mut at_priority = board.clone(); + at_priority.waiting_for = WaitingFor::Priority { player: proposer }; + assert!( + at_priority.last_loop_action_sequence.is_empty() + && at_priority.loop_detect_ring.len() >= 2, + "REACH-GUARD beat {beat}: steps (1)/(1b)/(2)/(2b) must all pass, else the mint \ + refuses above the classifier and spends nothing for a reason unrelated to cost" + ); + assert!( + at_priority.stack.len() > PROBE_BUDGET as usize, + "REACH-GUARD beat {beat}: the beat must carry more entries than the cap can pay \ + for, else saturation is not reachable at all; stack {} cap {PROBE_BUDGET}", + at_priority.stack.len() + ); + + let (outcome, meter) = + try_offer_bounded_cycle_shortcut_metered(&at_priority, false, ProbeCap::Shipped); + assert!( + outcome.is_err(), + "R16(ii-b): this beat must NOT offer — the whole point of the split is that \ + saturation here is a refusal on a beat that was refusing anyway. Got {outcome:?}" + ); + assert_eq!( + (meter.spent, meter.denied), + (PROBE_BUDGET, true), + "R16(ii-b) beat {beat}: the max observable spend at the shipped cap IS the cap, \ + and the exhaustion is LATCHED so it can be attributed rather than inferred. \ + meter {meter:?}, stack {}", + at_priority.stack.len() + ); + } + + // ─────────────────────────────────────────────────────────────────────────────────── + // §6 U3 ROWS — R14 / R19 / R29 / R31(completeness) / R32 + // ─────────────────────────────────────────────────────────────────────────────────── + + /// Shape (A): a proposer-controlled triggered ability declaring exactly ONE mandatory + /// PLAYER target (CR 115.2 "target opponent"), with the announcement ALREADY MADE. + /// + /// The announcement is load-bearing, not decoration: `optional_cleared_classification` + /// resolves the ability on the board `resolve_top` would hand it, and an UNANNOUNCED + /// target derives no events at all, which `probe_resolution` classifies `Prompted` + /// (§6 R11). A shape-(A) fixture without announced targets therefore has residual + /// `MayPrompt` for every slot vector and R19's transition could never fire. + fn u3_shape_a_entry(src: ObjectId, id: u64) -> StackEntry { + use crate::types::ability::{ + ControllerRef, Effect, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, + TypedFilter, + }; + let mut ability = ResolvedAbility::new( + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 1 }, + target: Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![], + controller: Some(ControllerRef::Opponent), + properties: vec![], + })), + }, + vec![TargetRef::Player(PlayerId(1))], + src, + PlayerId(0), + ); + ability.optional = true; + StackEntry { + id: ObjectId(id), + source_id: src, + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: src, + ability: Box::new(ability), + condition: None, + trigger_event: None, + description: None, + source_name: String::new(), + subject_match_count: None, + die_result: None, + }, + } + } + + /// §6 R7 — THE FROZEN PREFIX IS AN `(index, id)` IDENTITY, NOT A PRESENCE COUNT. + /// + /// CR 732.2a: `certified_period_touch` may exempt a `current.stack` entry from conjunct (6) + /// only when that entry sits at the SAME INDEX carrying the SAME `ObjectId` in every window + /// frame — i.e. it demonstrably did not participate in the observed period. A weaker test + /// ("something is at that index") would exempt an entry the period shifted underneath, and + /// a shifted entry is exactly one that DID move. + /// + /// This is the constructed unit R33/R21 do not cover: those rows measure the exemption on + /// the real dellian window, where every frame's prefix happens to be identity-stable, so + /// neither of them can separate the identity conjunct from a presence check. + /// + /// Arms, on ONE constructed 3-frame window (`[f0, f1, f2]` + `current`): + /// * **(a)** a stable `(index, id)` prefix of length 2 ⇒ `frozen_ids` contains exactly it; + /// * **(a′)** the reach-guard that makes (a) non-trivial: the stack's TAIL entry differs + /// across the frames, so `frozen_ids` is a PROPER subset and "freeze everything" fails; + /// * **(b)** one frame's stack shifted by a single index (an extra entry pushed at the + /// front) ⇒ the prefix ids are no longer at their own indices ⇒ NOTHING is frozen, even + /// though every id is still PRESENT in that frame. + /// + /// REVERT-PROBE (RUN, see the journal): weaken the identity conjunct to a presence check + /// (`frame.stack.get(*index).is_some()`) ⇒ (b)'s frozen set becomes the whole prefix again + /// ⇒ (b) FAILS. (a)/(a′) stay green under that probe, which is what makes (b) the arm the + /// identity conjunct is answerable to. + #[test] + fn r7_the_frozen_prefix_is_an_index_id_identity_never_a_presence_count() { + let entry = |id: u64| churn_entry(id, 0, lose_ability(1), None); + // ids 7001/7002 are the STABLE prefix; the tail differs per frame so the frozen set is + // a proper subset and this row cannot be satisfied by "freeze the whole stack". + let frame_with_tail = |tail: u64| { + let mut s = GameState::new_two_player(20); + s.stack.push_back(entry(7001)); + s.stack.push_back(entry(7002)); + s.stack.push_back(entry(tail)); + s + }; + let (f0, f1, f2) = ( + frame_with_tail(7010), + frame_with_tail(7011), + frame_with_tail(7012), + ); + let current = frame_with_tail(7013); + + // ── (a) + (a′) ── + let touch = certified_period_touch( + &[&f0, &f1, &f2], + ¤t, + PeriodCertification::BoardCovered, + ); + let frozen: Vec = touch.frozen_ids.iter().copied().collect(); + assert_eq!( + frozen, + vec![ObjectId(7001), ObjectId(7002)], + "(a) CR 732.2a: exactly the entries holding the SAME id at the SAME index in every \ + window frame are provably outside the observed period" + ); + assert!( + frozen.len() < current.stack.len(), + "(a′) reach-guard: the frozen set must be a PROPER subset — a fixture whose whole \ + stack froze could not tell the identity conjunct from `freeze everything`; \ + stack {} vs frozen {}", + current.stack.len(), + frozen.len() + ); + + // ── (b) one frame shifted by a single index; every id is still PRESENT in it ── + let shifted = { + let mut s = f1.clone(); + s.stack.push_front(entry(7099)); + s + }; + let shifted_ids: std::collections::BTreeSet = + shifted.stack.iter().map(|e| e.id).collect(); + assert!( + shifted_ids.contains(&ObjectId(7001)) && shifted_ids.contains(&ObjectId(7002)), + "(b) reach-guard: the shift must PRESERVE both prefix ids in that frame, otherwise \ + a presence check would reject them too and the arms would not separate" + ); + let shifted_touch = certified_period_touch( + &[&f0, &shifted, &f2], + ¤t, + PeriodCertification::BoardCovered, + ); + assert!( + shifted_touch.frozen_ids.is_empty(), + "(b) CR 732.2a: a single index shift means the entry moved WITHIN the observed \ + period, so no exemption is provable — presence at some index is not the property. \ + got {:?}", + shifted_touch.frozen_ids + ); + } + + /// R14 — THE DEGENERATE WINDOW ANNOUNCES `current.stack`, ELEMENT FOR ELEMENT. + /// + /// CR 732.2a + CR 608.1. With NO window frame there is no transition to observe, so the + /// honest degenerate reading is *"every current entry may announce"* — which is exactly + /// what makes `bounded_cycle_pin_slots` a thin alias of the window enumerator rather + /// than a second authority. + /// + /// ⚠ THE COMPARISON IS AGAINST THE TEST'S OWN INPUT DATA, NEVER AGAINST A SECOND CALL OF + /// THE FUNCTION UNDER TEST. Rounds 5/6 wrote this row as + /// `bounded_cycle_pin_slots_for_window(&certified_period_touch(&[], state, ..), p)` vs + /// `bounded_cycle_pin_slots(state, p)` — post-U3 the same function body on both sides, so + /// the equality held by construction AND the stated revert-probe edited a dependency BOTH + /// sides call. Round 7's replacement (a HEAD-captured frozen point sequence) was struck in + /// turn: U2's shape-(B) mint publishes on beats HEAD refuses, and a per-beat sequence + /// embeds `ObjectId` literals that a fixture re-dump renumbers. The PROPERTY against a + /// CONSTRUCTED frame has neither failure mode. + /// + /// REVERT-PROBE that actually flips: restore round 4's `window.len() < 2 ⇒ announced` + /// EMPTY branch ⇒ `announced.len() == 0` while the constructed stack is non-empty ⇒ the + /// element-for-element equality fails on the LENGTH assertion alone, and the ≥1-point + /// reach-guard fails with it. + #[test] + fn r14_the_degenerate_window_announces_the_current_stack_element_for_element() { + use crate::game::engine::{bounded_cycle_pin_slots_for_window, entry_publishes_pin_slots}; + + let (mut state, src) = u2_relief_board(); + // ≥2 entries, deliberately HETEROGENEOUS: the first publishes for P0, the second is + // controlled by P1 and the mint refuses it. `announced` must carry BOTH — the touch + // enumerates the period, the mint filters it — so an `announced` accidentally built + // from the mint's accepted set would fail the element-for-element equality. + let publishing = u2_shape_b_entry(src, 9140, u2_draw_effect(), |_| {}); + let mut foreign = u2_shape_b_entry(src, 9141, u2_draw_effect(), |_| {}); + foreign.controller = PlayerId(1); + state.stack.push_back(publishing); + state.stack.push_back(foreign); + assert!( + state.stack.len() >= 2, + "NON-DEGENERACY: a one-entry stack would make the ordering half of the claim \ + untestable and an empty one would make the whole property vacuous" + ); + + let expected: Vec<(usize, ObjectId)> = state + .stack + .iter() + .enumerate() + .map(|(i, e)| (i, e.id)) + .collect(); + + for cert in [ + PeriodCertification::BoardCovered, + PeriodCertification::BoardEqualOnly, + PeriodCertification::ResourceSignatureOnly, + ] { + let touch = certified_period_touch(&[], &state, cert); + let observed: Vec<(usize, ObjectId)> = touch + .announced + .iter() + .enumerate() + .map(|(i, (_, e))| (i, e.id)) + .collect(); + assert_eq!( + observed, expected, + "{cert:?}: the degenerate window's announced pairs are `current.stack` \ + element for element, IN ORDER" + ); + assert!( + touch + .announced + .iter() + .all(|(frame, _)| std::ptr::eq(*frame, &state)), + "{cert:?}: every degenerate pair's carrying frame IS `current` — there is no \ + other frame for it to be" + ); + assert!( + touch.frozen_ids.is_empty(), + "{cert:?}: with no window frame nothing is PROVEN frozen, on ANY certificate \ + value — the empty-window branch returns before the frozen walk" + ); + } + + // PAIRED POSITIVE REACH-GUARD, and it is what stops the property collapsing into a + // vacuous truth about a stack nothing publishes on. + let touch = certified_period_touch(&[], &state, PeriodCertification::ResourceSignatureOnly); + let points = bounded_cycle_pin_slots_for_window(&touch, PlayerId(0)); + assert!( + !points.is_empty(), + "reach-guard: at least one constructed entry must be ACCEPTED by the mint, or the \ + equality above would hold over a period the enumerator never publishes from" + ); + assert!( + entry_publishes_pin_slots(&state, &state.stack[1], PlayerId(0)).is_none(), + "reach-guard, other direction: the P1-controlled entry is REFUSED by the mint yet \ + still appears in `announced` — proof the touch is the period's enumeration and \ + not the mint's accepted set" + ); + } + + /// R19 — THE CACHED VERDICT HOLDS NOTHING SLOTS-DERIVED. + /// + /// The named regression row for the branch NOT taken (key the memo on + /// `(StackEntry.id, slots)`). If that option's risk returns one layer over, this is what + /// loses. + /// + /// **(a) BEHAVIOURAL.** `state` and `entry` are held FIXED and only `slots` varies, over + /// FOUR cases — `{}`, `{target}`, `{may}`, `{target, may}`. The vector is SHAPE-DEPENDENT + /// and both shapes are asserted, because D3 re-expressed the gate as *"`may` pinned AND + /// (`target.is_none()` OR target pinned)"*: + /// * **(a-A) shape (A)** — targeted AND optional ⇒ `[None, None, None, Some(residual)]`. + /// * **(a-B) shape (B)** — may-only ⇒ `[None, None, Some(residual), Some(residual)]`. + /// + /// The `{may}` case is the one that DIFFERS between the shapes, and asserting BOTH is what + /// makes D3's `target.is_none()` disjunct load-bearing: with (a-A) alone, deleting that + /// disjunct changes nothing and the row cannot see it. The two SINGLETON cases are what + /// make both disjuncts load-bearing, which a 0/1/2-slot sweep could not do. + /// + /// **(b) STRUCTURAL.** `EntryVerdict` destructures EXHAUSTIVELY into + /// `{ published, primary, residual }`, all three produced by functions whose signatures + /// take NO slots, so a future slots-derived field is a COMPILE ERROR rather than a review + /// miss. ⚠ `..` IS FORBIDDEN ON THAT DESTRUCTURE. Adding e.g. `pub slots_digest: u64` to + /// `EntryVerdict` makes the line below **E0027**; rustc's own `help:` then suggests adding + /// `..`, which silently disarms the guard. THE E0027 IS THIS GUARD FIRING, NOT A COMPILE + /// ERROR TO FIX. + /// + /// REVERT-PROBE (a): collapse the design to an id-keyed cache of the RELIEF verdict — + /// i.e. adopt option 1's shape with an incomplete key — ⇒ every case returns the `{}` + /// verdict `None` ⇒ the fourth arm's `Some(residual)` assertion FLIPS TO FAIL. The paired + /// positive reach-guard is mandatory and is asserted: the last case must return `Some` on + /// the unmutated design, otherwise the leading `None`s pass over a relief that never + /// relieves anything. + #[test] + fn r19_the_relief_vector_varies_only_with_slots_and_the_cached_value_is_slots_free() { + use crate::game::engine::entry_publishes_pin_slots; + use crate::game::resolution_prompt::ResolutionChoiceFreedom; + + let (mut state, src) = u2_relief_board(); + // Two live opponents, so the shape-(A) announcement is a REAL choice rather than a + // forced one — the mint's own acceptance is what this row rides, not target scarcity. + let shape_a = u3_shape_a_entry(src, 9190); + let shape_b = u2_shape_b_entry(src, 9191, u2_draw_effect(), |_| {}); + state.stack.push_back(shape_a.clone()); + state.stack.push_back(shape_b.clone()); + + let pub_a = entry_publishes_pin_slots(&state, &shape_a, PlayerId(0)) + .expect("(a-A) reach-guard: the shape-(A) fixture must reach the mint"); + let target = pub_a + .target + .clone() + .expect("(a-A) reach-guard: shape (A) publishes its CR 601.2c announcement slot"); + let may_a = pub_a + .may + .clone() + .expect("(a-A) reach-guard: shape (A) is optional, so it publishes its CR 603.5 gate"); + let pub_b = entry_publishes_pin_slots(&state, &shape_b, PlayerId(0)) + .expect("(a-B) reach-guard: the shape-(B) fixture must reach the mint"); + assert!( + pub_b.target.is_none(), + "(a-B) reach-guard: shape (B) announces NOTHING, so it publishes no target slot — \ + that `None` is exactly what the `{{may}}` case discriminates" + ); + let may_b = pub_b + .may + .clone() + .expect("(a-B) reach-guard: shape (B) publishes its CR 603.5 gate"); + + let mut verdicts = PeriodVerdicts::for_period(&[], &state, PlayerId(0)); + let f = verdicts + .frame_ix(&state) + .expect("reach-guard: the container holds the board the relief is asked about"); + + let relieved = |verdicts: &mut PeriodVerdicts<'_>, + entry: &StackEntry, + slots: &[DecisionSlot]| + -> bool { + matches!( + pinned_may_choice_relief(f, entry, verdicts, u2_scope(slots)), + Some(ResolutionChoiceFreedom::FreeUnlessReplacements(_)) + ) + }; + + // ── (a-A) shape (A): the gate needs BOTH slots ────────────────────────────────── + let vector_a = [ + relieved(&mut verdicts, &shape_a, &[]), + relieved(&mut verdicts, &shape_a, std::slice::from_ref(&target)), + relieved(&mut verdicts, &shape_a, std::slice::from_ref(&may_a)), + relieved(&mut verdicts, &shape_a, &[target.clone(), may_a.clone()]), + ]; + assert_eq!( + vector_a, + [false, false, false, true], + "(a-A) CR 603.5 + CR 601.2c: a targeted optional entry is relieved ONLY when both \ + published slots are pinned. The two SINGLETON cases are what make both disjuncts \ + of `may pinned AND (target.is_none() OR target pinned)` load-bearing" + ); + + // ── (a-B) shape (B): `target.is_none()` discharges the second conjunct outright ── + let vector_b = [ + relieved(&mut verdicts, &shape_b, &[]), + relieved(&mut verdicts, &shape_b, std::slice::from_ref(&target)), + relieved(&mut verdicts, &shape_b, std::slice::from_ref(&may_b)), + relieved(&mut verdicts, &shape_b, &[target.clone(), may_b.clone()]), + ]; + assert_eq!( + vector_b, + [false, false, true, true], + "(a-B) CR 601.2c: a may-only entry surfaces no announcement choice, so demanding a \ + pinned target would refuse relief the mint's own schema fully describes. The \ + `{{may}}` case is where the two shapes DIFFER — that difference is the whole \ + reason D3's `target.is_none()` disjunct exists" + ); + + // ── (b) STRUCTURAL: the cached value is slots-free BY DESTRUCTURE ─────────────── + // ⚠ NO `..`. A fourth field here is E0027 and the E0027 IS THIS GUARD FIRING. + let super::verdict_memo::EntryVerdict { + published, + primary, + residual, + } = verdicts.verdict(f, &shape_a); + assert!( + published.is_some() && matches!(primary, ResolutionChoiceFreedom::MayPrompt), + "(b) reach-guard: the destructured value must be the LIVE one this row's (a) arm \ + consumed — a mint answer plus the CR 603.5 `MayPrompt` that makes relief the \ + deciding layer" + ); + assert!( + residual.is_some(), + "(b) reach-guard: the optional-cleared residual is computed BECAUSE a `may` slot \ + is published — the field is not decorative" + ); + } + + /// R29 — THE CR 603.3c MID-CONSTRUCTION GUARD SURVIVES THE `FrameIx` REWRITE, AND IT + /// ANSWERS FROM THE CARRYING FRAME. + /// + /// Asserted AT THE RELIEF SEAM deliberately: an offer-level negative on this axis is + /// DOMINATED by conjunct (6)'s own refusals, and the relief has no upstream that can + /// satisfy it. + /// + /// * **(a) THE GUARD** — an entry the mint publishes a `target` slot for, that slot + /// PINNED (so the mint half alone answers `true`), with the frame's + /// `pending_trigger_entry = Some(entry.id)` ⇒ `false`. + /// * **(a′) MATCHED POSITIVE, byte-identical except the cursor** — `None` ⇒ `true`. This + /// is the reach-guard: without it, (a) passes over a mint that published nothing. + /// * **(b) THE EQUALITY, NOT THE PRESENCE** — a cursor naming NO live entry + /// (`ObjectId(u64::MAX)`) ⇒ still `true`, so the row pins `== Some(entry.id)` rather + /// than "any cursor refuses". In-tree precedent for exactly this control on the sibling + /// gate: `a_pinned_slot_skips_gate_three_and_six`'s gate-(1) control. + /// * **(c) WHICH BOARD** — the conjunct the 4-arg signature could not even express. The + /// pair is driven from a RETAINED frame, then the two boards are CROSSED: (c1) carrying + /// frame's cursor set, `current`'s clear ⇒ `false` (the carrying frame decides); (c2) + /// carrying frame's cursor clear, `current`'s set ⇒ `true` (the live board does NOT + /// decide for a retained pair). Byte-identical except which board holds the cursor. + /// + /// REVERT-PROBE (a)/(a′)/(b): delete + /// `if board.pending_trigger_entry == Some(entry.id) { return false; }` from + /// `entry_target_choice_is_pinned` — i.e. ship the round-35 4-arg signature, which makes + /// the statement unwritable — ⇒ (a) answers `true` ⇒ FLIPS, while (a′)/(b) stay green. + /// ⚠ That revert COMPILES, which is the whole reason this row exists: the fail-open + /// direction leaves no type error behind and `PeriodVerdicts.frames` is private, so an + /// executor who drops the board has no way to notice. + /// + /// REVERT-PROBE (c): pass the live `current` instead of the pair's carrying frame ⇒ (c1) + /// and (c2) BOTH FLIP, while (a)/(a′)/(b) stay green on the degenerate current-stack pair + /// where the two boards coincide. + #[test] + fn r29_the_cr_603_3c_cursor_is_read_from_the_pairs_carrying_frame() { + use crate::game::engine::entry_publishes_pin_slots; + + let (mut frame, src) = u2_relief_board(); + let entry = u3_shape_a_entry(src, 9290); + frame.stack.push_back(entry.clone()); + let published = entry_publishes_pin_slots(&frame, &entry, PlayerId(0)) + .expect("reach-guard: the fixture must reach the mint"); + let target = published.target.clone().expect( + "reach-guard: the mint must publish a TARGET slot, or the guard below \ + would be refused by the `target.is_some()` conjunct instead", + ); + let slots = vec![target]; + + // ── (a′) matched positive: no cursor ──────────────────────────────────────────── + let mut clear = frame.clone(); + clear.pending_trigger_entry = None; + { + let mut verdicts = PeriodVerdicts::for_period(&[], &clear, PlayerId(0)); + let f = verdicts + .frame_ix(&clear) + .expect("container holds the board"); + assert!( + entry_target_choice_is_pinned(&clear, f, &entry, &mut verdicts, u2_scope(&slots)), + "(a′) with the published slot pinned and no mid-construction cursor, the \ + announcement choice IS specified" + ); + } + + // ── (a) the guard: the cursor names THIS entry ────────────────────────────────── + let mut cursored = frame.clone(); + cursored.pending_trigger_entry = Some(entry.id); + { + let mut verdicts = PeriodVerdicts::for_period(&[], &cursored, PlayerId(0)); + let f = verdicts + .frame_ix(&cursored) + .expect("container holds the board"); + assert!( + !entry_target_choice_is_pinned( + &cursored, + f, + &entry, + &mut verdicts, + u2_scope(&slots) + ), + "(a) CR 603.3c: a mid-construction entry's announcement is not yet complete, \ + so no published slot can specify it — and `pending_trigger_entry` is set \ + exactly while a prompt is up, which is why the mint (a function of the \ + BOARD, never of the PROMPT) cannot carry it and the relief must" + ); + } + + // ── (b) EQUALITY, not presence ───────────────────────────────────────────────── + let mut foreign_cursor = frame.clone(); + foreign_cursor.pending_trigger_entry = Some(ObjectId(u64::MAX)); + { + let mut verdicts = PeriodVerdicts::for_period(&[], &foreign_cursor, PlayerId(0)); + let f = verdicts + .frame_ix(&foreign_cursor) + .expect("container holds the board"); + assert!( + entry_target_choice_is_pinned( + &foreign_cursor, + f, + &entry, + &mut verdicts, + u2_scope(&slots) + ), + "(b) the guard is `== Some(entry.id)`, not `is_some()` — a cursor naming no \ + live entry refuses nothing" + ); + } + + // ── (c) WHICH BOARD, on a RETAINED pair ──────────────────────────────────────── + // The container's frames are `[carrying, current]`, and `carrying` is NOT `current`, + // so the two boards can disagree — which is exactly the shape the degenerate + // current-stack pair cannot express. + let mut carrying = frame.clone(); + let mut current = frame.clone(); + current.stack.clear(); + + carrying.pending_trigger_entry = Some(entry.id); + current.pending_trigger_entry = None; + { + let ring = [&carrying]; + let mut verdicts = PeriodVerdicts::for_period(&ring, ¤t, PlayerId(0)); + let f = verdicts + .frame_ix(&carrying) + .expect("(c) reach-guard: the CARRYING frame is in the period"); + assert_ne!( + verdicts.frame_ix(&carrying), + verdicts.frame_ix(¤t), + "(c) reach-guard: the two boards must be DISTINCT frames, or (c1)/(c2) are \ + the same assertion twice" + ); + assert!( + !entry_target_choice_is_pinned( + &carrying, + f, + &entry, + &mut verdicts, + u2_scope(&slots) + ), + "(c1) the CARRYING frame decides: its cursor names this entry, so the \ + announcement is mid-construction on the board the announcement was made \ + against — regardless of what the live board says" + ); + } + + carrying.pending_trigger_entry = None; + current.pending_trigger_entry = Some(entry.id); + { + let ring = [&carrying]; + let mut verdicts = PeriodVerdicts::for_period(&ring, ¤t, PlayerId(0)); + let f = verdicts + .frame_ix(&carrying) + .expect("(c) reach-guard: the CARRYING frame is in the period"); + assert!( + entry_target_choice_is_pinned( + &carrying, + f, + &entry, + &mut verdicts, + u2_scope(&slots) + ), + "(c2) the LIVE board does not decide for a retained pair — byte-identical to \ + (c1) except which board holds the cursor" + ); + } + + // ── (c3) THE SAME CROSSING, AT THE PRODUCTION SEAM THAT WRITES THE ARGUMENT ───── + // (c1)/(c2) pin the PREDICATE's board-sensitivity, which is where the plan sites this + // row (an offer-level negative on this axis is dominated by conjunct (6)'s own + // refusals). But the argument the row is about is written in + // `stack_choices_are_all_specified`'s announcement loop, so the plan's stated + // revert-probe — "pass the live `current` instead of the pair's carrying frame" — + // needs a site that FLIPS. This arm is that site: the (c2) crossing driven end to + // end, where the correct argument CERTIFIES and the reverted one REFUSES. + // + // Both published slots are pinned here (the `may` too), so the entry also clears + // conjunct (6) and the `true` below is the whole predicate's answer, not one gate's. + { + let may = published + .may + .clone() + .expect("(c3) reach-guard: the fixture is optional, so it publishes a may gate"); + let both = vec![slots[0].clone(), may]; + let mut oldest = carrying.clone(); + oldest.stack.clear(); + let ring = [&oldest, &carrying]; + let touch = + certified_period_touch(&ring, ¤t, PeriodCertification::ResourceSignatureOnly); + assert!( + touch + .announced + .iter() + .any(|(frame, e)| std::ptr::eq(*frame, &carrying) && e.id == entry.id), + "(c3) reach-guard: the pair must arrive from the RETAINED frame — on a \ + current-stack pair the two boards coincide and the argument is untestable" + ); + assert!( + current.pending_trigger_entry == Some(entry.id) + && carrying.pending_trigger_entry.is_none(), + "(c3) reach-guard: the crossing must still be in place — `current` holds the \ + CR 603.3c cursor and the carrying frame does not" + ); + let mut verdicts = PeriodVerdicts::for_period(&ring, ¤t, PlayerId(0)); + assert!( + stack_choices_are_all_specified( + ¤t, + PlayerId(0), + &both, + Some(&touch), + &mut verdicts + ), + "(c3) with both published slots pinned and the mid-construction cursor sitting \ + only on the LIVE board, the announcement loop must certify — because it asks \ + the CARRYING frame. Passing `current` there makes the cursor bite, the `||`'s \ + other disjunct is also false (two legal assignments on the frame), and this \ + assertion FLIPS" + ); + } + } + + /// R32 — THE ANNOUNCEMENT LOOP'S OR-DISJUNCT READS THE PAIR'S CARRYING FRAME, NEVER + /// `current`. Paired with R29, which pins the same discipline for the other disjunct: + /// the two halves of one `||` must not read two boards. + /// + /// `stack_entry_has_no_ordering_input`'s arity does NOT change under 5d, which is why + /// only a row can pin the decision — §7 held it under *"Reused verbatim"*. It is + /// board-sensitive: `state.pending_trigger_entry`, then `forced_unique_targeting` → + /// `build_target_slots` + `auto_select_targets_for_ability`, i.e. the verdict is a + /// function of the board's legal-target POPULATION. + /// + /// THE PAIR. A two-frame window whose announced entry declares a target with **two** + /// legal assignments on its CARRYING FRAME and **one** on `current` (a creature that left + /// the battlefield after the announcement). + /// * **(a) CORRECT BOARD** ⇒ `false` (two assignments ⇒ not forced), and with no pin + /// published for that entry, `stack_choices_are_all_specified` ⇒ `false` — the + /// fail-closed answer. + /// * **(b) THE FAIL-OPEN TWIN** ⇒ passing `current` sees ONE legal assignment ⇒ `true` ⇒ + /// the entry is RELIEVED and the offer can certify over an announcement choice that is + /// not forced on the board where it is actually made. + /// * **(c) POSITIVE CONTROL that the instrument is keyed** — same window, an entry with + /// one legal assignment on BOTH boards ⇒ both arms agree on `true`, so (a)/(b) differ + /// BECAUSE OF THE BOARD and not because the fixture always disagrees. + /// + /// REVERT-PROBE: change `stack_entry_has_no_ordering_input(announced[i].0, entry)` to + /// `(state, entry)` in `stack_choices_are_all_specified`'s announcement loop ⇒ (b) is + /// what you get and (a)'s offer-level assertion FLIPS. + #[test] + fn r32_the_announcement_disjunct_reads_the_carrying_frame_not_the_live_board() { + use crate::game::scenario::GameScenario; + use crate::types::ability::{ + Effect, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, TypeFilter, TypedFilter, + }; + + // One creature the announcement can still reach on `current`, one that leaves. + let mut carrying = GameScenario::new_n_player(2, 7).build().state().clone(); + let stays = battlefield_creature(&mut carrying, 9320, 1); + let leaves = battlefield_creature(&mut carrying, 9321, 1); + + let creature_entry = |id: u64, announced: Vec| { + let ability = ResolvedAbility::new( + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: None, + properties: vec![], + }), + damage_source: None, + excess: None, + }, + announced, + ObjectId(9320), + PlayerId(0), + ); + StackEntry { + id: ObjectId(id), + source_id: ObjectId(9320), + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: ObjectId(9320), + ability: Box::new(ability), + condition: None, + trigger_event: None, + description: None, + source_name: String::new(), + subject_match_count: None, + die_result: None, + }, + } + }; + + let two_ways = creature_entry(9330, vec![TargetRef::Object(leaves)]); + carrying.stack.push_back(two_ways.clone()); + + // The window's oldest frame carries NO stack, so the entry ANNOUNCED inside it. + let mut oldest = carrying.clone(); + oldest.stack.clear(); + // `current`: the entry has resolved off the stack AND one legal target is gone. + let mut current = carrying.clone(); + current.stack.clear(); + current.objects.remove(&leaves); + current.battlefield.retain(|o| *o != leaves); + + // ── reach-guards: the two boards really carry DIFFERENT legal populations ─────── + assert_eq!( + carrying + .battlefield + .iter() + .filter(|o| carrying.objects.contains_key(o)) + .count(), + 2, + "reach-guard: the carrying frame must offer TWO legal assignments" + ); + assert!( + current.objects.contains_key(&stays) && !current.objects.contains_key(&leaves), + "reach-guard: `current` must offer exactly ONE — the fail-open direction needs a \ + target that is legal on the frame and gone from the live board" + ); + + // ── (a) / (b) THE MATCHED PAIR, one variable: which board ────────────────────── + assert!( + !stack_entry_has_no_ordering_input(&carrying, &two_ways), + "(a) CORRECT BOARD: two legal assignments on the carrying frame ⇒ the \ + announcement choice is NOT forced ⇒ fail-closed" + ); + assert!( + stack_entry_has_no_ordering_input(¤t, &two_ways), + "(b) THE FAIL-OPEN TWIN: on the live board one target has gone, so the assignment \ + LOOKS forced and the entry would be relieved — an offer certified over a choice \ + that is not forced where it is actually made" + ); + + // ── (a) at the OFFER-LEVEL seam, which is where the argument is actually written ─ + let ring = [&oldest, &carrying]; + let touch = + certified_period_touch(&ring, ¤t, PeriodCertification::ResourceSignatureOnly); + assert!( + touch + .announced + .iter() + .any(|(frame, e)| std::ptr::eq(*frame, &carrying) && e.id == two_ways.id), + "reach-guard: the pair must arrive from the RETAINED frame, not from \ + `current.stack` — on the degenerate pair the two boards coincide and the row \ + would be untestable" + ); + let mut verdicts = PeriodVerdicts::for_period(&ring, ¤t, PlayerId(0)); + assert!( + !stack_choices_are_all_specified( + ¤t, + PlayerId(0), + &[], + Some(&touch), + &mut verdicts + ), + "(a) offer level: with no pin published for the entry, the announcement loop's \ + `||` must refuse — and it can only refuse if its second disjunct read the \ + CARRYING frame. Passing `current` there yields (b) and this assertion flips" + ); + + // ── (c) POSITIVE CONTROL: one legal assignment on BOTH boards ────────────────── + let mut solo_carrying = carrying.clone(); + solo_carrying.objects.remove(&leaves); + solo_carrying.battlefield.retain(|o| *o != leaves); + let solo = creature_entry(9331, vec![TargetRef::Object(stays)]); + assert!( + stack_entry_has_no_ordering_input(&solo_carrying, &solo) + && stack_entry_has_no_ordering_input(¤t, &solo), + "(c) the instrument is KEYED, not always-disagreeing: an entry with one legal \ + assignment on both boards is answered identically on both" + ); + } + + /// R31 COMPLETENESS ARM (the U3 half of a row whose arms (a)/(a′)/(b) shipped in U2). + /// + /// U2's arms proved the closure on entries sitting on `current.stack`. This arm proves + /// the premise those arms silently rest on — *every minted pair is SCANNED* — for the + /// population U2 could not reach: a pair that ANNOUNCED inside the certified period and + /// has since left the stack. It ships here and not in U2 because its revert-probe names + /// `touch.announced`, which U3 creates: at U2 `stack_choices_are_all_specified` still + /// carried HEAD's 3-argument shape, so there was no `touch` to drop. + /// + /// Off-stack announced pairs are the MAJORITY population on both measured dumps + /// (`beats_offstack_nonzero` 157/161 F4, 19/23 dellian), so this is the common case, not + /// an edge one. + /// + /// THE BOARD: the entry carries `Effect::PayCost { payer: Controller }` — a member of + /// `effect_resolution_choice_freedom`'s fail-closed grouped arm, i.e. exactly U2 arm + /// (b)'s subject — announced on the retained carrying frame and ABSENT from + /// `current.stack`. Conjunct (6) must still refuse it. + /// + /// REVERT-PROBE: narrow the RESOLUTION loop back to `current.stack` only (drop the + /// `touch.announced` half of `pairs`) ⇒ the pair is never classified ⇒ the predicate + /// returns `true` ⇒ FLIPS. That is what makes "every minted pair is scanned" a MEASURED + /// premise rather than a stated one. + #[test] + fn r31_completeness_an_announced_off_stack_pair_is_still_scanned_by_conjunct_six() { + use crate::game::engine::entry_publishes_pin_slots; + use crate::types::ability::{AbilityCost, Effect, TargetFilter}; + + let (frame, src) = u2_relief_board(); + let entry = u2_shape_b_entry( + src, + 9310, + Effect::PayCost { + cost: AbilityCost::Mana { + cost: crate::types::mana::ManaCost::Cost { + shards: vec![], + generic: 1, + }, + }, + scale: None, + payer: TargetFilter::Controller, + }, + |_| {}, + ); + let may = entry_publishes_pin_slots(&frame, &entry, PlayerId(0)) + .expect("reach-guard: the fixture must reach the mint") + .may + .expect( + "reach-guard: it publishes its CR 603.5 gate, so the refusal below is the \ + RESIDUAL's and not the mint's", + ); + + // oldest ⇒ carrying ⇒ current: the entry announces on `carrying` and has RESOLVED + // OFF the stack by `current`. + let oldest = frame.clone(); + let mut carrying = frame.clone(); + carrying.stack.push_back(entry.clone()); + let current = frame.clone(); + + let ring = [&oldest, &carrying]; + let touch = + certified_period_touch(&ring, ¤t, PeriodCertification::ResourceSignatureOnly); + + // ── reach-guards: the pair is reachable ONLY through `announced` ──────────────── + assert!( + current.stack.is_empty(), + "reach-guard: with the entry still on `current.stack` this arm would be U2's \ + arm (b) again and the completeness premise would go untested" + ); + assert!( + touch + .announced + .iter() + .any(|(f, e)| std::ptr::eq(*f, &carrying) && e.id == entry.id), + "reach-guard: the window must actually MINT the pair, or the refusal below would \ + be a refusal over an empty population" + ); + assert!( + stack_entry_has_no_ordering_input(&carrying, &entry), + "reach-guard: shape (B) announces no choice, so the ANNOUNCEMENT loop passes and \ + the refusal below is attributable to conjunct (6)" + ); + + let mut verdicts = PeriodVerdicts::for_period(&ring, ¤t, PlayerId(0)); + assert!( + !stack_choices_are_all_specified( + ¤t, + PlayerId(0), + std::slice::from_ref(&may), + Some(&touch), + &mut verdicts + ), + "CR 732.2a: the described sequence is EVERY choice the shortcut makes, not the \ + subset that happens to sit on the stack at the offer beat. A `PayCost` residual \ + sits in the fail-closed grouped arm, so an announced-then-resolved pair must \ + still refuse the offer" + ); + assert!( + verdicts.conjunct6_asks() >= 1, + "attribution: conjunct (6) must have ASKED about the off-stack pair — a `false` \ + with zero asks would be some other gate's refusal. asks={}", + verdicts.conjunct6_asks() + ); + } + + /// R22's own NEGATIVE CONTROL — the VACUITY BOUNDARY, never coverage. + /// + /// At window offset zero the window-relative position EQUALS the absolute one + /// (`w_pos == abs ⟺ idx == 0`) and an id-only memo key serves the same frame it would + /// have computed anyway, so the shipped mint and BOTH reverts AGREE. Round 15's re-key + /// from two ring frames to three reproduced the diagnosed defect one parameter over + /// precisely because a "three-frame period" taken as the WHOLE ring plus `current` is + /// still `idx == 0`. + /// + /// This test is GREEN under every probe R22 runs. That measured agreement is the whole + /// point: it is what a fixture at this offset can prove, which is nothing. + #[test] + fn r22_control_at_window_offset_zero_the_two_key_arithmetics_agree() { + let (base, src) = u2_relief_board(); + let entry = u2_shape_b_entry(src, 9221, u2_draw_effect(), |_| {}); + let f1 = base.clone(); + let mut f2 = base.clone(); + f2.stack.push_back(entry.clone()); + let mut current = base.clone(); + current.stack.push_back(entry.clone()); + + let ring = [&f1, &f2]; + let mut flat = PeriodVerdicts::for_period(&ring, ¤t, PlayerId(0)); + let f = flat + .frame_ix(&f1) + .expect("the control's carrying frame is the FIRST — that is the whole defect"); + // `FrameIx`'s field is private outside its module — deliberately, it is a token and not + // a number — so offset zero is asserted by ORDER: nothing in the table precedes it. + let ix2 = flat.frame_ix(&f2).expect("f2 is in the period"); + let ixc = flat.frame_ix(¤t).expect("current is in the period"); + assert!( + f < ix2 && ix2 < ixc, + "the control is only a control AT offset zero: if the carrying frame stopped being \ + the FIRST, the fixture drifted into the discriminating shape and this test would \ + start claiming coverage it cannot support" + ); + assert!( + flat.verdict(f, &entry).published.is_some(), + "at idx == 0 every key arithmetic agrees — measured, and reported as a boundary \ + rather than as evidence" + ); + } + + /// R22 — THE VERDICT DOOR IS TOTAL, FRAME-CORRECT AND PROPOSER-CORRECT. + /// + /// The class row for the derived cache, and the ONLY row that can lose if a key-component + /// error returns. FIVE conjuncts. + /// + /// **(1) TOTALITY, STRUCTURAL.** `PeriodVerdicts::verdict(&mut self, f: FrameIx, + /// entry: &StackEntry) -> &EntryVerdict`. ⚠ AN `Option` RETURN, AN INDEXING OPERATOR, OR + /// ANY SIBLING `get(..)` ACCESSOR IS FORBIDDEN — a partial container has a MISS CONTRACT, + /// and a row asserting how a miss behaves asserts an unreachable state. Totality is scoped + /// honestly: it is total over every `FrameIx` THIS container minted, and MINTING + /// (`frame_ix`) is the membership question — conjunct (2′) is that half. + /// + /// **(2) FRAME-CORRECTNESS — THE PIN IS THE WINDOW OFFSET, NOT THE FRAME COUNT.** Round 15 + /// re-keyed this two frames → three and reproduced the diagnosed defect one parameter over: + /// `w_pos == abs ⟺ idx == 0`, so a "three-frame period" built as the WHOLE ring plus + /// `current` is exactly as vacuous as the two-frame form it replaced. The construction + /// requirement is therefore `ring.len() >= 3` **AND** the candidate window starting at + /// `idx >= 1` — a STRICT SUFFIX of the ring plus `current` — and BOTH are carried as + /// EXECUTABLE reach-guards, not prose. The period is built so the SAME entry id classifies + /// DIFFERENTLY per frame (the source object is absent from the older frame, so the mint + /// answers `None` there and `Some` on the carrying frame), and the consumed verdict must be + /// the CARRYING frame's, never the window-relative position's. + /// + /// The `idx == 0` shape is RETAINED as this conjunct's own NEGATIVE CONTROL: there the + /// shipped mint and the window-relative revert AGREE, and that measured agreement is the + /// VACUITY BOUNDARY — it must never be reported as coverage. + /// + /// **(2′) MINT-IDENTITY.** Every announced pair of a real window resolves through + /// `frame_ix`, and a FOREIGN frame — a fresh clone, byte-equal in CONTENT — resolves to + /// `None` and the consumer REFUSES. Correctness by IDENTITY, which index arithmetic cannot + /// satisfy on any `idx > 0` candidate. + /// + /// **(3) CONTAINER/CURRENT AGREEMENT — a PRODUCTION guard, not a `debug_assert`.** + /// Consumers resolve their own `current` through `frame_ix`; a memo built over a DIFFERENT + /// frame set yields `None` ⇒ certification refuses. + /// + /// **(4) PROPOSER COMPLETENESS.** The effective key is `(proposer, FrameIx, ObjectId)` with + /// the first component constant per container: a `for_period(A)` container publishes for A + /// while an `unproven` container publishes NOTHING for the SAME (frame, entry) — and each + /// resolves that frame through its OWN `frame_ix`, because a `FrameIx` never crosses the + /// container that minted it. The relief's agreement guard is the second half: pins minted + /// for A consumed under a container bound to B get `None`. + /// + /// REVERT-PROBES (all three RUN, see the journal): + /// * **(1)/(2)** key the memo by `ObjectId` ALONE (drop the `FrameIx` component) ⇒ the + /// id-keyed blindness returns, one entry gets ONE verdict across frames ⇒ (2) FLIPS while + /// the `idx == 0` control stays green — which is exactly the vacuity boundary. + /// * **(2′)/(3)** make the `current` resolution UNCHECKED (return the last index without + /// the `ptr::eq` test) ⇒ the foreign clone resolves and the mismatched memo certifies + /// against the wrong frame set ⇒ (2′) and (3) FLIP. + /// * **(4)** hard-code the container's proposer ⇒ the `for_period(A)`-vs-`unproven` pair + /// collapses to one answer ⇒ FLIPS. + #[test] + fn r22_the_verdict_door_is_total_frame_correct_and_proposer_correct() { + use crate::game::engine::entry_publishes_pin_slots; + + let (base, src) = u2_relief_board(); + let entry = u2_shape_b_entry(src, 9220, u2_draw_effect(), |_| {}); + + // The per-frame difference: the source object is GONE from the oldest frame, so the + // mint's `object_decision_source` conjunct answers `None` there and `Some` elsewhere. + // One entry id, two different verdicts — which is what an id-only key cannot express. + let mut f0 = base.clone(); + f0.objects.remove(&src); + let f1 = base.clone(); + // The entry ANNOUNCES at f2 — absent from the window's first frame, present after — + // which is what gives conjunct (2′) a real announced pair to quantify over. + let mut f2 = base.clone(); + f2.stack.push_back(entry.clone()); + let mut current = base.clone(); + current.stack.push_back(entry.clone()); + + assert!( + entry_publishes_pin_slots(&f1, &entry, PlayerId(0)).is_some() + && entry_publishes_pin_slots(&f0, &entry, PlayerId(0)).is_none(), + "(2) reach-guard: the two frames must give the SAME entry id DIFFERENT mint \ + answers, or the frame component of the key is unobservable and every assertion \ + below is vacuous" + ); + + // ── (2) THE OPERATING POINT: ring >= 3 AND the window a STRICT suffix (idx >= 1) ──── + let ring = [&f0, &f1, &f2]; + let idx = 1usize; + assert!( + ring.len() >= 3, + "(2) construction requirement: fewer than three ring frames cannot carry a strict \ + suffix, and the round-14 two-frame form was measured vacuous" + ); + assert!( + idx >= 1, + "(2) construction requirement: at idx == 0 the window-relative position EQUALS the \ + absolute one (`w_pos == abs ⟺ idx == 0`) and the revert cannot flip" + ); + let window = &ring[idx..]; + + let mut verdicts = PeriodVerdicts::for_period(&ring, ¤t, PlayerId(0)); + let ix0 = verdicts.frame_ix(&f0).expect("f0 is in the period"); + let ix1 = verdicts.frame_ix(&f1).expect("f1 is in the period"); + let ix2 = verdicts.frame_ix(&f2).expect("f2 is in the period"); + let ixc = verdicts + .frame_ix(¤t) + .expect("current is in the period"); + assert!( + ix0 < ix1 && ix1 < ix2 && ix2 < ixc, + "(2) the frames table is ordered by IDENTITY, ring-then-current: {ix0:?} {ix1:?} \ + {ix2:?} {ixc:?}" + ); + assert_ne!( + ix1, ix0, + "(2) reach-guard `abs != w_pos`: the CARRYING frame f1 sits at absolute index 1 \ + while window-relative arithmetic would place it at 0 (= f0). Equal indices here \ + would mean the fixture drifted back to the vacuous idx == 0 shape" + ); + + // (1) TOTALITY + (2) FRAME-CORRECTNESS on one run: a pair that was never pre-computed + // returns a REAL verdict (the signature returns `&EntryVerdict`, never an `Option`), + // and the two frames answer DIFFERENTLY for one entry id. + assert!( + verdicts.verdict(ix1, &entry).published.is_some(), + "(2) the consumed verdict is the CARRYING frame's — f1 holds the source object, so \ + the mint publishes" + ); + assert!( + verdicts.verdict(ix0, &entry).published.is_none(), + "(2) …and the OLDER frame's answer is its own. Under window-relative arithmetic \ + (or an id-only key) the carrying frame's cached `Some` would be served here" + ); + + // The idx == 0 negative control lives in its own test — see + // `r22_control_at_window_offset_zero_the_two_key_arithmetics_agree`, which must stay + // GREEN under this row's probes and must never be reported as coverage. + + // ── (2′) MINT-IDENTITY: every announced pair resolves; a foreign CLONE does not ───── + let touch = + certified_period_touch(window, ¤t, PeriodCertification::ResourceSignatureOnly); + assert!( + !touch.announced.is_empty(), + "(2′) reach-guard: the window must actually announce something, or the loop below \ + quantifies over nothing" + ); + for (frame, pair_entry) in &touch.announced { + assert!( + verdicts.frame_ix(frame).is_some(), + "(2′) every announced pair's carrying frame is minted by `frame_ix` — entry {:?}", + pair_entry.id + ); + } + let foreign = current.clone(); + assert!( + verdicts.frame_ix(&foreign).is_none(), + "(2′) POINTER IDENTITY, NOT EQUALITY: a fresh clone is byte-equal in content and \ + still outside the period, so it must not resolve. Index arithmetic cannot make \ + this distinction at all" + ); + // …and the CONSUMER refuses on it, fail-closed. + let foreign_touch = PeriodTouch { + announced: vec![(&foreign, &entry)], + frozen_ids: BTreeSet::new(), + }; + let mut v_foreign = PeriodVerdicts::for_period(&ring, ¤t, PlayerId(0)); + assert!( + !stack_choices_are_all_specified( + ¤t, + PlayerId(0), + &[], + Some(&foreign_touch), + &mut v_foreign + ), + "(2′) a frame outside this container's period costs a CERTIFICATE, never a wrong \ + one — the consumer's let-else on `frame_ix(..)` returns `false` and that is the \ + fail-closed arm" + ); + + // Conjuncts (3) and (4) are separate tests below, so each one's revert-probe is + // measured on its own assertion rather than shadowed by an earlier panic. + } + + /// R22 conjunct (3) — CONTAINER/CURRENT AGREEMENT IS A **PRODUCTION** GUARD. + /// + /// A `debug_assert` here would be compiled out of release and the mismatch would then be + /// silent in exactly the build that ships. Asserted as a MATCHED PAIR: the positive proves + /// the board certifies at all (without it the negative passes over a board that refuses for + /// unrelated reasons), the negative differs ONLY in which frame set the memo was built over. + /// + /// REVERT-PROBE (RUN): make `frame_ix` resolve unchecked — fall back to the last index + /// instead of returning `None` — and the mismatched container certifies against the wrong + /// frame set ⇒ the negative FLIPS. + #[test] + fn r22_conjunct3_a_memo_over_a_different_frame_set_refuses_certification() { + use crate::game::engine::entry_publishes_pin_slots; + + let (base, src) = u2_relief_board(); + let entry = u2_shape_b_entry(src, 9222, u2_draw_effect(), |_| {}); + let mut relieved_board = base.clone(); + relieved_board.stack.push_back(entry.clone()); + let may = entry_publishes_pin_slots(&relieved_board, &entry, PlayerId(0)) + .expect("(3) reach-guard: the fixture reaches the mint") + .may + .expect("(3) reach-guard: it publishes its CR 603.5 gate"); + let mut matched = PeriodVerdicts::for_period(&[], &relieved_board, PlayerId(0)); + assert!( + stack_choices_are_all_specified( + &relieved_board, + PlayerId(0), + std::slice::from_ref(&may), + None, + &mut matched + ), + "(3) POSITIVE: a container that holds the caller's `current` certifies this board — \ + without it the negative below would pass over a board that refuses anyway" + ); + let other_current = base.clone(); + let mut mismatched = PeriodVerdicts::for_period(&[], &other_current, PlayerId(0)); + assert!( + !stack_choices_are_all_specified( + &relieved_board, + PlayerId(0), + std::slice::from_ref(&may), + None, + &mut mismatched + ), + "(3) NEGATIVE: a memo built over a DIFFERENT frame set yields `None` for the \ + caller's `current`, so certification refuses. This is a PRODUCTION guard, not a \ + `debug_assert` compiled out of release" + ); + } + + /// R22 conjunct (4) — THE EFFECTIVE KEY CARRIES THE CONTAINER'S PROPOSER. + /// + /// `(proposer, FrameIx, ObjectId)`, with the first component constant per container and + /// therefore held by the container rather than by the memo key. Both halves are asserted: + /// a `for_period(A)` container publishes where an `unproven` one publishes NOTHING for the + /// SAME (frame, entry) — each resolving that frame through its OWN `frame_ix`, because a + /// `FrameIx` never crosses the container that minted it — and the relief refuses pins + /// minted for A when the container is bound to B (CR 603.5: the cached `published` IS the + /// mint's answer for the CONTAINER's proposer). + /// + /// REVERT-PROBES (both RUN): hard-code the proposer inside `verdict` ⇒ the + /// `for_period(A)`-vs-`unproven` pair collapses to one answer ⇒ FLIPS. Delete + /// `pinned_may_choice_relief`'s agreement guard ⇒ B's container relieves A's pins ⇒ FLIPS. + #[test] + fn r22_conjunct4_the_effective_key_carries_the_containers_proposer() { + use crate::game::engine::entry_publishes_pin_slots; + + let (base, src) = u2_relief_board(); + let entry = u2_shape_b_entry(src, 9223, u2_draw_effect(), |_| {}); + let mut relieved_board = base.clone(); + relieved_board.stack.push_back(entry.clone()); + let mut bound_to_a = PeriodVerdicts::for_period(&[], &relieved_board, PlayerId(0)); + let fa = bound_to_a + .frame_ix(&relieved_board) + .expect("(4) the container holds the board"); + assert!( + bound_to_a.verdict(fa, &entry).published.is_some(), + "(4) reach-guard, MANDATORY: the `Some` arm must fire, or the `None` below is \ + satisfied by a board that publishes nothing to anyone" + ); + let mut unproven_c = PeriodVerdicts::unproven(&relieved_board); + let fu = unproven_c.frame_ix(&relieved_board).expect( + "(4) each container resolves the frame through its OWN `frame_ix` — a \ + `FrameIx` never crosses the container that minted it", + ); + assert!( + unproven_c.verdict(fu, &entry).published.is_none(), + "(4) an `unproven` container binds NO proposer, so nothing is published: that is \ + the mint's own answer for 'no offer binds a proposer', not an invented one" + ); + // ── The relief half: pins minted for A, container bound to B ──────────────────────── + // + // ⚠ THE VACUITY THIS ARM HAD TO ESCAPE. Run against the P0-controlled `entry` above, + // this arm passes with the agreement guard DELETED — measured: probe P4 left it green. + // The mint's own `entry.controller != proposer` conjunct already answers `None` for B, + // so the negative was satisfied upstream of the guard it claimed to cover. The entry + // below is controlled by B, so B's container genuinely publishes and the guard is the + // ONLY thing left standing between A's pins and a relief minted for another seat. + let mut b_entry = u2_shape_b_entry(src, 9224, u2_draw_effect(), |a| { + a.controller = PlayerId(1); + }); + b_entry.controller = PlayerId(1); + let mut b_board = base.clone(); + b_board.stack.push_back(b_entry.clone()); + let b_may = entry_publishes_pin_slots(&b_board, &b_entry, PlayerId(1)) + .expect("(4) reach-guard: B's OWN entry reaches the mint under B") + .may + .expect("(4) reach-guard: and publishes its CR 603.5 gate under B"); + let b_slots = std::slice::from_ref(&b_may); + + let mut bound_to_b = PeriodVerdicts::for_period(&[], &b_board, PlayerId(1)); + let fb = bound_to_b + .frame_ix(&b_board) + .expect("(4) B's container holds the board"); + assert!( + pinned_may_choice_relief( + fb, + &b_entry, + &mut bound_to_b, + u3_scope_for(PlayerId(1), b_slots) + ) + .is_some(), + "(4) POSITIVE REACH-GUARD: under B's OWN pins this entry IS relieved. Without this \ + the negative below would be satisfied by an entry nobody can relieve" + ); + let mut bound_to_b2 = PeriodVerdicts::for_period(&[], &b_board, PlayerId(1)); + let fb2 = bound_to_b2 + .frame_ix(&b_board) + .expect("(4) same board, fresh container so the memo cannot carry B's answer over"); + assert!( + pinned_may_choice_relief( + fb2, + &b_entry, + &mut bound_to_b2, + u3_scope_for(PlayerId(0), b_slots) + ) + .is_none(), + "(4) CR 603.5: pins minted by A's offer may never be spent against a verdict this \ + container minted for B — the cached `published` IS the mint's answer for the \ + CONTAINER's proposer, so consuming it under another seat's pins would relieve a \ + choice that seat never described" + ); + } + + // ─────────────────────────────────────────────────────────────────────────────────── + // §6 R27 — THE `.live`-READER CONJUNCTS (a3) / (b) / (c) / (e) + // + // All four need what (a1)/(a2) did not: an announced pair whose CARRYING FRAME is a + // RETAINED SAMPLE rather than `current`. The shared fixture below is the only thing that + // makes them differ from the shipped `.normalized`-blind rows — and, measured, it is what + // lets (b) and (c) flip BEHAVIOURALLY on the mint's own carrier line, which round 7's + // self-built-window rows structurally could not (probe P6). + // ─────────────────────────────────────────────────────────────────────────────────── + + /// A retained ring whose NEWEST sample carries stack entries neither the older samples nor + /// the live board hold. + /// + /// CR 732.2a + CR 608.1: [`certified_period_touch`] announces an entry at the FIRST window + /// frame it appears on, so an entry seeded only into the newest retained sample is + /// announced with THAT SAMPLE as its carrying frame — the `frame != current` population + /// every `.live` reader is about, and the one a fixture assembled from `current.stack` + /// can never reach. `state.stack` is deliberately left EMPTY: with no live entry at all, + /// an arm that passed by reading the live board would have nothing to read. + /// + /// Certification is basis A's EQUALITY disjunct, and that is a construction property + /// rather than a hope: `setup` runs BEFORE any frame is snapshotted, so `ring[1]` + /// (the certifying prior at `span == 1`) and `current` agree on everything, and only + /// `ring[2]` — which the equality disjunct never compares — is widened. Both halves of + /// every sample are built exactly as `record_loop_detect_sample` builds them, so the + /// fixture cannot diverge from production's construction; in particular the `.normalized` + /// half really is `normalize_for_loop`d, which is what the instrument-liveness controls + /// below depend on. + /// + /// The per-frame life step is `game::engine`'s `drain_ring` orientation — frame `i` sits + /// `FRAMES - i` life above the live board — so the certified period moves a real CR 704.5a + /// resource and step (7) publishes a bound instead of refusing at `NoNarrowedLegalCount`. + fn ring_announcing_on_its_newest_sample( + setup: impl Fn(&mut GameState), + newest_sample: impl Fn(&mut GameState), + ) -> GameState { + use crate::game::scenario::GameScenario; + use crate::types::game_state::{LoopDetectionMode, WaitingFor}; + use crate::types::LoopDetectSample; + + const FRAMES: usize = 3; + let mut scenario = GameScenario::new_n_player(2, 7); + // Stocked libraries are load-bearing for the arms whose announced entry DRAWS: an + // empty library derives no `ZoneChange`, which moves the classification for a reason + // that has nothing to do with the carrier axis. + let names: Vec = (0..40).map(|i| format!("Filler {i}")).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + scenario.with_library_top(PlayerId(0), &refs); + scenario.with_library_top(PlayerId(1), &refs); + let mut state = scenario.build().state().clone(); + state.loop_detection = LoopDetectionMode::Interactive; + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + state.active_player = PlayerId(0); + state.last_loop_action_sequence.clear(); + setup(&mut state); + for i in 0..FRAMES { + let mut frame = state.clone(); + frame + .players + .iter_mut() + .find(|p| p.id == PlayerId(1)) + .expect("the two-seat scenario always has P1") + .life += (FRAMES - i) as i32; + if i + 1 == FRAMES { + newest_sample(&mut frame); + } + state + .loop_detect_ring + .push_back(std::sync::Arc::new(LoopDetectSample { + normalized: frame.normalize_for_loop(), + live: frame.loop_detect_live_sample(), + })); + } + state + } + + /// The battlefield source every announced entry names. `entered_battlefield_turn` is set + /// to the live turn because R27 (c)'s intervening-if reads exactly that through its + /// `TriggerSourceContext` (CR 603.4); the other three arms are indifferent to it. + fn announcing_ring_source(state: &mut GameState, id: u64) -> ObjectId { + let oid = ObjectId(id); + let mut object = GameObject::new( + oid, + CardId(77), + PlayerId(0), + "Retained Sample Source".to_string(), + Zone::Battlefield, + ); + object.card_types.core_types = vec![CoreType::Creature]; + object.incarnation = 3; + object.entered_battlefield_turn = Some(state.turn_number); + state.objects.insert(oid, object); + state.battlefield.push_back(oid); + oid + } + + /// One proposer-controlled triggered-ability entry for the newest sample's stack. + fn announced_trigger_entry( + id: u64, + src: ObjectId, + ability: crate::types::ability::ResolvedAbility, + condition: Option, + ) -> StackEntry { + StackEntry { + id: ObjectId(id), + source_id: src, + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: src, + ability: Box::new(ability), + condition, + trigger_event: None, + description: None, + source_name: String::new(), + subject_match_count: None, + die_result: None, + }, + } + } + + /// The ONE reach-guard every R27 `.live` arm runs before it asserts anything: the fixture + /// really announces from a retained sample, the live board carries no entry at all, and + /// the pair the production enumerator hands its consumers is that sample's — not + /// `current`'s. Returns the announced pair's carrying frame. + fn announced_from_retained_sample(state: &GameState, entry_id: u64) -> &GameState { + assert!( + state.stack.is_empty(), + "REACH-GUARD: `current.stack` must be EMPTY, else an arm could be satisfied by the \ + live board and the `.live`-reader claim would not be under test" + ); + let retained: Vec<&GameState> = state.loop_detect_ring.iter().map(|f| &f.live).collect(); + assert!( + retained.len() >= 3, + "REACH-GUARD: the walk needs `span >= 1` at `idx = len - 2`; got {} frames", + retained.len() + ); + let touch = certified_period_touch( + &retained[retained.len() - 2..], + state, + PeriodCertification::BoardEqualOnly, + ); + let (frame, entry) = *touch + .announced + .first() + .expect("REACH-GUARD: the newest sample's extra entry must ANNOUNCE in the window"); + assert_eq!( + (touch.announced.len(), entry.id), + (1, ObjectId(entry_id)), + "REACH-GUARD: exactly the constructed entry announces, so every assertion below is \ + about it and not about an incidental second pair" + ); + assert!( + !std::ptr::eq(frame, state), + "REACH-GUARD (the R27 precondition, executable rather than prose): the pair's \ + carrying frame must NOT be `current`. A fixture drift that collapsed the pair onto \ + the live board would leave every arm below passing for the degenerate reason" + ); + frame + } + + /// R27 (a3) — THE BEHAVIOUR: A RETAINED SAMPLE DERIVES THE SAME EVENT SET THE LIVE BOARD + /// DOES, AND THE NORMALIZED HALF DOES NOT. + /// + /// CR 732.2a + CR 104.4b + CR 111.1. (a2) pinned that every carrying frame is an + /// un-normalized board by reading `next_object_id`; this arm pins the CONSEQUENCE — that + /// the classification a `.live` carrier produces for an `Effect::Token` announcement is + /// the classification the live board produces, event for event. + /// + /// THE INSTRUMENT-LIVENESS CONTROL IS THE ARM THAT MAKES THE EQUALITY MEAN ANYTHING, and + /// it is MEASURED rather than predicted. The plan forecast the divergence at the + /// ALLOCATOR (`create_object` handing out `ObjectId(0)` over a live object); measured, the + /// derivation diverges one field earlier and more directly — `normalize_for_loop` runs + /// `clear_trigger_identity_recursive`, which sets `ability.source_id = ObjectId(0)`, and + /// the resolver carries that straight into `TokenSpec.source_id`. So a normalized carrier + /// proposes a token whose CR 111.1 source is the null object, and the two sets differ. + /// + /// ⚠ SCOPE, stated because a reader will ask why this arm is not an offer-level one: + /// BOTH derivations are `event_is_accounted`, so the mint OFFERS on either carrier and + /// (a3) alone has NO behavioural flip at the seam. The carrier axis IS flipped + /// behaviourally, on exactly the shared revert the plan names, by + /// `r27_b_a_stored_may_auto_choice_survives_the_ring` and + /// `r27_c_an_intervening_if_binds_with_the_retained_samples_trigger_source` below, and + /// structurally by `game::engine`'s `the_period_touch_window_is_carried_by_the_live_half`. + /// This arm's own flipping site is the control's: delete + /// `ResolvedAbility::clear_trigger_identity_recursive`'s `self.source_id = ObjectId(0)` + /// ⇒ the two halves stop differing ⇒ the control FAILS. + #[test] + fn r27_a3_a_retained_sample_derives_the_live_boards_event_set() { + use crate::game::engine::{try_offer_bounded_cycle_shortcut_metered, ProbeCap}; + use crate::game::resolution_prompt::ResolutionChoiceFreedom; + use crate::types::ability::{Effect, PtValue, QuantityExpr, ResolvedAbility, TargetFilter}; + use crate::types::proposed_event::ProposedEvent; + + const SRC: ObjectId = ObjectId(940); + const ENTRY: u64 = 954; + let state = ring_announcing_on_its_newest_sample( + |s| { + announcing_ring_source(s, SRC.0); + }, + |frame| { + let ability = ResolvedAbility::new( + Effect::Token { + name: "Servo".to_string(), + power: PtValue::Fixed(1), + toughness: PtValue::Fixed(1), + types: vec!["Artifact".to_string(), "Creature".to_string()], + colors: vec![], + keywords: vec![], + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: None, + enters_attacking: false, + supertypes: vec![], + static_abilities: vec![], + enter_with_counters: vec![], + }, + vec![], + SRC, + PlayerId(0), + ); + frame + .stack + .push_back(announced_trigger_entry(ENTRY, SRC, ability, None)); + }, + ); + let frame = announced_from_retained_sample(&state, ENTRY); + let entry = frame.stack.back().expect("the announced entry").clone(); + + // ── the two derivations, each on its own budget so neither starves the other ── + let mut on_frame_budget = ProbeBudget::for_test(1_000); + let mut on_live_budget = ProbeBudget::for_test(1_000); + let from_sample = + stack_entry_resolution_choice_freedom(frame, &entry, &mut on_frame_budget); + let from_live = stack_entry_resolution_choice_freedom(&state, &entry, &mut on_live_budget); + + // ── REACH-GUARD: both sides must be non-empty `FreeUnlessReplacements`, so the + // equality below cannot be two refusals or two empties matching ── + let derived = |freedom: &ResolutionChoiceFreedom| -> Vec { + match freedom { + ResolutionChoiceFreedom::FreeUnlessReplacements(events) => events.clone(), + ResolutionChoiceFreedom::MayPrompt => panic!( + "REACH-GUARD: an `Effect::Token` announcement must classify as a derived \ + event set on BOTH boards; a refusal here means the fixture never reached \ + the derivation and the equality would be vacuous" + ), + } + }; + let sample_events = derived(&from_sample); + let live_events = derived(&from_live); + assert!( + !sample_events.is_empty() && !live_events.is_empty(), + "REACH-GUARD: `probe_resolution` classifies an EMPTY derivation `Prompted`, so a \ + non-empty set is what proves the resolution really ran" + ); + + // ── (a3): the retained sample's derivation IS the live board's ── + assert_eq!( + from_sample, from_live, + "(a3) CR 732.2a: an announcement carried by a retained sample must classify exactly \ + as the same entry classified against the live board. Sequence equality is STRICTER \ + than the set equality the row claims, which is the safe direction — both sides come \ + from one deterministic `resolve_ability_chain`" + ); + + // ── INSTRUMENT-LIVENESS CONTROL: the OTHER half of the same sample, one field apart ── + let normalized_half = &state.loop_detect_ring[state.loop_detect_ring.len() - 1].normalized; + let normalized_entry = normalized_half + .stack + .back() + .expect("normalization preserves every `StackEntry.id` (R17 arm 3)") + .clone(); + let mut control_budget = ProbeBudget::for_test(1_000); + let from_normalized = stack_entry_resolution_choice_freedom( + normalized_half, + &normalized_entry, + &mut control_budget, + ); + let token_source = |freedom: &ResolutionChoiceFreedom| -> Option { + match freedom { + ResolutionChoiceFreedom::FreeUnlessReplacements(events) => { + events.iter().find_map(|event| match event { + ProposedEvent::CreateToken { spec, .. } => Some(spec.source_id), + _ => None, + }) + } + ResolutionChoiceFreedom::MayPrompt => None, + } + }; + assert_eq!( + (token_source(&from_sample), token_source(&from_normalized)), + (Some(SRC), Some(ObjectId(0))), + "(a3) CONTROL — CR 400.7 + CR 111.1: `normalize_for_loop` runs \ + `clear_trigger_identity_recursive`, which zeroes `ability.source_id`, and the \ + resolver carries that into `TokenSpec.source_id`. The normalized half therefore \ + proposes a token whose source is the NULL object. Without this arm the equality \ + above would be true of any two boards and would prove nothing" + ); + assert_ne!( + from_sample, from_normalized, + "(a3) CONTROL: and the two halves' derivations must genuinely DIFFER, so the \ + carrier is a choice with a consequence rather than a label" + ); + + // ── the seam companion: conjunct (6) really consumed THIS pair on the real mint ── + let (outcome, meter) = + try_offer_bounded_cycle_shortcut_metered(&state, false, ProbeCap::Shipped); + assert!( + outcome.is_ok() && meter.conjunct6_asks == 1, + "(a3) the board the equality is asserted on must be one the SEAM evaluates: the \ + mint offers and asks conjunct (6) exactly once — about the announced pair, since \ + `current.stack` is empty. Got {outcome:?}, meter {meter:?}" + ); + } + + /// R27 (b) — THE ENTRY-IDENTITY AXIS: A STORED CR 603.5 AUTO-CHOICE STILL REFUSES THE MINT + /// WHEN THE PAIR ARRIVES FROM THE RING. + /// + /// CR 603.5 + CR 732.2a. R25 pinned the mint's second-authority conjunct on a board whose + /// entry sits on `current.stack`; this is its missing production twin — the same board + /// driven THROUGH the ring, so the mint is asked about a retained sample. The refusal has + /// to survive that, because the announced population is where the mint's domain actually + /// lives (`bounded_cycle_pin_slots_for_window` maps over `touch.announced`). + /// + /// MATCHED PAIR, differing ONLY in the seeded record, so no upstream conjunct can dominate: + /// without it the board OFFERS and publishes exactly one CR 603.5 `MayChoice` point; with + /// it the mint publishes nothing, the relief has no `may` to spend, and step (6) refuses + /// `UnspecifiedChoiceWindow`. + /// + /// REVERT-PROBE (the plan's shared carrier revert, and it FLIPS — measured): point + /// `game::engine::bounded_cycle_offer`'s `ring_live` at `&f.normalized` ⇒ the carrying + /// frame becomes a comparand whose `ability.source_id` is `ObjectId(0)` + /// (`clear_trigger_identity_recursive`) ⇒ the `MayTriggerAutoChoiceKey` misses ⇒ the `may` + /// slot IS minted ⇒ the negative arm OFFERS. This is the flip round 7 could not obtain: + /// a row that builds its own window is blind to the mint's carrier, a row driven through + /// the mint is not. + #[test] + fn r27_b_a_stored_may_auto_choice_survives_the_ring() { + use crate::game::engine::{ + try_offer_bounded_cycle_shortcut_metered, BoundedOfferRefusal, ProbeCap, + }; + use crate::types::ability::{ + Effect, QuantityExpr, ResolvedAbility, TargetFilter, TriggerBaseSetInstanceRef, + TriggerDefinitionOccurrenceRef, TriggerDefinitionRef, + }; + use crate::types::game_state::{AutoMayChoice, MayTriggerAutoChoiceKey, MayTriggerOrigin}; + use crate::types::identifiers::ObjectIncarnationRef; + + const SRC: ObjectId = ObjectId(940); + const ENTRY: u64 = 952; + // The production shape: `triggers.rs` mints `Definition { definition_ref }` from the + // source's own incarnation plus the printed occurrence — built here identically. + let origin = MayTriggerOrigin::Definition { + definition_ref: TriggerDefinitionRef { + source: ObjectIncarnationRef::of(SRC, 3), + occurrence: TriggerDefinitionOccurrenceRef::Printed { + base_set: TriggerBaseSetInstanceRef::INITIAL, + printed_index: 0, + }, + }, + }; + let key = |origin: MayTriggerOrigin| MayTriggerAutoChoiceKey { + player: PlayerId(0), + source_id: SRC, + origin, + }; + let announce = |origin: MayTriggerOrigin| { + move |frame: &mut GameState| { + // Shape (B): OPTIONAL, declaring no target, so the mint publishes its CR 603.5 + // gate alone and the relief's residual is the same draw with `optional` cleared. + let mut ability = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + SRC, + PlayerId(0), + ); + ability.optional = true; + ability.may_trigger_origin = Some(origin.clone()); + frame + .stack + .push_back(announced_trigger_entry(ENTRY, SRC, ability, None)); + } + }; + + // ── MATCHED POSITIVE: no stored record ⇒ the CR 603.5 gate really asks ⇒ pin spendable + let open = ring_announcing_on_its_newest_sample( + |s| { + announcing_ring_source(s, SRC.0); + }, + announce(origin.clone()), + ); + announced_from_retained_sample(&open, ENTRY); + let (open_outcome, open_meter) = + try_offer_bounded_cycle_shortcut_metered(&open, false, ProbeCap::Shipped); + let published_may_points = match &open_outcome { + Ok(crate::types::game_state::WaitingFor::LoopShortcut { schema, .. }) => schema + .points + .iter() + .filter(|p| { + matches!( + p.kind, + crate::analysis::decision_template::DecisionPointKind::MayChoice + ) + }) + .count(), + other => panic!( + "MATCHED POSITIVE: the un-seeded board must OFFER, else the negative below is \ + a dominated refusal. Got {other:?}, meter {open_meter:?}" + ), + }; + assert_eq!( + published_may_points, 1, + "CR 603.5: the announced pair publishes exactly ONE `MayChoice` point, so the \ + negative's refusal is the loss of THAT point and not of an unrelated slot" + ); + + // ── NEGATIVE: the same board with one record seeded before the frames are snapshotted + let seeded_origin = origin.clone(); + let sealed = ring_announcing_on_its_newest_sample( + move |s| { + announcing_ring_source(s, SRC.0); + s.set_may_trigger_auto_choice(key(seeded_origin.clone()), AutoMayChoice::Decline); + }, + announce(origin.clone()), + ); + let sealed_frame = announced_from_retained_sample(&sealed, ENTRY); + assert_eq!( + sealed_frame.may_trigger_auto_choice(&key(origin.clone())), + Some(AutoMayChoice::Decline), + "REACH-GUARD: the record must be readable ON THE CARRYING FRAME under the key the \ + mint builds. Seeding it only on `current` would make the negative pass for the \ + wrong reason — and would be exactly the defect this row exists to catch" + ); + let (sealed_outcome, sealed_meter) = + try_offer_bounded_cycle_shortcut_metered(&sealed, false, ProbeCap::Shipped); + assert_eq!( + sealed_outcome, + Err(BoundedOfferRefusal::UnspecifiedChoiceWindow), + "(b) CR 603.5: a stored 'don't ask again' answer is a SECOND authority on the same \ + gate, and the gate returns before setting any prompt — so a pin minted for it \ + would be silently unused. The refusal must survive the pair arriving from a \ + retained sample. meter {sealed_meter:?}" + ); + assert_eq!( + (sealed_meter.conjunct6_asks, sealed_meter.certification), + (1, Some(PeriodCertification::BoardEqualOnly)), + "(b) ATTRIBUTION: the refusal is step (6)'s on the announced pair — certification \ + matched and conjunct (6) ran exactly once — not an earlier conjunct's" + ); + } + + /// R27 (c) — THE SCOPE-BINDING AXIS: A CR 603.4 INTERVENING-IF ON A RETAINED SAMPLE BINDS + /// WITH ITS TRIGGER SOURCE. + /// + /// CR 603.4 + CR 732.2a. `bind_resolution_scope` rechecks the intervening-if as the + /// ability resolves, reading `ability.trigger_source`; `normalize_for_loop` sets that to + /// `None`. A `TriggerCondition::SourceEnteredThisTurn` is TRUE only when the context is + /// present, so classifying such an entry against a normalized carrier takes the + /// absent-context path, the recheck fails, and `stack_entry_resolution_choice_freedom` + /// returns `MayPrompt` — a mandatory entry the mint publishes no `may` for, hence a + /// refusal the live board would never make. + /// + /// THE MATCHED PAIR IS THE CONTEXT ITSELF, byte-identical otherwise, so the offer's + /// existence is attributable to `trigger_source` and to nothing else on the board. The + /// plan's "must classify identically to the same entry classified against `current`" ships + /// as its own conjunct alongside. + /// + /// REVERT-PROBE (the shared carrier revert, and it FLIPS — measured): point + /// `bounded_cycle_offer`'s `ring_live` at `&f.normalized` ⇒ the POSITIVE arm stops + /// offering and returns `UnspecifiedChoiceWindow`. + #[test] + fn r27_c_an_intervening_if_binds_with_the_retained_samples_trigger_source() { + use crate::game::engine::{ + try_offer_bounded_cycle_shortcut_metered, BoundedOfferRefusal, ProbeCap, + }; + use crate::game::resolution_prompt::ResolutionChoiceFreedom; + use crate::types::ability::{Effect, QuantityExpr, ResolvedAbility, TriggerCondition}; + + const SRC: ObjectId = ObjectId(940); + const ENTRY: u64 = 953; + let announce = |with_context: bool| { + move |frame: &mut GameState| { + // MANDATORY: an optional entry would be relieved through the CR 603.5 pin and + // the refusal below would be about the wrong gate. + let mut ability = ResolvedAbility::new( + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 1 }, + target: None, + }, + vec![], + SRC, + PlayerId(0), + ); + if with_context { + let source = frame.objects[&SRC].clone(); + ability.trigger_source = Some( + crate::game::triggers::trigger_source_context_for_latch(frame, &source), + ); + } + frame.stack.push_back(announced_trigger_entry( + ENTRY, + SRC, + ability, + Some(TriggerCondition::SourceEnteredThisTurn), + )); + } + }; + + // ── POSITIVE: the context is present, the CR 603.4 recheck passes, the mint offers ── + let bound = ring_announcing_on_its_newest_sample( + |s| { + announcing_ring_source(s, SRC.0); + }, + announce(true), + ); + let bound_frame = announced_from_retained_sample(&bound, ENTRY); + let entry = bound_frame + .stack + .back() + .expect("the announced entry") + .clone(); + + // (c)'s own claim, at the predicate: the retained sample classifies the intervening-if + // exactly as the live board would. + let mut sample_budget = ProbeBudget::for_test(1_000); + let mut live_budget = ProbeBudget::for_test(1_000); + let from_sample = + stack_entry_resolution_choice_freedom(bound_frame, &entry, &mut sample_budget); + let from_live = stack_entry_resolution_choice_freedom(&bound, &entry, &mut live_budget); + assert!( + matches!( + from_sample, + ResolutionChoiceFreedom::FreeUnlessReplacements(_) + ), + "REACH-GUARD: the recheck must PASS on the carrying frame, else the equality below \ + is two refusals agreeing. Got {from_sample:?}" + ); + assert_eq!( + from_sample, from_live, + "(c) CR 603.4: the intervening-if recheck reads `ability.trigger_source`, and a \ + retained sample carries it, so the pair classifies exactly as the live board does" + ); + + // INSTRUMENT-LIVENESS CONTROL: the same sample's NORMALIZED half, one field apart. + let normalized_half = &bound.loop_detect_ring[bound.loop_detect_ring.len() - 1].normalized; + let normalized_entry = normalized_half + .stack + .back() + .expect("normalization preserves every `StackEntry.id`") + .clone(); + let mut control_budget = ProbeBudget::for_test(1_000); + assert_eq!( + stack_entry_resolution_choice_freedom( + normalized_half, + &normalized_entry, + &mut control_budget + ), + ResolutionChoiceFreedom::MayPrompt, + "(c) CONTROL — CR 603.4 + CR 400.7: `clear_trigger_identity_recursive` sets \ + `trigger_source = None`, so `check_trigger_condition_with_source` takes the \ + absent-context path, `bind_resolution_scope` returns false and the classifier is \ + fail-closed `MayPrompt`. Without this the equality above would hold on any board" + ); + + let (bound_outcome, bound_meter) = + try_offer_bounded_cycle_shortcut_metered(&bound, false, ProbeCap::Shipped); + assert!( + bound_outcome.is_ok(), + "(c) MATCHED POSITIVE: with the context present the mint OFFERS, so the negative \ + below is attributable to the context. Got {bound_outcome:?}, {bound_meter:?}" + ); + + // ── NEGATIVE: byte-identical except that the entry carries no `TriggerSourceContext` ── + let unbound = ring_announcing_on_its_newest_sample( + |s| { + announcing_ring_source(s, SRC.0); + }, + announce(false), + ); + announced_from_retained_sample(&unbound, ENTRY); + let (unbound_outcome, unbound_meter) = + try_offer_bounded_cycle_shortcut_metered(&unbound, false, ProbeCap::Shipped); + assert_eq!( + unbound_outcome, + Err(BoundedOfferRefusal::UnspecifiedChoiceWindow), + "(c) CR 603.4: with no trigger source the recheck cannot pass, the resolution scope \ + does not bind, and the fail-closed classifier refuses — which is exactly the \ + verdict a normalized carrier would force on the POSITIVE board. meter \ + {unbound_meter:?}" + ); + } + + /// R27 (e) — THE CANDIDATE-AUTHORITY HALF IS FRAME-SENSITIVE TOO. + /// + /// CR 614.1 + CR 616.1 + CR 732.2a. (a3) pins the EVENT half of the discharge against the + /// pair's carrying frame and says nothing about the half that CONSUMES it: + /// `resolution_events_are_discharged` hands `proposed_event_prompt_cause` a board, and + /// that board runs `find_applicable_replacements` over its OWN replacement population. A + /// wrong board there checks one frame's events against another frame's candidates, and + /// (a3) stays green while it does — both its sides are event sets. + /// + /// FOUR BOARDS, byte-identical except which one carries the definition and whether that + /// definition draws a PROMPT CAUSE (CR 614.1a: a mandatory replacement's own body is + /// stashed as a continuation and drained through an arbitrary `ResolvedAbility`, which can + /// set a non-priority `waiting_for` — the cause this fixture uses. CR 616.1's ORDERING + /// cause needs two competing candidates and is a different shape): + /// * **frame only** — the constructible direction (the sample holds the permanent, the + /// live board no longer does) ⇒ conjunct (6) REFUSES. + /// * **both** ⇒ also refuses, so the arm is not passing because the def is unreachable. + /// * **neither** ⇒ certifies, the reach-guard proving the fixture reaches the discharge. + /// * **frame, but causeless** — the same permanent, a MANDATORY definition with no body, + /// which is drawn as a candidate and yields NO cause ⇒ certifies. This is the control + /// that keeps the first arm from being "an extra object on the frame refuses". + /// + /// THE DEFINITION SHAPE IS MEASURED, NOT CHOSEN. An OPTIONAL draw replacement makes + /// `probe_resolution` itself prompt (the resolution raises a choice), so the entry is + /// already `MayPrompt` at the PRIMARY classification and the discharge is never reached — + /// the refusal would be real but would key on the wrong seam. A MANDATORY definition with + /// a `runtime_execute` body classifies as a derived event set and still yields + /// `ReplacementPromptCause::MandatoryBodyContinuation`, which is the shape that reaches + /// the CR 614.1 + CR 616.1 discharge tail. + /// + /// REVERT-PROBE (the plan's own, and it FLIPS — measured): pass the live `state` instead + /// of `frame` to `resolution_events_are_discharged` in `stack_choices_are_all_specified` + /// ⇒ the frame-only definition is invisible to `find_applicable_replacements` ⇒ the pair + /// CERTIFIES and the first arm FLIPS TO FAIL, while the both-boards and neither-board arms + /// stay green — so the pair discriminates the BOARD ARGUMENT and not the fixture. + #[test] + fn r27_e_the_discharge_reads_the_pairs_carrying_frame_not_the_live_board() { + use crate::game::engine::{ + try_offer_bounded_cycle_shortcut_metered, BoundedOfferRefusal, ProbeCap, + }; + use crate::types::ability::{ + DrawReplacementScope, Effect, QuantityExpr, ReplacementDefinition, ResolvedAbility, + TargetFilter, + }; + use crate::types::replacements::ReplacementEvent; + + const SRC: ObjectId = ObjectId(940); + const DEF_SRC: ObjectId = ObjectId(941); + const ENTRY: u64 = 955; + + // CR 614.1 scopes a definition to its controller's events, so the definition sits on a + // P0-controlled permanent and replaces P0's own draw. + let install = |with_body: bool| { + move |board: &mut GameState| { + let mut definition = ReplacementDefinition::new(ReplacementEvent::Draw); + // CR 121.2: a Draw definition must declare which stage it watches; the + // pipeline debug-asserts on one that declares neither. + definition.draw_scope = Some(DrawReplacementScope::IndividualDraw); + if with_body { + definition.runtime_execute = Some(Box::new(ResolvedAbility::new( + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 1 }, + target: None, + }, + vec![], + DEF_SRC, + PlayerId(0), + ))); + } + let mut object = GameObject::new( + DEF_SRC, + CardId(78), + PlayerId(0), + "Frame-Only Watcher".to_string(), + Zone::Battlefield, + ); + object.replacement_definitions.push(definition); + board.objects.insert(DEF_SRC, object); + board.battlefield.push_back(DEF_SRC); + } + }; + let announce = |board: &mut GameState| { + let ability = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + SRC, + PlayerId(0), + ); + board + .stack + .push_back(announced_trigger_entry(ENTRY, SRC, ability, None)); + }; + let seed_source = |s: &mut GameState| { + announcing_ring_source(s, SRC.0); + }; + + let neither = ring_announcing_on_its_newest_sample(seed_source, announce); + let frame_only = ring_announcing_on_its_newest_sample(seed_source, |board| { + install(true)(board); + announce(board); + }); + let both = ring_announcing_on_its_newest_sample( + |s| { + seed_source(s); + install(true)(s); + }, + announce, + ); + let causeless = ring_announcing_on_its_newest_sample(seed_source, |board| { + install(false)(board); + announce(board); + }); + + // ── the structural conjunct: ONE board carries both halves of the discharge ── + let frame = announced_from_retained_sample(&frame_only, ENTRY); + let verdicts = PeriodVerdicts::for_period( + &frame_only + .loop_detect_ring + .iter() + .map(|f| &f.live) + .collect::>(), + &frame_only, + PlayerId(0), + ); + let carried = verdicts + .frame_ix(frame) + .expect("the carrying frame is one this container holds"); + let live_ix = verdicts + .frame_ix(&frame_only) + .expect("`current` is the container's last frame"); + assert_ne!( + carried, live_ix, + "(e) STRUCTURAL: `PeriodVerdicts::frame_ix` mints by POINTER IDENTITY, so the pair's \ + board and the live board must resolve to DIFFERENT indices. A future refactor that \ + re-derived the discharge board from `current` would collapse them here rather than \ + silently cross-check one frame's events against another frame's candidates" + ); + + // ── REACH-GUARD: without a definition anywhere the pair certifies ── + let (neither_outcome, neither_meter) = + try_offer_bounded_cycle_shortcut_metered(&neither, false, ProbeCap::Shipped); + assert!( + neither_outcome.is_ok() && neither_meter.conjunct6_asks == 1, + "(e) REACH-GUARD: the fixture must reach and PASS the CR 616.1 tail when nothing \ + competes, else the three refusals below prove nothing about the board argument. \ + Got {neither_outcome:?}, meter {neither_meter:?}" + ); + + // ── the CONTROL: the same permanent on the frame, drawing a candidate with NO cause ── + let (causeless_outcome, causeless_meter) = + try_offer_bounded_cycle_shortcut_metered(&causeless, false, ProbeCap::Shipped); + assert!( + causeless_outcome.is_ok(), + "(e) CONTROL: a MANDATORY, bodyless definition on the carrying frame is a candidate \ + with NO prompt cause at all and must still certify. This is what makes the \ + frame-only refusal attributable to the CAUSE rather than to the extra permanent. \ + Got \ + {causeless_outcome:?}, meter {causeless_meter:?}" + ); + + // ── the both-boards arm, ASSERTED FIRST so the stated revert-probe demonstrates in ONE + // run that this arm is unaffected while the frame-only arm below flips ── + let (both_outcome, both_meter) = + try_offer_bounded_cycle_shortcut_metered(&both, false, ProbeCap::Shipped); + assert_eq!( + both_outcome, + Err(BoundedOfferRefusal::UnspecifiedChoiceWindow), + "(e) MATCHED POSITIVE: with the definition on BOTH boards the refusal also holds, \ + so the frame-only arm below is keyed to WHICH board carries it and not to the \ + definition being invisible to the pipeline. meter {both_meter:?}" + ); + + // ── (e): the definition the CARRYING FRAME holds refuses, even though `current` has none + let (frame_only_outcome, frame_only_meter) = + try_offer_bounded_cycle_shortcut_metered(&frame_only, false, ProbeCap::Shipped); + assert_eq!( + frame_only_outcome, + Err(BoundedOfferRefusal::UnspecifiedChoiceWindow), + "(e) CR 614.1 + CR 616.1: the candidate authority runs over the board the events \ + were DERIVED on. A definition applicable on the pair's carrying frame and absent \ + from the live board must still refuse — handing the discharge `current` would \ + check one frame's events against another frame's candidates. meter \ + {frame_only_meter:?}" + ); + } } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index f21bcb0c82..1e2bde8df2 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -55,22 +55,22 @@ //! classified before it compiles. Any type outside this set that can reach a read //! is in the conservative set above. //! -//! # Resolution-time choice classifier (a SEPARATE question family) +//! # Resolution-time choice classifier — LIVES IN `game::resolution_prompt` //! -//! Alongside the three read-axes lives an independent classifier -//! (`effect_resolution_choice_freedom` / `ability_resolution_choice_freedom`, -//! consumed by `analysis::resource::loop_states_cover_modulo_growth` item 6) -//! answering a FOURTH, orthogonal question (CR 608.2d): can resolving this -//! ability enter a resolution-time player choice (a non-priority `WaitingFor`)? -//! This is deliberately NOT a fourth `Axes` axis — `Axes::NONE` means "no -//! reads", which is orthogonal to "never prompts" (`Effect::Scry` reads nothing -//! yet always prompts), so folding a choice bit into `Axes` would make every -//! existing `NONE` arm silently claim choice-freeness. The classifier is -//! fail-closed (`MayPrompt` default — an unproven claim only costs a -//! false-negative cover rejection); promoting a variant to a choice-free -//! verdict is a SOUNDNESS claim ("resolving can never enter a non-priority -//! `WaitingFor`, for ANY state") and requires a resolver trace cited in the arm -//! plus a `..`-free destructure so a future field forces re-audit. +//! An independent classifier answering a FOURTH, orthogonal question +//! (CR 608.2d) — can resolving this ability enter a resolution-time player +//! choice (a non-priority `WaitingFor`)? — used to live here. It now lives in +//! `crate::game::resolution_prompt`, because answering it requires PROBING a +//! resolution and therefore requires a live board, which this module +//! deliberately never holds (pinned by +//! `resolution_prompt::tests::ability_scan_holds_no_game_state`, which asserts +//! this file carries no word-bounded board-type token at all — including in +//! this very sentence, which is why it is worded around the name). +//! +//! It is deliberately NOT a fourth `Axes` axis — `Axes::NONE` means "no reads", +//! which is orthogonal to "never prompts" (`Effect::Scry` reads nothing yet +//! always prompts), so folding a choice bit into `Axes` would make every +//! existing `NONE` arm silently claim choice-freeness. //! //! # Consumers of the read-axis classifiers after PR-6.75 //! @@ -96,8 +96,8 @@ use crate::types::ability::{ CountScope, Duration, EachDamageRecipient, Effect, EffectScope, FilterProp, ForEachCategoryAction, GuessSubject, KeeperConstraint, ManaProduction, ModalChoice, MultiTargetSpec, ObjectScope, PlayerFilter, PlayerScope, PtValue, QuantityExpr, QuantityRef, - RepeatContinuation, ReplacementCondition, ResolvedAbility, StaticCondition, TargetChoiceTiming, - TargetFilter, TrackedAnaphorSource, TriggerCondition, TypedFilter, + RepeatContinuation, ReplacementCondition, ResolvedAbility, StaticCondition, TargetFilter, + TrackedAnaphorSource, TriggerCondition, TypedFilter, }; use crate::types::game_state::TargetSelectionConstraint; use crate::types::keywords::{DisguiseCost, Keyword}; @@ -5901,320 +5901,6 @@ fn effect_census_role(e: &Effect) -> CensusRole { } } -// --------------------------------------------------------------------------- -// Resolution-time choice-freeness classifier (`analysis::resource` item 6). -// A separate question family from the three read-axes above — see the module -// header. Fail-closed default is `MayPrompt`. -// --------------------------------------------------------------------------- - -/// CR 732.2a + CR 608.2d: resolution-time choice-freeness verdict for the -/// growing-cascade cover gate (`analysis::resource` item 6). NOT an `Axes` -/// axis — this classifies RESOLVER prompting behavior, not AST reads (module -/// header rationale). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum ResolutionChoiceFreedom { - /// Resolving can never enter a non-priority `WaitingFor` in ANY state, - /// EXCEPT through the life-event replacement pipeline (single optional - /// candidate, replacement.rs:6221; CR 616.1 material ordering, - /// replacement.rs:6263; mandatory body-continuation drain, - /// replacement.rs:5511-5524 → engine_replacement.rs:1159). Callers MUST - /// pair this verdict with `analysis::resource::life_event_replacements_may_prompt` - /// — the paired environmental obligation is part of this variant's contract. - /// - /// There is deliberately no plain `Free` variant yet: both allow-listed - /// kinds (`GainLife`/`LoseLife`) genuinely can prompt via the life-event - /// replacement pipeline, so `Free` would be uninhabited today. Adding it - /// later is compiler-guided (a new variant flags every exhaustive match). - FreeUnlessLifeReplacements, - /// May prompt, or unproven — the fail-closed default. - MayPrompt, -} - -impl ResolutionChoiceFreedom { - /// Worst-of join for a resolution chain: `MayPrompt` dominates (a chain that - /// can prompt on either branch can prompt). - fn join(self, other: ResolutionChoiceFreedom) -> ResolutionChoiceFreedom { - if matches!(self, ResolutionChoiceFreedom::FreeUnlessLifeReplacements) - && matches!(other, ResolutionChoiceFreedom::FreeUnlessLifeReplacements) - { - ResolutionChoiceFreedom::FreeUnlessLifeReplacements - } else { - ResolutionChoiceFreedom::MayPrompt - } - } -} - -/// CR 608.2d: can resolving this single `Effect` ever offer a resolution-time -/// player choice? Exhaustive `match` with NO wildcard catch-all arm — a NEW -/// `Effect` variant fails to compile here until it is classified. Only the two -/// allow-list arms make a soundness claim (grounded by a resolver trace); every -/// other variant is the fail-closed `MayPrompt` (an ungrounded reject is only a -/// false-negative cover rejection, so grouped arms need no per-kind evidence). -fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom { - match e { - // ---- allow-list: choice-free EXCEPT the life-event replacement - // pipeline (destructured WITHOUT `..` so a new field forces a - // re-audit of the soundness claim) ---- - // - // CR 119.3 + CR 732.2a: resolver trace effects/life.rs — resolve_gain - // (life.rs:19-110) runs its OWN inline replace_event pipeline; its only - // prompt is ReplacementResult::NeedsChoice (life.rs:96-101). Player - // selection = pure filter eval (game/filter.rs: no WaitingFor); amount = - // pure quantity eval (game/quantity.rs: no WaitingFor). Verdict is - // payload-independent. CR 119.7 can't-gain short-circuit is deterministic. - // PAIRED OBLIGATION: caller runs life_event_replacements_may_prompt - // (resource.rs item 6), which also covers the mandatory body-continuation - // drain (H4 route c) and the Execute-arm stack.rs drain. - Effect::GainLife { - amount: _, - player: _, - } => ResolutionChoiceFreedom::FreeUnlessLifeReplacements, - // CR 119.3 + CR 732.2a: same shape — resolve_lose (life.rs:293-365), - // only prompt = NeedsChoice (life.rs:352-355). CR 119.8 can't-lose - // short-circuit is deterministic. Same PAIRED OBLIGATION. - Effect::LoseLife { - amount: _, - target: _, - } => ResolutionChoiceFreedom::FreeUnlessLifeReplacements, - // ---- everything else: fail-closed MayPrompt. Grouped so the compiler - // still enforces exhaustiveness (every variant is named); no payload - // scanning needed on the reject side. ---- - Effect::StartYourEngines { .. } - | Effect::ChangeSpeed { .. } - | Effect::DealDamage { .. } - | Effect::ApplyPostReplacementDamage { .. } - | Effect::EachDealsDamageEqualToPower { .. } - | Effect::OpponentGuess { .. } - | Effect::SwapChosenLabels { .. } - | Effect::Draw { .. } - | Effect::Pump { .. } - | Effect::PairWith { .. } - | Effect::Destroy { .. } - | Effect::Regenerate { .. } - | Effect::RemoveAllDamage { .. } - | Effect::Counter { .. } - | Effect::CounterAll { .. } - | Effect::Token { .. } - | Effect::SetTapState { .. } - | Effect::RemoveCounter { .. } - | Effect::ChooseCounterKind { .. } - | Effect::PutChosenCounter { .. } - | Effect::Sacrifice { .. } - | Effect::DiscardCard { .. } - | Effect::Mill { .. } - | Effect::Scry { .. } - | Effect::PumpAll { .. } - | Effect::DamageAll { .. } - | Effect::DamageEachPlayer { .. } - | Effect::EachPlayerCopyChosen { .. } - | Effect::DestroyAll { .. } - | Effect::ChangeZone { .. } - | Effect::ChangeZoneAll { .. } - | Effect::Dig { .. } - | Effect::GainControl { .. } - | Effect::GainControlAll { .. } - | Effect::ControlNextTurn { .. } - | Effect::Attach { .. } - | Effect::UnattachAll { .. } - | Effect::Surveil { .. } - | Effect::Fight { .. } - | Effect::Bounce { .. } - | Effect::BounceAll { .. } - | Effect::Explore - | Effect::ExploreAll { .. } - | Effect::Investigate - | Effect::Tribute { .. } - | Effect::TimeTravel - | Effect::BecomeMonarch - | Effect::NoOp - | Effect::Proliferate - | Effect::ProliferateTarget { .. } - | Effect::Populate - | Effect::Clash - // CR 701.4a + CR 608.2d: behold may prompt (`WaitingFor::BeholdChoice` - // when 2+ candidates) — fail-closed MayPrompt. - | Effect::Behold { .. } - | Effect::EndTheTurn - | Effect::EndCombatPhase - | Effect::Vote { .. } - | Effect::SeparateIntoPiles { .. } - | Effect::SwitchPT { .. } - | Effect::CopySpell { .. } - | Effect::EpicCopy { .. } - | Effect::CastCopyOfCard { .. } - | Effect::CopyTokenOf { .. } - | Effect::CreateTokenCopyFromPool { .. } - | Effect::Myriad - | Effect::Encore - | Effect::CombineHost { .. } - | Effect::ChooseAugmentAndCombineWithHost { .. } - | Effect::Meld { .. } - | Effect::ExileHaunting { .. } - | Effect::HideawayConceal { .. } - | Effect::CopyTokenBlockingAttacker { .. } - | Effect::BecomeCopy { .. } - // CR 707.2c: raises `WaitingFor::CopyTargetChoice` — prompts, fail-closed - // MayPrompt (never resolved through the normal chain, but classified here - // to keep the match exhaustive). - | Effect::ChoosePermanent { .. } - | Effect::GainActivatedAbilitiesOfTarget { .. } - | Effect::ChooseCard { .. } - | Effect::PutCounter { .. } - | Effect::PutCounterAll { .. } - | Effect::MultiplyCounter { .. } - | Effect::DoublePT { .. } - | Effect::DoublePTAll { .. } - | Effect::MoveCounters { .. } - | Effect::Animate { .. } - | Effect::ReturnAsAura { .. } - | Effect::RegisterBending { .. } - | Effect::GenericEffect { .. } - | Effect::Cleanup { .. } - | Effect::Mana { .. } - | Effect::Discard { .. } - | Effect::Shuffle { .. } - | Effect::Transform { .. } - // CR 710.4: `flip_permanent` offers no resolution-time choice (it is a - // status change or a silent no-op), exactly like `Transform`. - | Effect::FlipPermanent { .. } - | Effect::SearchLibrary { .. } - | Effect::SearchOutsideGame { .. } - | Effect::RevealHand { .. } - | Effect::RevealFromHand { .. } - | Effect::Reveal { .. } - | Effect::RevealTop { .. } - | Effect::ExileTop { .. } - | Effect::ExileFaceDownPile { .. } - | Effect::TargetOnly { .. } - | Effect::Choose { .. } - | Effect::ChooseDamageSource { .. } - | Effect::Suspect { .. } - | Effect::Unsuspect { .. } - | Effect::Connive { .. } - | Effect::PhaseOut { .. } - | Effect::PhaseIn { .. } - | Effect::ForceBlock { .. } - | Effect::ForceAttack { .. } - | Effect::SolveCase - | Effect::BecomePrepared { .. } - | Effect::BecomeUnprepared { .. } - | Effect::BecomeSaddled { .. } - | Effect::BecomeBlocked { .. } - | Effect::SetClassLevel { .. } - | Effect::CreateDelayedTrigger { .. } - | Effect::AddTargetReplacement { .. } - | Effect::AddRestriction { .. } - | Effect::ReduceNextSpellCost { .. } - | Effect::GrantNextSpellAbility { .. } - | Effect::AddPendingETBCounters { .. } - | Effect::AddPendingEntersModifications { .. } - | Effect::CreateEmblem { .. } - | Effect::PayCost { .. } - | Effect::CastFromZone { .. } - | Effect::FreeCastFromZones { .. } - | Effect::ExileResolvingSpellInsteadOfGraveyard { .. } - | Effect::PreventDamage { .. } - | Effect::CreateDamageReplacement { .. } - | Effect::CreateDrawReplacement { .. } - | Effect::LoseTheGame { .. } - | Effect::WinTheGame { .. } - | Effect::RollDie { .. } - | Effect::FlipCoin { .. } - | Effect::FlipCoins { .. } - | Effect::FlipCoinUntilLose { .. } - | Effect::RingTemptsYou - | Effect::VentureIntoDungeon - | Effect::VentureInto { .. } - | Effect::TakeTheInitiative - | Effect::ArrangePlanarDeckTop { .. } - | Effect::Planeswalk - | Effect::OpenAttractions { .. } - | Effect::RollToVisitAttractions - | Effect::AssembleContraptions { .. } - | Effect::AssembleContraptionsFromRollDifference - | Effect::CrankContraptions { .. } - | Effect::ReassembleContraption { .. } - | Effect::AssembleContraptionOnSprocket { .. } - | Effect::ReassembleContraptionOnSprocket { .. } - | Effect::PutSticker { .. } - | Effect::ApplySticker { .. } - | Effect::ProcessRadCounters - | Effect::GrantCastingPermission { .. } - | Effect::ChooseFromZone { .. } - | Effect::RememberCard { .. } - | Effect::ForEachCategory { .. } - | Effect::ChooseObjectsIntoTrackedSet { .. } - | Effect::ChooseAndSacrificeRest { .. } - | Effect::Exploit { .. } - | Effect::GainEnergy { .. } - | Effect::GivePlayerCounter { .. } - | Effect::LoseAllPlayerCounters { .. } - | Effect::ExileFromTopUntil { .. } - | Effect::RevealUntil { .. } - | Effect::Discover { .. } - | Effect::Heist { .. } - | Effect::HeistExile - | Effect::Cascade - | Effect::Ripple { .. } - | Effect::MiracleCast { .. } - | Effect::MadnessCast { .. } - | Effect::PutAtLibraryPosition { .. } - | Effect::ChooseDrawnThisTurnPayOrTopdeck { .. } - | Effect::PutOnTopOrBottom { .. } - | Effect::GiftDelivery { .. } - | Effect::Goad { .. } - | Effect::GoadAll { .. } - | Effect::Detain { .. } - | Effect::SetRoomDoorLock { .. } - | Effect::ExchangeControl { .. } - | Effect::ChangeTargets { .. } - | Effect::Manifest { .. } - | Effect::ManifestDread - | Effect::Cloak { .. } - | Effect::TurnFaceUp { .. } - | Effect::TurnFaceDown { .. } - | Effect::ExtraTurn { .. } - | Effect::GrantExtraLoyaltyActivations { .. } - | Effect::SkipNextTurn { .. } - | Effect::SkipNextStep { .. } - | Effect::AdditionalPhase { .. } - | Effect::Double { .. } - | Effect::EachSourceDealsDamage { .. } - | Effect::RuntimeHandled { .. } - | Effect::Incubate { .. } - | Effect::Amass { .. } - | Effect::Monstrosity { .. } - | Effect::Specialize - | Effect::Renown { .. } - | Effect::Bolster { .. } - | Effect::Adapt { .. } - | Effect::Learn - | Effect::Forage - | Effect::Harness - | Effect::CollectEvidence { .. } - | Effect::Endure { .. } - | Effect::BlightEffect { .. } - | Effect::Seek { .. } - | Effect::SetLifeTotal { .. } - | Effect::ExchangeLifeWithStat { .. } - | Effect::ExchangeLifeTotals { .. } - | Effect::SetDayNight { .. } - | Effect::GiveControl { .. } - | Effect::RemoveFromCombat { .. } - | Effect::Conjure { .. } - | Effect::ApplyPerpetual { .. } - | Effect::Intensify { .. } - | Effect::DraftFromSpellbook { .. } - | Effect::ChooseCounterAdjustment { .. } - | Effect::CreatePlaneswalkReplacement { .. } - | Effect::ChaosEnsues - | Effect::RedistributeLifeTotals - | Effect::ReverseTurnOrder - | Effect::ChooseOneOf { .. } - | Effect::Unimplemented { .. } => ResolutionChoiceFreedom::MayPrompt, - } -} - /// CR 732.2a / CR 705.1 / CR 706.1a / CR 701.9b: does resolving this single /// `Effect` draw on game randomness whose outcome determines the next action — a /// coin flip (CR 705.1), a die roll (CR 706.1a, incl. the planar / attraction / @@ -6494,113 +6180,6 @@ pub(crate) fn spell_ability_bears_randomness(def: &AbilityDefinition) -> bool { effects.iter().any(|&e| effect_is_randomness_bearing(e)) } -/// CR 608.2d + CR 732.2a: does resolving this ability (its whole chain) ever -/// enter a resolution-time player choice? The `ResolvedAbility` destructure is -/// EXHAUSTIVE with no `..` — the read-walk's `resolved_ability_axes` (:116) -/// classifications are deliberately NOT reused (this is a different question: -/// e.g. `optional` is read-free yet choice-bearing). A FUTURE field fails to -/// compile here until classified for the choice question. -pub(crate) fn ability_resolution_choice_freedom(a: &ResolvedAbility) -> ResolutionChoiceFreedom { - let ResolvedAbility { - // ---- choice-bearing: folded into the verdict below ---- - effect, - sub_ability, - else_ability, - optional, - optional_for, - optional_targeting, - unless_pay, - target_chooser, - target_choice_timing, - modal, - mode_abilities, - repeat_until, - // ---- choice-free: bound `_` with a one-line justification ---- - condition: _, // resolution branch selector, pure eval (both branches recursed) - duration: _, // continuous-effect lifetime, no prompt - player_scope: _, // iteration fan-out, pure player-filter eval - starting_with: _, // APNAP start override, no prompt - repeat_for: _, // "for each" count, pure quantity eval (game/quantity.rs) - announced_x: _, // CR 601.2b announce-time count, pure quantity eval, no prompt - multi_target: _, // announce-time variable-count bounds (Resolution case caught by timing) - target_constraints: _, // announce-time cross-target legality, no resolution prompt - distribution: _, // CR 601.2d concrete pre-assigned portions (announce-time) - targets: _, // concrete announced target refs (already resolved) - source_id: _, // object id - source_incarnation: _, // self-transform epoch latch, no resolution-time choice - trigger_source: _, // exact triggered-source authority, no choice - trigger_definition_ref: _, // exact trigger occurrence, no choice - force_block_attacker: _, // exact force-block referent, no choice - controller: _, // player id - original_controller: _, // player id - scoped_player: _, // player id (iteration binding) - kind: _, // AbilityKind tag (no payload) - context: _, // SpellContext: cast-time fact snapshot, not a live choice - description: _, // display string - selected_mode_labels: _, // display strings, no resolution-time choice - min_x_value: _, // u32 - cant_be_copied: _, // bool - copy_count_status: _, // status tag - forward_result: _, // bool - chosen_x: _, // concrete cast-time X (chosen at announcement, not resolution) - cost_paid_object: _, // concrete captured-object snapshot - cost_paid_object_ids: _, // concrete captured-object ids (issue #4948) - effect_context_object: _, // concrete captured-object snapshot - amassed_army_object: _, // concrete captured-object snapshot - ability_index: _, // usize provenance - may_trigger_origin: _, // provenance tag - target_selection_mode: _, // Chosen/Random tag (announce-time) - chosen_players: _, // concrete chosen player ids (already selected) - replacement_applied: _, // replacement provenance set, no prompt - sub_link: _, // SubAbilityLink kind tag - sibling_condition: _, // SiblingCondition replication marker, no resolution-time choice - parent_target_missing_reason: _, // seam flag - } = a; - - // CR 608.2d: an optional effect / optional targeting / opponent-may - // effect prompts the controller (or opponent) before execution - // (WaitingFor::OptionalEffectChoice, effects/mod.rs:4294). - if *optional || *optional_targeting || optional_for.is_some() { - return ResolutionChoiceFreedom::MayPrompt; - } - // CR 118.12: "unless a player pays {cost}" is a resolution-time pay prompt - // (also item-4 redundant — ability_scan.rs sets `projected` for it). - if unless_pay.is_some() { - return ResolutionChoiceFreedom::MayPrompt; - } - // CR 601.2c + CR 603.3d: a resolution-time target chooser announces targets (H3). - if target_chooser.is_some() { - return ResolutionChoiceFreedom::MayPrompt; - } - // CR 608.2d: resolution-timed target selection is a resolution-time choice even - // though `targets` is empty on the stack, which the ordering gate can't see (H3). - if matches!(target_choice_timing, TargetChoiceTiming::Resolution) { - return ResolutionChoiceFreedom::MayPrompt; - } - // CR 700.2b + CR 603.3c: a modal header / reflexive per-mode abilities open a - // mode choice at resolution (conservative — rejected even when the mode is baked). - if modal.is_some() || !mode_abilities.is_empty() { - return ResolutionChoiceFreedom::MayPrompt; - } - // CR 608.2c + CR 107.1c: only the controller-prompted repeat variant is a - // player choice; while / until-stop predicates are pure re-evaluation. - if matches!(repeat_until, Some(RepeatContinuation::ControllerChoice)) { - return ResolutionChoiceFreedom::MayPrompt; - } - - // CR 608.2c: the chain resolves the effect and, on the taken branch, a - // sub_ability / else_ability effect — join both (fail-safe: reject if either - // can prompt). - let mut acc = effect_resolution_choice_freedom(effect); - if let Some(sub) = sub_ability { - acc = acc.join(ability_resolution_choice_freedom(sub)); - } - if let Some(else_branch) = else_ability { - acc = acc.join(ability_resolution_choice_freedom(else_branch)); - } - acc -} - #[cfg(test)] mod tests { use super::*; @@ -8226,132 +7805,6 @@ mod tests { assert!(!ability_reads_sibling_mutable(&fixed_drain())); } - // ---- Resolution-time choice classifier: pinned in BOTH directions ---- - /// Guard test (9092a8961 standard): pins `effect_resolution_choice_freedom` - /// and the ability-level wrapper flips. - /// - /// The `FreeUnlessLifeReplacements` allow set is EXACTLY - /// `{Effect::GainLife, Effect::LoseLife}` — asserted below and pinned by the - /// allow-arm census (`rg -c 'ResolutionChoiceFreedom::FreeUnlessLifeReplacements' - /// ability_scan.rs` == 2, both inside `effect_resolution_choice_freedom`). A - /// future third allow arm must update this pin, the census, and add a - /// resolver-trace grounding row. - /// - /// Compiler-exhaustiveness leg: `effect_resolution_choice_freedom`'s match has - /// no wildcard catch-all, so a NEW `Effect` variant fails to compile until classified. - /// Executed revert-fail (documented in the commit): classifying `Effect::Scry` - /// ⇒ `FreeUnlessLifeReplacements` turns this test RED. - #[test] - fn resolution_choice_verdicts_are_exactly_pinned() { - use crate::types::ability::{ - AbilityCost, AbilityDefinition, AbilityKind, UnlessPayModifier, - }; - use ResolutionChoiceFreedom::{FreeUnlessLifeReplacements, MayPrompt}; - - // Allow-list (soundness claims) ⇒ FreeUnlessLifeReplacements. - let gain = Effect::GainLife { - amount: QuantityExpr::Fixed { value: 1 }, - player: TargetFilter::Controller, - }; - let lose = Effect::LoseLife { - amount: QuantityExpr::Fixed { value: 1 }, - target: None, - }; - assert_eq!( - effect_resolution_choice_freedom(&gain), - FreeUnlessLifeReplacements - ); - assert_eq!( - effect_resolution_choice_freedom(&lose), - FreeUnlessLifeReplacements - ); - - // Reject side: the finding's kinds + adjacent siblings ⇒ MayPrompt, each - // with its resolver-prompt raise-site citation. - let rejects = [ - Effect::Proliferate, // WaitingFor::ProliferateChoice — proliferate.rs:109 - Effect::Populate, // WaitingFor::PopulateChoice — populate.rs:50 - Effect::Clash, // WaitingFor::ClashChooseOpponent — clash.rs:47 - Effect::Behold { - filter: TargetFilter::Any, - }, // WaitingFor::BeholdChoice — behold.rs (2+ candidates) - Effect::Explore, // WaitingFor::ExploreChoice — explore.rs:191 - Effect::Scry { - count: QuantityExpr::Fixed { value: 1 }, - target: TargetFilter::Controller, - }, // Scry always prompts (bottom/top ordering) - Effect::Sacrifice { - target: TargetFilter::Any, - count: QuantityExpr::Fixed { value: 1 }, - min_count: 0, - }, // WaitingFor::EffectZoneChoice — sacrifice.rs:306 - Effect::DiscardCard { - count: 1, - target: TargetFilter::Any, - }, // discard selection prompt - ]; - for e in &rejects { - assert_eq!( - effect_resolution_choice_freedom(e), - MayPrompt, - "{e:?} must be MayPrompt" - ); - } - - // Explicit allow-set pin: exactly {GainLife, LoseLife}. Every other kind - // sampled above is on the reject side; the allow-arm census is the - // structural guard against a silent third allow arm. - assert!( - rejects - .iter() - .all(|e| effect_resolution_choice_freedom(e) == MayPrompt), - "the FreeUnlessLifeReplacements set is exactly {{Effect::GainLife, Effect::LoseLife}}" - ); - - // Ability-level wrapper flips: base ⇒ Free (paired positive reach-guard), - // each single-field mutation ⇒ MayPrompt (proves the FLIP, not something - // upstream, causes the rejection). - let base = ResolvedAbility::new(gain.clone(), Vec::new(), ObjectId(1), PlayerId(0)); - assert_eq!( - ability_resolution_choice_freedom(&base), - FreeUnlessLifeReplacements - ); - - let mut a = base.clone(); - a.optional = true; - assert_eq!(ability_resolution_choice_freedom(&a), MayPrompt); - - let mut a = base.clone(); - a.optional_targeting = true; - assert_eq!(ability_resolution_choice_freedom(&a), MayPrompt); - - let mut a = base.clone(); - a.unless_pay = Some(UnlessPayModifier { - cost: AbilityCost::Tap, - payer: TargetFilter::Controller, - }); - assert_eq!(ability_resolution_choice_freedom(&a), MayPrompt); - - let mut a = base.clone(); - a.target_chooser = Some(TargetFilter::Controller); - assert_eq!(ability_resolution_choice_freedom(&a), MayPrompt); - - let mut a = base.clone(); - a.target_choice_timing = TargetChoiceTiming::Resolution; - assert_eq!(ability_resolution_choice_freedom(&a), MayPrompt); - - let mut a = base.clone(); - a.mode_abilities = vec![AbilityDefinition::new(AbilityKind::Spell, Effect::NoOp)]; - assert_eq!(ability_resolution_choice_freedom(&a), MayPrompt); - - let mut a = base.clone(); - a.repeat_until = Some(RepeatContinuation::ControllerChoice); - assert_eq!(ability_resolution_choice_freedom(&a), MayPrompt); - - let mut a = base.clone(); - a.modal = Some(ModalChoice::default()); - assert_eq!(ability_resolution_choice_freedom(&a), MayPrompt); - } // ---- PR #5872 blocker-2 regression: scry look count is event-context ---- /// CR 701.22a + CR 603.2c: "the number of cards looked at while scrying diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index f2abb096d1..50009b948f 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -4960,12 +4960,12 @@ pub(crate) fn is_per_opponent_target_fanout(ability: &ResolvedAbility) -> bool { fn per_opponent_fanout_players(state: &GameState, controller: PlayerId) -> Vec { players::apnap_order_from(state, None, controller) .into_iter() - .filter(|id| { - *id != controller - && state.players.iter().any(|player| { - player.id == *id && !player.is_eliminated && !player.is_phased_out() - }) - }) + // Hygiene routing, behaviour-neutral BY CONSTRUCTION: the inline pair + // `!is_eliminated && !is_phased_out()` under a membership `any` is exactly what + // `players::player_exists_for_choice` spells (`is_alive` is itself membership ∧ + // ¬eliminated). Routed so an existence fix propagates here rather than leaving a + // fifth hand-inlined copy of the predicate. + .filter(|&id| id != controller && players::player_exists_for_choice(state, id)) .collect() } diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 7aa242ac0d..6f6dd8bdfe 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -12617,7 +12617,9 @@ fn continue_with_prepared( // is decided independently — `begin_deferred_target_selection` re-prompts // for every remaining group after this first one is recorded. if let Some(choice) = casting_costs::next_announcing_opponent_choice(&resolved) { - let candidates = crate::game::players::opponents(state, player); + // CR 601.2c + CR 115.10a: the announcer is CHOSEN, not targeted, so the + // candidate list is the CHOOSABLE opponents. + let candidates = crate::game::players::choosable_opponents(state, player); if candidates.len() >= 2 { let mut pending = PendingCast::new( prepared.object_id, diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 2bf48a4d97..c8df48f028 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -668,7 +668,9 @@ fn continue_after_gift_promised( cost_source: SpellCostSource, events: &mut Vec, ) -> Result { - let opponents = crate::game::players::opponents(state, player); + // CR 702.174a: "you may choose an opponent" — a CHOICE, not a target (CR 115.10a), + // so the recipient list is the CHOOSABLE opponents, not the raw seat relation. + let opponents = crate::game::players::choosable_opponents(state, player); if opponents.is_empty() { return Err(EngineError::InvalidAction( "Cannot promise a gift with no opponents".to_string(), @@ -1537,7 +1539,10 @@ pub(crate) fn begin_deferred_target_selection( // its announcing opponent chosen (and the controller has ≥2 opponents to pick // among), raise that decision before declaring targets. This loops once per // unassigned group, so each opponent-choice effect gets its own announcer. - let announcing_candidates = crate::game::players::opponents(state, player); + // CR 115.10a: the announcer is CHOSEN, not targeted — the SECOND mint of this + // variant, and it must narrow identically to the cast-time mint in `casting.rs` + // or the re-prompt would hand back a seat the first prompt excluded. + let announcing_candidates = crate::game::players::choosable_opponents(state, player); if announcing_candidates.len() >= 2 { if let Some(choice) = next_announcing_opponent_choice(&pending.ability) { return Ok(WaitingFor::ChooseAnnouncingOpponent { @@ -11795,10 +11800,14 @@ fn assist_offer_params( { return None; } + // CR 702.132a: "you may choose another player" — a CHOICE, not a target + // (CR 115.10a), so the seat is judged by `player_exists_for_choice` and NOT by the + // targeting-only exclusions. `p.id != player` stays: that is "another player" SCOPE, + // not legality. let candidates: Vec = state .players .iter() - .filter(|p| p.id != player && !p.is_eliminated) + .filter(|p| p.id != player && crate::game::players::player_exists_for_choice(state, p.id)) .map(|p| p.id) .collect(); if candidates.is_empty() { diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 0213bdd131..072095d36d 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -16968,6 +16968,79 @@ fn assist_no_offer_without_other_players() { ); } +/// R4f — CR 702.132a: assist is *"you may **choose** another player"*, a CHOICE and not a +/// target (CR 115.10a), so the helper list is the seats that still exist to be chosen. +/// A phased-out seat is treated as though it does not exist (the CR 702.26b MIRROR) and +/// must drop out of the offer; the eliminated exclusion this seam already had (CR 800.4 + +/// CR 102.1) must be PRESERVED, not traded for it. +/// +/// FOUR SEATS, NOT TWO, and that is load-bearing twice over: `setup_game_at_main_phase` is +/// two-player, so it cannot carry both an excluded and a surviving helper, and +/// `assist_offer_params` returns `None` when the candidate list is empty — so on a narrower +/// board the offer would never fire and a "P1 is not a candidate" assertion would pass +/// vacuously. THE OFFER MUST STILL FIRE, which is why the assertion is on the published +/// `AssistChoosePlayer` variant and is a TOTAL EQUALITY rather than a `!contains`. +/// +/// Contrast `assist_no_offer_without_other_players` directly above: that row asserts an +/// ABSENCE, which is exactly the shape this one must not copy. +/// +/// REVERT-PROBE: restore HEAD's `.filter(|p| p.id != player && !p.is_eliminated)` at the +/// `assist_offer_params` site ⇒ candidates become `[P1, P3]` ⇒ the equality FAILS. +#[test] +fn assist_offer_excludes_a_phased_out_helper_and_still_offers_the_rest() { + use crate::types::format::FormatConfig; + + let mut state = GameState::new(FormatConfig::commander(), 4, 42); + state.turn_number = 2; + state.phase = Phase::PreCombatMain; + state.active_player = PlayerId(0); + state.priority_player = PlayerId(0); + state.waiting_for = WaitingFor::Priority { + player: PlayerId(0), + }; + + // Setup anti-vacuity, asserted before anything is measured. + let mut setup_events = Vec::new(); + let transitioned = + crate::game::phasing::phase_out_player(&mut state, PlayerId(1), &mut setup_events); + assert_eq!( + transitioned, + vec![PlayerId(1)], + "phase_out_player must actually transition P1" + ); + assert!( + state.players[1].is_phased_out(), + "P1 must read as phased out" + ); + crate::game::elimination::eliminate_player(&mut state, PlayerId(2), &mut setup_events); + assert!(state.players[2].is_eliminated, "P2 must read as eliminated"); + + let obj_id = make_assist_spell(&mut state); + // CR 601.2h: "The player pays the total cost" — unpayable costs can't be paid, so the + // caster is staged with enough mana for {3}{R} or the cast never reaches the offer. + add_mana(&mut state, PlayerId(0), ManaType::Colorless, 3); + add_mana(&mut state, PlayerId(0), ManaType::Red, 1); + + // The PRODUCTION entry point, exactly as every other assist row drives it — never + // `assist_offer_params`, which is private. + let result = handle_cast_spell(&mut state, PlayerId(0), obj_id, CardId(22), &mut Vec::new()) + .expect("assist spell should begin casting"); + + match result { + WaitingFor::AssistChoosePlayer { + player, candidates, .. + } => { + assert_eq!(player, PlayerId(0)); + assert_eq!( + candidates, + vec![PlayerId(3)], + "phased-out P1 and eliminated P2 are out; the one valid helper is in" + ); + } + other => panic!("expected AssistChoosePlayer, got {other:?}"), + } +} + #[test] fn assist_decline_proceeds_to_normal_payment() { let mut state = setup_game_at_main_phase(); diff --git a/crates/engine/src/game/effects/choose.rs b/crates/engine/src/game/effects/choose.rs index e218acba8d..1a73994d35 100644 --- a/crates/engine/src/game/effects/choose.rs +++ b/crates/engine/src/game/effects/choose.rs @@ -624,10 +624,13 @@ fn compute_options( // satisfying that `PlayerFilter` — the controller then picks ONE of the // qualifying opponents (CR 608.2d handles ties), keeping it a single // pick rather than fanning the effect out to every tied opponent. + // CR 608.2d: "The player can't choose an option that's illegal or impossible" — + // a CHOICE, not a target (CR 115.10a), so the option list is the CHOOSABLE + // opponents. The distinctness and restriction filters below are untouched. ChoiceType::Opponent { restriction, distinctness, - } => players::opponents(state, controller) + } => players::choosable_opponents(state, controller) .iter() .filter(|id| { *distinctness != PlayerChoiceDistinctness::DistinctFromPriorChoices @@ -640,13 +643,20 @@ fn compute_options( }) .map(|id| id.0.to_string()) .collect(), - // CR 102.1: A player is one of the people in the game. + // CR 102.1: A player is one of the people in the game — so a seat that has LEFT + // the game (CR 800.4) is not one of the people to choose among, and neither is a + // phased-out seat (per the CR 702.26b MIRROR). CR 608.2d: the player can't choose + // an illegal option. `state.seat_order` is NOT pruned on elimination by any + // production writer, so without this conjunct the arm offers eliminated seats — + // a defect independent of phasing, and one every sibling choice seam already + // avoids. // CR 608.2c: `DistinctFromPriorChoices` (Gluntch's "choose a // second/third player") excludes players already chosen earlier in // this resolution; the default `Independent` does not. ChoiceType::Player { distinctness } => state .seat_order .iter() + .filter(|&&id| players::player_exists_for_choice(state, id)) .filter(|id| { *distinctness != PlayerChoiceDistinctness::DistinctFromPriorChoices || !already_chosen.contains(id) @@ -1321,6 +1331,102 @@ mod tests { } } + /// The shared 5-seat choice-legality board: P0 controller, **P1 phased out** through + /// the production API, **P2 eliminated**, P3/P4 valid. + /// + /// FIVE seats, not three, and that is a reach-guard rather than padding: `resolve` + /// early-returns when `options.is_empty()` and never publishes `NamedChoice` at all, + /// so a board narrow enough to empty the list would make every exclusion assertion + /// below pass vacuously. + fn choice_legality_board() -> GameState { + use crate::types::format::FormatConfig; + let mut state = GameState::new(FormatConfig::standard(), 5, 42); + let mut events = Vec::new(); + + // Anti-vacuity on the SETUP, asserted before anything is measured: + // `phase_out_player` returns the ids it transitioned, so a setup that silently + // no-opped fails loudly here instead of quietly weakening the row. + let transitioned = + crate::game::phasing::phase_out_player(&mut state, PlayerId(1), &mut events); + assert_eq!( + transitioned, + vec![PlayerId(1)], + "phase_out_player must actually transition P1" + ); + assert!( + state.players[1].is_phased_out(), + "P1 must read as phased out after the production call" + ); + + crate::game::elimination::eliminate_player(&mut state, PlayerId(2), &mut events); + assert!( + state.players[2].is_eliminated, + "P2 must read as eliminated after the production call" + ); + state + } + + /// CR 102.1 ("a player is one of the people in the game") + CR 608.2d ("the player + /// can't choose an option that's illegal or impossible"): "choose a player" must offer + /// neither an eliminated nor a phased-out seat. + /// + /// TWO INDEPENDENT BEHAVIOUR CHANGES, and this row asserts both. The eliminated seat + /// `"2"` was offered at HEAD — a strictly-live CR 102.1 defect with nothing to do with + /// phasing, because `state.seat_order` is not pruned on elimination and this arm + /// filtered only on `already_chosen`. The phased-out seat `"1"` is the phasing half. + /// + /// Total equality, never `!contains`: exclusion AND identity in one assertion. + /// + /// REVERT-PROBE: restore the raw `state.seat_order.iter()` ⇒ `"1"` and `"2"` both + /// reappear ⇒ FAILS. SECOND, NARROWER REVERT-PROBE: replace `player_exists_for_choice` + /// with bare `is_alive` ⇒ `"1"` reappears while `"2"` stays out ⇒ FAILS. The second + /// probe is what stops either behaviour change being credited to the other. + #[test] + fn choose_a_player_offers_neither_an_eliminated_nor_a_phased_out_seat() { + let mut state = choice_legality_board(); + let ability = make_choose_ability(ChoiceType::player()); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + match &state.waiting_for { + WaitingFor::NamedChoice { options, .. } => { + assert_eq!( + options, + &["0", "3", "4"], + "phased-out P1 and eliminated P2 are out; the controller and both \ + valid seats are in" + ); + } + other => panic!("Expected NamedChoice, got {other:?}"), + } + } + + /// CR 608.2d: "choose an opponent" must offer neither an eliminated nor a phased-out + /// seat. + /// + /// R4n and R4m are each other's ATTRIBUTION CONTROL: same board, same resolver, same + /// `compute_options` call, differing only in the `ChoiceType` arm — so a green pair + /// proves each fix landed on the arm it claims to rather than on shared machinery. + /// + /// REVERT-PROBE: restore `players::opponents` at the `ChoiceType::Opponent` arm ⇒ + /// `"1"` reappears ⇒ FAILS. + #[test] + fn choose_an_opponent_offers_neither_an_eliminated_nor_a_phased_out_seat() { + let mut state = choice_legality_board(); + let ability = make_choose_ability(ChoiceType::opponent()); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + match &state.waiting_for { + WaitingFor::NamedChoice { options, .. } => { + assert_eq!( + options, + &["3", "4"], + "phased-out P1 and eliminated P2 are out; both valid opponents are in" + ); + } + other => panic!("Expected NamedChoice, got {other:?}"), + } + } + #[test] fn choose_player_lists_all_players() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/effects/choose_from_zone.rs b/crates/engine/src/game/effects/choose_from_zone.rs index fd3e662459..3d1b73b962 100644 --- a/crates/engine/src/game/effects/choose_from_zone.rs +++ b/crates/engine/src/game/effects/choose_from_zone.rs @@ -101,7 +101,12 @@ pub fn resolve( // the decision out of APNAP defaults; the handler re-enters through // `resolve_with_choosing_player` with the picked opponent. if matches!(chooser, Chooser::Opponent) && !has_targeted_opponent(ability) { - let candidates: Vec = players::opponents(state, ability.controller) + // CR 608.2d: "The player can't choose an option that's illegal or impossible" — + // a resolution-time CHOICE, not a target (CR 115.10a), so the candidate list is + // the CHOOSABLE opponents. The pre-existing `!pl.is_eliminated` re-filter is left + // in place: it is redundant with `is_alive` inside the authority, and removing a + // redundant filter would turn a one-token routing into an unmeasured change. + let candidates: Vec = players::choosable_opponents(state, ability.controller) .into_iter() .filter(|&p| { state @@ -1085,7 +1090,20 @@ fn resolve_chooser(state: &GameState, ability: &ResolvedAbility, chooser: Choose return targeted_opponent; } // Fallback: first opponent in APNAP order (CR-correct for 2-player). - players::opponents(state, ability.controller) + // + // CR 115.10a + CR 608.2d: `choosable_opponents` — the SAME authority the + // multi-candidate prompt above consults — not the raw `opponents`. The two + // differ by `player_exists_for_choice`, i.e. by PHASED-OUT seats, since + // `opponents` filters only on `is_alive`. Routing the >= 2 path through the + // choice authority while leaving this < 2 path on the raw list made the two + // disagree about who is choosable, and it did so in the one case that gets + // NO prompt: a phased-out seat could be handed the choice instead of the + // only legal opponent, with nothing on screen to reveal it. + // + // Empty (every opponent phased out or gone) still degrades to the + // controller, which is the fail-closed direction and what CR 608.2d asks + // for — an impossible choice is not offered. + players::choosable_opponents(state, ability.controller) .into_iter() .next() .unwrap_or(ability.controller) @@ -1599,6 +1617,89 @@ mod tests { } } + /// R4h — CR 608.2d: *"The player can't choose an option that's illegal or impossible."* + /// The controller's pick of WHICH opponent chooses is a resolution-time choice, not a + /// target (CR 115.10a), so a phased-out seat (the CR 702.26b MIRROR) and a departed one + /// (CR 800.4 + CR 102.1) must both be absent from the published candidate list. + /// + /// FIVE SEATS, extending `resolve_with_opponent_chooser`'s construction rather than + /// copying its board: on that row's two-player board the `candidates.len() >= 2` gate + /// can never be met, so it publishes `ChooseFromZoneChoice` and never the + /// `ChooseFromZoneOpponentChooser` this row asserts. That gate is also this row's + /// REACH-GUARD — with fewer than two surviving opponents the resolver falls through to + /// the non-prompting path and an exclusion-only assertion would pass vacuously. + /// + /// REVERT-PROBE: restore `players::opponents` at the candidate derivation ⇒ P1 + /// reappears ⇒ the total equality FAILS. + #[test] + fn opponent_chooser_offer_excludes_a_phased_out_opponent_and_still_offers_the_rest() { + use crate::types::format::FormatConfig; + + let mut state = GameState::new(FormatConfig::standard(), 5, 42); + let mut setup_events = Vec::new(); + + // Setup anti-vacuity, asserted before anything is measured. + let transitioned = + crate::game::phasing::phase_out_player(&mut state, PlayerId(1), &mut setup_events); + assert_eq!( + transitioned, + vec![PlayerId(1)], + "phase_out_player must actually transition P1" + ); + assert!( + state.players[1].is_phased_out(), + "P1 must read as phased out" + ); + crate::game::elimination::eliminate_player(&mut state, PlayerId(2), &mut setup_events); + assert!(state.players[2].is_eliminated, "P2 must read as eliminated"); + + let card1 = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Card A".to_string(), + Zone::Exile, + ); + state + .tracked_object_sets + .insert(TrackedSetId(1), vec![card1]); + state.next_tracked_set_id = 2; + + let ability = ResolvedAbility::new( + Effect::ChooseFromZone { + count: 1, + zone: Zone::Exile, + additional_zones: Vec::new(), + zone_owner: ZoneOwner::Controller, + filter: None, + chooser: Chooser::Opponent, + up_to: false, + constraint: None, + selection: crate::types::ability::CardSelectionMode::Chosen, + }, + vec![], + ObjectId(100), + PlayerId(0), + ); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events).unwrap(); + + match &state.waiting_for { + WaitingFor::ChooseFromZoneOpponentChooser { + player, candidates, .. + } => { + assert_eq!(*player, PlayerId(0), "the controller makes this pick"); + assert_eq!( + *candidates, + vec![PlayerId(3), PlayerId(4)], + "phased-out P1 and eliminated P2 are out; both valid opponents are in" + ); + } + other => panic!("Expected ChooseFromZoneOpponentChooser, got {other:?}"), + } + } + #[test] fn resolve_with_targeted_opponent() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/effects/clash.rs b/crates/engine/src/game/effects/clash.rs index 9ab121436d..c20834ad34 100644 --- a/crates/engine/src/game/effects/clash.rs +++ b/crates/engine/src/game/effects/clash.rs @@ -26,11 +26,16 @@ pub fn resolve( ) -> Result<(), EffectError> { let controller = ability.controller; - // CR 701.30b: The clashing player chooses which opponent to clash with. + // CR 701.30b: "Choose an opponent. You and that opponent each clash." — a CHOICE, not + // a target (CR 115.10a), so the seat is judged by `player_exists_for_choice` and NOT + // by the targeting-only exclusions. `p.id != controller` stays: that is opponent + // SCOPE, not legality. let candidates: Vec = state .players .iter() - .filter(|p| p.id != controller && !p.is_eliminated) + .filter(|p| { + p.id != controller && crate::game::players::player_exists_for_choice(state, p.id) + }) .map(|p| p.id) .collect(); diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index cb56d0c19c..946bc54d9c 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -5736,7 +5736,7 @@ fn rebind_first_object_target( /// see the card's official ruling), so the up-front single-gate at the top of /// `resolve_chain_body` is suppressed for this shape and optionality is fired /// per-iteration inside the `repeat_for` loop instead. -fn has_kind_driven_repeat(ability: &ResolvedAbility) -> bool { +pub(crate) fn has_kind_driven_repeat(ability: &ResolvedAbility) -> bool { matches!( ability.repeat_for, Some(QuantityExpr::Ref { @@ -5759,7 +5759,10 @@ fn has_member_driven_repeat(ability: &ResolvedAbility) -> bool { ) && effect_iterates_over_parent_target(&ability.effect) } -fn has_member_driven_repeat_after_hydration(state: &GameState, ability: &ResolvedAbility) -> bool { +pub(crate) fn has_member_driven_repeat_after_hydration( + state: &GameState, + ability: &ResolvedAbility, +) -> bool { has_member_driven_repeat(&ability_with_event_context_targets(state, ability)) } @@ -5901,7 +5904,7 @@ fn optional_effect_is_infeasible(state: &GameState, ability: &ResolvedAbility) - /// continuation. Any repeated-optional ability whose cost pauses falls through to /// the generic `repeat_for` path and stays honestly unimplemented (no false /// green) until the driver gains pause-resume plumbing. -fn is_repeated_optional_payment(ability: &ResolvedAbility) -> bool { +pub(crate) fn is_repeated_optional_payment(ability: &ResolvedAbility) -> bool { ability.optional && is_synchronous_mana_pay_cost(&ability.effect) && matches!(ability.repeat_for, Some(QuantityExpr::Fixed { .. })) @@ -6468,7 +6471,7 @@ pub(crate) fn resolve_player_for_context_ref( /// acting subject (the target permanent's controller). This mirrors the /// `resolve_library_owner` logic in `search_library.rs` but applies generally /// to any optional effect whose embedded player-scope target is a context-ref. -fn optional_prompt_player(state: &GameState, ability: &ResolvedAbility) -> PlayerId { +pub(crate) fn optional_prompt_player(state: &GameState, ability: &ResolvedAbility) -> PlayerId { if let Effect::PayCost { payer, .. } = &ability.effect { if let Some(player) = crate::game::targeting::resolve_effect_player_ref(state, ability, payer) @@ -22166,6 +22169,59 @@ mod tests { assert!(evaluate_condition(&cond, &state, &ability)); } + /// CR 701.30b: "Choose an opponent. You and that opponent each clash." — a CHOICE, + /// not a target (CR 115.10a), so the offered list must exclude seats that cannot be + /// chosen at all while still offering every seat that can. + /// + /// Board: 5 seats. P0 clashes; **P1 phased out** through the production API; + /// **P2 eliminated**; P3/P4 valid. + /// + /// REACH-GUARD, and the reason the board is 5 seats rather than 3: `clash::resolve` + /// publishes `ClashChooseOpponent` only at `candidates.len() >= 2`. With one surviving + /// opponent it takes the immediate-clash path and publishes NOTHING, so an + /// exclusion-only assertion would pass vacuously on a narrower board. + /// + /// REVERT-PROBE: restore HEAD's `.filter(|p| p.id != controller && !p.is_eliminated)` + /// at this site ⇒ candidates become `[P1, P3, P4]` ⇒ the total-equality assertion + /// FAILS. That isolates exactly the conjunct this change adds. + #[test] + fn clash_offer_excludes_a_phased_out_opponent_and_still_offers_the_rest() { + let mut state = GameState::new(FormatConfig::standard(), 5, 42); + let mut events = Vec::new(); + + // Anti-vacuity on the SETUP, asserted FIRST: `phase_out_player` returns the ids it + // transitioned, so a setup that silently no-opped fails loudly here. + let transitioned = + crate::game::phasing::phase_out_player(&mut state, PlayerId(1), &mut events); + assert_eq!( + transitioned, + vec![PlayerId(1)], + "phase_out_player must actually transition P1" + ); + assert!(state.players[1].is_phased_out()); + crate::game::elimination::eliminate_player(&mut state, PlayerId(2), &mut events); + assert!(state.players[2].is_eliminated); + + let ability = ResolvedAbility::new(Effect::Clash, vec![], ObjectId(1), PlayerId(0)); + let mut events = Vec::new(); + clash::resolve(&mut state, &ability, &mut events).expect("clash resolves"); + + match &state.waiting_for { + WaitingFor::ClashChooseOpponent { + player, candidates, .. + } => { + assert_eq!(*player, PlayerId(0)); + // TOTAL EQUALITY, not `contains`: exclusion AND identity in one assertion. + assert_eq!( + candidates, + &vec![PlayerId(3), PlayerId(4)], + "phased-out P1 and eliminated P2 are out; both valid opponents are in" + ); + } + other => panic!("expected ClashChooseOpponent, got {other:?}"), + } + } + /// CR 701.30b: "Clash with an opponent" lets the clashing player CHOOSE the /// opponent. With two or more opponents the engine must pause on /// `ClashChooseOpponent` (offering every opponent) instead of silently diff --git a/crates/engine/src/game/effects/proliferate.rs b/crates/engine/src/game/effects/proliferate.rs index a5503083d7..e30320a60a 100644 --- a/crates/engine/src/game/effects/proliferate.rs +++ b/crates/engine/src/game/effects/proliferate.rs @@ -62,8 +62,13 @@ fn collect_proliferate_eligible(state: &GameState) -> Vec { .map(|id| TargetRef::Object(*id)) .collect(); + // CR 701.34a: proliferate "means to choose any number of permanents and/or players + // that have a counter" — a CHOICE, not a target (CR 115.10a), so the seat is judged by + // the existence authority and NOT by the targeting-only exclusions. for player in &state.players { - if !proliferatable_player_counters(player).is_empty() { + if !proliferatable_player_counters(player).is_empty() + && crate::game::players::player_exists_for_choice(state, player.id) + { eligible.push(TargetRef::Player(player.id)); } } @@ -623,6 +628,80 @@ mod tests { } } + /// R4a — CR 701.34a + CR 102.1 + the CR 702.26b MIRROR: the proliferate offer is a + /// production-observable MINT, and item 1 changes what it publishes. A seat that has + /// left the game, and a seat that is phased out, are not among the "permanents and/or + /// players" a proliferate choice may range over, however many counters they carry. + /// + /// THE ELIMINATED HALF IS STRICTLY LIVE, independent of phasing: at HEAD this seam had + /// ZERO existence conjuncts of any kind, so a poisoned seat that had already lost the + /// game was still offered to the chooser. + /// + /// TOTAL EQUALITY, never `!contains` — exclusion AND identity in one assertion. Four + /// discriminators ride on the one `assert_eq!`: + /// * P0 IN — the offer still fires and still reaches valid seats (the reach-guard: + /// `drive_single_proliferate_action` publishes NO prompt at all when the eligible + /// set is empty, so an exclusion-only row could pass on an offer that never fired); + /// * P1 OUT by phasing, P2 OUT by elimination — the two behaviour changes; + /// * P3 IN — a valid seat is not lost along with them; + /// * P4 OUT for having no counters — the COUNTER-DISCRIMINATION control, which proves + /// the list is "seats with counters, narrowed by existence" and not "every seat". + /// + /// REVERT-PROBE: drop the `player_exists_for_choice` conjunct ⇒ P1 and P2 reappear ⇒ + /// FAILS. NARROWER PROBE: replace it with bare `is_alive` ⇒ P1 alone reappears ⇒ FAILS, + /// which separates the phasing half from the elimination half. + #[test] + fn proliferate_offer_excludes_eliminated_and_phased_out_seats_and_keeps_the_rest() { + use crate::types::format::FormatConfig; + + let mut state = GameState::new(FormatConfig::standard(), 5, 42); + let mut events = Vec::new(); + + // Every seat but P4 is counter-eligible, so EXISTENCE is the only thing that can + // exclude P1 and P2 below. + for seat in [0usize, 1, 2, 3] { + state.players[seat].poison_counters = 1; + } + + // Setup anti-vacuity, asserted before anything is measured: the production APIs + // report what they transitioned, so a silent no-op fails loudly here. + let transitioned = + crate::game::phasing::phase_out_player(&mut state, PlayerId(1), &mut events); + assert_eq!( + transitioned, + vec![PlayerId(1)], + "phase_out_player must actually transition P1" + ); + assert!( + state.players[1].is_phased_out(), + "P1 must read as phased out" + ); + crate::game::elimination::eliminate_player(&mut state, PlayerId(2), &mut events); + assert!(state.players[2].is_eliminated, "P2 must read as eliminated"); + assert!( + state.players[1].poison_counters > 0 && state.players[2].poison_counters > 0, + "both excluded seats must still CARRY counters, or the counter filter would \ + drop them first and this row would never reach the existence conjunct" + ); + + let ability = make_proliferate_ability(); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).unwrap(); + + let WaitingFor::ProliferateChoice { eligible, .. } = &state.waiting_for else { + panic!("expected ProliferateChoice, got {:?}", state.waiting_for); + }; + assert_eq!( + eligible, + &vec![ + TargetRef::Player(PlayerId(0)), + TargetRef::Player(PlayerId(3)), + ], + "the offer ranges over counter-carrying seats that still exist: P1 is phased \ + out, P2 has left the game, P4 has no counters" + ); + } + #[test] fn proliferate_includes_players_with_generic_player_counters() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/effects/separate_piles.rs b/crates/engine/src/game/effects/separate_piles.rs index 7916abc32f..6b52a0e42f 100644 --- a/crates/engine/src/game/effects/separate_piles.rs +++ b/crates/engine/src/game/effects/separate_piles.rs @@ -226,11 +226,15 @@ fn resolve_revealed_from_library_top( }); // CR 608.2d + CR 700.3: "An opponent" — the controller chooses which opponent - // performs the partition. With a single opponent the choice is trivial. + // performs the partition. With a single opponent the choice is trivial. A CHOICE, + // not a target (CR 115.10a): existence authority only, no targeting exclusions. + // `p.id != controller` stays — opponent SCOPE, not legality. let candidates: Vec = state .players .iter() - .filter(|p| p.id != controller && !p.is_eliminated) + .filter(|p| { + p.id != controller && crate::game::players::player_exists_for_choice(state, p.id) + }) .map(|p| p.id) .collect(); @@ -301,11 +305,15 @@ fn resolve_exiled_this_way( } // CR 608.2d + CR 700.3: "An opponent" — the controller chooses which - // opponent performs the partition (trivial in two-player). + // opponent performs the partition (trivial in two-player). A CHOICE, not a target + // (CR 115.10a): existence authority only. The SECOND pile-source mint of this + // variant; it must narrow identically to the `RevealedFromLibraryTop` mint above. let candidates: Vec = state .players .iter() - .filter(|p| p.id != controller && !p.is_eliminated) + .filter(|p| { + p.id != controller && crate::game::players::player_exists_for_choice(state, p.id) + }) .map(|p| p.id) .collect(); @@ -561,6 +569,156 @@ mod tests { } } + /// R4k — CR 608.2d + CR 700.3: *"an opponent"* separates the piles, and WHICH opponent + /// is the controller's CHOICE (CR 115.10a), not a target. Both pile-source mints of + /// `SeparatePilesChooseOpponent` must narrow identically: a phased-out seat (the + /// CR 702.26b MIRROR) and a departed seat (CR 800.4 + CR 102.1) are not choosable. + /// + /// TWO ARMS, ONE PER MINT, because the two are separate code paths that were changed + /// separately — restoring either inline filter alone must flip its own arm. Neither is + /// reachable from the file's existing row: `make_an_example_ability` uses + /// `PileSource::Battlefield`, which dispatches to `resolve_battlefield` and reaches + /// neither site. + /// + /// FIVE SEATS: both mints publish only when `candidates.len() >= 2`, so with fewer + /// surviving opponents the resolver takes the single-opponent auto-partition branch and + /// publishes `SeparatePilesPartition` instead — an exclusion-only assertion would then + /// pass without the routed code ever running. Asserting the published variant IS the + /// reach-guard. Pre-fix value measured, not assumed: `[P1, P3, P4]`. + /// + /// REVERT-PROBE: restore either `.filter(|p| p.id != controller && !p.is_eliminated)` + /// ⇒ P1 reappears in that arm ⇒ that arm's total equality FAILS. + #[test] + fn separate_piles_offer_excludes_a_phased_out_opponent_at_both_pile_sources() { + use crate::types::format::FormatConfig; + + fn board() -> GameState { + let mut state = GameState::new(FormatConfig::standard(), 5, 42); + let mut setup_events = Vec::new(); + // Setup anti-vacuity, asserted before anything is measured. + let transitioned = + crate::game::phasing::phase_out_player(&mut state, PlayerId(1), &mut setup_events); + assert_eq!( + transitioned, + vec![PlayerId(1)], + "phase_out_player must actually transition P1" + ); + assert!( + state.players[1].is_phased_out(), + "P1 must read as phased out" + ); + crate::game::elimination::eliminate_player(&mut state, PlayerId(2), &mut setup_events); + assert!(state.players[2].is_eliminated, "P2 must read as eliminated"); + state + } + + fn pile_ability( + source_id: ObjectId, + controller: PlayerId, + ps: PileSource, + ) -> ResolvedAbility { + ResolvedAbility::new( + Effect::SeparateIntoPiles { + partition_subject: VoterScope::EachOpponent, + object_filter: TargetFilter::Typed( + crate::types::ability::TypedFilter::creature(), + ), + chooser: PlayerScope::Controller, + chosen_pile_effect: sacrifice_sub(), + pile_source: ps, + unchosen_pile_effect: None, + }, + Vec::new(), + source_id, + controller, + ) + } + + // ARM 1 — site 12, `RevealedFromLibraryTop`. At least one library card is staged, or + // `reveal_count == 0` short-circuits before the candidate derivation runs. + { + let mut state = board(); + let caster = state.players[0].id; + let card = crate::game::zones::create_object( + &mut state, + CardId(11), + caster, + "Top Card".to_string(), + Zone::Library, + ); + assert!( + state.players[0].library.contains(&card), + "reach-guard: the reveal needs a library card, or the resolver returns \ + before it ever derives candidates" + ); + + let ability = pile_ability( + ObjectId(100), + caster, + PileSource::RevealedFromLibraryTop { count: 1 }, + ); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).expect("resolves"); + match &state.waiting_for { + WaitingFor::SeparatePilesChooseOpponent { + player, candidates, .. + } => { + assert_eq!(*player, caster); + assert_eq!( + *candidates, + vec![PlayerId(3), PlayerId(4)], + "RevealedFromLibraryTop mint: phased-out P1 and eliminated P2 out, \ + both valid opponents in" + ); + } + other => panic!("expected SeparatePilesChooseOpponent, got {other:?}"), + } + } + + // ARM 2 — site 13, `ExiledThisWay`. The eligible set comes from the source's exile + // links, so one linked exiled card is staged or the resolver returns early. + { + let mut state = board(); + let caster = state.players[0].id; + let source_id = ObjectId(101); + let exiled = crate::game::zones::create_object( + &mut state, + CardId(12), + caster, + "Exiled Card".to_string(), + Zone::Exile, + ); + state.exile_links.push(crate::types::game_state::ExileLink { + exiled_id: exiled, + source_id, + kind: crate::types::game_state::ExileLinkKind::TrackedBySource, + }); + assert!( + !crate::game::players::linked_exile_cards_for_source(&state, source_id).is_empty(), + "reach-guard: the ExiledThisWay eligible set must be non-empty, or the \ + resolver returns before it ever derives candidates" + ); + + let ability = pile_ability(source_id, caster, PileSource::ExiledThisWay); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).expect("resolves"); + match &state.waiting_for { + WaitingFor::SeparatePilesChooseOpponent { + player, candidates, .. + } => { + assert_eq!(*player, caster); + assert_eq!( + *candidates, + vec![PlayerId(3), PlayerId(4)], + "ExiledThisWay mint: phased-out P1 and eliminated P2 out, both \ + valid opponents in" + ); + } + other => panic!("expected SeparatePilesChooseOpponent, got {other:?}"), + } + } + } + /// CR 700.3d: An opponent with no creatures is recorded as an empty /// `PileResult` and skipped. #[test] diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 029758cea3..caaa381d6e 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -2076,23 +2076,18 @@ fn token_creation_needs_choice( count, applied: HashSet::new(), }; - let candidates = replacement::find_applicable_replacements(state, &proposed, registry); - if candidates.is_empty() { - return false; - } - // (1) any single optional/MayCost applicable replacement → interactive. - let any_optional = candidates.iter().any(|rid| { - state - .objects - .get(&rid.source) - .and_then(|o| o.replacement_definitions.get(rid.index)) - .map(|r| replacement::replacement_mode_is_optional(&r.mode)) - .unwrap_or(true) // unknown ⇒ conservatively interactive - }); - // (2) ≥2 candidates whose ordering is material → CR 616.1 player choice. - let ordering_material = candidates.len() >= 2 - && replacement::replacement_ordering_is_material(state, &candidates, &proposed); - any_optional || ordering_material + // Delegates to the ONE prompt-cause authority. Term for term HEAD's two + // disjuncts: `OptionalCandidate` is the `any_optional` scan (with an + // unresolvable def — every virtual — conservatively optional) and + // `OrderingMaterial` is the `len() >= 2 && ordering_is_material` conjunct. + // + // `MandatoryBodyContinuation` is deliberately NOT read here. A drained body + // can set a non-priority `waiting_for`, so taking it would be a real + // token-batching change; it is left to its own change rather than smuggled + // into this delegation. + let causes = replacement::proposed_event_prompt_cause(state, &proposed, registry); + causes.contains(replacement::ReplacementPromptCause::OptionalCandidate) + || causes.contains(replacement::ReplacementPromptCause::OrderingMaterial) } /// CR 205: Extract the concrete `CoreType` set a `TypeFilter` counts, for the diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 35501d4e1e..69a8d6576d 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -1323,7 +1323,7 @@ fn reconcile_terminal_result(state: &mut GameState, result: &mut ActionResult) { LoopDetectionMode::On => { // Clone the Arc handles (cheap refcount bumps) to release the borrow on the // ring before the GameOver mutation below. - let priors: Vec> = + let priors: Vec> = state.loop_detect_ring.iter().cloned().collect(); let cur = crate::analysis::resource::ResourceVector::snapshot(state); // Carry the matching cycle's `delta` out of the scan alongside the winner so @@ -1334,17 +1334,19 @@ fn reconcile_terminal_result(state: &mut GameState, result: &mut ActionResult) { // fails either seam gate, continue scanning older priors (fail-safe). if let Some((winner, delta)) = priors.iter().enumerate().find_map(|(k, prior)| { let delta = crate::analysis::resource::ResourceVector::delta( - &crate::analysis::resource::ResourceVector::snapshot(prior), + &crate::analysis::resource::ResourceVector::snapshot(&prior.normalized), &cur, ); let winner = crate::analysis::loop_check::live_mandatory_loop_winner( - prior, state, &delta, + &prior.normalized, + state, + &delta, )?; // The matched window: the prior frame at `k`, every subsequent ring frame, // then the live state — all per-resolution, no gaps (a non-sampling beat // clears the ring, so a confirmed window is gap-free). let mut frames: Vec<&GameState> = - priors[k..].iter().map(|p| p.as_ref()).collect(); + priors[k..].iter().map(|p| &p.normalized).collect(); frames.push(state); // CR 704.5a + CR 104.4a (m9): the winner (sole non-faller) must never dip // across the window — a transient intra-cycle dip a net-delta check cannot @@ -1480,7 +1482,9 @@ fn interactive_loop_bridge(state: &mut GameState, result: &mut ActionResult) { // CR 732.2a: OPTIONAL winning drain — only the player with priority may propose // the shortcut. Keep that proposer distinct from the already-measured winner; a // loop can be detected during a different player's priority window. - let certificate = build_cert(prior.as_ref(), state, &delta, winner); + // `build_cert`'s only use of the frame is `board_delta(prior, state)`, a + // comparand read ⇒ the CR 104.4b `.normalized` half. + let certificate = build_cert(&prior.normalized, state, &delta, winner); // CR 732.2a: a non-targeted drain reifies no per-iteration player choice ⇒ carry an // empty pin list; only the `iteration_count` (from `win_kind`) is populated. let WaitingFor::Priority { player: proposer } = state.waiting_for else { @@ -1506,14 +1510,33 @@ fn interactive_loop_bridge(state: &mut GameState, result: &mut ActionResult) { return; } + // Path D: CR 732.2a BOUNDED cycle fast-forward. Only reached when Path A found no + // determinate winner — a drain lethal to SOME opponents leaves a second non-faller, so + // CR 104.2a's determinacy requirement (`loop_check`'s crown gate) refuses to crown and + // Path A returns `None`. This seam routes AROUND that gate rather than weakening it: it + // never calls `live_mandatory_loop_winner` and writes `predicted_winner: None`. + // Placed before Path B because Path B's CR 732.4 verdict is TERMINAL (it writes + // `GameOver` and returns), so a seam ordered after it could never be reached on a state + // Path B accepts. The two are disjoint anyway and the ordering does not paper over an + // overlap: Path B requires `has_no_loss_axis(&delta)`, while this seam only offers when + // `elimination_bounds` NARROWED below `MAX_SHORTCUT_CYCLES`, which happens only when the + // cycle drives some living seat toward a CR 704.5a / CR 704.5c / CR 104.3c threshold — + // i.e. exactly a loss axis. + if let Ok(offer) = try_offer_bounded_cycle_shortcut(state, mandatory) { + state.waiting_for = offer; + result.waiting_for = state.waiting_for.clone(); + return; + } + // Path B: CR 732.4 all-mandatory, net-progress, no-loss draw. Only reached when Path A // found no determinate winner. `mandatory` gates it (CR 732.5); a loss axis or an // optional loop falls through to the pre-feature halt. if mandatory { - let priors: Vec> = + let priors: Vec> = state.loop_detect_ring.iter().cloned().collect(); let cur = crate::analysis::resource::ResourceVector::snapshot(state); for prior in &priors { + let prior = &prior.normalized; let delta = crate::analysis::resource::ResourceVector::delta( &crate::analysis::resource::ResourceVector::snapshot(prior), &cur, @@ -1552,10 +1575,11 @@ fn interactive_loop_bridge(state: &mut GameState, result: &mut ActionResult) { // under their own control", the closest live realization of CR 104.4b's grant. if !mandatory { let controller = state.active_player; // sampler gate is Priority{active_player}: the driver - let priors: Vec> = + let priors: Vec> = state.loop_detect_ring.iter().cloned().collect(); let cur = crate::analysis::resource::ResourceVector::snapshot(state); for prior in &priors { + let prior = &prior.normalized; let delta = crate::analysis::resource::ResourceVector::delta( &crate::analysis::resource::ResourceVector::snapshot(prior), &cur, @@ -1649,17 +1673,22 @@ fn find_live_loop_winner( ) -> Option<( PlayerId, crate::analysis::resource::ResourceVector, - std::sync::Arc, + std::sync::Arc, )> { - let priors: Vec> = state.loop_detect_ring.iter().cloned().collect(); + let priors: Vec> = + state.loop_detect_ring.iter().cloned().collect(); let cur = crate::analysis::resource::ResourceVector::snapshot(state); priors.iter().enumerate().find_map(|(k, prior)| { let delta = crate::analysis::resource::ResourceVector::delta( - &crate::analysis::resource::ResourceVector::snapshot(prior), + &crate::analysis::resource::ResourceVector::snapshot(&prior.normalized), &cur, ); - let winner = crate::analysis::loop_check::live_mandatory_loop_winner(prior, state, &delta)?; - let mut frames: Vec<&GameState> = priors[k..].iter().map(|p| p.as_ref()).collect(); + let winner = crate::analysis::loop_check::live_mandatory_loop_winner( + &prior.normalized, + state, + &delta, + )?; + let mut frames: Vec<&GameState> = priors[k..].iter().map(|p| &p.normalized).collect(); frames.push(state); if !crate::analysis::loop_check::winner_life_never_dips(&frames, winner) { return None; @@ -1696,7 +1725,573 @@ fn build_cert( // The offer is only reached for an OPTIONAL loop. mandatory: false, residual_board_delta: crate::analysis::resource::board_delta(prior, state), + // CR 732.2a: only a producer that NARROWED the repetition bound states a per-period + // signature. The bounded-cycle offer overrides this field with functional-update + // syntax at its own call site; every other producer publishes none. + per_cycle: None, + } +} + +/// CR 732.2a: which conjunct of [`try_offer_bounded_cycle_shortcut`] refused to offer. +/// +/// Exhaustive and typed, in the order the conjuncts run. Production ignores the value (a +/// refusal is a refusal), but a negative test row must be able to say WHICH conjunct it is +/// about: an assertion that merely observes "no offer" silently stops testing its own +/// conjunct the moment an earlier one starts refusing first. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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, + /// (2) The priority holder is not the active player the ring sampler gates on. + ProposerIsNotActivePlayer, + /// (4) Neither certification basis matched. + NoCertification, + /// (5) `WinKind::Advantage` — no CR 704 threshold, so this is Path C's class. + AdvantageOnlyCycle, + /// (6) A per-iteration choice the cycle opens is not specified by a published slot. + UnspecifiedChoiceWindow, + /// (7) `elimination_bounds` produced no count in `1..MAX_SHORTCUT_CYCLES`. + NoNarrowedLegalCount, +} + +/// CR 732.2a: the THIRD entry predicate into the loop-shortcut pipeline — a BOUNDED cycle +/// fast-forward for a loop that is lethal to SOME opponents but crowns nobody. +/// +/// Path A ([`find_live_loop_winner`]) needs a determinate single winner, which CR 104.2a +/// makes impossible while two non-fallers live; Path B needs an all-mandatory no-loss draw. +/// A 4-player drain that kills two seats and leaves two is neither, so both fall through +/// and the loop grinds by hand. CR 732.2a still licenses a shortcut for it, PROVIDED the +/// proposal names a repetition count whose results are *predictable* — which is exactly what +/// this predicate establishes and refuses to offer without. +/// +/// Everything downstream of the `WaitingFor::LoopShortcut` this returns is shipped and +/// unchanged: the same offer shape Path A writes, the same declare handler, the same APNAP +/// window, the same materializer. Two field values keep the classes apart BY CONSTRUCTION +/// rather than by review vigilance: +/// * `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 +/// the complement, and `materialize_fixed_shortcut` dispatches on that same discriminant. +/// +/// 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 +/// conjuncts that all collapse to "no offer" are neither diagnosable nor testable: a negative +/// row asserting only the absence of an offer passes for the wrong reason as soon as an +/// upstream conjunct starts refusing first (domination), and `BoundedOfferRefusal` is what +/// lets such a row name the conjunct it is actually about. +/// CR 732.2a: the capability token that gates the cap-parameterised +/// [`crate::analysis::resource::PeriodVerdicts`] constructor. +/// +/// The unit field is PRIVATE, so the tuple constructor is nameable only inside +/// `game::engine` — the metered seam's own module. Any other site that tried to +/// build a fresh arbitrary-cap verdict container, whose spend the mint's meter +/// would never see, is E0603. Derive list pinned to `#[derive(Debug)]`: a derived +/// constructor would re-open arbitrary caps crate-wide, which is the same +/// strength as the defect this token closes. +#[derive(Debug)] +pub(crate) struct CapAuthority(()); + +/// CR 732.2a: the CLOSED cap domain the metered seam accepts. +/// +/// The seam is `pub` because rows outside this crate ride it, and it mints its +/// own [`CapAuthority`] for whoever calls it — so no token mechanism can gate +/// this route. It is gated at the VALUE instead: an arbitrary raise is +/// unrepresentable, and every image is fail-closed. Resolution is the SEAM's, +/// never the caller's. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbeCap { + /// The shipped budget. + Shipped, + /// `min(n, PROBE_BUDGET)` — expresses every starvation arm and can never raise. + Lowered(u32), + /// Twice the board's own link count, derived from the `state` the seam + /// probes rather than chosen by the caller. At the seam no window has been + /// selected yet, so nothing is exempt and the whole current stack IS the + /// non-exempt population; the work is therefore proportional to the input + /// the caller itself supplied. + RaisedTwiceLinks, +} + +/// CR 732.2a: the mint's meter SNAPSHOT, taken at seam exit. +/// +/// `spent`/`denied` are the probe budget's; the three `conjunct*` counters are +/// the verdict door's own, so an iteration claim has a surface to be asserted on +/// rather than being inferred from charges (which are measurably not a proxy for +/// iterations). +#[derive(Debug, Clone, Copy)] +pub struct MintMeter { + pub spent: u32, + pub denied: bool, + pub conjunct6_asks: u32, + pub conjunct6_frozen_skips: u32, + pub conjunct4_scans: u32, + /// CR 732.2a: WHICH certificate step 4/4b selected — `None` when the mint refused + /// before certification (steps 1/1b/2/2b, or no basis matched at all). + /// + /// It exists because the axis has NO other surface. Both bases now MEASURE + /// `frames_per_period`, so the published [`crate::analysis::loop_check::LoopCertificate`] + /// discriminates in neither direction (see `certified_bounded_cycle_offer`'s + /// attribution note), and the disjunct within basis A is invisible there entirely. + /// A row that must prove a real beat certified through a particular disjunct — the + /// frozen exemption is keyed to exactly one — would otherwise have no assert site. + pub certification: Option, +} + +/// The production entry point: delegates at the shipped cap and drops the meter, +/// so the refusal contract and every existing caller are untouched. +pub fn try_offer_bounded_cycle_shortcut( + state: &GameState, + mandatory: bool, +) -> Result { + try_offer_bounded_cycle_shortcut_metered(state, mandatory, ProbeCap::Shipped).0 +} + +/// CR 732.2a: the OBSERVATION-AND-CAP seam — the same mint, with the per-mint +/// probe cap supplied from the closed [`ProbeCap`] domain and the meter returned +/// instead of dropped. +/// +/// This is the only channel by which a cap other than the shipped one enters, and +/// the only surface on which `spent` / `denied` / the conjunct counters are +/// readable at all: the verdict container never escapes this function. +pub fn try_offer_bounded_cycle_shortcut_metered( + state: &GameState, + mandatory: bool, + cap: ProbeCap, +) -> (Result, MintMeter) { + let mut meter = MintMeter { + spent: 0, + denied: false, + conjunct6_asks: 0, + conjunct6_frozen_skips: 0, + conjunct4_scans: 0, + certification: None, + }; + let outcome = bounded_cycle_offer(state, mandatory, cap, &mut meter); + (outcome, meter) +} + +fn bounded_cycle_offer( + state: &GameState, + mandatory: bool, + cap: ProbeCap, + meter: &mut MintMeter, +) -> Result { + use crate::analysis::resource::{PeriodVerdicts, PROBE_BUDGET}; + + // (1) CR 732.2a: "the player with priority may suggest a shortcut." + let WaitingFor::Priority { player: proposer } = state.waiting_for else { + 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 + // invariant. It converts a silent misroute into an observable refusal. + if !state.last_loop_action_sequence.is_empty() { + return Err(BoundedOfferRefusal::DrivingSequenceNotEmpty); + } + // (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. + // It deliberately does NOT claim the proposer benefits from or controls the loop: + // CR 732.2a is explicit that the ending point "need not be the player proposing the + // shortcut" and that the described sequence is "for all players" (both verbatim). That a + // non-benefiting BYSTANDER may therefore propose is an inference from those clauses, not + // a quotation of them; its in-tree precedent is `analysis::loop_check::ShortcutProposal`'s + // own doc — "a player may propose a shortcut whose deterministic outcome wins the game + // for another player." (CR 732.3's fragmented-loop rule is a CONTRAST, not support.) + if proposer != state.active_player { + return Err(BoundedOfferRefusal::ProposerIsNotActivePlayer); + } + // (2b) CR 732.2a: with fewer than two retained frames there is no window, hence no + // certificate is reachable at all — basis A needs `span >= 1` and basis B needs three + // frames. Refusing HERE, before anything is materialized or classified, is what makes + // "nothing spends before the ring gate" structural: the verdict container does not yet + // exist for a consumer to ask. + if state.loop_detect_ring.len() < 2 { + return Err(BoundedOfferRefusal::NoCertification); + } + + // (4) CERTIFICATION — two bases, first match wins, NEVER combined. + // + // Basis A is a fifth copy of the ring `find_map` scan (`:481` the `On` reconcile, `:668` + // Path B, `:710` Path C, `:808` `find_live_loop_winner`). Recorded, not hidden: the repo + // already made this call at `find_live_loop_winner`'s own doc — "a deliberate, isolated + // copy … the `On` arm stays VERBATIM (byte-identity gate)" — and retargeting the four + // shipped walks would edit byte-identity-gated paths inside a feature commit. Newest + // prior first: the most recent recurrence is the least extrapolation. + // + // TWO PARALLEL VECS OVER ONE INDEX SPACE. They are built from the same `VecDeque` in the + // same order, so `ring.len() == ring_live.len()` by construction and `span`, `[idx..]` + // and basis B's `n - 1 - k` are unchanged expressions on both. + // CR 104.4b comparand half — every certification reader, unchanged in value. + let ring: Vec<&GameState> = state + .loop_detect_ring + .iter() + .map(|f| &f.normalized) + .collect(); + // CR 732.2a evaluable half — the period-touch domain. A normalized frame zeroes + // `next_object_id` and strips trigger identity, so it is a comparand and never a board to + // evaluate an announcement or a resolution against. + let ring_live: Vec<&GameState> = state.loop_detect_ring.iter().map(|f| &f.live).collect(); + + // The per-mint verdict door, constructed immediately after the ring materialization and + // before anything asks it. It allocates an empty memo and a `u32` budget and classifies + // NOTHING; the seam mints its own capability token because it, not its caller, resolves + // the cap. + let cap_value = match cap { + ProbeCap::Shipped => PROBE_BUDGET, + ProbeCap::Lowered(n) => n.min(PROBE_BUDGET), + ProbeCap::RaisedTwiceLinks => 2 * state.stack.len() as u32, + }; + let mut verdicts = PeriodVerdicts::for_period_with_cap( + &ring_live, + state, + proposer, + cap_value, + CapAuthority(()), + ); + + // ONE exit for the meter: everything that can ask the door lives below, and the snapshot + // is taken here rather than at each refusal so a future refusal arm cannot forget it. + // `certification` is the exception BY NECESSITY — it is not a counter the container + // accumulates but a choice made mid-walk, so it is written where it is decided and read + // back here through the same one exit. + let outcome = certified_bounded_cycle_offer( + state, + mandatory, + proposer, + &ring, + &ring_live, + &mut verdicts, + &mut meter.certification, + ); + meter.spent = verdicts.spent(); + meter.denied = verdicts.denied(); + meter.conjunct6_asks = verdicts.conjunct6_asks(); + meter.conjunct6_frozen_skips = verdicts.conjunct6_frozen_skips(); + meter.conjunct4_scans = verdicts.conjunct4_scans(); + outcome +} + +/// CR 732.2a: certification (step 4/4b), the choice gate and the bound — everything that can +/// ask the verdict door, split out so its caller owns exactly one meter snapshot. +#[allow(clippy::too_many_arguments)] +fn certified_bounded_cycle_offer<'a>( + state: &'a GameState, + mandatory: bool, + proposer: PlayerId, + ring: &[&'a GameState], + ring_live: &[&'a GameState], + verdicts: &mut crate::analysis::resource::PeriodVerdicts<'a>, + cert_out: &mut Option, +) -> Result { + use crate::analysis::decision_template::{ + DecisionPoint, DecisionPointKind, DecisionSlot, IterationCount, + }; + use crate::analysis::resource::{ + certified_period_touch, PeriodCertification, PeriodTouch, PeriodicDelta, ResourceVector, + }; + use crate::types::ability::TargetRef; + + let cur = ResourceVector::snapshot(state); + // Written as an explicit newest-first walk rather than `find_map` because the candidate + // body now threads `&mut verdicts` and carries an owned per-candidate `PeriodTouch` out. + let mut basis_a: Option<( + &GameState, + Vec, + PeriodTouch<'_>, + PeriodicDelta, + )> = None; + for idx in (0..ring.len()).rev() { + // The span, in RETAINED RING FRAMES, that this candidate pair covers. + // `ring.last()` is the sample `pass_priority_once_with_pipeline` recorded at THIS + // beat, before the bridge ran, so the newest frame is the current state and the + // span from `ring[idx]` is `len - 1 - idx`. + // + // A span of 0 is the pair `state` against its own snapshot. It is already refused + // by `net_progress_for` on the resulting zero delta in every production + // trajectory, but it is refused HERE too, explicitly: `materialize_fixed_shortcut` + // now DELIMITS a committed cycle by this count, and a published `0` would mean + // "one repetition spans no frames", which no drive can honour. Fail closed on the + // degenerate pair rather than rely on a downstream conjunct to catch it. + // + // EVALUATED FIRST, before the window is built, touched or minted from — `span >= 1` + // is `window.len() >= 2` identically, so this guard is also what keeps a degenerate + // window out of the touch and the mint. + let span = ring.len() - 1 - idx; + if span < 1 { + continue; + } + let prior = ring[idx]; + let window = &ring_live[idx..]; + // Built under `BoardCovered` unconditionally, because step 4 does not yet know which + // disjunct will match; step 4b keeps it or rebuilds it. + let touch_cover = certified_period_touch(window, state, PeriodCertification::BoardCovered); + // (3) The published per-iteration choices (5a's single authority), now enumerated + // over the CERTIFIED PERIOD's announced pairs rather than over the offer-beat stack. + let points = bounded_cycle_pin_slots_for_window(&touch_cover, proposer); + let slots: Vec = points.iter().map(|p| p.slot.clone()).collect(); + let delta = ResourceVector::delta(&ResourceVector::snapshot(prior), &cur); + // The existing disjunction, written as an `if / else if` that RECORDS the matching + // disjunct instead of discarding it. Semantics are byte-for-byte the `||` it + // replaces: the equality arm is still evaluated first and `_pinned` still runs only + // when it fails. The two disjuncts are mutually exclusive — equality compares the + // stack exactly (constant depth) while cover's item (2) forces strictly growing depth + // — so "which disjunct" is a total function with no both-matched case. + let cert = if crate::analysis::resource::loop_states_equal_modulo_resources(prior, state) { + Some(PeriodCertification::BoardEqualOnly) + } else if crate::analysis::resource::loop_states_cover_modulo_growth_pinned( + prior, + state, + proposer, + &slots, + &touch_cover, + verdicts, + ) { + Some(PeriodCertification::BoardCovered) + } else { + None + }; + let Some(cert) = cert else { + continue; + }; + if !delta.net_progress_for(proposer) { + continue; + } + // (4b) THE CERTIFIED TOUCH, keyed to the disjunct that actually matched. The cover + // disjunct supplies both premises the frozen subtraction rests on; the equality + // disjunct supplies only the depth one, so its period is rebuilt with the exemption + // withdrawn. `announced` is identical on both, so the mint is not re-derived. + let touch = match cert { + PeriodCertification::BoardCovered => touch_cover, + c => certified_period_touch(window, state, c), + }; + // Recorded HERE and not at the `if / else if`: a candidate that certifies and then + // dies on `net_progress_for` is not the certificate the mint carries forward, and a + // meter that named it would attribute the offer to a pair the walk discarded. + *cert_out = Some(cert); + basis_a = Some(( + prior, + points, + touch, + PeriodicDelta { + // MEASURED span, not the former hardcoded `1`. The walk is `.rev()`, so + // `idx` is usually `len - 2` and the span is 1 — but it is 1 by + // MEASUREMENT, not by assumption, and it is NOT always 1: the + // `interactive_3p_subset_lethal_does_not_crown` fixture's repetition + // spans TWO frames (a gain-life resolution then a lose-life one), and + // under the old hardcode its accepted drive committed nothing at all. + frames_per_period: span as u32, + delta, + victim_slot: Vec::new(), + }, + )); + break; + } + // Basis B consults NO board predicate: a period whose frame-deltas repeated twice in the + // retained ring is a signature on its own. Its certifying pair is the ring frame one + // period back and the ring's newest frame — the very pair `ring_delta_signature` + // measured, so the certificate's residual is derived from the same window as the delta. + // + // ⚠ WHAT ACTUALLY DECIDES A vs B ON A GROWING CASCADE — measured, because the intuitive + // answer is wrong and cost this lane a mislabelled row. It is NOT "resource-purity": a + // pure life↔life drain does not take basis A by recurring. BOTH known life-drain + // fixtures GROW their stack every period, so `loop_states_equal_modulo_resources` is + // FALSE on the certifying pair of each, and NEITHER certifies through the equal disjunct: + // + // * the basis-A fixture (`multiplayer_pure_life_drain_offers_at_three_and_four_players`, + // Blight-Priest + Exquisite Blood) certifies through + // `loop_states_cover_modulo_growth_pinned` at `ring[1]` — `stack[2->3]` at 3 players, + // `stack[3->5]` at 4. The one `eq == true` pair on its ring carries a zero δ and dies on + // `net_progress_for`, not on the board predicate. + // * the basis-B fixture (`dina_untargeted_drain_4p_offers_at_three_live_opponents`) has + // that SAME disjunct vetoed at cover **gate (5)** — the off-stack fire-time condition + // guard — by a `ModifyCost { Reduce, {2} }` static on a library card, gated on + // `LifeGainedThisTurn { Controller } >= 1`: a projected axis read at fire time. + // + // 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. + // + // ⚠ 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 + // derived `k` — so the two publish overlapping value ranges and NO value discriminates in + // either direction. (Before fix round 1 basis A published a hardcoded `1`, which made + // `!= 1` sufficient-but-not-necessary for "not basis A"; that inference is now dead too, + // since a basis-A span of 2 is exactly what the `interactive_3p_subset_lethal_does_not_crown` + // fixture publishes.) The only sound attribution is a discriminating probe: force + // `ring_delta_signature` to return `None` (basis B's sole entry point is the `None =>` + // arm below) — the rows that survive are basis A, the rows that fail are basis B. + let (cert_prior, points, touch, mut periodic) = match basis_a { + Some(hit) => hit, + None => { + let (k, delta) = crate::analysis::resource::ring_delta_signature(state) + .ok_or(BoundedOfferRefusal::NoCertification)?; + let n = ring.len(); + let start = n + .checked_sub(1 + k as usize) + .ok_or(BoundedOfferRefusal::NoCertification)?; + let cert_prior = *ring + .get(start) + .ok_or(BoundedOfferRefusal::NoCertification)?; + // Basis B EXEMPTS NOTHING, and that is derived rather than cautious: its + // certificate consults no board predicate, so it supplies no premise that the + // period cannot SHRINK the stack — a stack draining from the top under a ticking + // monotone resource satisfies the delta signature and leaves a large frozen + // bottom prefix the drain will reach. `announced` is unchanged; only the + // subtraction is withdrawn. + let window = ring_live + .get(start..) + .ok_or(BoundedOfferRefusal::NoCertification)?; + let touch = + certified_period_touch(window, state, PeriodCertification::ResourceSignatureOnly); + *cert_out = Some(PeriodCertification::ResourceSignatureOnly); + let points = bounded_cycle_pin_slots_for_window(&touch, proposer); + ( + cert_prior, + points, + touch, + PeriodicDelta { + frames_per_period: k, + delta, + victim_slot: Vec::new(), + }, + ) + } + }; + + // (5) CR 732.2a: the conjunct that proves this class is DISJOINT from Path C's + // revocable-∞ advantage mark. An `Advantage` cycle drives nobody toward a CR 704 + // threshold, so it has no bound to state and belongs to the other seam. + if crate::analysis::loop_check::classify_win_kind(proposer, &periodic.delta) + == crate::analysis::loop_check::WinKind::Advantage + { + return Err(BoundedOfferRefusal::AdvantageOnlyCycle); + } + + // (6) CR 732.2a "predictable results": every per-iteration choice the cycle opens must be + // a SPECIFIED one. `stack_choices_are_all_specified` is that question's authority — it + // shares gates (3)/(6)'s own predicates and pin relief verbatim, so the relief here can + // never be coarser than the mint that published the slots. + // + // Its own conjunct, not folded into step 4: basis A's disjunction may have matched on + // exact recurrence (which says nothing about choices) and basis B consults no board + // predicate at all. And deliberately NOT a second `loop_states_cover_modulo_growth_pinned` + // call: on the dina 4p drain that predicate refuses 66 of the beats this seam reaches, + // on a BOARD fact, without ever examining a choice. + // + // ⚠ ATTRIBUTION CORRECTED, and deliberately scoped to what was RE-MEASURED. An earlier + // revision named the refuser as the cover predicate's item (1) `object_resource_axes_match` + // STRICT compare. At the MINT/OFFER beat that is FALSE: instrumenting the cover gates on + // dina's offer beat shows `object_resource_axes_match == true` at every gate-(1) refusal + // observed (187 of 187 across the dina and the ≥3p life-drain drives); the actual refusals + // are gate (5) (an off-stack fire-time condition reading a projected axis) and, on older + // ring pairs, gate (1)'s `loop_states_equal` on the stack-cleared projected board. The 66 + // NON-OFFERING beats the original count came from were NOT re-measured in that round, so + // the item-(1) attribution may still hold for them — it is left standing for that + // population rather than overwritten with an unmeasured claim. Either way the conjunct's + // JUSTIFICATION is unchanged: cover refuses on board facts, and this seam asks about + // choices. + let slots: Vec = points.iter().map(|p| p.slot.clone()).collect(); + if !crate::analysis::resource::stack_choices_are_all_specified( + state, + proposer, + &slots, + Some(&touch), + verdicts, + ) { + return Err(BoundedOfferRefusal::UnspecifiedChoiceWindow); } + + // (7) THE BOUND. `declarable_victims` is the union of the published slots' legal targets + // — EMPTY for the untargeted class, where the victims are already in `delta.life`. + let declarable_victims: Vec = { + let mut v: Vec = points + .iter() + .filter_map(|p| match &p.kind { + DecisionPointKind::Targets { legal_targets, .. } => Some(legal_targets), + _ => None, + }) + .flatten() + .filter_map(|t| match t { + TargetRef::Player(p) => Some(*p), + _ => None, + }) + .collect(); + v.sort_unstable(); + v.dedup(); + v + }; + // CR 704.5a: what ONE repetition charges to whichever seat a slot's pin names. The + // max-vs-sum reasoning, the gain clamp and the fail-closed direction live on the + // function; `elimination_bounds` then sums the published slots per declarable victim. + // Extracted rather than inlined so the fork has a callable seam — `victim_slot` is empty + // on every trajectory that offers today, so this value is dropped in production and only + // `worst_seat_life_loss_is_the_max_seat_never_the_sum` discriminates max from sum. + let worst_seat_life_loss: i64 = periodic.delta.worst_seat_life_loss(); + periodic.victim_slot = points + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .map(|p| (p.slot.clone(), worst_seat_life_loss)) + .collect(); + // `.cloned()`, not `.copied()`: `(DecisionSlot, i64)` is not `Copy`. + let slot_magnitude: std::collections::BTreeMap = + periodic.victim_slot.iter().cloned().collect(); + let max_iterations = + periodic + .delta + .elimination_bounds(state, &declarable_victims, &slot_magnitude); + // A bound of 0 states no legal repetition. A bound AT the cap states no narrowing at all + // — this producer's whole claim is that it measured a CR 704.5a / CR 704.5c / CR 104.3c + // threshold inside the loop, so an unnarrowed result belongs to another seam. Checking + // the closed range here makes `schema.is_bounded()` true BY CONSTRUCTION for every offer + // this function mints, instead of an inference from step 5's `Advantage` rejection. + if !(1..MAX_SHORTCUT_CYCLES).contains(&max_iterations) { + return Err(BoundedOfferRefusal::NoNarrowedLegalCount); + } + + // (8) The certificate, with the two fields the bounded class states differently from + // Path A's spelled out at the site rather than mutated after the fact. + // `cert_current` is the live `state` on both bases, exactly as before: `build_cert`'s only + // use of the pair is `board_delta`, a comparand read. + let base = build_cert(cert_prior, state, &periodic.delta, proposer); + let certificate = crate::analysis::loop_check::LoopCertificate { + per_cycle: Some(periodic), + // CR 732.5: honest, and currently read by nothing in production — a loop nobody can + // break is still not forced to end, so this records the fact without acting on it. + mandatory, + ..base + }; + + // (9) The schema. `Fixed(max_iterations)` is the SUGGESTION and `max_iterations` the + // CEILING; the declare handler rejects any `Fixed(n)` above it and rejects `UntilLethal` + // outright, both already shipped. The pre-built `points` go in directly — the bounded + // path never calls `pinned_decisions_to_points`, whose legal sets are derived FROM the + // declared pins and would let a declaration ratify itself. + let schema = build_shortcut_schema( + points, + IterationCount::Fixed(max_iterations), + max_iterations, + ); + Ok(WaitingFor::LoopShortcut { + proposer, + predicted_winner: None, + certificate, + schema, + }) } /// CR 704.5a / CR 704.5c: a determinate lethal drain (0-or-less life / 10-poison) repeats @@ -1724,12 +2319,13 @@ fn pinned_decisions_to_points( pins: &[crate::analysis::decision_template::PinnedDecision], state: &GameState, controller: PlayerId, -) -> Vec { +) -> Option> { use crate::analysis::decision_template::{DecisionPoint, DecisionPointKind, PinnedDecision}; - pins.iter() - .filter_map(|pin| match pin { + let mut points = Vec::with_capacity(pins.len()); + for pin in pins { + let point = match pin { // CR 603.3b: trigger ordering is not a loop-declaration choice — no read-side peer. - PinnedDecision::Order { .. } => None, + PinnedDecision::Order { .. } => continue, // CR 702.51a: the untapped creatures the controller may tap for convoke. Sorted by // the public inner id: `im::HashMap::values()` order is nondeterministic and this Vec // serializes to the wire (cf. `resolve_source`'s `min_by_key` for the same reason). @@ -1741,10 +2337,10 @@ fn pinned_decisions_to_points( .map(|o| o.id) .collect(); tappable.sort_by_key(|id| id.0); - Some(DecisionPoint { + DecisionPoint { slot: slot.clone(), kind: DecisionPointKind::ConvokeTaps { tappable }, - }) + } } // FIX-1 (B1): reify the recorded fixed in-cycle choices. The drive replays these SAME // pins via `decision_template::resolve` (CR 608.2b ByIdentity live re-binding), so the @@ -1752,14 +2348,23 @@ fn pinned_decisions_to_points( // CR 608.2b: resolve each pinned target to its live legal `TargetRef` — the pinned // identity IS the singleton legal set (a fixed declinable ∞ offer, no FE re-selection). PinnedDecision::Targets { slot, targets } => { + // CR 732.2a: a proposal must describe a sequence "that may be legally taken + // based on the current game state". If ANY pinned target no longer resolves, + // the offer must be WITHDRAWN, not published — the `?` below is that + // withdrawal. `filter_map`ping the failure away instead would publish a point + // with a short `legal_targets` under `min_targets = targets.len()`: a + // self-inconsistent, UNDECLARABLE point that fails downstream as + // `IllegalPinValue`/`UnknownChoice` rather than as "there is no offer". + // Dropping the point entirely is also wrong — it would let + // `predictability_gate`'s coverage check pass trivially. let legal_targets: Vec = targets .iter() - .filter_map(|t| { + .map(|t| { crate::analysis::decision_template::resolve_target_ref(t, slot, 0, state) }) - .collect(); + .collect::>>()?; let count = targets.len().min(u32::MAX as usize) as u32; - Some(DecisionPoint { + DecisionPoint { slot: slot.clone(), kind: DecisionPointKind::Targets { legal_targets, @@ -1767,19 +2372,19 @@ fn pinned_decisions_to_points( max_targets: count, ordered: true, }, - }) + } } // CR 608.2d: the latched mana color — a read-only fixed point (no legal set to bound). - PinnedDecision::ManaColor { slot, color } => Some(DecisionPoint { + PinnedDecision::ManaColor { slot, color } => DecisionPoint { slot: slot.clone(), kind: DecisionPointKind::ManaColor { color: *color }, - }), + }, PinnedDecision::Mode { slot, indices } => { let mut available_modes = indices.clone(); available_modes.sort_unstable(); available_modes.dedup(); let count = indices.len().min(u32::MAX as usize) as u32; - Some(DecisionPoint { + DecisionPoint { slot: slot.clone(), kind: DecisionPointKind::Mode { available_modes, @@ -1791,18 +2396,431 @@ fn pinned_decisions_to_points( .collect::>() .len(), }, - }) + } } - PinnedDecision::MayChoice { slot, .. } => Some(DecisionPoint { + PinnedDecision::MayChoice { slot, .. } => DecisionPoint { slot: slot.clone(), kind: DecisionPointKind::MayChoice, - }), - PinnedDecision::UnlessBreak { slot, .. } => Some(DecisionPoint { + }, + PinnedDecision::UnlessBreak { slot, .. } => DecisionPoint { slot: slot.clone(), kind: DecisionPointKind::UnlessBreak, - }), - }) - .collect() + }, + }; + points.push(point); + } + Some(points) +} + +/// CR 115.2 + CR 732.2a: does the ability's HEAD effect declare the "target opponent" PLAYER +/// filter — a `Typed` filter with no type constraints, no object properties, and +/// `controller: Opponent`, the shape `game::targeting::find_legal_targets` collapses to +/// players-only (`crates/engine/src/game/targeting.rs:192-193`)? +/// +/// SHAPE ACCEPTANCE ONLY, and the `bool` return is what enforces it: the published legal +/// set must come from the announcement authority (`ability_utils::build_target_slots`), never +/// from here, because this predicate reads the HEAD effect's filter while the choice being +/// announced can belong to a CHAINED sub-ability (CR 601.2c reached via CR 603.3d). Handing +/// the filter back would re-open exactly that divergence, so it is not handed back. +/// +/// What the `controller` conjunct contributes, and nothing else does: `controller: You` / +/// `None` ALSO collapses to players, so an all-`Player` legal set alone would admit a single +/// forced seat, which is not the per-opponent choice a bounded drain cycle pins. Measured on +/// the 3p drain board: `Typed{[], You, []}` builds ONE mandatory slot whose legal set is +/// `[Player(0)]`, and only this conjunct rejects it. +/// +/// What the `type_filters` / `properties` conjuncts contribute (issue #2004 — "target token +/// you control" must not collapse to a player) is the MIRROR of that chained-slot +/// divergence: an object-shaped HEAD effect whose single announced slot is nonetheless a +/// player choice. When the head announces its own slot, an object-shaped filter is already +/// rejected upstream — it either enumerates OBJECTS (the caller's all-`Player` conjunct) or +/// enumerates nothing, making `build_target_slots` return `Err` (the caller's cardinality +/// conjunct). It is only when the head announces NOTHING +/// (`TargetChoiceTiming::Resolution`) and a chained `target opponent` sub-ability supplies +/// the one slot that these two conjuncts become the sole rejector — which is the board +/// `bounded_cycle_pin_slots_conjuncts_are_each_load_bearing` measures them on. +fn declares_opponent_player_target(ability: &crate::types::ability::ResolvedAbility) -> bool { + use crate::types::ability::{ControllerRef, TargetFilter}; + let Some(TargetFilter::Typed(tf)) = ability.effect.target_filter() else { + return false; + }; + tf.type_filters.is_empty() + && tf.properties.is_empty() + && tf.controller == Some(ControllerRef::Opponent) +} + +/// What ONE accepted stack entry publishes: the slot keys, plus the legal set the +/// ANNOUNCEMENT authority itself built for the target slot. +pub(crate) struct EntryPinSlots { + /// CR 115.2 target choice — `index: 0`. `None` for shape (B), the may-only entry: + /// announcing it surfaces NO choice at all (`targets.is_empty()` and zero built slots), + /// so there is no CR 601.2c announcement choice for a pin to specify. + pub(crate) target: Option, + /// CR 603.5 "may" gate — `index: 1`, `Some` only if `ability.optional` — the mint + /// additionally refuses on recipient, stored auto-choice and prompt-cardinality grounds + /// (see the `may` mint below), so `None` here does NOT imply the ability is mandatory. + /// `DecisionSlot`'s sub-index disambiguates two choices of ONE ability instance (target + /// vs. may gate). + pub(crate) may: Option, + /// The legal set of the ONE announcement slot, taken VERBATIM from + /// `ability_utils::build_target_slots` — the same authority that decided there is + /// exactly one mandatory choice. Deriving it a second time from the head effect's + /// filter would let the two disagree about WHICH choice is being published, which is + /// the same class of divergence the cardinality conjunct closes about HOW MANY. + /// Empty for shape (B), which publishes no target slot to carry a legal set for. + pub(crate) legal_targets: Vec, +} + +/// CR 732.2a: the per-iteration choice slots ONE stack entry publishes for `proposer`, or +/// `None` when it publishes none. +/// +/// SINGLE AUTHORITY, and that is the whole point of its existence: the MINT +/// ([`bounded_cycle_pin_slots_for_window`]) maps it over the certified period's announced +/// pairs, of which `state.stack` is the zero-window degenerate case, and the RELIEF +/// (`analysis::resource`'s CR 732.2a gate-(3)/(6) pin skip) calls it for one entry. Because +/// both sides ask the same function, the relief predicate cannot be COARSER than the mint +/// predicate — relieving a verdict the published pin does not specify is impossible by +/// construction rather than by convention. +/// +/// The acceptance conjuncts, in the order the EXTENSION POINT's preconditions name them: +/// (c) `entry.controller == proposer` — CR 732.2a leaves every OTHER player owning their own +/// choices, so an opponent-controlled entry is never pinnable; the entry is a triggered +/// ability (a spell / activated ability re-announces from scratch); ANNOUNCING it requires +/// either exactly one mandatory choice over players whose head effect declares the +/// player-target shape (shape (A): asked of the announcement authority itself, +/// `ability_utils::build_target_slots` — the function the relief's own +/// `forced_unique_targeting` rebuilds slots with — rather than of a proxy, plus +/// [`declares_opponent_player_target`]), or NO announcement choice at all (shape (B): +/// `targets.is_empty()` and zero built slots); and its source object still exists, so the +/// slot can re-bind (CR 400.7 incarnation, fail-closed on absence). +/// +/// SCOPE OF THE ANSWER: because the relief is a `continue` at gate (3), the relief +/// predicate must be no coarser than EVERY fact `stack_entry_has_no_ordering_input` +/// rejects on — not just the target one. Correspondence, in that function's own order: +/// entry kind (the destructure below), `pending_trigger_entry` (the ONE state-dependent +/// fact, enforced at the relief so this enumerator stays pure — see the block below), +/// `multi_target` / `distribution` / `target_constraints` (the block below), and the +/// target choice itself, which is the one fact the published slot actually answers. +pub(crate) fn entry_publishes_pin_slots( + state: &GameState, + entry: &StackEntry, + proposer: PlayerId, +) -> Option { + use crate::analysis::decision_template::DecisionSlot; + if entry.controller != proposer { + return None; + } + let StackEntryKind::TriggeredAbility { ability, .. } = &entry.kind else { + return None; + }; + // The published slot answers ONE target (`min_targets: 1, max_targets: 1` below). A + // variable-count choice (CR 601.2c "if the spell has a variable number of targets, the + // player announces how many"), a divide/distribute assignment (CR 601.2d), or a + // cross-target constraint (CR 601.2c "the same target can't be chosen multiple times" / + // "must be chosen") — all reached for a triggered ability via CR 603.3d — is + // announcement-time ordering input NO published slot specifies. These are the ABILITY's + // own facts, so they live here, where mint and relief share them: the gate-(3) relief + // is a `continue` that discharges the whole of `stack_entry_has_no_ordering_input`, + // which rejects on each of them independently of its target check. + // + // The fourth fact that function rejects on — `pending_trigger_entry == entry.id`, + // CR 603.3c mid-construction — is deliberately NOT here: it is a property of the + // COMPARED STATE, not of the offer's schema, and reading it would break this + // enumerator's PROMPT-state independence (see [`bounded_cycle_pin_slots`]: it never + // reads `waiting_for`, nor `pending_trigger_entry`, which is set exactly while a prompt + // is up). NOT a claim of purity over a three-field surface: the CR 603.5 recipient + // conjunct below resolves a player through `optional_prompt_player` → + // `resolve_effect_player_ref`, which reaches ELEVEN distinct `GameState` fields — + // `state.players`, `state.seat_order`, `state.format_config`, `state.objects`, + // `state.lki_cache`, `state.stack`, `state.current_trigger_event`, + // `state.last_created_token_ids`, `state.last_revealed_ids`, + // `state.last_zone_changed_ids` and `state.resolution_stack`. The contract is narrower + // and exact — the mint is a function of the BOARD, never of the PROMPT — and it is what + // keeps the mint's verdict stable across a prompted and an unprompted beat. + // It is set exactly + // while a `TriggerTargetSelection` prompt is up, so a mint that read it would publish + // nothing on a prompted board — measured on dump B, where it zeroes the emblem slot. + // It is enforced at the relief instead (`analysis::resource::entry_target_choice_is_pinned`), + // which makes the relief strictly NARROWER than the mint — never coarser. + if ability.multi_target.is_some() + || ability.distribution.is_some() + || !ability.target_constraints.is_empty() + { + return None; + } + // THE ANNOUNCEMENT AUTHORITY, not a proxy for it. Everything above is an ability FACT; + // this is the only conjunct that asks the questions the published point actually + // answers — "how many choices does announcing this entry require, is each one + // mandatory, and WHICH objects or players may be chosen?" `Effect::target_filter()` + // (below) cannot answer any of them: it reports the head effect's filter, while + // CR 601.2c ("if the spell uses the word 'target' in multiple places, the same object + // or player can be chosen once for each instance") — reached for a triggered ability + // via CR 603.3d — makes a CHAINED sub-ability's own target a SECOND independent choice, + // with its OWN legal set. `build_target_slots` is the function + // `stack_entry_has_no_ordering_input` itself rebuilds slots with (via + // `forced_unique_targeting`), so mint and relief now measure the same quantities. + // + // Exactly one MANDATORY slot over PLAYERS, and each of the three parts is load-bearing + // — the first two discriminated by + // `bounded_cycle_pin_slots_requires_a_single_mandatory_announcement_slot`, the third by + // `bounded_cycle_pin_slots_legal_set_comes_from_the_announcement_authority`: + // * `len() == 1` — a chained second "target" (2 slots) or an effect whose filter the + // SLOT BUILDER declines (0 slots: `triggers::extract_target_filter_from_effect` + // carves out `Sacrifice`/`UnattachAll`/… for which `Effect::target_filter()` still + // returns `Some`, and `target_choice_timing == Resolution` surfaces no stack slot at + // all) would leave the published `min/max_targets: 1` contradicting the announcement. + // * `!optional` — `ability.optional_targeting` ("up to one target") makes the real + // minimum ZERO (CR 601.2c), and its slot may legally carry an EMPTY legal set, so a + // `min_targets: 1` point would over-state the choice the offer specifies. + // * every legal target is a PLAYER (CR 115.2) — the head effect can declare the + // player shape while the ONE slot the announcement actually surfaces belongs to a + // chained sub-ability targeting OBJECTS (measured: head `LoseLife` at + // `TargetChoiceTiming::Resolution` contributing 0 slots + a chained + // `LoseLife{Typed{[Creature]}}` contributing 1, legal set three objects). A + // `TargetPin::Player` cannot specify such a choice, so publishing it would hand + // gate (3)'s `continue` a slot no pin can answer. + // + // `Err` (no legal target, CR 603.3d) also yields `None` — fail-closed, matching this + // function's contract that the schema can only ever UNDER-publish. Purity survives: + // `build_target_slots` never reads `state.waiting_for` (its only hit in + // `ability_utils.rs` is a test at `:7722`). + let source = object_decision_source(state, entry.source_id)?; + // CR 603.5 + CR 732.2a: `entry.controller == proposer` above bounds who OWNS the entry; + // it does NOT bound who the resolver ASKS, nor WHETHER it asks, nor HOW MANY TIMES. + // Three mint-time conjunct groups, all FAIL-CLOSED pre-filters on the ONE gate a + // `MayChoice` pin is for — the CR 603.5 gate inside `resolve_chain_body` + // (`effects/mod.rs`, the `if ability.optional && !has_kind_driven_repeat(..)` block). + // THIS IS THE ONE PLACE `may` IS MINTED, so the guards cover shape (A) and shape (B) + // together rather than being restated per shape. Soundness over the OTHER FOUR + // production producers of `WaitingFor::OptionalEffectChoice` is NOT claimed here; it is + // discharged at the consumption point, where the instrument is total. + // + // (a) RECIPIENT. `optional_prompt_player` is THIS gate's own recipient authority — + // five of its branches route to a NON-controller and the last is EFFECT-AGNOSTIC + // (CR 503.1a + CR 608.2d, the `scoped_player` class whose printed member is Braids, + // Conjurer Adept — "At the beginning of each player's upkeep, that player may put an + // artifact, creature, or land card from their hand onto the battlefield."), so + // asking the same function the gate asks keeps THIS pair from drifting. Without + // it a proposer's pin can be spent as another seat's CR 603.5 choice. + // (b) SECOND AUTHORITY. A stored "don't ask again" auto-choice ALREADY ANSWERS this + // may and the gate returns BEFORE setting any prompt, so a pin minted here would + // be silently unused — invisible even to a fail-closed inject arm. The key is + // built exactly as the gate builds it; `player` is `proposer` only because `&&` + // short-circuits left to right and (a) has already proved them equal. Present ⇒ + // refuse; `may_trigger_origin: None` ⇒ no key exists ⇒ nothing to refuse. + // (c) CARDINALITY. CR 732.2a: the shortcut describes THE sequence of choices, so one + // published slot may stand for exactly ONE CR 603.5 prompt. Production suppresses + // the single up-front gate for three `repeat_for` shapes and re-fires optionality + // PER ITERATION (CR 608.2c + CR 608.2d) instead. `has_kind_driven_repeat` keys on + // `repeat_for` ALONE — no `Effect` restriction — so an optional `PutCounter` / + // `Draw` / `Token` of that shape would otherwise mint ONE slot for N prompts. Ask + // production's own three predicates rather than re-deriving them here, which is + // the same authority-sharing rule (a) follows. + let may = (ability.optional + && crate::game::effects::optional_prompt_player(state, ability) == proposer + && !crate::game::effects::has_kind_driven_repeat(ability) + && !crate::game::effects::has_member_driven_repeat_after_hydration(state, ability) + && !crate::game::effects::is_repeated_optional_payment(ability) + && ability.may_trigger_origin.as_ref().is_none_or(|origin| { + state + .may_trigger_auto_choice(&crate::types::game_state::MayTriggerAutoChoiceKey { + player: proposer, + source_id: ability.source_id, + origin: origin.clone(), + }) + .is_none() + })) + .then(|| DecisionSlot { + source: source.clone(), + index: 1, + }); + let mut slots = super::ability_utils::build_target_slots(state, ability).ok()?; + // SHAPE (B) — may-only. The announcement authority surfaced NO choice, so there is no + // CR 601.2c target for a pin to specify and the entry publishes its CR 603.5 gate + // alone. `ability.targets.is_empty()` is what makes "zero built slots" mean "declares + // nothing" rather than "declared something the builder declined"; `optional` is + // inherited from the `may` expression, which is `None` without it. A `may` the three + // conjunct groups above suppressed leaves shape (B) with NO slot at all, so the whole + // entry publishes `None` — the fail-closed direction. + if slots.is_empty() { + if !ability.targets.is_empty() { + return None; + } + return Some(EntryPinSlots { + target: None, + may: Some(may?), + legal_targets: vec![], + }); + } + if slots.len() != 1 { + return None; + } + let slot = slots.swap_remove(0); + if slot.optional + || !slot + .legal_targets + .iter() + .all(|target| matches!(target, crate::types::ability::TargetRef::Player(_))) + { + return None; + } + // SHAPE conjunct only — the legal set above is already the announcement authority's, and + // the predicate's `bool` return makes re-deriving one from the head filter impossible + // rather than merely discouraged. This rejects a head effect that is not the CR 115.2 + // "target opponent" declaration, which an all-`Player` legal set alone does not + // (`controller: You` builds exactly one mandatory slot whose legal set is the + // controller — measured). + if !declares_opponent_player_target(ability) { + return None; + } + // Shape (A) — targeted. Index 1 is kept for the may slot in BOTH shapes, so slot + // identity is stable across them. + Some(EntryPinSlots { + target: Some(DecisionSlot { source, index: 0 }), + may, + legal_targets: slot.legal_targets, + }) +} + +/// CR 732.2a: the per-iteration decision points a BOUNDED cycle shortcut must publish for +/// `proposer` — one `Targets` point per proposer-controlled triggered-ability SOURCE that +/// declares a single *player* target (CR 115.2), plus a `MayChoice` point (CR 603.5) when +/// that ability is optional. +/// +/// Only a *published* slot is a "specified choice" in CR 732.2a's sense; an unpublished +/// per-opponent choice would make the proposal a conditional action. This is the SINGLE +/// authority for that slot set — the offer's cover call, its schema, the drive's cover call +/// and the per-cycle `predictability_gate` all read the same list. +/// +/// A function of the BOARD, never of the PROMPT. NOT a purity claim over a three-field +/// `(state.stack, state.objects, proposer)` surface — that would be false: the CR 603.5 +/// recipient conjunct in the body resolves a player through `optional_prompt_player` → +/// `resolve_effect_player_ref`, which reaches ELEVEN distinct `GameState` fields (enumerated +/// at that conjunct). What actually holds, and what the callers rely on, is the narrower +/// PROMPT-independence: it deliberately does **not** +/// read `state.waiting_for`, and it cannot: both production call sites run at +/// `WaitingFor::Priority` (`interactive_loop_bridge`'s destructure, and the drive's +/// `Priority{active}` settle arm), where no prompt and no materialized `legal_targets` +/// exist. The legal set is the one `ability_utils::build_target_slots` built for the +/// accepted announcement slot, carried through verbatim — so the SAME authority answers +/// how many choices exist and which targets each admits. That builder routes this filter +/// shape to the native authority [`crate::game::targeting::find_legal_targets`] (via +/// `ability_utils::legal_targets_for_ability_filter_uncapped`'s `relative_kind.is_none()` +/// / `!needs_ability_context` arm), whose empty-`Typed` players branch already excludes +/// departed seats — CR 800.4 (multiplayer games continue after players leave) + CR 102.1 +/// (a player is one of the people in the game): a seat that has left the game is no longer +/// one of them, so it is not choosable by anything; player phasing per the CR 702.26b +/// MIRROR (permanent-phasing text, NEVER authority for players). NOT CR 800.4a, which +/// governs a departed player's objects, control effects and priority — not the legality of +/// a choice. Never a declaration, so the offer still cannot ratify its own pin. Note the +/// enumeration context +/// shifts with the authority: it is now the ABILITY's own `controller`/`source_id` +/// (CR 601.2c — the ability's controller announces its targets) rather than the offer's +/// `proposer` and the stack entry's `source_id`. +/// +/// Fail-closed: an entry whose source object is gone yields NO point (rather than a point +/// with an unbindable slot), so the schema can only ever under-publish. +/// +/// Class served: every proposer-controlled triggered ability on the stack whose declared +/// target is a player — never a named card. Command-zone sources (CR 114.2 emblems) are +/// included; [`slot_source_prompted`] is the matching half at replay time. +/// +/// PER SOURCE, NOT PER ENTRY: N stack entries from ONE source mint N byte-identical +/// `DecisionSlot`s (real boards reach 35 entries on one source), and the sub-index +/// disambiguates choices WITHIN an ability instance, not instances of it +/// ([`crate::analysis::decision_template::DecisionSlot`]'s own doc). Publishing the same +/// slot N times would make the frontend render N identical pickers and +/// `predictability_gate` demand N pins for a choice [`inject_pinned_answer`] answers ONCE +/// per source (its `find_map` matches on the slot's SOURCE and is index-blind). So the +/// offer publishes the SET of open choices; one state-independent pin ("always target +/// P1") specifies every instance of it. +/// +/// VISIBILITY: the `#[cfg(any(test, feature = "test-support"))]` gate this shipped behind +/// has LIFTED, exactly as its own note said it would — [`try_offer_bounded_cycle_shortcut`] +/// is the production caller the gate was waiting for. It stays `pub` because the +/// integration suite that pins its behaviour links the library. +pub fn bounded_cycle_pin_slots( + state: &GameState, + proposer: PlayerId, +) -> Vec { + // The DEGENERATE one-frame case of the window enumerator: with no window frame there is + // no transition to observe, so `certified_period_touch`'s `window.is_empty()` branch seeds + // `announced` from `state.stack` — same authority, same set, same order, same dedup, hence + // byte-identical to the snapshot mint this used to be. The alias holds NO certificate, so + // it passes `ResourceSignatureOnly`: the type is never allowed to claim one its caller + // does not hold (and the empty-window branch yields `frozen_ids: ∅` on any value). + bounded_cycle_pin_slots_for_window( + &crate::analysis::resource::certified_period_touch( + &[], + state, + crate::analysis::resource::PeriodCertification::ResourceSignatureOnly, + ), + proposer, + ) +} + +/// CR 732.2a: the per-iteration choice slots ONE CERTIFIED PERIOD publishes for `proposer`. +/// +/// Maps the single authority [`entry_publishes_pin_slots`] over `touch.announced`, with the +/// same per-slot dedup [`bounded_cycle_pin_slots`] applies — each pair evaluated against ITS +/// OWN carrying frame, which is the ring sample's LIVE half and never a `normalize_for_loop` +/// product: a normalized frame would key the stored-auto-choice refusal on `ObjectId(0)` and +/// would make `build_target_slots` take its `trigger_source: None` fall-through and publish a +/// WIDER legal set than the live board admits. +/// +/// It calls the RAW mint rather than the verdict door on purpose: routing it through the door +/// would eagerly classify (and charge for) every announced pair at slot-enumeration time, +/// which is exactly the pre-population pass this design removes. The relief side reads the +/// CACHED mint through the door instead. +pub(crate) fn bounded_cycle_pin_slots_for_window( + touch: &crate::analysis::resource::PeriodTouch<'_>, + proposer: PlayerId, +) -> Vec { + use crate::analysis::decision_template::{DecisionPoint, DecisionPointKind}; + let mut points: Vec = Vec::new(); + for (frame, entry) in &touch.announced { + let Some(pins) = entry_publishes_pin_slots(frame, entry, proposer) else { + continue; + }; + // CR 601.2c: a `Targets` point only when the entry actually announces a choice. + // Shape (B) publishes `target: None` — announcing it surfaces no target at all — + // so a `min_targets: 1` point would over-state the sequence CR 732.2a describes. + if let Some(target) = pins.target.filter(|t| !points.iter().any(|p| &p.slot == t)) { + points.push(DecisionPoint { + slot: target, + kind: DecisionPointKind::Targets { + // VERBATIM the slot `ability_utils::build_target_slots` built for this + // announcement — not a second derivation. That is what makes WHICH + // choice is published and HOW MANY choices are published the same + // authority's answers, so they cannot disagree. + legal_targets: pins.legal_targets, + // Exactly one, and that cannot contradict the ANNOUNCEMENT: the + // acceptance test admits an entry only when + // `ability_utils::build_target_slots` — the authority that decides how + // many choices announcing it actually requires (CR 601.2c via + // CR 603.3d) — yields exactly one MANDATORY slot. An ability-fact + // check alone (`multi_target` / CR 601.2d division) does NOT bound + // the slot count: a chained sub-ability's own "target" is a second + // instance of the word and a second slot. + min_targets: 1, + max_targets: 1, + ordered: false, + }, + }); + } + // CR 603.5: "the choice is made when the ability resolves" — a "may" gate on the + // same source is a SECOND per-iteration choice, published so the declaration must + // pin it too. + if let Some(may) = pins.may { + if !points.iter().any(|p| p.slot == may) { + points.push(DecisionPoint { + slot: may, + kind: DecisionPointKind::MayChoice, + }); + } + } + } + points } /// CR 732.2a: assemble a loop-shortcut offer's READ-side schema from its already-reified @@ -1811,8 +2829,8 @@ fn pinned_decisions_to_points( /// `iteration_count` and `max_iterations` are separate inputs on purpose: the first is the /// SUGGESTION the frontend seeds its picker with, the second is the LEGAL CEILING the /// declared-count check enforces. A producer that cannot compute a real bound passes -/// `MAX_SHORTCUT_CYCLES`, which is what every offer built today does — so the ceiling is -/// inert until a producer narrows it. +/// `MAX_SHORTCUT_CYCLES`. The bounded-cycle producer narrows it; the drain and object-growth +/// producers do not — so the ceiling is live for bounded offers only. fn build_shortcut_schema( points: Vec, iteration_count: crate::analysis::decision_template::IterationCount, @@ -1896,6 +2914,19 @@ fn apply_confirmed_shortcut( || proposal .predicted_winner .is_some_and(|winner| !crate::game::players::is_alive(state, winner)) + // CR 732.2a + CR 603.5: `template.owner` decides WHOSE CR 603.5 choice a pin may + // answer (the drive's seat guard in `inject_pinned_answer`). The two live ingresses + // bind it at declare; a RESTORED `WaitingFor::RespondToShortcut` is plain serde and + // the untrusted-restore scrubber rewrites only the two PRE-CAST waits, so it never + // ran that firewall. Re-validate the SAME invariant at the one point every drive + // passes through, so the seat guard is meaningful on every ingress and not only the + // two live ones. Refusing (rather than forcing `owner = proposer`) keeps the + // fail-closed direction every other conjunct at this seam uses: forcing would make a + // tampered proposal runnable under a rewritten owner. + || proposal + .template + .as_ref() + .is_some_and(|t| t.owner != proposal.proposer) { priority::reset_priority(state); // CR 800.4a: priority passes to the next player in turn order still in the game. @@ -1989,8 +3020,19 @@ fn apply_until_lethal_shortcut( let cap = auto_pass_loop_max_iterations(&committed); let mut running = committed.clone(); for i in 0..period { - match drive_one_shortcut_cycle(&running, &boundary, proposal.template.as_ref(), i, cap) - { + // The SAME single authority the `Fixed(N)` drive reads. Unreachably `Some` today — + // `handle_declare_shortcut` rejects `UntilLethal` against a bounded offer, and the + // bounded producer is the only one that publishes a signature — so this is + // behaviour-identical to the former no-delimiter call; it is threaded so the two + // drives cannot drift apart on what delimits a cycle. + match drive_one_shortcut_cycle( + &running, + &boundary, + proposal.template.as_ref(), + i, + cap, + proposal.per_cycle.as_ref().map(|pd| pd.frames_per_period), + ) { CycleOutcome::Recurred { state: s, .. } => running = *s, CycleOutcome::CrossLethal { state: s, @@ -2169,6 +3211,42 @@ fn shortcut_drive_period( .clamp(1, MAX_SHORTCUT_CYCLES) } +/// CR 732.2a: the index range the declare-time firewall must validate a pin over — the range +/// the ACCEPTED COUNT will actually drive, read off the two drive loops themselves: +/// `materialize_fixed_shortcut` drives `for i in 0..n` for a `Fixed(n)`, and +/// `apply_until_lethal_shortcut` drives whole periods for `UntilLethal`, whose length is +/// [`shortcut_drive_period`]. +/// +/// THIS IS NOT `shortcut_drive_period`, and the difference is the whole point of the fix. +/// That helper answers "how many cycles must one measurement aggregate" — a schedule property +/// with nothing to do with the declared count. Validating over it both ACCEPTED a pin whose +/// driven image leaves the offer's PUBLISHED legal set at an index the count reaches, and +/// REFUSED conforming declarations whose count is shorter than the schedule. +/// +/// NO CONSERVATIVE PADDING. Widening the range with `.max(shortcut_drive_period(..))` is +/// bug-preserving — it re-imports the schedule-derived period the invariant exists to remove, +/// and at a count of 1 over a length-2 rotation `Ok` is the CORRECT answer. Do not re-derive +/// and re-add that term. +/// +/// PRECONDITION, discharged at its call site: the count is already cap-checked, because +/// `handle_declare_shortcut` runs the `MAX_SHORTCUT_CYCLES` / `max_iterations` match ABOVE +/// the pin-validation block. Without that ordering a hostile `Fixed(4e9)` would become a +/// four-billion-iteration validation loop. Exactly ONE call site consumes this helper. +/// +/// Exhaustive over `IterationCount` with no wildcard, so a future variant build-breaks here +/// and forces a range decision instead of silently inheriting one. +fn shortcut_validated_range( + count: &crate::analysis::decision_template::IterationCount, + template: Option<&crate::analysis::decision_template::DecisionTemplate>, +) -> crate::analysis::decision_template::IterationIndex { + match count { + crate::analysis::decision_template::IterationCount::Fixed(n) => *n, + crate::analysis::decision_template::IterationCount::UntilLethal => { + shortcut_drive_period(template) + } + } +} + /// PR-7 Combo-UI Stage 2: the typed result of driving ONE whole loop-shortcut cycle on a /// clone. Exhaustive at both call sites (`materialize_fixed_shortcut`, `apply_until_lethal_ /// shortcut`) — no silent `_` that could crown or roll back on an unhandled outcome. @@ -2199,12 +3277,35 @@ enum CycleOutcome { /// Uses the INTERNAL `apply_action` path throughout (via `pass_priority_once_with_pipeline` /// and the injector), never the top-level reconcile boundary, so the detection hook cannot /// recurse mid-drive. +/// +/// # Two cycle delimiters, and why the second one exists +/// +/// `frames_per_period` is the published [`crate::analysis::resource::PeriodicDelta`] span, or +/// `None` for every offer whose producer states no per-period signature. When it is `Some(k)`, +/// a cycle ALSO completes once `k` retained ring frames have been recorded since the cycle +/// began. +/// +/// Board recurrence alone is not a delimiter for the class +/// [`try_offer_bounded_cycle_shortcut`] mints on certification basis **B**: that basis consults +/// no board predicate at all — it certifies a periodic *delta* over a ring window — so +/// `loop_states_equal_modulo_resources` and `loop_states_cover_modulo_growth` are both FALSE at +/// every settle beat by construction. Without the frame delimiter such a drive can only end at +/// the beat cap (`Abort`, committing zero cycles) or by crossing lethal, and the declared `n` +/// is inert: `Fixed(1)` and `Fixed(3)` produce byte-identical boards. +/// +/// The frame count is the same quantity `frames_per_period` names, measured the same way: the +/// single `record_loop_detect_sample` call site lives in `pass_priority_once_with_pipeline`, +/// which is the very function this loop steps, so a driven beat samples the ring under exactly +/// the gates an observed beat does. A new frame is detected by `Arc` identity of the ring's +/// back rather than by length, because the ring evicts at `LOOP_DETECT_RING_CAP` and a length +/// delta reads 0 once it is full. fn drive_one_shortcut_cycle( committed: &GameState, boundary: &GameState, template: Option<&crate::analysis::decision_template::DecisionTemplate>, iteration: crate::analysis::decision_template::IterationIndex, cycle_beat_cap: usize, + frames_per_period: Option, ) -> CycleOutcome { let mut work = committed.clone(); priority::reset_priority(&mut work); @@ -2213,12 +3314,14 @@ fn drive_one_shortcut_cycle( }; let mut ev: Vec = Vec::new(); let mut beat = 0usize; + let mut frames_this_cycle = 0u32; loop { beat += 1; if beat > cycle_beat_cap { return CycleOutcome::Abort; // runaway backstop } + let ring_back_before = work.loop_detect_ring.back().map(std::sync::Arc::as_ptr); // A FRESH per-beat buffer (see the former inline note): reusing one growing buffer // would make `run_post_action_pipeline` re-scan prior beats' events and re-fire // already-consumed triggers. @@ -2235,12 +3338,20 @@ fn drive_one_shortcut_cycle( }; } // Active-player settle beat: cycle complete iff the board recurred (constant-depth - // equal-modulo-resources OR ω-covering growth). + // equal-modulo-resources OR ω-covering growth) or the published period's worth of + // ring frames has elapsed. This is the ONLY beat kind the ring samples at (the + // sampler's own gate is `Priority{player == active_player}`), so the frame counter + // is advanced here and nowhere else. Ok(WaitingFor::Priority { player }) if player == work.active_player => { ev.append(&mut beat_events); + let ring_back_after = work.loop_detect_ring.back().map(std::sync::Arc::as_ptr); + if ring_back_after.is_some() && ring_back_after != ring_back_before { + frames_this_cycle += 1; + } let norm = work.normalize_for_loop(); if crate::analysis::resource::loop_states_equal_modulo_resources(boundary, &norm) || crate::analysis::resource::loop_states_cover_modulo_growth(boundary, &norm) + || frames_per_period.is_some_and(|k| frames_this_cycle >= k) { return CycleOutcome::Recurred { state: Box::new(work), @@ -2276,15 +3387,17 @@ fn drive_one_shortcut_cycle( /// There is deliberately NO top-level `template.ok_or(...)` guard: the `OrderTriggers` arm is /// TEMPLATE-INDEPENDENT (the real 2p Vito drive raises OrderTriggers with a `template = None` /// declaration, and the forced-unique target auto-selects at dispatch), so a top guard would -/// wrongly abort it. The template guard lives INSIDE the `TriggerTargetSelection` arm, the only -/// arm that consumes pins. +/// wrongly abort it. Each pin-consuming arm therefore carries its own guard: the +/// `TriggerTargetSelection` arm is the only arm that consumes a CR 608.2b `Targets` pin, and the +/// `OptionalEffectChoice` arm consumes the CR 603.5 `MayChoice` pin and carries its own seat + +/// beat guards on top of the same `template.ok_or(..)`. fn inject_pinned_answer( work: &mut GameState, template: Option<&crate::analysis::decision_template::DecisionTemplate>, iteration: crate::analysis::decision_template::IterationIndex, prompt: &WaitingFor, ) -> Result<(), RecastAbort> { - use crate::analysis::decision_template::{ConcreteDecision, ConcreteTarget}; + use crate::analysis::decision_template::{ConcreteDecision, ConcreteTarget, MayChoiceOption}; match prompt { // CR 603.3b / CR 732.2a: auto-order the confirmed shortcut's simultaneous // same-controller triggers by identity order (0..len). Template-INDEPENDENT and @@ -2314,10 +3427,7 @@ fn inject_pinned_answer( .into_iter() .find_map(|d| match d { ConcreteDecision::Targets { slot, targets } - if crate::analysis::decision_template::resolve_source( - &slot.source, - work, - ) == Some(source_id) => + if slot_source_prompted(work, &slot.source, source_id) => { Some(targets) } @@ -2340,15 +3450,111 @@ fn inject_pinned_answer( .map_err(|_| RecastAbort)?; Ok(()) } - // CR 732.2a "no conditional actions": any other prompt (mode / may / unless / X) has - // no Stage-2 pin producer ⇒ fail-closed. + // CR 603.5 + CR 732.2a: answer an in-cycle "may" from the pin its owner declared. + // + // The recipient is read OFF THE PROMPT, which is the only TOTAL instrument: + // `WaitingFor::OptionalEffectChoice` has five production producers and exactly one + // consults `effects::optional_prompt_player`, so the mint's recipient conjunct is a + // PREDICTION over one of five producers while this comparison is an OBSERVATION of + // the prompt in hand. Precondition (c) of the pin extension point — "only the acting + // player's own choices are pinnable" (`analysis::resource`) — is what it enforces. + // + // `template.owner` is only a legitimate comparand because `handle_declare_shortcut` + // firewalls it to the engine-issued `LoopShortcutOffer.proposer` at declare (and + // `apply_confirmed_shortcut` re-validates the same invariant for the restore ingress, + // which never runs the declare handler). Without those two, this test compares an + // attacker-chosen value against itself. + WaitingFor::OptionalEffectChoice { + player, source_id, .. + } => { + let template = template.ok_or(RecastAbort)?; + if *player != template.owner { + return Err(RecastAbort); + } + // CR 603.5 vs CR 603.3c + CR 700.2b: pin the BEAT as well as the seat. A + // `MayChoice` pin binds the RESOLUTION-time question (CR 603.5). While a trigger + // is still mid-construction the engine asks a same-`source_id` + // ANNOUNCEMENT-time one instead — the optional-modal gate raised out of + // `begin_pending_trigger_target_selection`, which runs with the construction + // cursor (`pending_trigger`) still live. `slot_source_prompted` cannot separate + // them: it matches the SOURCE OBJECT and both prompts carry it. So a live cursor + // means the prompt in hand may be the announcement-time question the pin does not + // answer ⇒ fail-closed. + if work.pending_trigger.is_some() { + return Err(RecastAbort); + } + let decisions = crate::analysis::decision_template::resolve(template, iteration, work) + .map_err(|_| RecastAbort)?; + let take = decisions + .into_iter() + .find_map(|d| match d { + ConcreteDecision::MayChoice { slot, take } + if slot_source_prompted(work, &slot.source, *source_id) => + { + Some(take) + } + _ => None, + }) + .ok_or(RecastAbort)?; + apply_action( + work, + *player, + GameAction::DecideOptionalEffect { + accept: take == MayChoiceOption::Take, + }, + None, + ) + .map_err(|_| RecastAbort)?; + Ok(()) + } + // CR 732.2a "no conditional actions": any other prompt (mode / unless / X) has no + // Stage-2 pin producer ⇒ fail-closed. `may` left this list when the mint gained its + // `EntryPinSlots.may` producer and the arm above; the remainder is still unpinnable. _ => Err(RecastAbort), } } -/// PR-7 Phase 4b: CR 732.2a finite materialization of a confirmed `Fixed(N)` loop -/// shortcut. Drives `n` whole cycles of the constant-depth (or ω-covering) loop, -/// committing atomically per cycle. If a cycle crosses lethal, the win arrives +/// CR 608.2b + CR 114.2: does this SLOT's source identify the ability instance that raised +/// the prompt carrying `source_id`? +/// +/// [`crate::analysis::decision_template::resolve_source`] is deliberately BATTLEFIELD-ONLY, +/// and that filter IS the CR 608.2b (`docs/MagicCompRules.txt:2789`) legality re-check for +/// `ByIdentity` **target** pins — a pinned target that left the battlefield must stop +/// matching. It must not be widened. But a SLOT's source only identifies WHICH ability +/// instance prompts, and CR 114.2 (`:828`) puts a planeswalker EMBLEM — "both owned and +/// controlled by that player" — in the **command zone**, where it stays for the whole game +/// and raises its triggers from. So the command-zone disjunct lives HERE, at the caller, +/// scoped to object identity + the pinned CR 400.7 incarnation. +/// +/// Graveyard / exile / hand sources still fail ⇒ the caller aborts to manual play. +fn slot_source_prompted( + state: &GameState, + src: &crate::analysis::decision_template::DecisionSource, + source_id: ObjectId, +) -> bool { + if crate::analysis::decision_template::resolve_source(src, state) == Some(source_id) { + return true; + } + // CR 114.2: the command-zone arm. `AllCopies` is card-identity matching and an emblem + // has no card, so only `ThisObject` participates. + let crate::types::game_state::YieldTarget::ThisObject { + source_id: pinned_id, + incarnation, + .. + } = src + else { + return false; + }; + *pinned_id == source_id + && state.objects.get(pinned_id).is_some_and(|o| { + o.zone == crate::types::zones::Zone::Command + && (incarnation.is_none() || *incarnation == Some(o.incarnation)) + }) +} + +/// PR-7 Phase 4b: CR 732.2a finite materialization of a confirmed `Fixed(N)` loop +/// shortcut. Drives `n` whole cycles of the constant-depth (or ω-covering) loop, +/// committing atomically per cycle. If a cycle crosses lethal, the win arrives /// mid-drive already applied to `work` (CR 704.5a via `run_post_action_pipeline`'s /// SBA pass) ⇒ COMMIT + STOP, un-clamped — `n` may be ≥ the true cycles-to-lethal /// (CR 732.2a "a specified number of times" places no upper bound relative to the @@ -2429,6 +3635,11 @@ fn materialize_fixed_shortcut( } let template = proposal.template.clone(); + // CR 732.2a: the per-period signature the offer published, carried verbatim onto the + // proposal. `None` for every producer that states none, and that `None` is what keeps + // every pre-bounded offer's drive byte-identical: no frame delimiter, no conformance + // check, board recurrence alone — exactly the shipped behavior. + let per_cycle = proposal.per_cycle.as_ref(); // Last fully-completed cycle (clean owned O(1) rollback); starts at the offer state — // `apply_confirmed_shortcut`'s doc comment establishes the board is unchanged since the @@ -2480,12 +3691,35 @@ fn materialize_fixed_shortcut( // inline beat loop for a non-targeted `Fixed(N)` drain (which raises no mid-cycle // prompt, so the injector is inert); a targeted drive additionally answers each // OrderTriggers / target prompt from the pins. - match drive_one_shortcut_cycle(&committed, &boundary, template.as_ref(), i, cycle_beat_cap) - { + match drive_one_shortcut_cycle( + &committed, + &boundary, + template.as_ref(), + i, + cycle_beat_cap, + per_cycle.map(|pd| pd.frames_per_period), + ) { CycleOutcome::Recurred { state: s, mut events, } => { + // CR 732.2a "predictable results" + CR 704.5a: the CONFORMANCE CHECK the + // published signature exists for. `elimination_bounds` divided the CR 704 + // headroom by `per_cycle.delta`, so a committed cycle that moved a + // DIFFERENT amount invalidates the very bound the table agreed to — the + // remaining repetitions could carry a seat past a threshold inside the + // proposal. Measured before commit, on the same axes the bound reads, and + // fail-closed: a divergent cycle is dropped whole and the drive hands back + // to manual play with the last conforming cycle intact. + if let Some(pd) = per_cycle { + let actual = crate::analysis::resource::ResourceVector::delta( + &crate::analysis::resource::ResourceVector::snapshot(&committed), + &crate::analysis::resource::ResourceVector::snapshot(&s), + ); + if actual != pd.delta { + break 'cycles; + } + } committed = *s; // ATOMIC: commit state ... result.events.append(&mut events); // ... with its events together continue 'cycles; @@ -2505,6 +3739,25 @@ fn materialize_fixed_shortcut( } // Runaway cap / unpinned prompt / engine error ⇒ abort to manual. The aborting // cycle's events were already dropped (no partial-cycle event leak). + // + // ⚠ THE TWO LETHAL ARMS ARE ASYMMETRIC, and a future drive must learn that here + // rather than by accident. A cycle that takes EVERY remaining opponent to 0 at once + // reaches `WaitingFor::GameOver` and lands in the `CrossLethal` arm above: it + // COMMITS and the game ends. A cycle that takes ONE seat to 0 while >= 2 players + // survive raises no `GameOver` (CR 104.2a crowns nobody), the loop's shape changes + // under it as the drained seat leaves, no settle beat recurs, and it arrives HERE — + // THAT CYCLE rolls back whole while every PRIOR conforming cycle stays committed, + // `eliminated` is empty, priority is handed back. (It is not a whole-drive rollback: + // the `break` below falls through to `*state = committed`, which is the last WHOLE + // cycle, not the offer state.) Both are out of contract for a legitimately-derived + // bound (`elimination_bounds` reserves `life - 1` of CR 704.5a headroom, so a + // within-bound count crosses no threshold), so either arm means the published bound + // was wrong. The atomic per-cycle refusal is the designed property — NO HALF-APPLIED + // PERIOD, EVER — and it is why the out-of-contract cycle is dropped rather than + // materialized: its remaining repetitions were bounded by a delta the board stops + // moving the moment a drain target leaves the game. Rows: + // `bounded_fixed_drive_stops_at_the_first_lethal_cycle` (total wipe) and + // `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle` (partial). CycleOutcome::Abort => break 'cycles, } } @@ -2661,7 +3914,7 @@ fn pinnable_mana_color( /// FIX-1 (CR 400.7): a live-object identity source for a pin — `ThisObject` bound to the object's /// CURRENT incarnation, so a re-entered permanent (new incarnation) stops matching and the loop is /// correctly re-detected rather than falsely replayed. `None` if the object is absent. -fn object_decision_source( +pub(crate) fn object_decision_source( state: &GameState, id: ObjectId, ) -> Option { @@ -3430,7 +4683,9 @@ fn try_offer_object_growth_shortcut( // times — it is materialized once as an unbounded axis — so it states no narrowed count // bound and keeps the global safety limit. let schema = build_shortcut_schema( - pinned_decisions_to_points(&schema_template.decisions, state, caster), + // CR 732.2a: an unresolvable pin WITHDRAWS the offer rather than publishing an + // undeclarable point — see `pinned_decisions_to_points`. + pinned_decisions_to_points(&schema_template.decisions, state, caster)?, shortcut_iteration_count(certificate.win_kind), MAX_SHORTCUT_CYCLES, ); @@ -3752,42 +5007,21 @@ fn handle_declare_shortcut( // any template the caller supplies is inert for the drive (the loop raises no target // prompt). This preserves the established `Fixed(N)` drain behavior (the resolve-firewall // materialize tests drive a synthetic pin against the empty drain schema). - if !offer.schema.points.is_empty() { - match &template { - Some(t) => { - let required: Vec = - offer.schema.points.iter().map(|p| p.slot.clone()).collect(); - let period = shortcut_drive_period(Some(t)); - if crate::analysis::decision_template::predictability_gate(t, &required).is_err() - || crate::analysis::decision_template::validate_pins( - offer.schema, - t, - period, - state, - ) - .is_err() - { - reject_shortcut_declaration(state, &mut result); - return Ok(result); - } - } - // CR 732.2a: a `template: None` declaration against a NON-EMPTY schema skips the - // validation above entirely — the pins the offer published are never checked. That - // 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() => { - reject_shortcut_declaration(state, &mut result); - return Ok(result); - } - None => {} - } - } - // CR 732.2a SAFETY LIMIT (see MAX_SHORTCUT_CYCLES): reject an over-cap Fixed count at + // ⚠ ORDER IS LOAD-BEARING: the count cap runs BEFORE the pin validation below, because + // `shortcut_validated_range` derives the validated range FROM the declared count and so + // must not be handed an unchecked one — a `Fixed(4_000_000_000)` would otherwise become + // a four-billion-iteration validation loop. Observation-equivalence of the reorder is + // structural: all six refusal arms across the three blocks (this match, the CR 732.2a + + // CR 603.5 `template.owner` firewall between them, and the pin-validation block) land on + // the same single authority (`reject_shortcut_declaration`), and + // `handle_declare_shortcut` pushes NO events at all, so no row can observe which block + // refused first. + // IMPLEMENTATION BUDGET BOUND (see MAX_SHORTCUT_CYCLES) — deliberately NOT labelled as a + // CR 732.2a constraint: the rules place no ceiling on how many times a shortcut may be + // repeated (CR 732.2a's own example runs to a million), so this ceiling is ours, not the + // game's. The label matters because a maintainer applying the CR 732.2a iff to a branch + // that wears a CR number will either trust it wrongly or delete it wrongly. Reject an + // over-cap Fixed count at // the single authority — BEFORE the proposal is built — into the same fail-closed // manual-play handback the pin validation above uses. This is THE catastrophic remote // vector: `Fixed(u32)` scalar-encodes up to ~4.3e9 cycles in ~10 bytes, sailing through @@ -3821,7 +5055,7 @@ fn handle_declare_shortcut( // producer measured a CR 704 threshold inside the loop; running it "until lethal" // would run past that threshold. crate::analysis::decision_template::IterationCount::UntilLethal - if offer.schema.max_iterations < MAX_SHORTCUT_CYCLES => + if offer.schema.is_bounded() => { reject_shortcut_declaration(state, &mut result); return Ok(result); @@ -3831,6 +5065,67 @@ fn handle_declare_shortcut( crate::analysis::decision_template::IterationCount::Fixed(_) | crate::analysis::decision_template::IterationCount::UntilLethal => {} } + // CR 732.2a + CR 603.5: the declared template's `owner` is CLIENT-SUPPLIED — the + // `GameAction::DeclareShortcut { template }` payload arrives here verbatim — and it is + // the comparand `inject_pinned_answer` uses to decide WHOSE CR 603.5 choice a pin may + // answer. Bind it to the engine-issued seat here, at declare, or that seat guard + // compares an attacker-chosen value against itself. `offer.proposer` is engine state, + // copied from `WaitingFor::LoopShortcut { proposer }`. + // + // PLACEMENT IS LOAD-BEARING: this sits OUTSIDE the `!offer.schema.points.is_empty()` + // block below, so it is reached for every declaration regardless of schema emptiness — + // an empty-schema offer skips `predictability_gate` / `validate_pins` entirely and would + // otherwise reach the proposal with an unvalidated owner. It is the SIXTH sibling of the + // five refusal arms and lands on their single authority (`reject_shortcut_declaration`), + // so no row can observe which refusal fired first — the "sixth reject path added later" + // that authority's doc anticipates. Defence in depth for the RESTORE ingress (a persisted + // `WaitingFor::RespondToShortcut` never runs this handler) lives on + // `apply_confirmed_shortcut`'s consumption guard. + if template.as_ref().is_some_and(|t| t.owner != offer.proposer) { + reject_shortcut_declaration(state, &mut result); + return Ok(result); + } + if !offer.schema.points.is_empty() { + match &template { + Some(t) => { + let required: Vec = + offer.schema.points.iter().map(|p| p.slot.clone()).collect(); + // CR 732.2a: validate over the range the ACCEPTED COUNT will drive, not + // over the schedule's own period. `shortcut_drive_period` answers a + // different question (how many cycles one measurement must aggregate), and + // using it here both ACCEPTED a pin whose driven image leaves the published + // set at an index the count reaches, and REFUSED conforming declarations + // whose count is shorter than the schedule. + let validated_range = shortcut_validated_range(&count, Some(t)); + if crate::analysis::decision_template::predictability_gate(t, &required).is_err() + || crate::analysis::decision_template::validate_pins( + offer.schema, + t, + validated_range, + state, + ) + .is_err() + { + reject_shortcut_declaration(state, &mut result); + return Ok(result); + } + } + // CR 732.2a: a `template: None` declaration against a NON-EMPTY schema skips the + // validation above entirely — the pins the offer published are never checked. That + // 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() => { + reject_shortcut_declaration(state, &mut result); + return Ok(result); + } + None => {} + } + } let proposal = crate::analysis::loop_check::ShortcutProposal { proposer: offer.proposer, predicted_winner: offer.predicted_winner, @@ -3838,6 +5133,9 @@ fn handle_declare_shortcut( unbounded: offer.certificate.unbounded.clone(), win_kind: offer.certificate.win_kind, template, + // CR 732.2a: the drive reads ONE authority for what a conformant cycle looks like — + // the confirmed certificate's own signature, copied, never re-derived. + per_cycle: offer.certificate.per_cycle.clone(), }; // CR 732.2b: living opponents in APNAP turn order, starting after the proposer. let opps: Vec = crate::game::players::apnap_order_from( @@ -12841,7 +14139,9 @@ mod stage2_injector_tests { let template = two_drainer_template(drainer_a, P1, drainer_b, P2); let cap = auto_pass_loop_max_iterations(&committed); - match drive_one_shortcut_cycle(&committed, &boundary, Some(&template), 0, cap) { + // `None`: this row is about the injector arm on a board-recurring targeted loop, so + // it drives under the same no-signature delimiter every pre-bounded offer uses. + match drive_one_shortcut_cycle(&committed, &boundary, Some(&template), 0, cap, None) { CycleOutcome::CrossLethal { winner, state, .. } => { assert_eq!( winner, @@ -12914,72 +14214,1808 @@ mod stage2_injector_tests { "RoundRobin(MAX+5) clamps to MAX_SHORTCUT_CYCLES" ); } -} -/// FIX-1 interruptibility (memory: combo-interruptibility-acceptance-criterion) — the Kilo loop's -/// CR 732.2a offer must FLIP off when the loop is defused. Driven from the REAL 4p dump through the -/// public `apply()` boundary (recording live), then the offer is re-derived at the private -/// `try_offer_object_growth_shortcut` seam (the plan's sanctioned private-fn revert-probe form). -#[cfg(test)] -mod kilo_interruptibility_tests { - use super::*; - use crate::analysis::decision_template::{PinnedDecision, TargetPin}; - use crate::types::ability::TargetRef; - use crate::types::game_state::{ManaChoice, PayCostKind, YieldTarget}; - use crate::types::mana::{ManaColor, ManaType}; + /// Place a bare object in `zone` without touching the zone vectors — enough for the + /// identity/zone predicates under test, which read `state.objects` only. + fn place(state: &mut GameState, id: u64, zone: crate::types::zones::Zone) -> ObjectId { + let oid = ObjectId(id); + let mut o = crate::game::game_object::GameObject::new( + oid, + CardId(0), + P0, + "Emblem".to_string(), + zone, + ); + o.incarnation = 3; + state.objects.insert(oid, o); + oid + } - const P0: PlayerId = PlayerId(0); - const KILO: ObjectId = ObjectId(402); - const FREED: ObjectId = ObjectId(403); - const RELIC: ObjectId = ObjectId(404); - const PENTAD: ObjectId = ObjectId(405); - const RELIC_TAP_MANA: usize = 1; - const FREED_UNTAP: usize = 1; + /// CR 114.2 + CR 608.2b: a pinned SLOT whose source is a command-zone emblem must match + /// the prompt that emblem raised; a graveyard or exile source must NOT. + /// + /// This is the zone predicate `inject_pinned_answer`'s `TriggerTargetSelection` arm + /// dispatches on. Its production drive lands with the bounded offer in a later commit, + /// so it is pinned here at the seam — the shipped BATTLEFIELD arm is exercised + /// end-to-end by `injector_routes_pinned_targets_per_source` above and by the + /// `kilo_live_offer_from_real_dump` rows, and this row asserts that arm is unchanged. + /// + /// REVERT-PROBES: (a) delete the command-zone disjunct in `slot_source_prompted` ⇒ the + /// Command assertion FAILS (and `inject_pinned_answer` would `RecastAbort` on an + /// emblem-pinned drive); (b) widen the disjunct to accept any zone ⇒ the graveyard and + /// exile assertions FAIL; (c) drop the incarnation conjunct ⇒ the CR 400.7 assertion + /// FAILS. + #[test] + fn command_zone_sourced_slot_matches_and_graveyard_still_aborts() { + use crate::types::zones::Zone; + let mut state = GameScenario::new_n_player(2, 7).build().state().clone(); + let battlefield = place(&mut state, 900, Zone::Battlefield); + let emblem = place(&mut state, 901, Zone::Command); + let graveyard = place(&mut state, 902, Zone::Graveyard); + let exiled = place(&mut state, 903, Zone::Exile); + + let pin = |id: ObjectId, inc: Option| YieldTarget::ThisObject { + source_id: id, + incarnation: inc, + trigger_description: None, + }; - fn load_migrated_dump() -> GameState { + // Shipped behaviour, unchanged: the battlefield arm still matches. + assert!( + slot_source_prompted(&state, &pin(battlefield, Some(3)), battlefield), + "the shipped CR 608.2b battlefield arm must be untouched" + ); + // NEW: CR 114.2 — an emblem lives in the command zone and prompts from there. + assert!( + slot_source_prompted(&state, &pin(emblem, Some(3)), emblem), + "CR 114.2: a command-zone emblem's slot must match the prompt it raised" + ); + // Fail-closed: every other off-battlefield zone still misses ⇒ `RecastAbort`. + assert!( + !slot_source_prompted(&state, &pin(graveyard, Some(3)), graveyard), + "a graveyard-sourced slot must NOT match — the drive aborts to manual" + ); + assert!( + !slot_source_prompted(&state, &pin(exiled, Some(3)), exiled), + "an exile-sourced slot must NOT match" + ); + // CR 400.7: the command arm re-binds ONE incarnation, exactly like the + // battlefield arm — a re-created emblem does not answer the old pin. + assert!( + !slot_source_prompted(&state, &pin(emblem, Some(2)), emblem), + "CR 400.7: a stale incarnation must not match even in the command zone" + ); + // A pin naming a DIFFERENT object never answers this prompt. + assert!( + !slot_source_prompted(&state, &pin(emblem, Some(3)), battlefield), + "the matcher is keyed on identity, not merely on zone" + ); + } + + /// CR 732.2a + CR 603.5: `bounded_cycle_pin_slots` publishes the per-iteration TARGET + /// choice for a proposer-controlled player-targeting trigger, plus a second `MayChoice` + /// point (disambiguated by `slot.index`) when that trigger is optional. + /// + /// MATCHED PAIRS, one variable each: + /// * `optional` false ⇒ 1 point; true ⇒ 2 points. REVERT-PROBE: delete the `optional` + /// branch ⇒ the 2-point assertion FAILS. + /// * controller == proposer ⇒ published; a bystander proposer gets nothing. + /// REVERT-PROBE: delete the `entry.controller != proposer` filter ⇒ the bystander + /// assertion FAILS. + #[test] + fn bounded_cycle_pin_slots_publishes_the_may_gate_of_an_optional_trigger() { + use crate::analysis::decision_template::{DecisionPointKind, DecisionSlot}; + use crate::types::ability::{ + ControllerRef, Effect, QuantityExpr, ResolvedAbility, TargetFilter, TypedFilter, + }; + + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + let src = place(&mut state, 910, crate::types::zones::Zone::Battlefield); + + let entry = |id: u64, controller: PlayerId, optional: bool| { + let mut ability = ResolvedAbility::new( + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 1 }, + target: Some(TargetFilter::Typed(TypedFilter { + type_filters: vec![], + controller: Some(ControllerRef::Opponent), + properties: vec![], + })), + }, + vec![], + src, + controller, + ); + ability.optional = optional; + StackEntry { + id: ObjectId(id), + source_id: src, + controller, + kind: StackEntryKind::TriggeredAbility { + source_id: src, + ability: Box::new(ability), + condition: None, + trigger_event: None, + description: None, + source_name: String::new(), + subject_match_count: None, + die_result: None, + }, + } + }; + let live_source = + object_decision_source(&state, src).expect("the source is on the battlefield"); + let expected_slot = |index: u8| DecisionSlot { + source: live_source.clone(), + index, + }; + + // Mandatory: exactly one point, the target choice. + state.stack.push_back(entry(920, P0, false)); + let mandatory = bounded_cycle_pin_slots(&state, P0); + assert_eq!( + mandatory.len(), + 1, + "a mandatory trigger publishes one point" + ); + assert_eq!(mandatory[0].slot, expected_slot(0)); + assert!( + matches!( + &mandatory[0].kind, + DecisionPointKind::Targets { legal_targets, .. } + if *legal_targets == vec![TargetRef::Player(P1), TargetRef::Player(P2)] + ), + "the legal set comes from `find_legal_targets`, not from a declaration: {:?}", + mandatory[0].kind + ); + + // Bystander proposer: the same board publishes nothing for a seat that controls + // none of the entries (CR 732.2a — the proposer specifies their OWN choices). + assert!( + bounded_cycle_pin_slots(&state, P1).is_empty(), + "a bystander proposer controls none of these choices" + ); + + // Optional: the SAME entry with one field flipped publishes the CR 603.5 gate too. + state.stack.clear(); + state.stack.push_back(entry(921, P0, true)); + let optional = bounded_cycle_pin_slots(&state, P0); + assert_eq!( + optional.len(), + 2, + "an optional trigger publishes two points" + ); + assert_eq!( + optional[1].slot, + expected_slot(1), + "`slot.index` disambiguates" + ); + assert_eq!(optional[1].kind, DecisionPointKind::MayChoice); + + // Fail-closed: no source object ⇒ no point (never a slot that cannot re-bind). + state.objects.remove(&src); + assert!( + bounded_cycle_pin_slots(&state, P0).is_empty(), + "an absent source emits nothing rather than an unbindable slot" + ); + } + + /// Each of [`declares_opponent_player_target`]'s three conjuncts, discriminated + /// SEPARATELY. The gross "accept any `Typed`" widening is caught by the row above; this + /// row is what fails when ONE conjunct is dropped. + /// + /// Why each matters: `find_legal_targets` collapses a `Typed` filter to PLAYERS ONLY + /// when both `type_filters` and `properties` are empty (`targeting.rs:192-193`, issue + /// #2004). A type- or property-bearing filter therefore falls through to OBJECT + /// enumeration — publishing it would put a point whose legal set is object refs into + /// player-pin machinery. `controller: You` does collapse to players, but to exactly ONE + /// (the controller), which is not the per-opponent choice a bounded drain cycle pins. + /// + /// THE BOARD IS LOAD-BEARING, and picking the wrong one is what hollows this row out. + /// On a head-announced board an object-shaped filter enumerates NOTHING, so + /// `build_target_slots` returns `Err` and the caller's CARDINALITY conjunct rejects + /// first — measured: with both arms on that board, dropping `type_filters.is_empty()` or + /// `properties.is_empty()` left the row GREEN. Those two arms therefore run on the + /// MIRROR shape, the one thing they alone reject: an object-shaped head at + /// `TargetChoiceTiming::Resolution` (announces nothing) chained to a `target opponent` + /// sub-ability (announces the one slot). Cardinality and all-`Player` both pass there. + /// The per-arm reach-guard asserts that announced slot verbatim, so a future change that + /// re-hollows an arm fails the reach-guard instead of passing silently. + /// + /// REVERT-PROBES (each measured on the boards below): drop `type_filters.is_empty()` ⇒ + /// ONLY the Creature arm publishes; drop `properties.is_empty()` ⇒ ONLY the Token arm; + /// drop `controller == Some(Opponent)` ⇒ ONLY the `You` arm. The accepted shape is + /// asserted to publish in the SAME row, so a constant-`false` predicate cannot pass. + #[test] + fn bounded_cycle_pin_slots_conjuncts_are_each_load_bearing() { + use crate::types::ability::{ + ControllerRef, Effect, FilterProp, QuantityExpr, ResolvedAbility, TargetChoiceTiming, + TargetFilter, TargetRef, TypeFilter, TypedFilter, + }; + + let mut base = GameScenario::new_n_player(3, 7).build().state().clone(); + let src = place(&mut base, 940, crate::types::zones::Zone::Battlefield); + + let board = |ability: ResolvedAbility| { + let mut state = base.clone(); + state.stack.push_back(StackEntry { + id: ObjectId(950), + source_id: src, + controller: P0, + kind: StackEntryKind::TriggeredAbility { + source_id: src, + ability: Box::new(ability), + condition: None, + trigger_event: None, + description: None, + source_name: String::new(), + subject_match_count: None, + die_result: None, + }, + }); + state + }; + let accepted = TypedFilter { + type_filters: vec![], + controller: Some(ControllerRef::Opponent), + properties: vec![], + }; + let head_only = |tf: TypedFilter| { + ResolvedAbility::new( + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 1 }, + target: Some(TargetFilter::Typed(tf)), + }, + vec![], + src, + P0, + ) + }; + // The head's own choice is made on resolution, so CR 601.2c is never reached for it + // and it announces no slot; the ONE announced slot is the chained sub-ability's + // `target opponent` player choice (CR 603.3d). Cardinality and all-`Player` pass + // whatever shape the head declares — which is what leaves the head-shape conjunct + // alone to reject. + let chained = |head: TypedFilter| { + let mut ability = head_only(head); + ability.target_choice_timing = TargetChoiceTiming::Resolution; + ability.sub_ability = Some(Box::new(head_only(accepted.clone()))); + ability + }; + + // POSITIVE CONTROL, same row: the accepted "target opponent" shape publishes. + assert_eq!( + bounded_cycle_pin_slots(&board(head_only(accepted.clone())), P0).len(), + 1, + "the accepted CR 115.2 player shape must publish — otherwise the three zeros \ + below are vacuous" + ); + + let opponents = vec![TargetRef::Player(P1), TargetRef::Player(P2)]; + // Collected, not asserted per-arm: a revert-probe must show which arms flipped, and + // a bare `assert!` in the loop would abort at the first and hide the rest. + let mut still_published: Vec<&str> = Vec::new(); + for (label, ability, announced) in [ + ( + "type_filters: a creature filter enumerates OBJECTS, not players", + chained(TypedFilter { + type_filters: vec![TypeFilter::Creature], + ..accepted.clone() + }), + opponents.clone(), + ), + ( + "properties: issue #2004 — `token` is an object characteristic", + chained(TypedFilter { + properties: vec![FilterProp::Token], + ..accepted.clone() + }), + opponents.clone(), + ), + ( + "controller: `You` is a single forced seat, not a per-opponent choice", + head_only(TypedFilter { + controller: Some(ControllerRef::You), + ..accepted.clone() + }), + vec![TargetRef::Player(P0)], + ), + ] { + let state = board(ability); + let announcement = crate::game::ability_utils::build_target_slots( + &state, + state.stack[0] + .ability() + .expect("the board pushes a trigger"), + ) + .map(|slots| { + slots + .iter() + .map(|slot| (slot.optional, slot.legal_targets.clone())) + .collect::>() + }) + .ok(); + assert_eq!( + announcement, + Some(vec![(false, announced)]), + "reach-guard [{label}]: exactly ONE mandatory slot and every candidate a \ + PLAYER — so the cardinality and all-`Player` conjuncts both PASS on this \ + board and the head-shape conjunct under test is the SOLE rejector" + ); + if !bounded_cycle_pin_slots(&state, P0).is_empty() { + still_published.push(label); + } + } + assert!( + still_published.is_empty(), + "each conjunct must reject its own arm ALONE; these arms published anyway: \ + {still_published:?}" + ); + + // ORDERING-INPUT CONJUNCTS. The published point declares `min/max_targets: 1`, and + // the gate-(3) relief is a bare `continue` that discharges the WHOLE of + // `stack_entry_has_no_ordering_input` (analysis/resource.rs) — which rejects on + // four facts, only one of which a slot answers. The three ABILITY facts must block + // publication outright (the state-dependent fourth, `pending_trigger_entry`, is the + // relief's, pinned by `a_pinned_slot_skips_gate_three_and_six`'s arm 5). Shares the + // positive control above. + { + use crate::types::ability::MultiTargetSpec; + use crate::types::game_state::TargetSelectionConstraint; + + fn ability_of(state: &mut GameState) -> &mut ResolvedAbility { + let StackEntryKind::TriggeredAbility { ability, .. } = &mut state.stack[0].kind + else { + unreachable!("the fixture board pushes a TriggeredAbility") + }; + ability.as_mut() + } + + type Mutate = fn(&mut GameState); + let ordering_input: [(&str, Mutate); 3] = [ + ("multi_target — CR 601.2c variable target count", |s| { + ability_of(s).multi_target = Some(MultiTargetSpec::fixed(1, 2)) + }), + ("distribution — CR 601.2d divide-among", |s| { + ability_of(s).distribution = Some(vec![(TargetRef::Player(P1), 1)]) + }), + ("target_constraints — CR 601.2c cross-target", |s| { + ability_of(s).target_constraints = + vec![TargetSelectionConstraint::DifferentTargetPlayers] + }), + ]; + for (label, mutate) in ordering_input { + let mut state = board(head_only(accepted.clone())); + mutate(&mut state); + assert!( + bounded_cycle_pin_slots(&state, P0).is_empty(), + "{label}: announcement-time ordering input NO published slot specifies \ + ⇒ the mint must not publish" + ); + } + } + } + + /// The real 4p acceptance board (dump B): a CR 114.2 emblem in the COMMAND zone + /// (obj 541, incarnation 0) whose triggered ability drains `target opponent`. + fn load_dellian_dump() -> GameState { use crate::types::game_state::PersistedGameState; use std::io::Read; - let gz: &[u8] = include_bytes!("../../tests/fixtures/kilo_freed_relic_pentad_4p.json.gz"); + let gz: &[u8] = include_bytes!("../../tests/fixtures/dellian_emblem_conqueror_4p.json.gz"); let mut json = String::new(); flate2::read::GzDecoder::new(gz) .read_to_string(&mut json) .expect("fixture inflates"); let envelope: serde_json::Value = serde_json::from_str(&json).expect("envelope parses"); - // Route through the REAL production restore chokepoint so the FIX-3 migration hook - // (`migrate_transient_loop_sequence`) drops the dump's 6 stale pinless steps on load — - // exactly as the integration helper does. Deserializing directly would bypass the hook, - // leaving the stale prefix so the live drive yields an 8-step (not 2-step) sequence. + // Cross the dump through the PRODUCTION decoder rather than a bare `GameState` + // decode wrapped in `Raw`: `PersistedGameState`'s own `Deserialize` runs + // `reject_legacy_raw_prompt_authority` and `decode_persisted_resolution_state` + // first, so this helper exercises the chokepoint the server's `from_persisted` + // and WASM's `decode_restored_game_state` actually funnel through — including + // the CR 732.2a load-seam bound invariant. + // `.expect(..)`, not `?`: `into_game_state` returns `GameState`, not `Result`. serde_json::from_value::(envelope["gameState"].clone()) - .expect("gameState restores through the persisted ingress") + .expect("gameState deserializes through the production decoder") .into_game_state() } - fn beat_actor(state: &GameState) -> PlayerId { - match &state.waiting_for { - WaitingFor::Priority { player } - | WaitingFor::PayCost { player, .. } - | WaitingFor::ChooseManaColor { player, .. } - | WaitingFor::ProliferateChoice { player, .. } => *player, - WaitingFor::LoopShortcut { proposer, .. } => *proposer, - other => panic!("unexpected beat: {other:?}"), - } + const EMBLEM: ObjectId = ObjectId(541); + + /// CR 601.2c + CR 115.1: the migrated fixture's `effect_kind` is not an assertion of + /// taste — it is exactly what the ANNOUNCEMENT AUTHORITY builds for this ability on + /// this board. `scripts/migrate-dump-fixture.sh` takes the value as an explicit + /// argument precisely so the engine, and never a jq name→variant table, stays the + /// authority for it; this row is what holds the script's operator to that. + /// + /// It is also the row that would have caught upstream #6718 (`0468df1f4`, which added + /// `TargetSelectionSlot::effect_kind` with no `#[serde(default)]`) the day it landed: + /// `TargetSelectionSlot` derives `PartialEq`/`Eq`, so this compares ALL five fields, + /// not just the migrated one. + /// + /// REVERT-PROBE: re-run the migration script with `--effect-kind NoOp` ⇒ the stamped + /// slot stops matching what `build_target_slots` derives ⇒ FAILS. This assertion is + /// the SOLE guard on the migrated value — nothing on the drive path reads the field + /// (its only production readers are the two `target_intent` calls in the interaction + /// DTO projection), so a wrong value cannot move the game and must be caught by a + /// reading test instead of by a behavioural one. + #[test] + fn dellian_dump_slots_are_what_the_announcement_authority_builds() { + let state = load_dellian_dump(); + let pt = state + .pending_trigger + .as_ref() + .expect("dellian dump pauses on a pending trigger"); + // `let … else` rather than `if let`: it fails LOUDLY if the dump ever restores + // into some other prompt, which would otherwise make the assertion unreachable + // rather than false. + let WaitingFor::TriggerTargetSelection { target_slots, .. } = &state.waiting_for else { + panic!( + "dellian dump must restore into TriggerTargetSelection, got {:?}", + state.waiting_for + ); + }; + // Reach-guard: an empty slot vector would satisfy a total-equality assertion + // vacuously on both sides. + assert_eq!( + target_slots.len(), + 1, + "the dellian dump publishes exactly one target slot" + ); + assert_eq!( + &crate::game::ability_utils::build_target_slots(&state, &pt.ability) + .expect("the emblem's drain ability builds its announcement slots"), + target_slots, + "the restored dump's slots must equal what the announcement authority builds" + ); } - /// Drive ONE full live cycle via the public boundary, recording the pinned period. - fn drive_one_live_cycle(state: &mut GameState) { - apply( - state, - P0, - GameAction::ActivateAbility { - source_id: RELIC, - ability_index: RELIC_TAP_MANA, - }, - ) - .expect("activate Relic mana ability"); - let mut freed_activated = false; - for _ in 0..200 { - let actor = beat_actor(state); - match state.waiting_for.clone() { + /// CR 732.2a: the offer publishes the SET of open per-iteration choices — one point per + /// SOURCE, not one per stack ENTRY. + /// + /// `DecisionSlot`'s sub-index disambiguates two choices of ONE ability instance, so N + /// entries from one source would mint N byte-identical slots: N identical frontend + /// pickers, and `predictability_gate` demanding N pins for a choice + /// `inject_pinned_answer` answers ONCE per source (its `find_map` matches on the slot's + /// SOURCE and is index-blind). Real boards reach this shape — this very dump carries 35 + /// entries on source 25, 34 on 126 and 34 on 208. + /// + /// Built on the LOADED 4p board plus ONE measured mutation: a byte-copy of the real + /// emblem entry under a fresh stack-entry id, which is exactly what a second loop + /// iteration puts there. + /// + /// REVERT-PROBE: drop either `points.iter().any(|p| p.slot == ..)` dedupe guard ⇒ the + /// two-entry board publishes 2 points ⇒ FAILS. + #[test] + fn bounded_cycle_pin_slots_publishes_one_point_per_source_not_per_entry() { + let mut state = load_dellian_dump(); + let emblem_entry = state + .stack + .iter() + .find(|e| e.source_id == EMBLEM) + .expect("reach-guard: the dump carries the emblem's trigger") + .clone(); + let single = bounded_cycle_pin_slots(&state, P0); + // Reach-guard, re-derived from the loaded board rather than from a literal: the + // mint qualifies FOUR sources here — the CR 115.2 emblem target (541) plus three + // shape-(B) CR 603.5 may-only sources (126, 208, 274). 126 and 208 carry 34 stack + // entries apiece, so the shipped board ALREADY exercises the dedupe: without it + // this count would be 69, not 4. + assert_eq!( + single.len(), + 4, + "reach-guard: the shipped board qualifies exactly four sources" + ); + assert_eq!( + single + .iter() + .filter(|p| matches!( + p.kind, + crate::analysis::decision_template::DecisionPointKind::Targets { .. } + )) + .count(), + 1, + "reach-guard: exactly one of them is the emblem's TARGETS point" + ); + + let mut second = emblem_entry; + second.id = ObjectId(9_001); + state.stack.push_back(second); + assert_eq!( + state.stack.iter().filter(|e| e.source_id == EMBLEM).count(), + 2, + "reach-guard: two live entries now share one source" + ); + + assert_eq!( + bounded_cycle_pin_slots(&state, P0), + single, + "a second entry from the SAME source is the same open choice — one published \ + point, byte-identical to the one-entry board's" + ); + } + + /// CR 114.2 + CR 608.2b, on a REAL restored 4p board: `inject_pinned_answer` accepts a + /// pin whose slot source is the COMMAND-zone emblem (obj 541) that raised the prompt. + /// + /// This is the production-path row for [`slot_source_prompted`]. The seam is live + /// TODAY: `inject_pinned_answer` calls it, and every pin-recording site builds its slot + /// source with the zone-agnostic `object_decision_source`, so a command-zone-sourced pin + /// already flips from `RecastAbort` (safe handback) to accepted injection. + /// + /// The dump ships AT that prompt (`TriggerTargetSelection { source_id: 541 }`), so no + /// synthetic placement is involved. + /// + /// REVERT-PROBES (each measured): delete the command-zone disjunct ⇒ the accept arm + /// raises `RecastAbort` ⇒ FAILS; drop the incarnation conjunct ⇒ the stale-pin arm is + /// accepted ⇒ FAILS. The negative arms are paired with a positive on the SAME board, so + /// neither an always-accept nor an always-abort matcher survives. + /// + /// R3 — CR 608.2b + CR 113.7a: this row is ALSO the witness that a pin's SEAT legality + /// does not depend on recovering the SOURCE's characteristics. The choice authority + /// `game::players::player_exists_for_choice` takes no source parameter at all, by + /// design: a command-zone emblem has no battlefield LKI to recover, and if seat + /// legality consulted the source, a pin raised by such a source would be invalidated + /// for a reason the rules do not state. The claim is STRUCTURAL — there is no source + /// parameter to revert — so what makes it checkable is the PAIR: this row proves a + /// source with no recoverable LKI still answers its prompt, and + /// `analysis::decision_template::tests::a_dead_player_pin_is_illegal`'s LIVE half + /// proves the seat check is nonetheless doing work on that same pin kind. (Contrast + /// `targeting::player_is_legal_target`, which DOES take a source — because targeting + /// exclusions are source-relative, CR 702.11c, and choice legality is not.) + #[test] + fn a_command_zone_pin_answers_a_real_restored_boards_prompt() { + let state = load_dellian_dump(); + + // ── reach guards, all read off the loaded board ── + let emblem = state + .objects + .get(&EMBLEM) + .expect("reach-guard: dump B carries the emblem object"); + assert_eq!( + emblem.zone, + crate::types::zones::Zone::Command, + "reach-guard: CR 114.2 puts the emblem in the command zone" + ); + let emblem_incarnation = emblem.incarnation; + let WaitingFor::TriggerTargetSelection { + source_id: Some(prompt_source), + .. + } = &state.waiting_for + else { + panic!( + "reach-guard: the dump ships at the emblem's target prompt; got {:?}", + state.waiting_for + ); + }; + assert_eq!( + *prompt_source, EMBLEM, + "reach-guard: it is the EMBLEM's prompt that is up" + ); + + let src = object_decision_source(&state, EMBLEM).expect("the emblem object exists"); + // The control that makes this row non-vacuous: the shipped battlefield-only + // `resolve_source` does NOT match this source, so an accept can only come from the + // CR 114.2 disjunct. + assert_eq!( + crate::analysis::decision_template::resolve_source(&src, &state), + None, + "CR 608.2b: `resolve_source` is battlefield-only and must stay so" + ); + + let template = |source: YieldTarget| DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot: DecisionSlot { + source: source.clone(), + index: 0, + }, + targets: vec![TargetPin::Player(P1)], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::UntilLethal, + }, + key: DecisionGroupKey::from_sources(&[source], DecisionKind::LoopChoice), + }; + let prompt = state.waiting_for.clone(); + + // ── ACCEPT: the command-zone pin answers the prompt on the real board ── + let mut work = state.clone(); + inject_pinned_answer(&mut work, Some(&template(src.clone())), 0, &prompt) + .expect("CR 114.2: the emblem's own pin must answer the prompt it raised"); + assert_ne!( + work.waiting_for, prompt, + "the prompt was actually consumed, not silently skipped" + ); + assert!( + !matches!( + &work.waiting_for, + WaitingFor::TriggerTargetSelection { + source_id: Some(id), + .. + } if *id == EMBLEM + ), + "the emblem's target prompt is answered; got {:?}", + work.waiting_for + ); + + // ── CR 400.7: a pin latched to a stale incarnation must NOT answer it ── + let stale = YieldTarget::ThisObject { + source_id: EMBLEM, + incarnation: Some(emblem_incarnation + 1), + trigger_description: None, + }; + let mut stale_work = state.clone(); + assert!( + inject_pinned_answer(&mut stale_work, Some(&template(stale)), 0, &prompt).is_err(), + "CR 400.7: a stale-incarnation pin hands back to manual play" + ); + + // ── fail-closed: a pin naming a DIFFERENT object never answers this prompt ── + let other = state + .stack + .iter() + .map(|e| e.source_id) + .find(|id| *id != EMBLEM) + .expect("the 152-deep stack carries other sources"); + let other_src = object_decision_source(&state, other).expect("that source exists"); + let mut other_work = state.clone(); + assert!( + inject_pinned_answer(&mut other_work, Some(&template(other_src)), 0, &prompt).is_err(), + "a pin for another source does not answer the emblem's prompt" + ); + } + + // ───────────────────────── 5d U2 — the shape-(B) mint ───────────────────────── + + use crate::types::ability::ResolvedAbility; + + /// A 3-seat board plus one battlefield source object, shared by every U2 mint row so a + /// row's verdict cannot come from board differences. + fn u2_board() -> (GameState, ObjectId) { + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + let src = place(&mut state, 930, crate::types::zones::Zone::Battlefield); + (state, src) + } + + /// A proposer-controlled OPTIONAL, NO-TARGET triggered ability — shape (B)'s own shape. + /// + /// `target_choice_timing: Resolution` is what makes it shape (B), and it is the class's + /// real rules shape rather than a test convenience: CR 601.2c announces only DECLARED + /// targets, so Braids, Conjurer Adept — "At the beginning of each player's upkeep, that + /// player may put an artifact, creature, or land card from their hand onto the + /// battlefield." (CR 503.1a + CR 608.2d; text verified against Scryfall) — chooses its + /// subject AT RESOLUTION and surfaces zero announcement slots. + /// + /// The `Effect::PutCounter` fixtures below are a SYNTHETIC STAND-IN, never Braids' + /// printed effect: they exercise the same CLASS (per-player-upkeep optional, + /// resolution-time subject, zero announcement slots) on an effect this mint's + /// allow-list admits. The recipient branch they ride is EFFECT-AGNOSTIC (see + /// `a_may_slot_is_minted_only_for_the_seat_the_cr_603_5_gate_will_ask`), which is what + /// makes the substitution sound rather than a convenience. Measured on `u2_board`: + /// `build_target_slots` returns `Ok(0)` for every fixture below (each row asserts the + /// consequence through its own matched positive), so each really reaches the shape-(B) + /// arm rather than falling out at an upstream conjunct. + fn shape_b(src: ObjectId, effect: crate::types::ability::Effect) -> ResolvedAbility { + let mut ability = ResolvedAbility::new(effect, vec![], src, P0); + ability.optional = true; + ability.target_choice_timing = crate::types::ability::TargetChoiceTiming::Resolution; + ability + } + + fn shape_b_entry(id: u64, src: ObjectId, ability: ResolvedAbility) -> StackEntry { + StackEntry { + id: ObjectId(id), + source_id: src, + controller: P0, + kind: StackEntryKind::TriggeredAbility { + source_id: src, + ability: Box::new(ability), + condition: None, + trigger_event: None, + description: None, + source_name: String::new(), + subject_match_count: None, + die_result: None, + }, + } + } + + /// `Effect::PutCounter` on a `ScopedPlayer`-scoped creature filter — inside D4.3's + /// six-arm scope filter, and the exact filter shape + /// `filter_uses_relative_controller_scoped` keys on. + fn scoped_put_counter() -> crate::types::ability::Effect { + use crate::types::ability::{ + ControllerRef, Effect, QuantityExpr, TargetFilter, TypedFilter, + }; + Effect::PutCounter { + target: TargetFilter::Typed(TypedFilter { + type_filters: vec![crate::types::ability::TypeFilter::Creature], + controller: Some(ControllerRef::ScopedPlayer), + properties: vec![], + }), + counter_type: crate::types::counter::CounterType::Plus1Plus1, + count: QuantityExpr::Fixed { value: 1 }, + } + } + + /// R23, conjuncts 1–2 — **CR 603.5: the `may` pin binds the prompt's RECIPIENT, not only + /// the entry's OWNER.** + /// + /// Asserted AT THE MINT SEAM, deliberately: an offer-level negative would be satisfied by + /// D4.3's scope filter on many boards regardless of this guard (the + /// upstream-conjunct-dominates trap). The mint has no such upstream. + /// + /// `entry.controller == proposer` bounds who OWNS the entry; it does not bound who the + /// resolver ASKS. `optional_prompt_player`'s last branch is EFFECT-AGNOSTIC — it fires on + /// `ability.scoped_player` plus a `ScopedPlayer`-scoped `target_filter()` (CR 503.1a + + /// CR 608.2d, the Braids, Conjurer Adept class — whose printed effect puts an artifact, + /// creature, or land card from hand onto the battlefield, NOT a counter; `PutCounter` + /// here is a synthetic stand-in for the class, which that branch admits precisely + /// BECAUSE it is effect-agnostic) — so an allow-listed `PutCounter` reaches + /// it. Without the conjunct, P0's pin would be spendable as P1's CR 603.5 choice. + /// + /// MATCHED POSITIVE, on the same instrument and differing in exactly `scoped_player`: it + /// proves the fixture reaches the mint at all, so the negative is keyed to the recipient + /// axis and not to one of the four upstream conjuncts. + /// + /// REVERT-PROBE: delete `&& effects::optional_prompt_player(state, ability) == proposer` + /// from the `may` mint ⇒ the scoped-player entry publishes a `MayChoice` slot ⇒ the + /// negative arm FLIPS TO FAIL. + #[test] + fn a_may_slot_is_minted_only_for_the_seat_the_cr_603_5_gate_will_ask() { + let (mut state, src) = u2_board(); + + // ── negative: the gate will ask P1, not the proposer ── + let mut scoped = shape_b(src, scoped_put_counter()); + scoped.scoped_player = Some(P1); + assert_eq!( + crate::game::effects::optional_prompt_player(&state, &scoped), + P1, + "reach-guard: the recipient authority must really route this entry to the OTHER \ + seat, or the negative below is about nothing" + ); + let negative = shape_b_entry(940, src, scoped); + assert!( + entry_publishes_pin_slots(&state, &negative, P0).is_none(), + "CR 603.5: a `may` the resolver will ask ANOTHER seat publishes no pin slot" + ); + + // ── matched positive: byte-identical except `scoped_player` ── + let unscoped = shape_b(src, scoped_put_counter()); + assert_eq!( + crate::game::effects::optional_prompt_player(&state, &unscoped), + P0, + "reach-guard: with no scoped player the gate asks the controller = proposer" + ); + let positive = shape_b_entry(941, src, unscoped); + let published = entry_publishes_pin_slots(&state, &positive, P0) + .expect("the matched positive must reach the mint and publish"); + assert!( + published.may.is_some(), + "the matched positive publishes the CR 603.5 gate" + ); + assert!( + published.target.is_none(), + "shape (B): announcing it surfaces no CR 601.2c choice, so no target slot" + ); + assert!( + published.legal_targets.is_empty(), + "no target slot carries no legal set" + ); + + // The published pair also reaches the point mint as ONE `MayChoice` point. + state.stack.push_back(positive); + let points = bounded_cycle_pin_slots(&state, P0); + assert_eq!( + points.len(), + 1, + "shape (B) publishes exactly the may point: {points:?}" + ); + assert_eq!( + points[0].kind, + crate::analysis::decision_template::DecisionPointKind::MayChoice + ); + assert_eq!( + points[0].slot.index, 1, + "index 1 is the may slot in BOTH shapes" + ); + } + + /// R23, conjunct 3 — **the PRODUCER census, so a new producer is a COUNTED event.** + /// + /// The struck form of this conjunct pinned `optional_prompt_player`'s own call-site count, + /// which is trivially stable at 2 and moves neither when the guard is deleted nor when an + /// unguarded producer is added — this plan's own "verify the seam, not the line" defect, + /// committed. What actually bounds the mint conjunct's reach is how many things PRODUCE + /// `WaitingFor::OptionalEffectChoice`: the conjunct is a fail-closed pre-filter on ONE of + /// them, and soundness over the others is discharged at the consumption point. + /// + /// The five production producers are named individually, and exactly one of them is inside + /// the CR 603.5 gate that consults the recipient authority. If a sixth appears, this row + /// fails and whoever added it must decide where its recipient is bound. + /// + /// ⚠ **ADJUDICATED IN U4, NOT RELAXED.** The census moved `34 ⇒ 37`. The PRODUCER half is + /// unchanged at **5** and its per-file list is byte-identical (only one line NUMBER moved, + /// `game/engine.rs:10433 ⇒ :10493`, because U4's arm sits above it) — that half is what this + /// row's claim is about, and it did not move. The `+1` READER is `game/engine.rs`'s new + /// `OptionalEffectChoice` arm in `inject_pinned_answer`, i.e. the CONSUMPTION point this + /// doc already names as where soundness over the other four producers is discharged; the + /// `+2` are U4's own `#[cfg(test)]` fixtures. A new READER is the benign case — adjudicate + /// it, do not relax the assert. + /// + /// ⚠ **RE-ADJUDICATED IN THE 5d LOW-FIX, NOT RELAXED.** One line NUMBER moved again, + /// `game/engine.rs:10493 ⇒ :10500`, on the same terms as U4's shift above. Cause: the + /// LOW-fix added a net **+7 DOC lines** above that producer (the mint's corrected + /// board-not-prompt contract, and the Braids, Conjurer Adept Oracle-text correction) — + /// comments only, not one executable line. The producer itself is BYTE-IDENTICAL (the + /// `return Ok(Some(WaitingFor::OptionalEffectChoice` head, diffed against `HEAD`), the + /// total stays **37** and the partition stays **5/7/25**, and the other four entries are + /// unchanged. The two companion asserts above run FIRST and both fired GREEN on the run + /// that caught this — which is the evidence that the SET did not move and only this + /// entry's coordinate did. A line-number-only shift is the benign case; a changed + /// producer set is not, and stays a counted event. + /// + /// ⚠ **RE-ADJUDICATED ON THE REBASE ONTO UPSTREAM #6842 (`8121fd1c6`), NOT RELAXED.** + /// The row fired again; the PRODUCER COUNT IS STILL **5** and no sixth producer exists. + /// Four of five coordinates shifted and one did not: + /// `game/effects/mod.rs:5896/5973/8927 ⇒ :5918/5995/8949` (uniform **+22**, lines that + /// commit adds above them in that file), `game/engine.rs:10500 ⇒ :10589` (**+89**, same + /// cause), and `game/effects/scoped_library_search.rs:452` **UNMOVED**. + /// Evidence this is a coordinate shift and not a set change: each of the five was re-read + /// at its new coordinate and diffed against the pre-rebase tree (`chain3-prefold-backup`) + /// at its old one — all five are BYTE-IDENTICAL, same files, same order, and the one + /// entry at an unchanged coordinate is byte-identical in place, which a gained-or-lost + /// producer could not produce. Same set, new line numbers ⇒ benign, re-baselined here. + /// NOTE for the record: this rebase did NOT add a CR 603.5 producer. An earlier report of + /// mine said upstream had added one; that was wrong — the row fired on coordinates. + /// + /// ⚠ **RE-ADJUDICATED ON THE REBASE ONTO UPSTREAM #6851 (`96e41b3ab`), NOT RELAXED.** + /// The row fired in CI but not locally, because CI builds the MERGE ref (branch + main) + /// while the branch was still based on `e12447f4f`. The PRODUCER COUNT IS STILL **5**, no + /// sixth producer exists, and this time only ONE coordinate moved: + /// `game/engine.rs:10589 ⇒ :10640` (**+51**), with `game/effects/mod.rs:5918/5995/8949` + /// and `game/effects/scoped_library_search.rs:452` all **UNMOVED**. + /// Evidence this is a coordinate shift and not a set change, three independent ways: + /// (1) all five producers were re-read at their new coordinates and diffed against the + /// pre-rebase tree at their old ones — **byte-identical**, same files, same order, with a + /// negative control confirming the diff instrument discriminates (the new tree at the OLD + /// coordinate `:10589` is a bare `}`, not the producer); (2) the +51 is fully accounted + /// for by #6851's own insertions ABOVE this producer in the same file — measured net + /// `+51` from `git diff -U0 e12447f4f 96e41b3ab`, so predicted `10589+51 = 10640` equals + /// the observed coordinate exactly, and #6851's whole-file delta is also `+51`, i.e. it + /// adds nothing below; (3) the total stays **37** and the partition stays **5/7/25**, so + /// neither a producer nor a reader was gained or lost. Same set, one new line number ⇒ + /// benign, re-baselined here. + #[test] + fn the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event() { + /// Every `.rs` under the crate's `src`, and the `#[cfg(test)]`-attributed + /// column-0 `mod … {` … column-0 `}` spans inside it. A whole file whose stem + /// ends `_tests` is test-only (its parent declares it under `#[cfg(test)]`). + fn rs_files(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("readable source dir") { + let path = entry.expect("readable dir entry").path(); + if path.is_dir() { + rs_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + fn cfg_test_spans(lines: &[&str]) -> Vec<(usize, usize)> { + let mut spans = Vec::new(); + let mut i = 0; + while i < lines.len() { + if lines[i].trim() == "#[cfg(test)]" { + let mut j = i + 1; + while j < lines.len() + && (lines[j].trim_start().starts_with("#[") || lines[j].trim().is_empty()) + { + j += 1; + } + let is_mod = j < lines.len() + && lines[j].starts_with(['m', 'p']) + && lines[j].contains("mod ") + && lines[j].trim_end().ends_with('{'); + if is_mod { + let mut k = j + 1; + while k < lines.len() && lines[k] != "}" { + k += 1; + } + spans.push((j, k)); + i = k; + } + } + i += 1; + } + spans + } + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + rs_files(&root, &mut files); + files.sort(); + assert!(files.len() > 100, "reach-guard: the walker found the crate"); + + // The needle is ASSEMBLED so this row's own source cannot be counted by its own + // instrument. `..` excludes multi-line READ destructures whose rest-pattern sits on + // a later line — the inflation the raw grep suffers from. + let needle = format!("WaitingFor::{}Choice {{", "OptionalEffect"); + let (mut producers, mut readers, mut in_test) = (Vec::new(), Vec::new(), 0usize); + for path in &files { + let text = std::fs::read_to_string(path).expect("readable source file"); + let lines: Vec<&str> = text.lines().collect(); + let spans = cfg_test_spans(&lines); + let rel = path + .strip_prefix(&root) + .expect("under src") + .display() + .to_string(); + let test_file = rel.trim_end_matches(".rs").ends_with("_tests"); + for (n, line) in lines.iter().enumerate() { + if !line.contains(&needle) || line.contains("..") { + continue; + } + if test_file || spans.iter().any(|(a, b)| (*a..=*b).contains(&n)) { + in_test += 1; + } else if line.contains("waiting_for = ") || line.contains("Ok(Some(") { + producers.push(format!("{rel}:{}", n + 1)); + } else { + readers.push(format!("{rel}:{}", n + 1)); + } + } + } + + assert_eq!( + producers.len() + readers.len() + in_test, + 37, + "CR 603.5 prompt census drifted. A new PRODUCER must have its recipient bound \ + somewhere — the mint's conjunct (a) covers exactly ONE of them. A new READER is \ + the benign case (U4's own consumption arm was one): adjudicate it in this doc and \ + name the site, do not merely move the number.\n\ + producers={producers:#?}\nreaders={readers:#?}" + ); + assert_eq!( + (producers.len(), readers.len(), in_test), + (5, 7, 25), + "the partition, not just the total: five PRODUCTION producers, seven PRODUCTION \ + readers (they read `state.waiting_for` and never write it — the seventh is U4's \ + `inject_pinned_answer` arm), 25 `#[cfg(test)]` lines.\nproducers={producers:#?}\n\ + readers={readers:#?}" + ); + assert_eq!( + producers, + vec![ + // DRIFT LOG for these three, newest last. Every entry is pure line movement + // with the producer re-read and sha256-compared at its new coordinate; none has + // ever been a real sixth producer. + // #6842 (8121fd1c6): `:5896/:5973/:8927 ⇒ :5918/:5995/:8949`, uniform +22. + // #6933: `engine.rs :10640 ⇒ :11427` (that entry, below). + // #6955 (c9daf66e3): `:8949 ⇒ :8970`, +21 == that commit's insertion count, + // and the other two did NOT move, which located the insertion below them. + // #6961 (2ead7aab1) + v0.44.0: `:5918/:5995/:8970 ⇒ :5996/:6073/:9048`, + // uniform +78 above all three (whole-file delta +153/-15). + // + // ⚠ 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 + // `main` — so an upstream insertion above a producer reds it in CI while the + // branch tree stays green, until the branch merges that upstream. Diagnose by + // rebuilding the merge tree (`git merge-tree --write-tree HEAD upstream/main`) + // and comparing coordinates there, NOT by editing pins to match a local tree. + // + // Five drifts, all upstream, zero true positives. The pin stays line-exact + // because that is what makes a NEW mint a counted event; a function + + // content-hash anchor would end the drift class while keeping that property, + // and is offered as a follow-up rather than taken unannounced mid-review. + "game/effects/mod.rs:5996".to_string(), + "game/effects/mod.rs:6073".to_string(), + "game/effects/mod.rs:9048".to_string(), + // UNMOVED across the rebase, and that is itself evidence the SET did not + // move: a census that had gained or lost a producer would not leave this + // entry both byte-identical AND at the same coordinate. + "game/effects/scoped_library_search.rs:452".to_string(), + // 5d LOW-fix: `:10493 ⇒ :10500`, a doc-only line shift (+7 comment lines + // above); producer byte-identical, total 37 and partition 5/7/25 untouched. + // Rebase onto #6842: `:10500 ⇒ :10589`, on the same terms — that commit adds + // lines above this producer too. Producer byte-identical. + // Rebase onto #6851 (96e41b3ab): `:10589 ⇒ :10640`, again on the same terms. + // The +51 is exactly #6851's measured net insertion above this line (and its + // whole-file delta is also +51, so it adds nothing below). The OTHER FOUR + // entries did not move at all this time — a census that had gained or lost a + // producer could not leave four entries byte-identical AND in place. + // + // Fold of upstream #6933 (409956671, merged by the maintainer as d1a5270a4): + // `:10640 ⇒ :11427`, +787. engine.rs's whole-file delta over the same range is + // +1134, so 787 lands above this producer and 347 below — consistent with a + // file that grew around it rather than one that gained a mint. Identity + // re-established at the new coordinate rather than assumed: the line is + // byte-identical by sha256 to `ea1b0ac19:engine.rs:10640`, and it is still + // inside `begin_pending_trigger_target_selection` (fn opens at :11278), which + // is the producer this row NAMES below. The old coordinate now holds + // copy-target-slot code that mints nothing. The OTHER FOUR entries did not + // move, which is the same set-preservation evidence as the previous rebases. + "game/engine.rs:11427".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 \ + in `scoped_library_search`, and `begin_pending_trigger_target_selection`'s \ + ANNOUNCEMENT-time modal prompt. Four of the five choose `player` WITHOUT \ + consulting the recipient authority, which is exactly why the mint conjunct is a \ + fail-closed pre-filter and not a soundness proof" + ); + + // Exactly ONE of them routes through the recipient authority: the CR 603.5 gate. + let effects_src = std::fs::read_to_string(root.join("game/effects/mod.rs")) + .expect("readable effects module"); + let authority = format!("{}_prompt_player", "optional"); + assert_eq!( + effects_src.matches(&authority).count(), + 2, + "one definition + exactly one call — the CR 603.5 gate's `let prompt_player = ..`. \ + A second call inside `effects/mod.rs` means a second producer started consulting \ + the authority and this row's partition needs re-deriving" + ); + } + + /// R25 — **a stored `may` auto-choice is a SECOND authority on the same CR 603.5 + /// question, and the mint must refuse to it.** + /// + /// Without the conjunct the pin is minted, then the gate consumes the stored choice and + /// **returns before setting any prompt** — so `inject_pinned_answer` is never entered and + /// the fail-closed `_ => Err(RecastAbort)` arm the design leans on cannot fire on a prompt + /// that is never raised. The declared `Take` would be silently replaced by the stored + /// `Decline`. `MayTriggerAutoChoiceKey`/`Record` are `Serialize + Deserialize`, so a real + /// dump can carry one. + /// + /// MATCHED POSITIVE, differing ONLY in the seeded record, so no upstream conjunct can + /// dominate. + /// + /// REVERT-PROBE: delete the `ability.may_trigger_origin.as_ref().is_none_or(…is_none())` + /// conjunct from the `may` mint ⇒ the seeded entry publishes a `MayChoice` slot ⇒ the + /// negative arm FLIPS TO FAIL. + #[test] + fn a_stored_may_auto_choice_is_a_second_authority_the_mint_refuses_to() { + use crate::types::ability::{ + Effect, QuantityExpr, TargetFilter, TriggerBaseSetInstanceRef, + TriggerDefinitionOccurrenceRef, TriggerDefinitionRef, + }; + use crate::types::game_state::{AutoMayChoice, MayTriggerAutoChoiceKey, MayTriggerOrigin}; + use crate::types::identifiers::ObjectIncarnationRef; + + let (mut state, src) = u2_board(); + // The production shape: `triggers.rs` mints `Definition { definition_ref }` from the + // source's own incarnation plus the printed occurrence — built here identically. + let origin = MayTriggerOrigin::Definition { + definition_ref: TriggerDefinitionRef { + source: ObjectIncarnationRef::of(src, 3), + occurrence: TriggerDefinitionOccurrenceRef::Printed { + base_set: TriggerBaseSetInstanceRef::INITIAL, + printed_index: 0, + }, + }, + }; + let with_origin = |src: ObjectId| { + let mut ability = shape_b( + src, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + ); + ability.may_trigger_origin = Some(origin.clone()); + ability + }; + + // ── matched positive: no stored record ⇒ the gate WILL prompt ⇒ mint publishes ── + let entry = shape_b_entry(950, src, with_origin(src)); + assert!( + state.may_trigger_auto_choices.is_empty(), + "reach-guard: the positive arm runs with NO stored record" + ); + assert!( + entry_publishes_pin_slots(&state, &entry, P0) + .expect("the positive arm publishes") + .may + .is_some(), + "with no stored answer the CR 603.5 gate really asks, so the pin is spendable" + ); + + // ── negative: the SAME board with one record seeded ── + state.set_may_trigger_auto_choice( + MayTriggerAutoChoiceKey { + player: P0, + source_id: src, + origin: origin.clone(), + }, + AutoMayChoice::Decline, + ); + assert_eq!( + state.may_trigger_auto_choice(&MayTriggerAutoChoiceKey { + player: P0, + source_id: src, + origin, + }), + Some(AutoMayChoice::Decline), + "reach-guard: the mint's key must be the key the seed stored, or the negative \ + passes for the wrong reason" + ); + assert!( + entry_publishes_pin_slots(&state, &entry, P0).is_none(), + "CR 603.5: a stored auto-choice already answers this may, so a minted pin would \ + be silently unused — refuse it at the mint. Shape (B) has no other slot, so the \ + whole entry publishes nothing" + ); + } + + /// R30 — **one published `MayChoice` slot stands for exactly ONE CR 603.5 prompt.** + /// + /// CR 732.2a requires the shortcut to describe *the* sequence of choices; a schema point + /// is a choice SURFACE, so a slot that answers one prompt while the resolution opens N is + /// a schema that under-describes its own sequence. Production suppresses the single + /// up-front CR 603.5 gate for three `repeat_for` shapes and re-fires optionality PER + /// ITERATION (CR 608.2c + CR 608.2d) instead. The mint asks production's own three + /// predicates rather than re-deriving them — re-deriving is the drift defect one symbol + /// over. + /// + /// THREE ARMS, one per predicate, each with its matched positive on the same instrument: + /// * **(a) kind-driven — the reachable one.** `has_kind_driven_repeat` matches on + /// `repeat_for` and on NOTHING else (no `Effect` restriction), so an allow-listed + /// optional `PutCounter` of that shape reaches it. **(a′)** differs only in + /// `repeat_for: None`. + /// * **(b) member-driven — live for an allow-listed effect.** `Effect::Token` with + /// `attach_to: Some(ParentTarget)` (Asinine Antics' shape, named in + /// `effect_parent_ref_slots`' own doc) is inside the allow list and reaches + /// `effect_iterates_over_parent_target`. **(b′)** differs in exactly the predicate's + /// own deciding leaf: `attach_to: Some(LastCreated)` is ALSO a context ref, so the head + /// filter is still `owner`, the shape is still (B) and the `repeat_for` is still + /// `ObjectCount` — only `filter_refs_parent_target` flips. Without (b′) a blanket + /// "refuse every `ObjectCount`" would pass, which is coarser than production and a mint + /// cost. + /// * **(c) repeated optional payment — DISCLOSED as not independently reachable.** It + /// requires `Effect::PayCost`, which D4.3's scope filter refuses at conjunct (6), so no + /// certifiable offer carries such an entry. The arm asserts the mint's refusal only and + /// claims NO closed hole; it ships so the mint asks the same three questions production + /// asks. + /// + /// REVERT-PROBE: delete the three sub-conjuncts from the `may` mint ⇒ (a) and (b) FLIP TO + /// FAIL while (a′)/(b′) stay green — the pairs discriminate the conjunct, not the fixture. + #[test] + fn one_published_may_slot_stands_for_exactly_one_cr_603_5_prompt() { + use crate::types::ability::{ + AbilityCondition, AbilityCost, Effect, PtValue, QuantityExpr, QuantityRef, TargetFilter, + }; + + let (state, src) = u2_board(); + let publishes_may = |ability: ResolvedAbility, id: u64| -> bool { + let entry = shape_b_entry(id, src, ability); + entry_publishes_pin_slots(&state, &entry, P0) + .and_then(|p| p.may) + .is_some() + }; + + // ── (a) kind-driven, and (a′) its matched positive ── + let kind_driven = || { + let mut a = shape_b(src, scoped_put_counter()); + a.repeat_for = Some(QuantityExpr::Ref { + qty: QuantityRef::DistinctCounterKindsAmong { + filter: TargetFilter::Controller, + }, + }); + a + }; + assert!( + crate::game::effects::has_kind_driven_repeat(&kind_driven()), + "reach-guard: production's own predicate must say TRUE for arm (a)'s fixture" + ); + assert!( + !publishes_may(kind_driven(), 960), + "(a) CR 608.2c/608.2d: a `DistinctCounterKindsAmong` repeat fires ONE prompt PER \ + ITERATION, so a single slot would under-describe the CR 732.2a sequence" + ); + let mut kind_positive = kind_driven(); + kind_positive.repeat_for = None; + assert!( + publishes_may(kind_positive, 961), + "(a′) byte-identical except `repeat_for: None` ⇒ published, so (a) keys on the \ + repeat axis and not on `optional`, the recipient or the auto-choice conjunct" + ); + + // ── (b) member-driven, and (b′) the one-leaf matched positive ── + let token_attached = |attach: TargetFilter| { + let mut a = shape_b( + src, + Effect::Token { + name: "Cursed Role".to_string(), + power: PtValue::Fixed(1), + toughness: PtValue::Fixed(1), + types: vec!["Enchantment".to_string()], + colors: vec![], + keywords: vec![], + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: Some(attach), + enters_attacking: false, + supertypes: vec![], + static_abilities: vec![], + enter_with_counters: vec![], + }, + ); + a.repeat_for = Some(QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: TargetFilter::Controller, + }, + }); + a + }; + assert!( + crate::game::effects::has_member_driven_repeat_after_hydration( + &state, + &token_attached(TargetFilter::ParentTarget) + ), + "reach-guard: the `ParentTarget` fixture must really reach \ + `effect_iterates_over_parent_target`" + ); + assert!( + !publishes_may(token_attached(TargetFilter::ParentTarget), 962), + "(b) CR 608.2c/608.2d: an `ObjectCount` repeat over a parent-target ref fires one \ + prompt per iterated member" + ); + assert!( + !crate::game::effects::has_member_driven_repeat_after_hydration( + &state, + &token_attached(TargetFilter::LastCreated) + ), + "reach-guard: `LastCreated` is also a context ref, so (b′) differs from (b) in \ + the predicate's own deciding leaf and in nothing else" + ); + assert!( + publishes_may(token_attached(TargetFilter::LastCreated), 963), + "(b′) a blanket `refuse every ObjectCount` would be coarser than production and \ + would fail here" + ); + + // ── (c) repeated optional payment — refusal asserted, reach DISCLOSED as closed ── + let repeated_payment = || { + let mut a = shape_b( + src, + Effect::PayCost { + cost: AbilityCost::Mana { + cost: crate::types::mana::ManaCost::Cost { + shards: vec![], + generic: 1, + }, + }, + scale: None, + payer: TargetFilter::Controller, + }, + ); + a.repeat_for = Some(QuantityExpr::Fixed { value: 2 }); + let mut sub = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + src, + P0, + ); + sub.condition = Some(AbilityCondition::WhenYouDo); + a.sub_ability = Some(Box::new(sub)); + a + }; + assert!( + crate::game::effects::is_repeated_optional_payment(&repeated_payment()), + "reach-guard: production's own predicate must say TRUE for arm (c)'s fixture" + ); + assert!( + !publishes_may(repeated_payment(), 964), + "(c) CR 603.12a: the payment process offers its `may` PER iteration. This arm \ + asserts the mint's refusal only — `Effect::PayCost` is outside D4.3's six-arm \ + allow list, so no certifiable offer carries such an entry and NO closed hole is \ + claimed here" + ); + } + + // ────────── 5d U4 — the `OptionalEffectChoice` arm and its two TOTAL head guards ────────── + + /// Life the shared U4 fixture's suspended optional ability gains when its CR 603.5 choice + /// is TAKEN. Named rather than inlined so every "the pin was APPLIED" assertion below is + /// keyed to the fixture instead of to a literal. + const U4_MAY_LIFE: i32 = 5; + + /// A board parked on a REAL CR 603.5 resolution-time prompt. `asked` is the seat the + /// resolver is asking, and the suspended optional ability is THAT seat's, so taking the + /// choice gains `asked` exactly `U4_MAY_LIFE` life. + /// + /// The life delta is the observable that separates "the pinned `DecideOptionalEffect` was + /// dispatched" from "the injector returned `Ok(())` having done nothing": an empty board + /// would answer `Ok(())` just as happily. + /// + /// The pinned source is a BATTLEFIELD object because `resolve_source` is battlefield-only + /// (CR 400.7 incarnation binding) — on any other zone `slot_source_prompted` would refuse + /// every arm below for a reason none of them is about. + fn u4_may_board(asked: PlayerId) -> (GameState, ObjectId) { + use crate::types::ability::{Effect, QuantityExpr, TargetFilter}; + let mut state = GameScenario::new_n_player(3, 7).build().state().clone(); + let src = place(&mut state, 970, Zone::Battlefield); + let mut optional = ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: U4_MAY_LIFE }, + player: TargetFilter::Controller, + }, + vec![], + src, + asked, + ); + optional.optional = true; + state.push_optional_effect_frame(crate::types::resolution::OptionalEffectFrame { + ability: Box::new(optional), + trigger_event: None, + trigger_match_count: None, + }); + state.waiting_for = WaitingFor::OptionalEffectChoice { + player: asked, + source_id: src, + description: None, + may_trigger_key: None, + }; + (state, src) + } + + /// A template carrying exactly one CR 603.5 `MayChoice` pin for `src` (slot index 1 — the + /// may slot in both mint shapes), declared by `owner`. + fn u4_may_template( + src: ObjectId, + owner: PlayerId, + take: crate::analysis::decision_template::MayChoiceOption, + ) -> DecisionTemplate { + let source = this_object(src); + DecisionTemplate { + owner, + decisions: vec![PinnedDecision::MayChoice { + slot: DecisionSlot { + source: source.clone(), + index: 1, + }, + take, + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(1), + }, + key: DecisionGroupKey::from_sources(&[source], DecisionKind::LoopChoice), + } + } + + /// R23, conjunct 4 — **CR 603.5 + CR 732.2a: a `may` pin answers only the seat the PROMPT + /// names.** + /// + /// The mint's recipient conjunct (U2) is a PREDICTION over one of five + /// `WaitingFor::OptionalEffectChoice` producers — only one of them consults + /// `optional_prompt_player` — so it is partial by construction. This guard reads the + /// recipient OFF THE PROMPT, which is total over all five and over any sixth. + /// + /// MATCHED POSITIVE, same instrument, same template, differing in exactly one fixture + /// parameter (the seat the prompt names — and, coherently, the controller of the suspended + /// optional ability, since the resolver asks that ability's controller): the pinned + /// `DecideOptionalEffect` is dispatched and `U4_MAY_LIFE` life is gained. + /// + /// Two reach-guards keep the negative off the arm's other refusals: the pin's slot really + /// does match the prompted source on the NEGATIVE's own board (so the `find_map` is not the + /// refuser), and the beat guard's cursor is absent on both boards. + /// + /// The POSITIVE is asserted FIRST, deliberately: under the revert-probe below one run then + /// shows the positive PASSING and the negative FAILING, which is what proves the pair + /// discriminates the SEAT rather than the fixture. + /// + /// REVERT-PROBE: delete `if *player != template.owner { return Err(RecastAbort); }` ⇒ the + /// negative arm FLIPS TO FAIL (it returns `Ok(())` and P1 gains the life P0's pin bought). + #[test] + fn a_may_pin_answers_only_the_seat_the_prompt_names() { + use crate::analysis::decision_template::MayChoiceOption; + + // ── MATCHED POSITIVE: the template against ITS OWNER's own prompt ── + let (mut asks_owner, src) = u4_may_board(P0); + let template = u4_may_template(src, P0, MayChoiceOption::Take); + let own_prompt = asks_owner.waiting_for.clone(); + let p0_before = life(&asks_owner, P0); + inject_pinned_answer(&mut asks_owner, Some(&template), 0, &own_prompt) + .expect("CR 603.5: the owner's own pin answers the owner's own choice"); + assert_eq!( + life(&asks_owner, P0), + p0_before + U4_MAY_LIFE, + "the pinned `DecideOptionalEffect {{ accept: true }}` was really APPLIED — an \ + `Ok(())` that dispatched nothing would leave this unchanged" + ); + assert_ne!( + asks_owner.waiting_for, own_prompt, + "the prompt was consumed, not silently skipped" + ); + + // ── NEGATIVE: same template (owner P0), but the prompt asks P1 ── + let (mut asks_other, other_src) = u4_may_board(P1); + assert_eq!( + other_src, src, + "both boards mint the same pinned source object" + ); + let prompt = asks_other.waiting_for.clone(); + assert!( + slot_source_prompted(&asks_other, &this_object(src), src), + "reach-guard: the pin's slot MATCHES the prompted source on this very board, so a \ + refusal below cannot be the `find_map`'s" + ); + assert!( + asks_other.pending_trigger.is_none(), + "reach-guard: no construction cursor, so the BEAT guard cannot be the refuser" + ); + let p1_before = life(&asks_other, P1); + assert!( + inject_pinned_answer(&mut asks_other, Some(&template), 0, &prompt).is_err(), + "CR 603.5: a pin owned by the proposer must not answer ANOTHER seat's choice" + ); + assert_eq!( + life(&asks_other, P1), + p1_before, + "the refusal is fail-closed: nothing was dispatched as P1" + ); + assert_eq!( + asks_other.waiting_for, prompt, + "the other seat's prompt is still standing, for a human to answer" + ); + } + + /// R23, conjunct 5 — **CR 603.5 vs CR 603.3c + CR 700.2b: the pin binds the BEAT as well as + /// the seat.** + /// + /// A `MayChoice` pin answers the RESOLUTION-time question (CR 603.5). The engine also asks a + /// same-`source_id` ANNOUNCEMENT-time question while a trigger is still mid-construction + /// (the optional-modal gate, CR 603.3c / CR 700.2b), and `slot_source_prompted` cannot + /// separate the two: it matches the SOURCE OBJECT and both prompts carry it. That is why the + /// pair below uses the SAME `source_id` in the cursor as in the prompt — a differing-source + /// fixture would be refused by the slot lookup instead and the row would report the wrong + /// guard. + /// + /// Both arms hold the seat guard SATISFIED (`player == template.owner == P0`), asserted + /// below, so conjunct 4's guard cannot be what decides either arm. + /// + /// (5-pos) is asserted FIRST so that under the revert-probe ONE run shows the positive + /// passing and the negative failing — the evidence that the pair discriminates the CURSOR + /// and not the fixture. + /// + /// REVERT-PROBE: delete `if work.pending_trigger.is_some() { return Err(RecastAbort); }` ⇒ + /// (5-neg) FLIPS TO FAIL while (5-pos) and conjuncts 1–4 stay green. + #[test] + fn a_may_pin_never_answers_the_announcement_time_question() { + use crate::analysis::decision_template::MayChoiceOption; + + let template = |src: ObjectId| u4_may_template(src, P0, MayChoiceOption::Take); + + // ── (5-pos): the same board with NO construction cursor ── + let (mut cursor_clear, src) = u4_may_board(P0); + assert!(cursor_clear.pending_trigger.is_none()); + let pos_prompt = cursor_clear.waiting_for.clone(); + let pos_before = life(&cursor_clear, P0); + inject_pinned_answer(&mut cursor_clear, Some(&template(src)), 0, &pos_prompt).expect( + "(5-pos) CR 603.5: with no cursor the pin answers its own resolution-time question", + ); + assert_eq!( + life(&cursor_clear, P0), + pos_before + U4_MAY_LIFE, + "(5-pos) the pinned choice was applied" + ); + + // ── (5-neg): a LIVE construction cursor on the SAME source ── + let (mut cursor_live, neg_src) = u4_may_board(P0); + assert_eq!(neg_src, src, "the pair is built from one fixture"); + let mut mid_construction = ResolvedAbility::new( + crate::types::ability::Effect::GainLife { + amount: crate::types::ability::QuantityExpr::Fixed { value: 1 }, + player: crate::types::ability::TargetFilter::Controller, + }, + vec![], + src, + P0, + ); + // The production shape that raises a same-source ANNOUNCEMENT-time prompt: an optional + // modal trigger, asked before its modes are chosen (CR 603.3c / CR 700.2b). + mid_construction.optional = true; + cursor_live.pending_trigger = Some(Box::new(super::triggers::PendingTrigger { + source_id: src, + controller: P0, + condition: None, + ability: Box::new(mid_construction), + timestamp: 0, + target_constraints: vec![], + distribute: None, + trigger_event: None, + modal: None, + mode_abilities: vec![], + description: None, + may_trigger_origin: None, + subject_match_count: None, + die_result: None, + })); + let prompt = cursor_live.waiting_for.clone(); + assert_eq!( + prompt, pos_prompt, + "(5-neg) differs from (5-pos) in `work.pending_trigger` and in NOTHING else the \ + injector reads — the prompts are equal" + ); + let WaitingFor::OptionalEffectChoice { + player: asked, + source_id: prompt_source, + .. + } = &prompt + else { + panic!("the fixture parks on a CR 603.5 prompt; got {prompt:?}"); + }; + assert_eq!( + *asked, + template(src).owner, + "reach-guard: the SEAT guard is satisfied on both arms, so conjunct 4 cannot be \ + what decides this pair" + ); + assert_eq!( + cursor_live + .pending_trigger + .as_ref() + .map(|t| t.source_id) + .expect("(5-neg) carries a cursor"), + *prompt_source, + "the cursor names the SAME source as the prompt — that identity is what makes this \ + arm non-vacuous, because `slot_source_prompted` matches on exactly that object" + ); + let before = life(&cursor_live, P0); + assert!( + inject_pinned_answer(&mut cursor_live, Some(&template(src)), 0, &prompt).is_err(), + "CR 603.5 vs CR 603.3c: with a live construction cursor the prompt in hand may be \ + the ANNOUNCEMENT-time question the pin does not answer ⇒ fail-closed" + ); + assert_eq!( + life(&cursor_live, P0), + before, + "(5-neg) fail-closed: no `DecideOptionalEffect` was dispatched" + ); + assert_eq!( + cursor_live.waiting_for, prompt, + "(5-neg) the prompt still stands" + ); + } + + /// The arm's own leaf, covered in BOTH directions — **CR 603.5: a "may" is binary, and the + /// pin says WHICH.** + /// + /// The injector's `accept` flag is one equality test against `MayChoiceOption`'s take + /// variant — the single place the typed pin becomes the engine's boolean. A + /// single-direction row would pass just as happily against the INVERTED mapping, which is + /// why both options are driven on one fixture. (That comparison is deliberately NOT quoted + /// verbatim here: a textual revert-probe whose needle also matched this doc line would + /// silently no-op — the tripwire this row's own probe hit on its first run.) + /// + /// `Decline` is separated from "nothing was dispatched" by the prompt: a declined optional + /// effect CONSUMES the prompt (the frame resolves its decline branch) while a refusal leaves + /// it standing. Both facts are asserted, so neither arm can pass by inaction. + #[test] + fn a_may_pin_dispatches_the_option_it_names_in_both_directions() { + use crate::analysis::decision_template::MayChoiceOption; + + for take in [MayChoiceOption::Take, MayChoiceOption::Decline] { + let (mut board, src) = u4_may_board(P0); + let prompt = board.waiting_for.clone(); + let before = life(&board, P0); + inject_pinned_answer( + &mut board, + Some(&u4_may_template(src, P0, take)), + 0, + &prompt, + ) + .expect("both options are legal answers to a CR 603.5 prompt"); + assert_ne!( + board.waiting_for, prompt, + "{take:?}: the prompt is ANSWERED either way — that is what separates a \ + `Decline` from a fail-closed refusal" + ); + assert_eq!( + life(&board, P0) - before, + match take { + MayChoiceOption::Take => U4_MAY_LIFE, + MayChoiceOption::Decline => 0, + }, + "{take:?}: the pin's own option decides `accept`, so an inverted mapping fails \ + on one of these two arms" + ); + } + } + + /// A live `LoopShortcut` offer with an EMPTY schema, proposed by P0 on `state`. + fn u4_park_on_offer(state: &mut GameState) { + use crate::analysis::decision_template::ShortcutDecisionSchema; + use crate::analysis::loop_check::{LoopCertificate, WinKind}; + use crate::analysis::resource::BoardDelta; + state.waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: None, + certificate: LoopCertificate { + unbounded: vec![], + win_kind: WinKind::LethalDamage, + mandatory: false, + residual_board_delta: BoardDelta::default(), + per_cycle: None, + }, + schema: ShortcutDecisionSchema::default(), + }; + } + + /// R28 arm (b) — **the DRIVE seam cannot see a `template.owner` the engine never bound; the + /// DECLARE firewall is what makes the drive's seat guard meaningful.** + /// + /// ⚠ **(b1) ASSERTS A MEASURED BREACH, NOT A DESIRED BEHAVIOUR.** `template.owner` arrives + /// verbatim from the client, and `inject_pinned_answer` holds the template but not the + /// offer — so when an attacker sets `owner` to the seat the prompt names, the seat guard + /// compares that value against itself and passes. Measured on this tree: the injector + /// returns `Ok(())` and dispatches the PROPOSER's pinned choice as the OTHER seat's + /// `GameAction::DecideOptionalEffect` (P1 gains `U4_MAY_LIFE`). That is the whole reason the + /// binding lives at declare (`handle_declare_shortcut`) and at consumption + /// (`apply_confirmed_shortcut`), one layer above this one. + /// + /// ⚠ **PLAN DEVIATION, DISCLOSED:** §6 R28(b) predicts the drive seam refuses this pair + /// (*"must still `RecastAbort`"*). It does not, and cannot — the same cell's own analysis + /// says so two sentences later (*"under the round-33 design alone it returns `Ok(())`"*). + /// The arm ships keyed to the measurement, with (b2) supplying the refusal the row is + /// really about. If a future change closes the drive seam (e.g. by threading the + /// engine-issued proposer into the injector), (b1) FLIPS and must be re-keyed onto the new + /// refusal rather than deleted. + /// + /// **(b2) THE REFUSAL, AT THE SEAM THAT HAS THE ENGINE-ISSUED COMPARAND.** The identical + /// hostile template declared against a live `LoopShortcut { proposer: P0 }` is refused into + /// the manual handback — no `ShortcutProposal` is built — so the (b1) configuration is + /// unreachable in production. **MATCHED POSITIVE:** byte-identical except `owner`, which + /// opens APNAP; without it, "refused" would be indistinguishable from "this constructed + /// offer refuses everything". + /// + /// REVERT-PROBE: delete `if template.as_ref().is_some_and(|t| t.owner != offer.proposer)` + /// from `handle_declare_shortcut` ⇒ **(b2) FLIPS TO FAIL** (the hostile declaration builds a + /// proposal and APNAP opens), while **(b1) MUST NOT FLIP** — the injector reads no firewall. + /// The pair therefore proves the two halves measure two different seams rather than one + /// seam asserted twice. Arm (b) and arm (c) (`apply_confirmed_shortcut`, U2, integration) + /// take OPPOSITE reverts by design: (b) is about the drive-side comparand, (c) about the + /// restore ingress that never reaches declare. + #[test] + fn r28_b_the_drive_seat_guard_compares_a_client_supplied_owner_against_itself() { + use crate::analysis::decision_template::{IterationCount, MayChoiceOption}; + + // ── (b1) DRIVE seam: prompt player P1, template owner P1 (the attacker's choice) ── + let (mut board, src) = u4_may_board(P1); + let hostile = u4_may_template(src, P1, MayChoiceOption::Take); + let prompt = board.waiting_for.clone(); + let p1_before = life(&board, P1); + let outcome = inject_pinned_answer(&mut board, Some(&hostile), 0, &prompt); + assert!( + outcome.is_ok(), + "(b1) MEASURED: with `owner` set to the prompt's own seat the drive guard has \ + nothing to compare — it passes. Got {outcome:?}" + ); + assert_eq!( + life(&board, P1), + p1_before + U4_MAY_LIFE, + "(b1) and the proposer's pinned value was really dispatched AS P1 — this is the \ + breach the declare-time binding closes, measured rather than argued" + ); + + // ── (b2) DECLARE seam: the same template, refused by the engine-issued comparand ── + for owner in [P1, P0] { + let (mut state, offer_src) = u4_may_board(P0); + assert_eq!(offer_src, src, "one fixture feeds both halves"); + u4_park_on_offer(&mut state); + apply_action( + &mut state, + P0, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: Some(u4_may_template(src, owner, MayChoiceOption::Take)), + }, + None, + ) + .expect("the declaration is dispatched either way — refusal is a HANDBACK"); + + if owner == P1 { + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "(b2) CR 732.2a + CR 603.5 + CR 800.4a: a declaration whose `owner` is not \ + the engine-issued proposer hands priority back; got {:?}", + state.waiting_for + ); + assert!( + !matches!(state.waiting_for, WaitingFor::RespondToShortcut { .. }), + "(b2) no `ShortcutProposal` carrying the hostile owner may exist — that is \ + what makes (b1)'s configuration production-unreachable" + ); + } else { + let WaitingFor::RespondToShortcut { proposal, .. } = &state.waiting_for else { + panic!( + "(b2) matched positive: the honest declaration must open APNAP, so the \ + refusal above is keyed to `owner` and not to this constructed offer \ + refusing everything; got {:?}", + state.waiting_for + ); + }; + assert_eq!( + proposal.template.as_ref().map(|t| t.owner), + Some(P0), + "(b2) the proposal that IS built carries the engine-bound owner" + ); + } + } + } +} + +/// FIX-1 interruptibility (memory: combo-interruptibility-acceptance-criterion) — the Kilo loop's +/// CR 732.2a offer must FLIP off when the loop is defused. Driven from the REAL 4p dump through the +/// public `apply()` boundary (recording live), then the offer is re-derived at the private +/// `try_offer_object_growth_shortcut` seam (the plan's sanctioned private-fn revert-probe form). +#[cfg(test)] +mod kilo_interruptibility_tests { + use super::*; + use crate::analysis::decision_template::{PinnedDecision, TargetPin}; + use crate::types::ability::TargetRef; + use crate::types::game_state::{ManaChoice, PayCostKind, YieldTarget}; + use crate::types::mana::{ManaColor, ManaType}; + + const P0: PlayerId = PlayerId(0); + const KILO: ObjectId = ObjectId(402); + const FREED: ObjectId = ObjectId(403); + const RELIC: ObjectId = ObjectId(404); + const PENTAD: ObjectId = ObjectId(405); + const RELIC_TAP_MANA: usize = 1; + const FREED_UNTAP: usize = 1; + + fn load_migrated_dump() -> GameState { + use crate::types::game_state::PersistedGameState; + use std::io::Read; + let gz: &[u8] = include_bytes!("../../tests/fixtures/kilo_freed_relic_pentad_4p.json.gz"); + let mut json = String::new(); + flate2::read::GzDecoder::new(gz) + .read_to_string(&mut json) + .expect("fixture inflates"); + let envelope: serde_json::Value = serde_json::from_str(&json).expect("envelope parses"); + // Route through the REAL production restore chokepoint so the FIX-3 migration hook + // (`migrate_transient_loop_sequence`) drops the dump's 6 stale pinless steps on load — + // exactly as the integration helper does. Deserializing directly would bypass the hook, + // leaving the stale prefix so the live drive yields an 8-step (not 2-step) sequence. + // Decoding AS `PersistedGameState` (rather than decoding a bare `GameState` and + // wrapping it) additionally routes the dump through + // `reject_legacy_raw_prompt_authority` + `decode_persisted_resolution_state`. + // `.expect(..)`, not `?`: `into_game_state` returns `GameState`, not `Result`. + serde_json::from_value::(envelope["gameState"].clone()) + .expect("gameState deserializes through the production decoder") + .into_game_state() + } + + fn beat_actor(state: &GameState) -> PlayerId { + match &state.waiting_for { + WaitingFor::Priority { player } + | WaitingFor::PayCost { player, .. } + | WaitingFor::ChooseManaColor { player, .. } + | WaitingFor::ProliferateChoice { player, .. } => *player, + WaitingFor::LoopShortcut { proposer, .. } => *proposer, + other => panic!("unexpected beat: {other:?}"), + } + } + + /// Drive ONE full live cycle via the public boundary, recording the pinned period. + fn drive_one_live_cycle(state: &mut GameState) { + apply( + state, + P0, + GameAction::ActivateAbility { + source_id: RELIC, + ability_index: RELIC_TAP_MANA, + }, + ) + .expect("activate Relic mana ability"); + let mut freed_activated = false; + for _ in 0..200 { + let actor = beat_actor(state); + match state.waiting_for.clone() { WaitingFor::LoopShortcut { .. } => return, WaitingFor::PayCost { kind: PayCostKind::TapCreatures { .. }, @@ -13028,263 +16064,1240 @@ mod kilo_interruptibility_tests { apply(state, actor, GameAction::PassPriority).expect("pass priority"); } } - other => panic!("unexpected beat: {other:?}"), + other => panic!("unexpected beat: {other:?}"), + } + } + panic!("drive did not settle"); + } + + /// Matched pair: with the loop intact the offer re-derives (`Some`); removing Freed (Kilo can + /// no longer untap, the cycle is no longer mana-neutral) means the recorded `Activate 403#1` + /// step's ability definition can no longer be resolved (its object is gone), so `try_offer` + /// aborts at the pre-drive ability-def resolution ⇒ `None`. Pass-vs-defuse FLIPS the outcome. + #[test] + fn freed_removed_defuses_the_offer() { + let mut driven = load_migrated_dump(); + drive_one_live_cycle(&mut driven); + assert_eq!( + driven.last_loop_action_sequence.len(), + 2, + "the live cycle recorded the clean 2-step pinned period" + ); + + // Re-derive the empty-stack priority window the offer fires from (the recorded period is + // intact; the board is a valid loop state — Kilo untapped, mana-neutral). + let mut intact = driven.clone(); + intact.waiting_for = WaitingFor::Priority { player: P0 }; + assert!(intact.stack.is_empty(), "settled to an empty stack"); + assert!( + try_offer_object_growth_shortcut(&intact).is_some(), + "undefused: the intact loop re-derives the CR 732.2a offer" + ); + + // Defuse: remove Freed AFTER recording. The re-drive can no longer re-find/re-activate it. + let mut defused = intact.clone(); + defused.objects.remove(&FREED); + defused.battlefield.retain(|id| *id != FREED); + assert!( + try_offer_object_growth_shortcut(&defused).is_none(), + "defused (Freed removed): the re-drive aborts ⇒ NO offer — the outcome flips" + ); + } + + /// Reset a driven state (which settles at `LoopShortcut`) back to the empty-stack priority + /// window the offer re-derives from, so `try_offer_object_growth_shortcut` can be probed + /// directly (the plan's sanctioned private-fn revert-probe form). The board is the valid + /// post-cycle loop state (Kilo untapped, mana-neutral). + fn at_priority_window(mut state: GameState) -> GameState { + state.waiting_for = WaitingFor::Priority { player: P0 }; + assert!( + state.stack.is_empty(), + "the driven cycle settled to an empty stack" + ); + state + } + + /// Hostile fixture — two-legendary identity binding (memory: verify-the-seam-not-the-line). + /// The tap-cost pin stores the EXACT tapped `ObjectId` (`TargetPin::ByIdentity`), so with two + /// legal untapped legendary creatures on the board the detection re-drive must re-bind to the + /// RECORDED Kilo (402), NOT the decoy. Positive: record tapping Kilo ⇒ offer. Revert-probe + /// (FLIP, run in-test): repoint ONLY the tap-cost pin's identity to the decoy (an equally-legal + /// legendary) on the SAME board + recording ⇒ the re-drive taps the decoy, whose becomes-tapped + /// proliferate trigger (source = decoy) has NO matching pin (the proliferate pin is keyed to + /// Kilo 402) ⇒ `RecastAbort` ⇒ NO offer. If replay ignored the pin identity (re-bound to "any + /// legal legendary" or always Kilo) this mutation would NOT change the outcome — so the flip + /// proves the recorded identity is load-bearing. + #[test] + fn tap_pin_rebinds_to_recorded_legendary_not_a_decoy() { + let mut state = load_migrated_dump(); + + // Add a SECOND untapped legendary creature P0 controls (a Kilo clone with a fresh id) so + // the Relic tap cost has two legal choices the identity binding must disambiguate. + let decoy_id = ObjectId(state.next_object_id); + state.next_object_id += 1; + let mut decoy = state.objects[&KILO].clone(); + decoy.id = decoy_id; + // Distinct name: CR 704.5j (the legend rule) would otherwise force a ChooseLegend SBA + // between two same-named legends — we want two co-existing legal legendary tap targets. + decoy.name = "Decoy Legend".to_string(); + decoy.base_name = "Decoy Legend".to_string(); + decoy.attachments = Vec::new(); // the clone is NOT the Freed-enchanted creature + decoy.tapped = false; + state.objects.insert(decoy_id, decoy); + state.battlefield.push_back(decoy_id); + + drive_one_live_cycle(&mut state); + assert_eq!( + state.last_loop_action_sequence.len(), + 2, + "reach-guard: the live cycle recorded the clean 2-step pinned period" + ); + + // Positive: the recorded ByIdentity(Kilo 402) tap pin re-binds to Kilo on replay ⇒ offer. + let intact = at_priority_window(state.clone()); + assert!( + try_offer_object_growth_shortcut(&intact).is_some(), + "two legal legendaries present + recorded Kilo ⇒ the offer fires" + ); + + // Revert-probe (FLIP): repoint ONLY the tap-cost pin (its slot source resolves to Relic + // 404) to the decoy. Board, recording, and the proliferate pin (keyed to Kilo 402) are all + // unchanged. + let mut repointed = intact.clone(); + let mut mutated = false; + for step in repointed.last_loop_action_sequence.iter_mut() { + for pin in step.pins.iter_mut() { + if let PinnedDecision::Targets { slot, targets } = pin { + if matches!(&slot.source, YieldTarget::ThisObject { source_id, .. } if *source_id == RELIC) + { + *targets = vec![TargetPin::ByIdentity(YieldTarget::ThisObject { + source_id: decoy_id, + incarnation: None, + trigger_description: None, + })]; + mutated = true; + } + } + } + } + assert!( + mutated, + "reach-guard: the tap-cost pin (slot source Relic) was found + repointed" + ); + assert!( + try_offer_object_growth_shortcut(&repointed).is_none(), + "repointing the tap pin to the decoy FLIPS the offer OFF ⇒ recorded identity is load-bearing" + ); + } + + /// Hostile fixture — wrong-color drive. The `ManaColor` pin latches the color the player + /// produced (Blue, to pay Freed's `{U}`, CR 608.2d). Positive: Blue ⇒ mana-neutral cycle ⇒ + /// offer. Revert-probe (FLIP, run in-test): relatch the color to Red on the SAME recording ⇒ + /// the re-drive produces Red, Freed's `{U}` untap is unpayable ⇒ the second step aborts ⇒ NO + /// offer. The latched color value is load-bearing. + #[test] + fn mana_color_pin_replays_recorded_color() { + let mut state = load_migrated_dump(); + drive_one_live_cycle(&mut state); + let state = at_priority_window(state); + + // Positive: the latched Blue color pays Freed's {U} ⇒ offer. + assert!( + try_offer_object_growth_shortcut(&state).is_some(), + "the recorded Blue mana-color pin completes the mana-neutral cycle ⇒ offer" + ); + + // Revert-probe (FLIP): relatch the color to Red. + let mut wrong = state.clone(); + let mut mutated = false; + for step in wrong.last_loop_action_sequence.iter_mut() { + for pin in step.pins.iter_mut() { + if let PinnedDecision::ManaColor { color, .. } = pin { + *color = ManaColor::Red; + mutated = true; + } } } - panic!("drive did not settle"); + assert!( + mutated, + "reach-guard: the ManaColor pin was found + relatched" + ); + assert!( + try_offer_object_growth_shortcut(&wrong).is_none(), + "a Red mana-color pin cannot pay Freed's {{U}} ⇒ the drive aborts ⇒ NO offer" + ); } - /// Matched pair: with the loop intact the offer re-derives (`Some`); removing Freed (Kilo can - /// no longer untap, the cycle is no longer mana-neutral) means the recorded `Activate 403#1` - /// step's ability definition can no longer be resolved (its object is gone), so `try_offer` - /// aborts at the pre-drive ability-def resolution ⇒ `None`. Pass-vs-defuse FLIPS the outcome. + /// Synthetic positive/negative drive-replay reach-guard (plan §7 unit c). The SAME recorded + /// 2-step period is driven WITH pins (offer) and WITHOUT (abort). The `len()==2` anchor holds + /// in BOTH variants, so the negative's None is a drive-abort at the unpinned + /// `PayCost{TapCreatures}`, NOT a vacuous "no sequence to drive" upstream short-circuit + /// (memory: discriminator-vacuous-if-upstream-conjunct-dominates). #[test] - fn freed_removed_defuses_the_offer() { + fn drive_replay_requires_the_recorded_pins() { + let mut state = load_migrated_dump(); + drive_one_live_cycle(&mut state); + let state = at_priority_window(state); + + // Anchor (holds in BOTH variants): the recorded 2-step period is present. + assert_eq!( + state.last_loop_action_sequence.len(), + 2, + "reach-guard anchor: the recorded period exists ⇒ any None is a drive-abort, not a missing seq" + ); + + // Positive: the recorded pins drive the replay to completion ⇒ offer. + assert!( + try_offer_object_growth_shortcut(&state).is_some(), + "with the recorded pins the replay completes ⇒ offer" + ); + + // Negative: strip the pins from the SAME period ⇒ the replay hits the unpinned tap cost ⇒ + // abort ⇒ NO offer. The anchor proves the None is the drive-abort, not an empty sequence. + let mut unpinned = state.clone(); + for step in unpinned.last_loop_action_sequence.iter_mut() { + step.pins.clear(); + } + assert_eq!( + unpinned.last_loop_action_sequence.len(), + 2, + "reach-guard anchor: the period is still present in the negative variant" + ); + assert!( + try_offer_object_growth_shortcut(&unpinned).is_none(), + "without the pins the drive aborts at the unpinned tap cost ⇒ NO offer" + ); + } + + /// [LOW-1] declined-axis ∞ lifecycle — characterization/regression guard (memory: + /// combo-interruptibility-acceptance-criterion). A declined `Counters`/`Life` axis leaves its + /// ∞ capability marker in `unbounded_resources` intentionally (CR 732.2b never forces a + /// shortcut). This test guards the MEASURED retirement path (a) documented at the boundary + /// seam: the empty-stack offer hook `try_offer_object_growth_shortcut` (engine.rs:472) is NOT + /// gated by existing ∞ marks, so a later genuine re-detection RE-OFFERS the loop and can + /// re-collapse the declined axis once the observer is gone. + /// + /// DISCRIMINATING LEG (the re-offer assertion): with a pre-existing declined ∞ mark injected + /// for P0, the offer STILL fires. If a future regression ∞-gated the offer hook (e.g. to + /// suppress re-offering a declined axis), this flips to `None`. Positive control / reach-guard: + /// the SAME state WITHOUT the mark also offers (proving the mark is what the assertion isolates, + /// and the recorded 2-step period is intact — a `None` would be a drive-abort, not a missing + /// sequence). + #[test] + fn declined_infinity_mark_does_not_suppress_reoffer() { + use crate::analysis::resource::ResourceAxis; + let mut driven = load_migrated_dump(); drive_one_live_cycle(&mut driven); + let base = at_priority_window(driven); + + // Reach-guard anchor: the recorded period is present (a `None` below is a real gating + // decision, never an empty-sequence artifact). assert_eq!( - driven.last_loop_action_sequence.len(), + base.last_loop_action_sequence.len(), 2, - "the live cycle recorded the clean 2-step pinned period" + "reach-guard: the live cycle recorded the clean 2-step pinned period" + ); + // Positive control: without any ∞ mark the intact loop re-derives the offer. + assert!( + try_offer_object_growth_shortcut(&base).is_some(), + "positive control: the intact loop offers when no ∞ mark is present" ); - // Re-derive the empty-stack priority window the offer fires from (the recorded period is - // intact; the board is a valid loop state — Kilo untapped, mana-neutral). - let mut intact = driven.clone(); - intact.waiting_for = WaitingFor::Priority { player: P0 }; - assert!(intact.stack.is_empty(), "settled to an empty stack"); + // Inject a pre-existing DECLINED ∞ axis for P0 (as if an earlier boundary declined the life + // axis and left it ∞-marked for manual play). The offer hook reads `waiting_for` + stack + + // `samples()` + `last_loop_action_sequence` — never `unbounded_resources` — so the mark + // must NOT suppress the re-offer. + let mut marked = base.clone(); + marked.mark_unbounded_loop(P0, &[ResourceAxis::Life(P0)]); assert!( - try_offer_object_growth_shortcut(&intact).is_some(), - "undefused: the intact loop re-derives the CR 732.2a offer" + marked + .unbounded_resources + .get(&P0) + .is_some_and(|axes| axes.contains(&ResourceAxis::Life(P0))), + "reach-guard: the declined ∞ Life mark is present on the probed state" + ); + assert!( + try_offer_object_growth_shortcut(&marked).is_some(), + "the empty-stack offer hook is NOT ∞-gated: a persisted declined ∞ axis does not \ + suppress a genuine re-detection re-offering the loop (CR 732.2a / CR 732.2b)" ); + } - // Defuse: remove Freed AFTER recording. The re-drive can no longer re-find/re-activate it. - let mut defused = intact.clone(); - defused.objects.remove(&FREED); - defused.battlefield.retain(|id| *id != FREED); + /// Plant ONE extra `Targets` pin into the recorded period's first step, on a slot that + /// no prompt in the replay answers. That is what makes these three rows isolate the + /// OFFER-BUILDER: the drive never consults the planted pin, while + /// `pinned_decisions_to_points` — which builds its points from exactly + /// `build_recast_template(&seq[0]).decisions` — always does. + fn plant_offer_pin(state: &mut GameState, pin: TargetPin) -> GameState { + let mut planted = state.clone(); + let step = planted + .last_loop_action_sequence + .first_mut() + .expect("reach-guard: the recorded period has a first step to plant into"); + step.pins.push(PinnedDecision::Targets { + slot: crate::analysis::decision_template::DecisionSlot { + source: YieldTarget::ThisObject { + source_id: KILO, + incarnation: None, + trigger_description: None, + }, + // An index no live prompt publishes, so only the point-builder reads it. + index: 99, + }, + targets: vec![pin], + }); + planted + } + + /// R4b — CR 732.2a: *"a sequence of game choices ... that may be legally taken based on + /// the current game state"*. If a pinned target no longer resolves, there IS no such + /// sequence, so the offer must be WITHDRAWN — not published with a short legal set. + /// + /// Before the fix, `pinned_decisions_to_points` `filter_map`ped the unresolvable pin out + /// of `legal_targets` while keeping `min_targets = targets.len()`, publishing a point + /// that no legal declaration could satisfy: the player is offered a shortcut they cannot + /// take, and the failure surfaces later as `IllegalPinValue` instead of as "no offer". + /// + /// MATCHED PAIR on one board, one variable — the planted pin's identity: + /// * live object ⇒ the point resolves ⇒ the offer FIRES (the reach-guard: it proves + /// the plant itself does not break the drive, so the negative arm's `None` is the + /// withdrawal and not a broken fixture); + /// * absent object ⇒ the offer is WITHDRAWN. + /// + /// REVERT-PROBE: restore the `filter_map` (drop the `?`) ⇒ the negative arm publishes an + /// undeclarable point instead of withdrawing ⇒ FAILS. Reachable TODAY and independent of + /// item 1: this arm's pin is `ByIdentity`, and no player legality is involved. + #[test] + fn an_unresolvable_identity_pin_withdraws_the_offer() { + let mut driven = load_migrated_dump(); + drive_one_live_cycle(&mut driven); + let base = at_priority_window(driven); + + let live = plant_offer_pin( + &mut base.clone(), + TargetPin::ByIdentity(YieldTarget::ThisObject { + source_id: KILO, + incarnation: None, + trigger_description: None, + }), + ); assert!( - try_offer_object_growth_shortcut(&defused).is_none(), - "defused (Freed removed): the re-drive aborts ⇒ NO offer — the outcome flips" + try_offer_object_growth_shortcut(&live).is_some(), + "reach-guard: a planted pin that RESOLVES leaves the offer intact, so the \ + negative arm below is the withdrawal and not the plant" + ); + + let mut dangling = plant_offer_pin( + &mut base.clone(), + TargetPin::ByIdentity(YieldTarget::ThisObject { + source_id: ObjectId(999_999), + incarnation: None, + trigger_description: None, + }), + ); + assert!( + !dangling.objects.contains_key(&ObjectId(999_999)), + "setup: the planted identity must genuinely be absent from the board" + ); + assert!( + try_offer_object_growth_shortcut(&dangling).is_none(), + "CR 732.2a: a pin that cannot resolve withdraws the offer" ); + // The withdrawal is the whole point: nothing was published to be declared. + assert!(matches!(dangling.waiting_for, WaitingFor::Priority { .. })); + dangling.last_loop_action_sequence.clear(); } - /// Reset a driven state (which settles at `LoopShortcut`) back to the empty-stack priority - /// window the offer re-derives from, so `try_offer_object_growth_shortcut` can be probed - /// directly (the plan's sanctioned private-fn revert-probe form). The board is the valid - /// post-cycle loop state (Kilo untapped, mana-neutral). - fn at_priority_window(mut state: GameState) -> GameState { - state.waiting_for = WaitingFor::Priority { player: P0 }; + /// R4d — the same withdrawal, on the OTHER end of the invariant: a `TargetPin::Player` + /// aimed at a seat that has left the game (CR 800.4 + CR 102.1) no longer resolves, so + /// the offer is withdrawn rather than ratifying its own pin. + /// + /// This is the offer-builder half of the pair whose MINT half is + /// `effects::proliferate`'s R4a row: the two ends of the invariant that a Player pin + /// must never reach materialization validated only against a legal set derived from the + /// pins themselves. `pinned_decisions_to_points` derives its legal sets FROM the pins, + /// so on this route the seat's existence check inside `resolve_target` is the only + /// authority there is. + /// + /// MATCHED PAIR, one variable (`is_eliminated`): live seat ⇒ offer fires; departed seat + /// ⇒ offer withdrawn. REVERT-PROBE: drop the existence conjunct in + /// `players::player_exists_for_choice` ⇒ the departed seat resolves ⇒ the offer is + /// published ⇒ FAILS. + #[test] + fn a_departed_player_pin_withdraws_the_offer() { + let mut driven = load_migrated_dump(); + drive_one_live_cycle(&mut driven); + let base = at_priority_window(driven); + let victim = PlayerId(1); + + let live = plant_offer_pin(&mut base.clone(), TargetPin::Player(victim)); assert!( - state.stack.is_empty(), - "the driven cycle settled to an empty stack" + !live.players[1].is_eliminated, + "reach-guard: the seat starts in the game" + ); + assert!( + try_offer_object_growth_shortcut(&live).is_some(), + "reach-guard: a Player pin on a LIVE seat leaves the offer intact" + ); + + let mut departed = live.clone(); + departed.players[1].is_eliminated = true; + assert!( + try_offer_object_growth_shortcut(&departed).is_none(), + "a Player pin aimed at a departed seat withdraws the offer (CR 732.2a)" + ); + } + + /// R2b — the CR 115.10a boundary, enforced at the OFFER level rather than explained in a + /// comment. A shrouded seat (CR 702.18a) is un-TARGETable, and this row proves the + /// offer-builder still publishes it as a CHOICE: the point carries it in + /// `legal_targets`, and the offer FIRES. + /// + /// This is the enforcement half of the pair whose explanation half is the site-4 comment + /// and whose seam-level half is + /// `analysis::decision_template::tests::a_shrouded_seat_is_untargetable_yet_still_ + /// choosable_at_the_pin_recheck`. Without it, re-introducing target-scoped conjuncts at + /// site 4 would silently restore the over-veto at the one seam that publishes an offer. + /// + /// REVERT-PROBE: route `resolve_target`'s `TargetPin::Player` arm through + /// `targeting::player_is_legal_target` ⇒ the shrouded seat stops resolving ⇒ the offer is + /// WITHDRAWN ⇒ both assertions FAIL. The paired positive that keeps this from passing on + /// an un-shrouded board is the shroud reach-guard asserted first. + #[test] + fn a_shrouded_player_pin_is_still_published_by_the_offer_builder() { + use crate::types::statics::StaticMode; + + let mut driven = load_migrated_dump(); + drive_one_live_cycle(&mut driven); + let mut base = at_priority_window(driven); + let victim = PlayerId(1); + + // P1 gains shroud, through the single TCE construction authority — the same route + // a resolved "target player gains shroud until end of turn" effect takes. + base.add_transient_continuous_effect( + KILO, + P0, + crate::types::ability::Duration::UntilEndOfTurn, + crate::types::ability::TargetFilter::SpecificPlayer { id: victim }, + vec![ + crate::types::ability::ContinuousModification::AddStaticMode { + mode: StaticMode::Shroud, + }, + ], + None, + ); + crate::game::layers::flush_layers(&mut base); + + // Reach-guard, and the paired positive: the shroud actually bites at the TARGET + // seam. Without this the row would pass on a board with no shroud at all. + assert!( + crate::game::static_abilities::player_cannot_be_targeted_by(&base, victim, KILO, P0), + "CR 702.18a: the planted shroud must make the seat un-targetable" + ); + + let planted = plant_offer_pin(&mut base, TargetPin::Player(victim)); + let (_, schema) = try_offer_object_growth_shortcut(&planted) + .expect("CR 115.10a: a shrouded seat is still CHOOSABLE, so the offer fires"); + assert!( + schema.points.iter().any(|point| matches!( + &point.kind, + crate::analysis::decision_template::DecisionPointKind::Targets { legal_targets, .. } + if legal_targets.contains(&TargetRef::Player(victim)) + )), + "the published point carries the shrouded seat: exclusion belongs to the TARGET \ + seam, not to this one" ); + } +} + +/// FIX ROUND 1 (MED-2) — a named negative row per [`try_offer_bounded_cycle_shortcut`] conjunct +/// that no tracked test was exercising. +/// +/// 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. +/// 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 +/// re-dressed, because it shipped with no runner and no filter recorded beside it and a bare +/// count means nothing without both (fix round 2, LOW-2: a count in this file named a shape it +/// did not have). It is also not reproducible on this tree BY DESIGN — the rows below now exist, +/// so deleting either conjunct today flips its named row, which is the entire point. Each row's +/// own REVERT-PROBE line is the reproducible claim; run it with +/// `cargo test -p phase-engine --lib -- game::engine::bounded_offer_conjunct_tests::` (module filter on +/// the `engine` lib target). +/// +/// # Why these are UNIT rows on a synthetic ring +/// +/// Each row must reach ONE conjunct and refuse there, which means holding every earlier conjunct +/// satisfied on purpose. A ring is the input the certification step reads, and +/// `GameState::normalize_for_loop` is `pub(crate)`, so an integration test cannot build one. The +/// refusal is asserted BY REASON (`BoundedOfferRefusal`), never as a bare "no offer": a row that +/// only observes absence silently stops testing its own conjunct the moment an EARLIER one +/// starts refusing first, which is the domination trap the enum exists to close. +#[cfg(test)] +mod bounded_offer_conjunct_tests { + use super::{try_offer_bounded_cycle_shortcut, BoundedOfferRefusal}; + use crate::game::scenario::GameScenario; + use crate::types::game_state::{GameState, LoopDetectionMode, WaitingFor}; + use crate::types::player::PlayerId; + + const P0: PlayerId = PlayerId(0); + const P1: PlayerId = PlayerId(1); + + /// A 2-player board parked at `Priority{P0}` (P0 active) whose retained ring encodes a + /// period seen twice: `frames` successive normalized snapshots, each mutated by `shape`. + /// + /// `2k + 1 = 3` frames at `k = 1` is the smallest ring `ring_delta_signature` will certify, + /// and every frame shares `turn_number` / `phase` / `extra_phases`, so the CR 703.1 + /// turn-position conjunct passes and this fixture is not silently testing that instead. + fn ring_state(frames: usize, shape: impl Fn(&mut GameState, usize)) -> GameState { + let mut scenario = GameScenario::new_n_player(2, 7); + // A stocked library is load-bearing, not scenery: the period this fixture encodes IS a + // library delta, and an empty library makes every frame identical ⇒ a zero per-period + // vector ⇒ `ring_delta_signature` returns `None` and every row below refuses at + // `NoCertification` instead of at the conjunct it is about. + let names: Vec = (0..40).map(|i| format!("Filler {i}")).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + scenario.with_library_top(P0, &refs); + scenario.with_library_top(P1, &refs); + let mut runner = scenario.build(); + let mut state = runner.state_mut().clone(); + state.loop_detection = LoopDetectionMode::Interactive; + state.waiting_for = WaitingFor::Priority { player: P0 }; + state.active_player = P0; + state.last_loop_action_sequence.clear(); + for i in 0..frames { + let mut frame = state.clone(); + shape(&mut frame, i); + // Both halves built exactly as `record_loop_detect_sample` builds them. + state + .loop_detect_ring + .push_back(std::sync::Arc::new(crate::types::LoopDetectSample { + normalized: frame.normalize_for_loop(), + live: frame.loop_detect_live_sample(), + })); + } state } - /// Hostile fixture — two-legendary identity binding (memory: verify-the-seam-not-the-line). - /// The tap-cost pin stores the EXACT tapped `ObjectId` (`TargetPin::ByIdentity`), so with two - /// legal untapped legendary creatures on the board the detection re-drive must re-bind to the - /// RECORDED Kilo (402), NOT the decoy. Positive: record tapping Kilo ⇒ offer. Revert-probe - /// (FLIP, run in-test): repoint ONLY the tap-cost pin's identity to the decoy (an equally-legal - /// legendary) on the SAME board + recording ⇒ the re-drive taps the decoy, whose becomes-tapped - /// proliferate trigger (source = decoy) has NO matching pin (the proliferate pin is keyed to - /// Kilo 402) ⇒ `RecastAbort` ⇒ NO offer. If replay ignored the pin identity (re-bound to "any - /// legal legendary" or always Kilo) this mutation would NOT change the outcome — so the flip - /// proves the recorded identity is load-bearing. + /// R20 — RELIEF-PATH BUDGET STARVATION IS COVERAGE, NEVER SOUNDNESS. + /// + /// CR 732.2a. The named regression row for the branch NOT taken (hoist only the primary + /// and let the budget absorb the relief path). A board whose non-exempt stack carries more + /// in-scope chain LINKS than the cap can pay for must REFUSE — no certificate consumed, no + /// offer, no `WaitingFor::LoopShortcut` write — and the refusal must be attributable to + /// the budget rather than to any upstream conjunct. + /// + /// THE BOUND IS PER-LINK, NOT PER-ENTRY, which is why the last entry is CHAINED: the + /// classifier recurses into `sub_ability`, so an N-link chain charges up to N. A per-entry + /// regression passes a per-entry row and fails this one. + /// + /// THREE CONJUNCTS, because `spent == cap` alone proves consumption, not BINDINGNESS: + /// (i) the SAME board offers once the budget stops binding (`ProbeCap::RaisedTwiceLinks`, + /// board-derived so no arbitrary raise is representable) — a matched positive on the + /// constructed board, not on a proxy; (ii) at the shipped cap the refusal is + /// `UnspecifiedChoiceWindow` with `denied == true` at `spent == PROBE_BUDGET`; (iii) a + /// PRE-CHARGE refusal on the same board reads a clean meter, which is the control that + /// keeps `denied` meaningful. + /// + /// (ii)'s VARIANT is reachable only because this board's basis-A match comes from the + /// EQUALITY disjunct: the `||` short-circuits, the charging cover call never runs, and + /// exhaustion lands one gate later in `stack_choices_are_all_specified`. That precondition + /// is carried as an EXECUTABLE reach-guard, not as prose, so a fixture drift to the cover + /// path fails loudly instead of silently flipping the variant. + /// + /// REVERT-PROBE: delete `probe_resolution`'s `try_charge_one` arm ⇒ the exhausted budget + /// falls through to the clone-and-resolve ⇒ the over-budget board OFFERS ⇒ FLIPS. #[test] - fn tap_pin_rebinds_to_recorded_legendary_not_a_decoy() { - let mut state = load_migrated_dump(); + fn r20_an_over_budget_relief_path_refuses_instead_of_certifying() { + use crate::analysis::resource::{loop_states_equal_modulo_resources, PROBE_BUDGET}; + use crate::game::engine::{try_offer_bounded_cycle_shortcut_metered, ProbeCap}; - // Add a SECOND untapped legendary creature P0 controls (a Kilo clone with a fresh id) so - // the Relic tap cost has two legal choices the identity binding must disambiguate. - let decoy_id = ObjectId(state.next_object_id); - state.next_object_id += 1; - let mut decoy = state.objects[&KILO].clone(); - decoy.id = decoy_id; - // Distinct name: CR 704.5j (the legend rule) would otherwise force a ChooseLegend SBA - // between two same-named legends — we want two co-existing legal legendary tap targets. - decoy.name = "Decoy Legend".to_string(); - decoy.base_name = "Decoy Legend".to_string(); - decoy.attachments = Vec::new(); // the clone is NOT the Freed-enchanted creature - decoy.tapped = false; - state.objects.insert(decoy_id, decoy); - state.battlefield.push_back(decoy_id); + // One more entry than the cap can pay for, with the last one CHAINED so the population + // is links rather than entries. + let entries = PROBE_BUDGET as usize + 1; + let state = equality_ring_with_stack(entries, true); - drive_one_live_cycle(&mut state); + // ── reach-guards, all three before any verdict ────────────────────────────────── + assert!( + state.loop_detect_ring.len() >= 2, + "REACH-GUARD: a ring-starved board refuses at the ring gate with a clean meter and \ + this row would pass for the wrong reason" + ); assert_eq!( - state.last_loop_action_sequence.len(), - 2, - "reach-guard: the live cycle recorded the clean 2-step pinned period" + state.stack.len(), + entries, + "REACH-GUARD: the non-exempt population must exceed the cap ({PROBE_BUDGET})" + ); + let prior = &state.loop_detect_ring[state.loop_detect_ring.len() - 2].normalized; + assert!( + loop_states_equal_modulo_resources(prior, &state), + "REACH-GUARD (the (ii) precondition, executable rather than prose): this board must \ + match basis A through the EQUALITY disjunct. On a cover-matched board the charging \ + cover call exhausts FIRST and the refusal is `NoCertification` with the same meter" ); - // Positive: the recorded ByIdentity(Kilo 402) tap pin re-binds to Kilo on replay ⇒ offer. - let intact = at_priority_window(state.clone()); + // ── (i) THE SAME BOARD OFFERS once the budget stops binding ───────────────────── + let (raised, raised_meter) = + try_offer_bounded_cycle_shortcut_metered(&state, false, ProbeCap::RaisedTwiceLinks); assert!( - try_offer_object_growth_shortcut(&intact).is_some(), - "two legal legendaries present + recorded Kilo ⇒ the offer fires" + raised.is_ok(), + "(i) with the cap raised to 2x the board's own link count the SAME board must \ + certify and offer — this is what pins the budget as the binding refusal below, \ + rather than some upstream conjunct. Got {raised:?}, meter {raised_meter:?}" + ); + // Keyed to the POPULATION counter, not to `spent`, and deliberately: `spent` measures + // CHARGING, so a revert-probe that deletes the charge arm would abort this conjunct + // first and mask the flip belonging to (ii). `conjunct6_asks` measures what the gate + // actually examined, which is the evidence (i) is here to give. + assert!( + !raised_meter.denied && raised_meter.conjunct6_asks >= entries as u32, + "(i) the raised arm must have examined the whole non-exempt population without \ + denial; meter {raised_meter:?}" ); - // Revert-probe (FLIP): repoint ONLY the tap-cost pin (its slot source resolves to Relic - // 404) to the decoy. Board, recording, and the proliferate pin (keyed to Kilo 402) are all - // unchanged. - let mut repointed = intact.clone(); - let mut mutated = false; - for step in repointed.last_loop_action_sequence.iter_mut() { - for pin in step.pins.iter_mut() { - if let PinnedDecision::Targets { slot, targets } = pin { - if matches!(&slot.source, YieldTarget::ThisObject { source_id, .. } if *source_id == RELIC) - { - *targets = vec![TargetPin::ByIdentity(YieldTarget::ThisObject { - source_id: decoy_id, - incarnation: None, - trigger_description: None, - })]; - mutated = true; - } - } - } - } + // ── (ii) AT THE SHIPPED CAP: refuse, exhausted, and no offer ──────────────────── + let (refused, meter) = + try_offer_bounded_cycle_shortcut_metered(&state, false, ProbeCap::Shipped); assert!( - mutated, - "reach-guard: the tap-cost pin (slot source Relic) was found + repointed" + matches!(refused, Err(BoundedOfferRefusal::UnspecifiedChoiceWindow)), + "(ii) exhaustion reads `Prompted`, so the step-6 predicate goes false and the \ + refusal is an unspecified window. Got {refused:?}, meter {meter:?}" ); assert!( - try_offer_object_growth_shortcut(&repointed).is_none(), - "repointing the tap pin to the decoy FLIPS the offer OFF ⇒ recorded identity is load-bearing" + meter.denied && meter.spent == PROBE_BUDGET, + "(ii) the cap must be CONSUMED and the denial latched — that pair is the \ + exhaustion witness the refusal variant alone cannot carry. meter {meter:?}" + ); + + // ── (iii) THE CLEAN-METER CONTROL, keyed to a PRE-CHARGE refusal on the SAME board ─ + let mut not_at_priority = state.clone(); + not_at_priority.waiting_for = WaitingFor::DiscardToHandSize { + player: P0, + count: 1, + cards: Vec::new(), + }; + let (control, control_meter) = + try_offer_bounded_cycle_shortcut_metered(¬_at_priority, false, ProbeCap::Shipped); + assert!( + matches!(control, Err(BoundedOfferRefusal::NotAtPriority)), + "(iii) the control arm must refuse UPSTREAM of the first charge; got {control:?}" + ); + assert!( + !control_meter.denied && control_meter.spent == 0, + "(iii) a pre-charge refusal reads a clean meter BY CONTROL-FLOW POSITION — without \ + this control, `denied` would look like a property of refusals in general \ + rather than of exhaustion. meter {control_meter:?}" ); } - /// Hostile fixture — wrong-color drive. The `ManaColor` pin latches the color the player - /// produced (Blue, to pay Freed's `{U}`, CR 608.2d). Positive: Blue ⇒ mana-neutral cycle ⇒ - /// offer. Revert-probe (FLIP, run in-test): relatch the color to Red on the SAME recording ⇒ - /// the re-drive produces Red, Freed's `{U}` untap is unpayable ⇒ the second step aborts ⇒ NO - /// offer. The latched color value is load-bearing. + /// R33 arm (a′2) — THE SELECTION SITE: AN EQUALITY-CERTIFIED CANDIDATE CARRIES NO FROZEN + /// EXEMPTION, EVEN THOUGH ITS WINDOW HAS ONE AVAILABLE. + /// + /// CR 732.2a + CR 608.1. Arms (a)/(b)/(a′1) prove the CONSTRUCTOR keys the subtraction to + /// the certificate value. This arm proves §3 D2's step 4b actually SELECTS the right + /// value: it is the only arm that fails on the round-39 shape (one `BoardCovered` + /// certificate for the whole `equality || cover` disjunction), which every other arm in + /// the row passes unchanged. + /// + /// The fixture is CONSTRUCTED, not fixture-mined: `drain_ring`'s frames are board-equal at + /// every index, so `loop_states_equal_modulo_resources` matches and — by the mutual + /// exclusivity of the two disjuncts (equality = constant depth, cover = strictly growing + /// depth) — `stack_covers` cannot. One stack entry is seeded IDENTICALLY into `current` + /// and into both halves of every retained frame, which is what makes the window carry a + /// non-empty observed-frozen set: the exemption is genuinely AVAILABLE here, and the row + /// is about it being genuinely WITHDRAWN. + /// + /// REVERT-PROBE 3 (the round's signature probe): delete step 4b and let conjunct (6) + /// consume step 3's `touch_cover` directly ⇒ this row FLIPS while (a)/(b)/(a′1)/(c) all + /// still pass — which is exactly why the arm exists. #[test] - fn mana_color_pin_replays_recorded_color() { - let mut state = load_migrated_dump(); - drive_one_live_cycle(&mut state); - let state = at_priority_window(state); + fn r33_equality_certified_candidate_carries_no_frozen_exemption() { + use crate::analysis::resource::{certified_period_touch, PeriodCertification}; + use crate::game::engine::{try_offer_bounded_cycle_shortcut_metered, ProbeCap}; - // Positive: the latched Blue color pays Freed's {U} ⇒ offer. + let state = equality_ring_with_stack(1, false); + + // REACH-GUARD 1: the exemption is genuinely AVAILABLE on this candidate's window — + // otherwise "withdrawn" is indistinguishable from "there was nothing to withdraw". + let live: Vec<&GameState> = state.loop_detect_ring.iter().map(|f| &f.live).collect(); + let window = &live[live.len() - 2..]; + let available = certified_period_touch(window, &state, PeriodCertification::BoardCovered); assert!( - try_offer_object_growth_shortcut(&state).is_some(), - "the recorded Blue mana-color pin completes the mana-neutral cycle ⇒ offer" + !available.frozen_ids.is_empty(), + "REACH-GUARD: the constructed window must carry a non-empty observed-frozen set" ); - // Revert-probe (FLIP): relatch the color to Red. - let mut wrong = state.clone(); - let mut mutated = false; - for step in wrong.last_loop_action_sequence.iter_mut() { - for pin in step.pins.iter_mut() { - if let PinnedDecision::ManaColor { color, .. } = pin { - *color = ManaColor::Red; - mutated = true; + let (outcome, meter) = + try_offer_bounded_cycle_shortcut_metered(&state, false, ProbeCap::Shipped); + + // REACH-GUARD 2: the EQUALITY arm is the one that matched. Without this the row could + // pass on a candidate that never certified, or one that took the cover disjunct. + assert_eq!( + meter.certification, + Some(PeriodCertification::BoardEqualOnly), + "REACH-GUARD: the constructed pair must certify through the EQUALITY disjunct, \ + which is the case this arm is about; outcome {outcome:?}" + ); + + // THE RULING: equality supplies P2 but not P4, so the period carried forward exempts + // NOTHING — observable as conjunct (6) skipping nothing while it really does run. + assert_eq!( + meter.conjunct6_frozen_skips, 0, + "(a′2) step 4b must REBUILD the equality candidate's period with the subtraction \ + withdrawn; a non-zero skip count means the `BoardCovered` touch from step 3 was \ + carried into conjunct (6). meter {meter:?}" + ); + assert!( + meter.conjunct6_asks > 0, + "REACH-GUARD against a vacuous skip count: conjunct (6) must actually have RUN, \ + else `skips == 0` is trivially true. meter {meter:?}" + ); + } + + /// Mill `victim` by one card per retained frame — a constant per-frame library delta, which + /// is a period observed twice at `frames >= 3`. + fn mill_ring(victim: PlayerId, frames: usize) -> GameState { + ring_state(frames, move |frame, i| { + let player = frame + .players + .iter_mut() + .find(|p| p.id == victim) + .expect("seat exists"); + for _ in 0..i { + player.library.pop_back(); + } + }) + } + + /// Drain `victim` by one life per retained frame — a constant per-frame LIFE delta. + /// + /// The counterpart to [`mill_ring`], and the difference is exactly the one basis A turns on: + /// library size is BOARD (`loop_states_equal_modulo_resources` compares it), while life is a + /// PROJECTED resource (`project_out_resources` removes it). So a mill ring can only certify + /// through basis B's `ring_delta_signature`, whereas a drain ring's frames are board-EQUAL + /// at every index and certify through basis A's first disjunct. + /// + /// ⚠ The REASON basis A refuses a mill ring is not uniform across the ring, and an earlier + /// revision of this doc claimed it was ("a mill ring's frames are board-UNEQUAL"). MEASURED + /// in fix round 3 (LOW-5) and recorded at + /// [`a_zero_span_certifying_pair_never_publishes_a_zero_width_period`]: `mill_ring`'s frames + /// are board-unequal at every index EXCEPT the OLDEST, which pops zero cards and IS + /// board-equal (`span = 2`, `eq = true`) — that one is refused by `net_progress_for` on its + /// zero δ instead. Basis A still certifies nothing on a mill ring; only the per-index reason + /// differs. + /// + /// Frame `i` sits `frames - i` life ABOVE the live state, so the newest frame is exactly one + /// period ahead of it and every older frame one more — i.e. the live state is the far end of + /// the period, which is the orientation `ResourceVector::delta(prior, current)` reads. + fn drain_ring(victim: PlayerId, frames: usize) -> GameState { + ring_state(frames, move |frame, i| { + let player = frame + .players + .iter_mut() + .find(|p| p.id == victim) + .expect("seat exists"); + player.life += (frames - i) as i32; + }) + } + + /// A [`drain_ring`] whose stack carries `links` in-scope chain links, seeded IDENTICALLY + /// into `current` and into both halves of every retained frame. + /// + /// Two properties come out of that one construction, which is why both rows share it: + /// board equality survives (so basis A matches on its FIRST disjunct), and every entry + /// sits at the SAME INDEX in every window frame (so the window carries a non-empty + /// observed-frozen set — the thing the certificate is allowed to subtract, or not). + /// + /// Each entry is a MANDATORY, choice-free `LoseLife`, so conjunct (6) can accept it and a + /// refusal is never attributable to an unspecified choice. `chained` gives the LAST entry + /// a `sub_ability`, which the classifier recurses into: that is what makes the budget a + /// per-LINK bound rather than a per-ENTRY one, and what a per-entry regression would miss. + fn equality_ring_with_stack(entries: usize, chained: bool) -> GameState { + use crate::types::ability::{Effect, QuantityExpr, ResolvedAbility}; + use crate::types::game_state::{StackEntry, StackEntryKind}; + use crate::types::identifiers::{CardId, ObjectId}; + use crate::types::LoopDetectSample; + use std::sync::Arc; + + let mut state = drain_ring(P1, 3); + let src = ObjectId(941); + let mut source = crate::game::game_object::GameObject::new( + src, + CardId(0), + P0, + "Frozen Ticker".to_string(), + crate::types::zones::Zone::Battlefield, + ); + source.incarnation = 3; + let lose_one = || { + ResolvedAbility::new( + Effect::LoseLife { + amount: QuantityExpr::Fixed { value: 1 }, + target: None, + }, + vec![], + src, + P0, + ) + }; + let built: Vec = (0..entries) + .map(|i| { + let mut ability = lose_one(); + if chained && i + 1 == entries { + ability.sub_ability = Some(Box::new(lose_one())); + } + StackEntry { + id: ObjectId(951 + i as u64), + source_id: src, + controller: P0, + kind: StackEntryKind::TriggeredAbility { + source_id: src, + ability: Box::new(ability), + condition: None, + trigger_event: None, + description: None, + source_name: String::new(), + subject_match_count: None, + die_result: None, + }, } + }) + .collect(); + let inject = |g: &mut GameState| { + g.objects.insert(src, source.clone()); + g.battlefield.push_back(src); + for e in &built { + g.stack.push_back(e.clone()); } - } - assert!( - mutated, - "reach-guard: the ManaColor pin was found + relatched" + }; + inject(&mut state); + let frames: std::collections::VecDeque> = state + .loop_detect_ring + .iter() + .map(|s| { + let mut normalized = s.normalized.clone(); + let mut live = s.live.clone(); + inject(&mut normalized); + inject(&mut live); + Arc::new(LoopDetectSample { normalized, live }) + }) + .collect(); + state.loop_detect_ring = frames; + state + } + + /// STEP (2) `ProposerIsNotActivePlayer`. CR 732.2a lets the player with priority propose; + /// this conjunct additionally requires that player to be the ACTIVE one, because the ring + /// sampler only samples at `Priority{active_player}` — which is what establishes the + /// proposer held priority at every certified frame. + /// + /// REVERT-PROBE: delete the conjunct ⇒ arm ⓑ stops returning `ProposerIsNotActivePlayer` + /// and falls through to a later conjunct (or an offer) ⇒ FAILS. + #[test] + fn a_non_active_priority_holder_mints_no_bounded_offer() { + let mut state = mill_ring(P1, 3); + + // ⓐ REACH-GUARD / positive control: the SAME ring certifies for the active player, so + // ⓑ's refusal is attributable to the seat and not to an unsatisfied earlier conjunct. + let armed = try_offer_bounded_cycle_shortcut(&state, false); + assert_ne!( + armed, + Err(BoundedOfferRefusal::ProposerIsNotActivePlayer), + "the active player must NOT be refused by step (2); got {armed:?}" ); - assert!( - try_offer_object_growth_shortcut(&wrong).is_none(), - "a Red mana-color pin cannot pay Freed's {{U}} ⇒ the drive aborts ⇒ NO offer" + assert_ne!( + armed, + Err(BoundedOfferRefusal::NoCertification), + "REACH-GUARD: the ring must actually certify, else ⓑ never reaches step (2); \ + got {armed:?}" + ); + + // ⓑ one field reassigned: priority moves to the non-active seat. + state.waiting_for = WaitingFor::Priority { player: P1 }; + assert_eq!( + try_offer_bounded_cycle_shortcut(&state, false), + Err(BoundedOfferRefusal::ProposerIsNotActivePlayer), + "CR 732.2a: the ring sampler gates on `Priority{{active_player}}`, so a proposer \ + who is not the active player did not hold priority at the certified frames" ); } - /// Synthetic positive/negative drive-replay reach-guard (plan §7 unit c). The SAME recorded - /// 2-step period is driven WITH pins (offer) and WITHOUT (abort). The `len()==2` anchor holds - /// in BOTH variants, so the negative's None is a drive-abort at the unpinned - /// `PayCost{TapCreatures}`, NOT a vacuous "no sequence to drive" upstream short-circuit - /// (memory: discriminator-vacuous-if-upstream-conjunct-dominates). + /// STEP (5) `AdvantageOnlyCycle`. CR 732.2a: this producer's whole claim is that it measured + /// a CR 704 threshold INSIDE the loop and divided the headroom by the per-period magnitude. + /// An `Advantage` cycle drives nobody toward such a threshold, so it has no bound to state + /// and belongs to Path C's revocable-infinity mark instead. + /// + /// The pair is a SELF-mill against an OPPONENT-mill, which is exactly the discrimination + /// `classify_win_kind` makes: `Decking` requires "an unbounded downward library delta on a + /// player other than the loop's controller", so a controller milling themselves falls + /// through to `Advantage`. Without this conjunct that self-mill takes Path D and is offered + /// a bound — `elimination_bounds` narrows on `narrow(p.library.len(), -library_delta[p])` + /// for the PROPOSER too, so it happily produces one. + /// + /// REVERT-PROBE: delete the conjunct ⇒ arm ⓑ stops returning `AdvantageOnlyCycle` ⇒ FAILS. #[test] - fn drive_replay_requires_the_recorded_pins() { - let mut state = load_migrated_dump(); - drive_one_live_cycle(&mut state); - let state = at_priority_window(state); + fn a_self_mill_advantage_cycle_mints_no_bounded_offer() { + // ⓐ POSITIVE CONTROL: the SAME shape aimed at the OPPONENT is `Decking`, not + // `Advantage`, so step (5) must let it through. Without this arm ⓑ would pass for a + // fixture that simply never certifies. + let opponent_mill = try_offer_bounded_cycle_shortcut(&mill_ring(P1, 3), false); + assert_ne!( + opponent_mill, + Err(BoundedOfferRefusal::AdvantageOnlyCycle), + "CR 104.3c: milling an OPPONENT is a win kind, not an advantage engine; got \ + {opponent_mill:?}" + ); + assert_ne!( + opponent_mill, + Err(BoundedOfferRefusal::NoCertification), + "REACH-GUARD: the mill ring must certify, else neither arm reaches step (5); got \ + {opponent_mill:?}" + ); - // Anchor (holds in BOTH variants): the recorded 2-step period is present. + // ⓑ the same period, victim = the proposer. assert_eq!( - state.last_loop_action_sequence.len(), - 2, - "reach-guard anchor: the recorded period exists ⇒ any None is a drive-abort, not a missing seq" + try_offer_bounded_cycle_shortcut(&mill_ring(P0, 3), false), + Err(BoundedOfferRefusal::AdvantageOnlyCycle), + "CR 732.2a: a cycle that drives nobody toward a CR 704 threshold has no bound to \ + state, so it belongs to Path C's revocable-infinity mark, not to this seam" ); + } - // Positive: the recorded pins drive the replay to completion ⇒ offer. + /// STEP (7) `NoNarrowedLegalCount`, LOWER end. `elimination_bounds` returning 0 states that + /// no repetition is legal at all — a seat is already AT the CR 704 threshold's last legal + /// step — and `1..MAX_SHORTCUT_CYCLES` refuses it rather than minting a `Fixed(0)` offer + /// whose acceptance would commit nothing while spending the CR 732.2b window. + /// + /// ⚠ SCOPE, stated because the reviewer's probe targeted the OTHER end. Widening the check + /// to `1..=MAX_SHORTCUT_CYCLES` flips nothing in the tracked suite, and that is not an + /// oversight: the upper end is DOMINATED by step (5). A bound of exactly + /// `MAX_SHORTCUT_CYCLES` means no axis narrowed, i.e. the period drives no living seat + /// toward any CR 704 threshold, which is precisely what `classify_win_kind` reports as + /// `Advantage` — so such a cycle has already been refused two conjuncts earlier. This row + /// therefore covers the reachable end and names the reason the other is unreachable rather + /// than leaving it as an untested branch of unknown status. + /// + /// REVERT-PROBE: change the range to `0..MAX_SHORTCUT_CYCLES` ⇒ arm ⓑ mints an offer ⇒ FAILS. + #[test] + fn a_bound_of_zero_mints_no_bounded_offer() { + // ⓐ POSITIVE CONTROL: a full library certifies and narrows to a legal count. + let healthy = try_offer_bounded_cycle_shortcut(&mill_ring(P1, 3), false); assert!( - try_offer_object_growth_shortcut(&state).is_some(), - "with the recorded pins the replay completes ⇒ offer" + healthy.is_ok(), + "REACH-GUARD: the un-narrowed fixture must OFFER, else ⓑ's refusal could come from \ + any earlier conjunct; got {healthy:?}" ); - // Negative: strip the pins from the SAME period ⇒ the replay hits the unpinned tap cost ⇒ - // abort ⇒ NO offer. The anchor proves the None is the drive-abort, not an empty sequence. - let mut unpinned = state.clone(); - for step in unpinned.last_loop_action_sequence.iter_mut() { - step.pins.clear(); - } + // ⓑ the same ring, with the victim's library already empty at the offer beat: CR 104.3c + // headroom 0 ⇒ `0 / 1 == 0` ⇒ no legal repetition count. + let mut state = mill_ring(P1, 3); + state + .players + .iter_mut() + .find(|p| p.id == P1) + .expect("seat exists") + .library + .clear(); assert_eq!( - unpinned.last_loop_action_sequence.len(), - 2, - "reach-guard anchor: the period is still present in the negative variant" + try_offer_bounded_cycle_shortcut(&state, false), + Err(BoundedOfferRefusal::NoNarrowedLegalCount), + "CR 104.3c: with zero cards left there is no legal repetition, and a `Fixed(0)` \ + offer would spend the CR 732.2b response window to commit nothing" + ); + } + + /// FIX ROUND 2 — basis A's `span >= 1` fail-closed guard, which shipped in fix round 1 with + /// no row of its own. + /// + /// The basis-A walk is `.rev()`, so the FIRST candidate it tries is `ring.last()` — the + /// sample `pass_priority_once_with_pipeline` recorded at this very beat, whose span from the + /// current state is 0. In a production trajectory that pair carries a zero δ and dies on + /// `net_progress_for`, but nothing structural forces that: this fixture's newest retained + /// frame is board-equal to the live state and its δ IS net progress (the reach-guards below + /// assert exactly that, so the guard is provably reached rather than assumed to be). + /// + /// The fixture is a [`drain_ring`], NOT a [`mill_ring`], and the difference is load-bearing: + /// library size is BOARD, so basis A certifies NOTHING on a mill ring, every mill-ring row in + /// this module is really exercising basis B, and a guard inside the basis-A walk is + /// unreachable from that fixture. + /// + /// ⚠ Basis A is a DISJUNCTION, and the board-equality reach-guard below — which does FAIL on + /// `mill_ring(P1, 3)` — measures only its FIRST half. Fix round 3 (LOW-5) MEASURED the second, + /// `loop_states_cover_modulo_growth_pinned`, rather than leaving the wider claim resting on + /// the narrower evidence: a probe evaluating both disjuncts plus `net_progress_for` at every + /// `(prior, live)` pair of `mill_ring(P1, 3)` (temporary `#[test]` in this module, run with + /// `cargo test -p phase-engine --lib -- game::engine::bounded_offer_conjunct_tests:: --nocapture`, + /// then reverted) reports `cover = false` at ALL THREE ring indices. Both halves refuse, so + /// the conclusion holds. + /// + /// The probe also corrects the REASON at one index, which the board-inequality phrasing had + /// wrong: `mill_ring`'s OLDEST frame pops zero cards, so it IS board-equal to the live state + /// (`span = 2`, `eq = true`) and is refused by `net_progress_for` on its zero δ, not by board + /// inequality. Newest-first, the `.rev()` walk therefore refuses `span = 0` at `span >= 1`, + /// `span = 1` on `eq`/`cover` both false, and `span = 2` on net progress. (`drain_ring(P1, 3)` + /// under the same probe: `eq = true` and `net = true` at every index, `cover = false` + /// throughout — so it certifies through the FIRST disjunct at `span = 1`, which is the pair + /// this row's guard is about.) + /// + /// A published `frames_per_period: 0` would mean "one repetition spans no retained frames", + /// which `drive_one_shortcut_cycle`'s delimiter cannot honour — `frames_this_cycle >= 0` + /// holds before a single beat is driven, so the first settle beat would complete a "cycle" + /// that moved nothing, and `materialize_fixed_shortcut`'s conformance check would then drop + /// every one of them. The bounded offer would be minted, accepted, and commit nothing. + /// + /// REVERT-PROBE: delete `span >= 1 &&` from the basis-A closure ⇒ the span-0 pair certifies + /// first and the published `frames_per_period` is 0 ⇒ this row FAILS. + #[test] + fn a_zero_span_certifying_pair_never_publishes_a_zero_width_period() { + use crate::analysis::resource::{loop_states_equal_modulo_resources, ResourceVector}; + + let state = drain_ring(P1, 3); + + // ── REACH-GUARDS: the span-0 pair really is a certifying candidate on this fixture, so + // the guard is what refuses it. Both halves of basis A's first disjunct, asserted on + // the exact pair the `.rev()` walk reaches first. + let newest = state + .loop_detect_ring + .back() + .expect("the fixture builds a ring") + .normalized + .clone(); + assert!( + loop_states_equal_modulo_resources(&newest, &state), + "REACH-GUARD: the newest retained frame must be board-equal to the live state, else \ + the span-0 pair fails the board predicate and the guard is never the refuser" + ); + let span_zero_delta = ResourceVector::delta( + &ResourceVector::snapshot(&newest), + &ResourceVector::snapshot(&state), ); assert!( - try_offer_object_growth_shortcut(&unpinned).is_none(), - "without the pins the drive aborts at the unpinned tap cost ⇒ NO offer" + span_zero_delta.net_progress_for(P0), + "REACH-GUARD: the span-0 pair must carry net progress, else `net_progress_for` \ + refuses it first and this row would pass without the guard existing; δ \ + {span_zero_delta:?}" + ); + + let offer = try_offer_bounded_cycle_shortcut(&state, false) + .expect("REACH-GUARD: the fixture must OFFER, else nothing publishes a period"); + let WaitingFor::LoopShortcut { certificate, .. } = &offer else { + panic!("a bounded offer is a LoopShortcut window; got {offer:?}") + }; + let per_cycle = certificate + .per_cycle + .as_ref() + .expect("a bounded offer publishes its per-period signature"); + assert!( + per_cycle.frames_per_period >= 1, + "CR 732.2a: a repetition spans at least one retained ring frame — a published 0 is a \ + delimiter no drive can honour; got {}", + per_cycle.frames_per_period ); } - /// [LOW-1] declined-axis ∞ lifecycle — characterization/regression guard (memory: - /// combo-interruptibility-acceptance-criterion). A declined `Counters`/`Life` axis leaves its - /// ∞ capability marker in `unbounded_resources` intentionally (CR 732.2b never forces a - /// shortcut). This test guards the MEASURED retirement path (a) documented at the boundary - /// seam: the empty-stack offer hook `try_offer_object_growth_shortcut` (engine.rs:472) is NOT - /// gated by existing ∞ marks, so a later genuine re-detection RE-OFFERS the loop and can - /// re-collapse the declined axis once the observer is gone. + // ─────────────────────────────────────────────────────────────────────────────────── + // The two CARRIER rows. Both are about WHICH object identifies a retained beat, an axis + // that has exactly one behavioural surface (`PeriodVerdicts::frame_ix`) and one source + // surface (this file), so each ships with one arm on each. + // ─────────────────────────────────────────────────────────────────────────────────── + + /// Symbol-anchored extent of a column-0 `fn` in THIS file, as `(head, end)` line indices — + /// the §6 R8 self-census extractor: signature line to the first column-0 `}`. + #[cfg(test)] + fn engine_fn_extent(lines: &[&str], signature: &str) -> (usize, usize) { + let head = lines + .iter() + .position(|l| l.starts_with(signature)) + .unwrap_or_else(|| panic!("extractor found no column-0 `{signature}`")); + let end = lines[head..] + .iter() + .position(|l| *l == "}") + .map(|i| head + i) + .unwrap_or_else(|| panic!("`{signature}` has no column-0 closing brace")); + assert!( + end - head > 5, + "`{signature}` extent {head}-{end} is degenerate — the extractor is not keyed" + ); + (head, end) + } + + /// Code lines (comments excluded, per R8's ruling: a comment reads nothing) of an extent + /// that contain `needle`, as absolute line indices. + #[cfg(test)] + fn engine_code_hits(lines: &[&str], extent: (usize, usize), needle: &str) -> Vec { + (extent.0..=extent.1) + .filter(|i| !lines[*i].trim_start().starts_with("//")) + .filter(|i| lines[*i].contains(needle)) + .collect() + } + + /// `Arc::as_ptr` BEAT IDENTITY IS THE SAMPLE, NOT ONE OF ITS HALVES — the U0 ruling that + /// had no falsifier until `FrameIx` existed. /// - /// DISCRIMINATING LEG (the re-offer assertion): with a pre-existing declined ∞ mark injected - /// for P0, the offer STILL fires. If a future regression ∞-gated the offer hook (e.g. to - /// suppress re-offering a declined axis), this flips to `None`. Positive control / reach-guard: - /// the SAME state WITHOUT the mark also offers (proving the mark is what the assertion isolates, - /// and the recorded 2-step period is intact — a `None` would be a drive-abort, not a missing - /// sequence). + /// CR 732.2a. U0 split each retained ring element into a CR 104.4b comparand + /// (`normalized`) and a CR 732.2a evaluable (`live`), and left `drive_one_shortcut_cycle`'s + /// ring-advance detector on `Arc::as_ptr` — the SAMPLE's allocation — with the ruling that + /// it must never be re-based onto a field address. U0 could not assert that: nothing at + /// that step distinguished the two halves as identities. U3's verdict door does. + /// + /// ARM 1, BEHAVIOURAL — the door's identity domain is the LIVE half and ONLY it. + /// [`crate::analysis::resource::PeriodVerdicts::frame_ix`] resolves by `std::ptr::eq` + /// against the very table `verdict` indexes, so a beat identified by the `normalized` + /// half is a DIFFERENT identity from the one every period-touch consumer keys on. The two + /// field addresses are asserted distinct first, which is what makes the choice load-bearing + /// rather than a distinction without a difference. + /// + /// ARM 2, STRUCTURAL — the detector still reads the sample. Two sites, both + /// `loop_detect_ring.back().map(std::sync::Arc::as_ptr)`, and NO raw-pointer field + /// address anywhere in the extent. + /// + /// REVERT-PROBE: re-base either site to `.map(|s| &s.normalized as *const _)` ⇒ arm 2's + /// site count goes 2 → 1 AND its `as *const` count goes 0 → 1 ⇒ FLIPS. (Arm 1 is + /// deliberately NOT reachable by that edit — it pins the property the edit would violate, + /// so the two arms partition "is the rule still true" from "is the code still obeying it".) #[test] - fn declined_infinity_mark_does_not_suppress_reoffer() { - use crate::analysis::resource::ResourceAxis; + fn arc_as_ptr_beat_identity_is_the_sample_not_one_of_its_halves() { + use crate::analysis::resource::PeriodVerdicts; - let mut driven = load_migrated_dump(); - drive_one_live_cycle(&mut driven); - let base = at_priority_window(driven); + // ── ARM 1: behavioural ─────────────────────────────────────────────────────────── + let state = ring_state(3, |frame, i| { + frame.turn_number += i as u32; + }); + assert_eq!( + state.loop_detect_ring.len(), + 3, + "REACH-GUARD: the ring must carry samples, else the universals below are vacuous" + ); + // Built exactly as `bounded_cycle_offer` builds its `ring_live` — the CR 732.2a + // evaluable half. The binding is named differently ON PURPOSE: a verbatim copy of the + // production line makes the carrier revert-probe (a whole-line replace of that line) + // match twice and silently no-op, which is how the probe for the sibling row below + // failed to apply on its first run. + let evaluable: Vec<&GameState> = state.loop_detect_ring.iter().map(|f| &f.live).collect(); + let verdicts = + PeriodVerdicts::for_period_with_cap(&evaluable, &state, P0, 0, super::CapAuthority(())); + for (i, sample) in state.loop_detect_ring.iter().enumerate() { + assert!( + !std::ptr::eq(&sample.live, &sample.normalized), + "sample {i}: the two halves must be DISTINCT addresses, else `beat identity \ + is the sample, not a half` is a distinction without a difference" + ); + assert!( + verdicts.frame_ix(&sample.live).is_some(), + "sample {i}: the verdict door's frame table IS the live half — this is the \ + identity every period-touch consumer keys on" + ); + assert!( + verdicts.frame_ix(&sample.normalized).is_none(), + "sample {i}: the comparand half is NOT in the door's domain. A beat identity \ + based on `&s.normalized` would therefore name an object no `FrameIx` can \ + ever resolve — that is the concrete harm the U0 ruling forbids" + ); + } - // Reach-guard anchor: the recorded period is present (a `None` below is a real gating - // decision, never an empty-sequence artifact). + // ── ARM 2: structural ──────────────────────────────────────────────────────────── + let src = include_str!("engine.rs"); + let lines: Vec<&str> = src.lines().collect(); + let extent = engine_fn_extent(&lines, "fn drive_one_shortcut_cycle("); + // Needles ASSEMBLED at runtime so this test's own source cannot be counted by its own + // instrument. + let sample_identity = format!("loop_detect_ring.back().map(std::sync::Arc::{}ptr)", "as_"); + let field_address = format!("as {}const", '*'); assert_eq!( - base.last_loop_action_sequence.len(), + engine_code_hits(&lines, extent, &sample_identity).len(), 2, - "reach-guard: the live cycle recorded the clean 2-step pinned period" + "the ring-advance detector must read the SAMPLE's allocation at both its before \ + and after sites, in {}-{}", + extent.0 + 1, + extent.1 + 1 ); - // Positive control: without any ∞ mark the intact loop re-derives the offer. + assert_eq!( + engine_code_hits(&lines, extent, &field_address).len(), + 0, + "no raw-pointer FIELD address may appear in the detector's extent — that is the \ + re-basing the U0 ruling forbids" + ); + // POSITIVE CONTROL against a dead grep, same extractor and same filter. assert!( - try_offer_object_growth_shortcut(&base).is_some(), - "positive control: the intact loop offers when no ∞ mark is present" + !engine_code_hits(&lines, extent, "frames_this_cycle").is_empty(), + "the instrument must be able to find a token that IS there" ); + assert!( + engine_code_hits(&lines, extent, "certified_period_touch").is_empty(), + "…and must not find one that is not" + ); + } - // Inject a pre-existing DECLINED ∞ axis for P0 (as if an earlier boundary declined the life - // axis and left it ∞-marked for manual play). The offer hook reads `waiting_for` + stack + - // `samples()` + `last_loop_action_sequence` — never `unbounded_resources` — so the mark - // must NOT suppress the re-offer. - let mut marked = base.clone(); - marked.mark_unbounded_loop(P0, &[ResourceAxis::Life(P0)]); + /// R27 (a2), STRUCTURAL HALF — THE PERIOD-TOUCH WINDOW IS CARRIED BY THE `live` HALF. + /// + /// CR 732.2a + CR 104.4b. The behavioural half + /// (`analysis::resource::tests::r27_a2_every_announced_pair_carries_an_unnormalized_evaluation_board`) + /// builds its own window, so it cannot flip on an edit to the MINT's carrier. This arm is + /// that edit's detector: `bounded_cycle_offer` builds exactly two ring vecs — the CR 104.4b + /// comparand from `&f.normalized` and the CR 732.2a evaluable from `&f.live` — and every + /// `certified_period_touch` window inside `certified_bounded_cycle_offer` is sliced from + /// the evaluable one. + /// + /// REVERT-PROBE: point `ring_live` at `&f.normalized` (rounds 13–33's carrier) ⇒ the + /// `&f.live` count goes 1 → 0 ⇒ FLIPS. + #[test] + fn the_period_touch_window_is_carried_by_the_live_half() { + let src = include_str!("engine.rs"); + let lines: Vec<&str> = src.lines().collect(); + let mint = engine_fn_extent(&lines, "fn bounded_cycle_offer("); + let live_needle = format!("&f.{}", "live"); + let norm_needle = format!("&f.{}", "normalized"); + + let live_hits = engine_code_hits(&lines, mint, &live_needle); + let norm_hits = engine_code_hits(&lines, mint, &norm_needle); + assert_eq!( + live_hits.len(), + 1, + "exactly ONE evaluable ring vec is built, in {}-{}", + mint.0 + 1, + mint.1 + 1 + ); + assert_eq!(norm_hits.len(), 1, "…and exactly one comparand ring vec"); assert!( - marked - .unbounded_resources - .get(&P0) - .is_some_and(|axes| axes.contains(&ResourceAxis::Life(P0))), - "reach-guard: the declined ∞ Life mark is present on the probed state" + lines[live_hits[0]].contains("ring_live"), + "the evaluable half must be the one bound to `ring_live`; line {} reads `{}`", + live_hits[0] + 1, + lines[live_hits[0]].trim() ); + + let certified = engine_fn_extent(&lines, "fn certified_bounded_cycle_offer<'a>("); + let touch_needle = format!("certified_period{}touch(", '_'); + let touch_sites = engine_code_hits(&lines, certified, &touch_needle); assert!( - try_offer_object_growth_shortcut(&marked).is_some(), - "the empty-stack offer hook is NOT ∞-gated: a persisted declined ∞ axis does not \ - suppress a genuine re-detection re-offering the loop (CR 732.2a / CR 732.2b)" + touch_sites.len() >= 2, + "REACH-GUARD: the certification step must actually call the period touch; found \ + {} sites", + touch_sites.len() ); + for site in &touch_sites { + assert!( + lines[*site].contains("window"), + "every period-touch call must be handed a WINDOW, never a raw ring; line {} \ + reads `{}`", + site + 1, + lines[*site].trim() + ); + } + let window_bindings = engine_code_hits(&lines, certified, "let window"); + assert!( + !window_bindings.is_empty(), + "REACH-GUARD: the windows must be bound inside this extent" + ); + for binding in &window_bindings { + assert!( + lines[*binding].contains("ring_live"), + "every window is sliced from the EVALUABLE ring; line {} reads `{}`", + binding + 1, + lines[*binding].trim() + ); + } } } diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 49a00a4768..573b12d8bd 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -2684,9 +2684,17 @@ fn filter_inner_for_object( if source_controller == Some(obj_ctrl) { return false; } - // CR 102.3 + CR 800.4a: A player who has left the game is - // not an opponent; cards in their zones are not legal - // targets (Captain N'ghathrod class). + // Two claims, two authorities — kept apart deliberately. + // SEAT: CR 800.4 + CR 102.1 — a player who has left the game is + // no longer one of the people in the game, so they are not an + // opponent. (CR 102.3 is scoped to games BETWEEN TEAMS and does + // not define "opponent" in a free-for-all, which is the board + // this seam serves; the engine's free-for-all authority is + // `topology::is_opponent`.) + // OBJECTS: CR 800.4a — "all objects owned by that player leave + // the game" — so cards in their zones are not legal targets + // (Captain N'ghathrod class). This is the half CR 800.4a really + // governs. if !super::players::is_alive(state, obj_ctrl) { return false; } diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 723d8467c0..d3dff9378f 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -2448,7 +2448,9 @@ fn loop_shortcut_projection( // CR 732.2a (MagicCompRules.txt:6372): the picker's ceiling is the offer's own // CR 704 bound, never the raw global safety limit — a count above it would // specify a sequence containing an elimination, which is a conditional action. - // The engine owns this number; the frontend renders it. + // The engine owns this number; the frontend renders it. An unnarrowed offer + // states `MAX_SHORTCUT_CYCLES`; a bounded offer states less. Either way this is + // the offer's own bound, clamped at the same authority. // // CR 704.5a (MagicCompRules.txt:5492): `elimination_bounds` returns `0` to // mean "no legal repetition exists and the caller must not offer". A @@ -8896,6 +8898,18 @@ fn materialize_loop_shortcut_response( .iter() .map(|point| point.slot.clone()) .collect::>(); + // TRAP REMOVAL, NOT A BUG FIX — recorded so the next reader does not "correct" this + // literal into `shortcut_validated_range(..)` and then wonder what changed. This + // decoder emits only `Player` and `ByIdentity` pins, both of which resolve + // INDEPENDENTLY of `iteration`, so validating at index 0 alone is correct by + // construction here: a wider range would re-resolve the same pin to the same value. + // It is also strictly weaker than the declare-path firewall rather than a second + // hole — `1` is a prefix of any range that path validates. It cannot mint a + // `Fixed(0)` either: the count-spec projection's `Fixed` arm hard-codes `min: 1` + // beside its `debug_assert!(schema.max_iterations >= 1, ..)` and its clamp. + // ⚠ Navigation trap: `shortcut_drive_period`'s doc enumerates its own consumers, and + // this site consumes `validate_pins` WITHOUT consuming that helper, so it is + // invisible from there. if predictability_gate(template, &required).is_err() || validate_pins(authoritative_schema, template, 1, authoritative_state).is_err() { diff --git a/crates/engine/src/game/mod.rs b/crates/engine/src/game/mod.rs index a0fc3cc2db..a6ca8ed500 100644 --- a/crates/engine/src/game/mod.rs +++ b/crates/engine/src/game/mod.rs @@ -145,6 +145,7 @@ pub mod public_state; pub mod quantity; pub mod replacement; pub mod replay; +pub(crate) mod resolution_prompt; pub mod restrictions; pub mod room; pub(crate) mod sacrifice; diff --git a/crates/engine/src/game/phasing.rs b/crates/engine/src/game/phasing.rs index 1de5f6d815..62d27312f6 100644 --- a/crates/engine/src/game/phasing.rs +++ b/crates/engine/src/game/phasing.rs @@ -293,11 +293,12 @@ pub fn execute_untap_step_phasing(state: &mut GameState, events: &mut Vec bool { .any(|p| p.id == player && !p.is_eliminated) } +/// May this seat be CHOSEN AT ALL — whether the choice is a target (CR 115.1) or not +/// (CR 115.10a)? The EXISTENCE half only. +/// +/// CR 800.4: "multiplayer games can continue after one or more players have left the +/// game" — a departed seat is no longer one of CR 102.1's "people in the game", so it is +/// not choosable by anything. +/// +/// CR 702.26b as an explicit MIRROR, never as authority: 702.26b is PERMANENT phasing +/// ("a phased-out permanent is treated as though it does not exist"); the engine's +/// PLAYER-level phased-out flag mirrors that wording. The MIRROR label is load-bearing — +/// 702.26b is not a rule about players and must not be read as one. +/// +/// NOTE the CR set deliberately does NOT include CR 800.4a: that rule governs a departed +/// player's OBJECTS, control effects and priority — not the legality of a choice. +/// +/// TARGETED choices need MORE than this: see [`crate::game::targeting::player_is_legal_target`], +/// which adds the targeting-only exclusions. CR 115.10a is the boundary — a seat that is +/// merely *chosen* (CR 701.34a proliferate, CR 701.30b clash, CR 702.132a assist) is not a +/// target, so those exclusions must NOT be applied here. Applying them would refuse a +/// legal choice, which is exactly the over-veto class this split exists to prevent. +/// +/// This is the choke point for the CHOICE-ENUMERATION class: every seam that materializes +/// a list of seats offered to a player to pick from routes through this function (directly +/// or through [`choosable_opponents`]), so the enumerating sides cannot drift apart. +pub fn player_exists_for_choice(state: &GameState, player: PlayerId) -> bool { + is_alive(state, player) + && !state + .players + .iter() + .any(|p| p.id == player && p.is_phased_out()) +} + /// CR 607.2d / CR 607.2m (by analogy): true iff `player`'s durable per-player /// `chosen_attributes` records a `ChosenAttribute::Label` equal to `label` /// (case-insensitive). Single authority consulted by every "player who last @@ -166,6 +198,27 @@ pub fn opponents(state: &GameState, player: PlayerId) -> Vec { .collect() } +/// CR 115.10a: the opponents of `player` that may be CHOSEN — [`opponents`] narrowed by +/// [`player_exists_for_choice`]. +/// +/// Opponent-hood itself is whatever [`opponents`] says (CR 102.2 in a two-player game, +/// CR 102.3 in a game between teams; a free-for-all has no single defining rule and the +/// engine's authority is `topology::is_opponent`). This function adds no relation — only +/// the existence conjunct. +/// +/// This is a SIBLING of [`opponents`], never a conjunct pushed INTO it. `opponents` is the +/// seat-RELATION authority, consumed across the whole engine by combat, targeting, +/// visibility and replacement — each governed by its own CR section — so widening it +/// would silently change every one of them. Same two-layer split as +/// [`crate::game::targeting::player_is_legal_target`] vs [`player_exists_for_choice`]: the +/// CHOICE seam gets the conjunct, the RELATION seam does not. +pub fn choosable_opponents(state: &GameState, player: PlayerId) -> Vec { + opponents(state, player) + .into_iter() + .filter(|&id| player_exists_for_choice(state, id)) + .collect() +} + /// CR 102.2 / CR 102.3: Whether `other` is an opponent of `player`. pub fn is_opponent(state: &GameState, player: PlayerId, other: PlayerId) -> bool { super::topology::is_opponent(state, player, other) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index c931c0403d..48a70eb5df 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -7353,6 +7353,237 @@ pub fn find_applicable_replacements( candidates } +// =========================================================================== +// CR 614.1a + CR 616.1 — the ONE prompt-cause authority over a proposed event. +// +// Derived from the SAME candidate authority the live pipeline uses +// (`find_applicable_replacements`), so VIRTUAL candidates — which have no +// `ReplacementDefinition` at all and are therefore invisible to any def scan — +// are included by construction. A name-derived class map was fail-open twice +// over: a `ProposedEvent::CreateToken` also draws `ReplacementEvent::ChangeZone` +// defs (Giada, Font of Hope), and no def scan can see a virtual. +// =========================================================================== + +/// CR 614.1a + CR 616.1: why the live replacement pipeline can open a player +/// choice on one proposed event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReplacementPromptCause { + /// CR 614.1a: a single optional / `MayCost` candidate prompts. An + /// unresolvable def — which every virtual candidate is — is conservatively + /// optional, mirroring `unwrap_or(true)` at the token gate. + OptionalCandidate, + /// A mandatory body continuation (`execute` / `runtime_execute`) is stashed + /// as a `PostReplacementContinuation` and drained through an arbitrary + /// `ResolvedAbility`, which can set a non-priority `waiting_for`. + MandatoryBodyContinuation, + /// CR 616.1: two or more candidates whose ordering is material — the + /// affected player orders them. + OrderingMaterial, +} + +impl ReplacementPromptCause { + const fn bit(self) -> u8 { + match self { + ReplacementPromptCause::OptionalCandidate => 1 << 0, + ReplacementPromptCause::MandatoryBodyContinuation => 1 << 1, + ReplacementPromptCause::OrderingMaterial => 1 << 2, + } + } +} + +/// A SET of [`ReplacementPromptCause`], never one cause. +/// +/// The shape production already computes: `token_creation_needs_choice` asks +/// `any_optional || ordering_material` — a DISJUNCTION over the whole candidate +/// list. A single-cause return cannot express a disjunction, so on a board whose +/// first-decided cause is `MandatoryBodyContinuation` while a *different* +/// candidate is optional it would answer `false` where the live gate answers +/// `true` — fail-OPEN. No precedence is invented, because production asks for +/// none. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct ReplacementPromptCauses(u8); + +impl ReplacementPromptCauses { + /// No cause — the identity for [`ReplacementPromptCauses::union`]. + pub(crate) const NONE: Self = Self(0); + + pub(crate) const fn of(cause: ReplacementPromptCause) -> Self { + Self(cause.bit()) + } + + pub(crate) const fn contains(self, cause: ReplacementPromptCause) -> bool { + self.0 & cause.bit() != 0 + } + + pub(crate) const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + pub(crate) const fn is_empty(self) -> bool { + self.0 == 0 + } +} + +/// CR 614.1a + CR 616.1: can the live replacement pipeline open a player choice +/// on THIS proposed event, and why? Read-only (`&GameState`, no +/// `apply_single_replacement`), and derived from the pipeline's own candidate +/// authority rather than from a name-keyed class map. +pub(crate) fn proposed_event_prompt_cause( + state: &GameState, + event: &ProposedEvent, + registry: &IndexMap, +) -> ReplacementPromptCauses { + let candidates = find_applicable_replacements(state, event, registry); + if candidates.is_empty() { + return ReplacementPromptCauses::NONE; + } + let mut causes = ReplacementPromptCauses::NONE; + for rid in &candidates { + let def = state + .objects + .get(&rid.source) + .and_then(|o| o.replacement_definitions.get(rid.index)); + let Some(def) = def else { + // CR 614.1a: no resolvable definition (every virtual candidate) ⇒ + // conservatively interactive. + causes = causes.union(ReplacementPromptCauses::of( + ReplacementPromptCause::OptionalCandidate, + )); + continue; + }; + if replacement_mode_is_optional(&def.mode) { + causes = causes.union(ReplacementPromptCauses::of( + ReplacementPromptCause::OptionalCandidate, + )); + } else if def.execute.is_some() || def.runtime_execute.is_some() { + causes = causes.union(ReplacementPromptCauses::of( + ReplacementPromptCause::MandatoryBodyContinuation, + )); + } + } + // CR 616.1: ordering is only a choice when at least two candidates compete. + if candidates.len() >= 2 && replacement_ordering_is_material(state, &candidates, event) { + causes = causes.union(ReplacementPromptCauses::of( + ReplacementPromptCause::OrderingMaterial, + )); + } + causes +} + +/// CR 732.2a: does this proposed event's applier write a board axis the +/// completeness witness can observe — life, poison, counters, battlefield +/// cardinality, the CR 121.1 draw ledger or the CR 120.3a damage ledger? +/// +/// EXHAUSTIVE over all 29 `ProposedEvent` variants with NO wildcard: a new +/// variant fails to compile until it is classified, and an unclassified variant +/// costs COVERAGE (the probe refuses) rather than soundness. This is the single +/// partition — `probe_resolution`'s fourth `Prompted` arm and the R10′ witness's +/// `predicted_axes` both read it, so the resolver and the witness cannot drift +/// about which variants are accounted. +/// +/// THREE ZERO-PAYLOAD GUARDS ride the same rule, because a zero-valued event +/// writes no axis while still drawing candidates: candidate selection carries +/// `> 0` gates (the shield-damage arm and the CR 702.150a Compleated loyalty +/// arm), so a still-zero amount must be honest-red rather than silently +/// candidate-free. +pub(crate) fn event_is_accounted(event: &ProposedEvent) -> bool { + match event { + // ---- accounted: the applier's principal board write, or its own + // engine-maintained turn ledger, is on an axis the witness reads ---- + // CR 119.3: `player.life`. + ProposedEvent::LifeGain { .. } | ProposedEvent::LifeLoss { .. } => true, + // Zone cardinalities. + ProposedEvent::ZoneChange { .. } => true, + // Object / player counters. Zero-payload guard: the CR 702.150a + // Compleated virtual candidate is drawn under `count > 0`. + ProposedEvent::AddCounter { count, .. } => *count > 0, + // `zones::create_object` ⇒ battlefield cardinality. + ProposedEvent::CreateToken { .. } => true, + // CR 120.3a: the player branch DELEGATES its life write to a companion + // `LifeLoss`, but keeps `state.damage_dealt_this_turn`. Zero-payload + // guard: the shield-damage virtual candidate is drawn under `amount > 0`. + ProposedEvent::Damage { amount, .. } => *amount > 0, + // CR 121.1: DELEGATES every card to `zone_pipeline::move_object`, but + // keeps `player.cards_drawn_this_turn`. + ProposedEvent::Draw { count, .. } => *count > 0, + // ---- unaccounted: no axis of its own ⇒ the probe refuses. Named, not + // wildcarded, so a new variant is a compile error here. ---- + ProposedEvent::TokenEntry { .. } + | ProposedEvent::SearchFound { .. } + | ProposedEvent::Scry { .. } + | ProposedEvent::Mill { .. } + | ProposedEvent::CoinFlip { .. } + | ProposedEvent::Explore { .. } + | ProposedEvent::Connive { .. } + | ProposedEvent::Proliferate { .. } + | ProposedEvent::RemoveCounter { .. } + | ProposedEvent::MoveCounter { .. } + | ProposedEvent::Discard { .. } + | ProposedEvent::Tap { .. } + | ProposedEvent::Untap { .. } + | ProposedEvent::TurnFaceUp { .. } + | ProposedEvent::Destroy { .. } + | ProposedEvent::Sacrifice { .. } + | ProposedEvent::BeginTurn { .. } + | ProposedEvent::BeginPhase { .. } + | ProposedEvent::ProduceMana { .. } + | ProposedEvent::EmptyManaPool { .. } + | ProposedEvent::Planeswalk { .. } + | ProposedEvent::Attach { .. } => false, + } +} + +thread_local! { + /// CR 614.1a: armed only inside a speculative probe run. `None` = disarmed, + /// which is every production resolution. + static PROPOSED_EVENT_RECORDER: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +/// Restores the PREVIOUS recorder on drop, not a hard reset, so nesting composes +/// exactly as `SimulationProbeGuard` does. `[profile.dev]` / `[profile.test]` +/// set no `panic` key, so the default is `unwind` — save/restore alone would +/// leak the armed recorder past a caught panic in the test profile as well as +/// in the server build. +struct ProposedEventRecorderGuard(Option>); + +impl Drop for ProposedEventRecorderGuard { + fn drop(&mut self) { + let previous = self.0.take(); + PROPOSED_EVENT_RECORDER.with(|cell| *cell.borrow_mut() = previous); + } +} + +/// Runs `f` with the recorder armed; returns every [`ProposedEvent`] that +/// reached `pipeline_loop` inside it — the pipeline BODY, not the +/// `replace_event` wrapper. `pipeline_loop` has 9 call sites and +/// `replace_combat_damage_batch` bypasses `replace_event` entirely, so recording +/// at the wrapper would be blind to every combat-damage event. +/// +/// Purely OBSERVATIONAL: `pipeline_loop` takes no behavioural branch on the +/// recorder, so an armed run and an unarmed run cannot diverge. +pub(crate) fn record_proposed_events(f: F) -> Vec { + let guard = ProposedEventRecorderGuard( + PROPOSED_EVENT_RECORDER.with(|cell| cell.borrow_mut().replace(Vec::new())), + ); + f(); + let recorded = PROPOSED_EVENT_RECORDER + .with(|cell| cell.borrow_mut().take()) + .unwrap_or_default(); + drop(guard); + recorded +} + +/// The recorder hook. Called once per `pipeline_loop` entry, before any +/// candidate is drawn, so the recorded event is the one the resolver proposed. +fn record_proposed_event(event: &ProposedEvent) { + PROPOSED_EVENT_RECORDER.with(|cell| { + if let Some(buffer) = cell.borrow_mut().as_mut() { + buffer.push(event.clone()); + } + }); +} + /// CR 614.1b + CR 614.10: Read-only probe for whether a turn-start skip /// replacement would replace the proposed turn with nothing. This deliberately /// does not call `replace_event`, so projection code can answer display-only @@ -8942,6 +9173,11 @@ fn pipeline_loop( registry: &IndexMap, events: &mut Vec, ) -> ReplacementResult { + // The single recording point (CR 614.1a). This is the pipeline BODY every one + // of the 9 entries runs, so an event a resolver proposes cannot avoid it. + // Disarmed (a no-op) outside a speculative probe. + record_proposed_event(&proposed); + loop { if depth >= MAX_REPLACEMENT_DEPTH { break; @@ -19678,6 +19914,120 @@ mod tests { "damage dealt TO Swans must still be prevented" ); } + + /// **§6 R13 — RECORDING-POINT COMPLETENESS.** + /// + /// The derivation `probe_resolution` builds is only as complete as the point + /// the recorder is hooked at. R13 pins the INVARIANT, never a call-site + /// count (U1 itself moves one caller of `find_applicable_replacements` from + /// `effects/token.rs` into this file): **every production path that goes on + /// to apply a replacement routes through `fn pipeline_loop`, where the one + /// hook sits.** + /// + /// Two conjuncts, because either alone is passable for the wrong reason: + /// + /// 1. STRUCTURAL — exactly one hook call site, and its enclosing top-level + /// `fn` is `pipeline_loop`. The needle is ASSEMBLED AT RUNTIME so this + /// test's own source text cannot be counted by its own instrument (the + /// self-referential-contamination shape that made the deleted + /// `resolution_choice_verdicts_are_exactly_pinned` census state `== 3` + /// while returning 8). + /// 2. RUNTIME — the two production appliers that reach the pipeline by + /// DIFFERENT routes both land in the recorder. `replace_combat_damage_batch` + /// is the discriminating one: it calls `pipeline_loop` directly and + /// **bypasses `replace_event` entirely**, so a hook on the wrapper is blind + /// to every combat-damage event while still passing conjunct 1's spirit. + /// + /// REVERT-PROBE (RUN, not argued): move the hook from `pipeline_loop` into + /// `replace_event` ⇒ the batch arm records nothing ⇒ this test FAILS. + #[test] + fn every_applying_path_reaches_the_recorder_because_the_hook_is_in_pipeline_loop() { + // Assembled, never written whole: a literal needle would appear in this + // file and be counted by the very scan that looks for it. + let hook_needle = format!("{}{}", "record_proposed_", "event(&"); + let fn_header = format!("\n{}{} ", "fn ", "pipeline_loop("); + let src = std::fs::read_to_string(format!( + "{}/src/game/replacement.rs", + env!("CARGO_MANIFEST_DIR") + )) + .expect("this module's own source is readable"); + let sites: Vec = src.match_indices(&hook_needle).map(|(at, _)| at).collect(); + assert_eq!( + sites.len(), + 1, + "the recorder must have exactly ONE call site; found {}", + sites.len() + ); + // EVERY top-level visibility, not just the bare `fn `. Measured: with + // only `"\nfn "` this scan silently passed when the hook was moved into + // `pub fn replace_event` — the nearest preceding COLUMN-0 `fn ` was + // `pipeline_loop`'s own header, so the wrong placement read as right. + let enclosing = ["\nfn ", "\npub fn ", "\npub(crate) fn "] + .iter() + .filter_map(|header| src[..sites[0]].rfind(header)) + .max() + .expect("a top-level `fn` encloses the hook"); + assert!( + src[enclosing..].starts_with(fn_header.trim_end()), + "the hook must sit in `pipeline_loop` — the pipeline BODY every entry \ + runs — not in the `replace_event` wrapper that \ + `replace_combat_damage_batch` bypasses" + ); + + // Conjunct 2. One board, one event, two routes. + let mut state = GameState::new_two_player(7); + let source = crate::game::zones::create_object( + &mut state, + CardId(1), + PlayerId(0), + "R13 Source".to_string(), + Zone::Battlefield, + ); + let damage = ProposedEvent::Damage { + source_id: source, + target: TargetRef::Player(PlayerId(1)), + amount: 3, + is_combat: true, + applied: HashSet::new(), + }; + let is_our_damage = |event: &ProposedEvent| { + matches!( + event, + ProposedEvent::Damage { + amount: 3, + is_combat: true, + .. + } + ) + }; + + let mut events = Vec::new(); + let batch_recorded = record_proposed_events(|| { + let _ = replace_combat_damage_batch(&mut state, &mut events, vec![damage.clone()]); + }); + assert!( + batch_recorded.iter().any(is_our_damage), + "CR 510.2: the combat-damage batch enters `pipeline_loop` DIRECTLY, so its \ + proposed events must still reach the derivation; recorded {batch_recorded:?}" + ); + + let wrapper_recorded = record_proposed_events(|| { + let _ = replace_event(&mut state, damage.clone(), &mut events); + }); + assert!( + wrapper_recorded.iter().any(is_our_damage), + "positive control on the OTHER route: the `replace_event` wrapper also \ + funnels through the same hook; recorded {wrapper_recorded:?}" + ); + + // Non-vacuity of the instrument itself: an armed extent that proposes + // nothing records nothing, so the two `any(..)`s above are attributable + // to the drives and not to a recorder that always reports something. + assert!( + record_proposed_events(|| {}).is_empty(), + "an armed extent with no pipeline entry records nothing" + ); + } } #[cfg(test)] diff --git a/crates/engine/src/game/resolution_prompt.rs b/crates/engine/src/game/resolution_prompt.rs new file mode 100644 index 0000000000..4be3c26823 --- /dev/null +++ b/crates/engine/src/game/resolution_prompt.rs @@ -0,0 +1,1752 @@ +//! CR 608.2d + CR 616.1 + CR 732.2a — resolution-time choice-freeness. +//! +//! Split out of `game/ability_scan.rs`, which the module header defines as *"a +//! single compiler-exhaustive, wildcard-free walk of a resolved ability's typed +//! AST"* and which contains ZERO references to `GameState` in any form. Probing a +//! resolution needs a board, so threading `&GameState` into that file would break +//! its stated contract. `ResolutionChoiceFreedom`'s own doc already flagged the +//! mismatch: it *"classifies RESOLVER prompting behavior, not AST reads"*. +//! +//! The verdict carries the EVENTS the resolution proposes, not a class name. A +//! name-derived replacement class map was fail-open in two independent ways — +//! it missed defs drawn through a second registry key, and it could not see a +//! virtual candidate at all, because a virtual has no `ReplacementDefinition`. + +use crate::game::engine::SimulationProbeGuard; +use crate::game::{effects, replacement}; +use crate::types::ability::{ + Effect, QuantityExpr, RepeatContinuation, ResolvedAbility, TargetChoiceTiming, +}; +use crate::types::game_state::{GameState, WaitingFor}; +use crate::types::proposed_event::ProposedEvent; + +use crate::analysis::resource::ProbeBudget; + +/// CR 732.2a + CR 608.2d: resolution-time choice-freeness verdict for the +/// growing-cascade cover gate (`analysis::resource` item 6). NOT an +/// `ability_scan::Axes` axis — this classifies RESOLVER prompting behavior, not +/// AST reads. +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum ResolutionChoiceFreedom { + /// The exact `ProposedEvent`s this resolution runs through the replacement + /// pipeline, DERIVED by running the real resolver on a throwaway clone. + /// + /// The caller discharges CR 616.1 against the pipeline's own candidate + /// authority (`replacement::proposed_event_prompt_cause`) — never against a + /// name-derived class map, which was fail-open: a + /// `ProposedEvent::CreateToken` draws `ReplacementEvent::ChangeZone` defs + /// too, and no def scan sees a virtual candidate. + /// + /// NEVER EMPTY: `probe_resolution` returns `Prompted` on an empty + /// derivation, so a caller's `events.iter().any(..)` can never discharge + /// vacuously. + FreeUnlessReplacements(Vec), + /// May prompt, or unproven — the fail-closed default. + MayPrompt, +} + +// A `join` combinator lived here, concatenating the event sets of a root verdict and +// the separately-probed `sub_ability` / `else_ability` verdicts. It is deleted rather +// than kept-and-allowed: with the probe running once at the chain root, there are no +// sibling verdicts left to combine, and the union it performed was the mechanism by +// which events from the NOT-taken branch entered the derived set. Keeping a dead +// combinator that documents superseded semantics is how the next reader concludes the +// union still happens. + +/// CR 616.1 + CR 614.1a: what ONE resolution asks of the player, observed by +/// RUNNING the real resolver on a throwaway clone. Never a hand-written +/// per-effect event list — measured, one `Effect::DealDamage` resolution +/// proposes `{Damage, LifeLoss}` (CR 120.3a) and one `Effect::Draw` proposes +/// `{Draw, ZoneChange}` (CR 121.1); a hand list omitted both companions. +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum ResolutionProbe { + /// The resolver ran without opening a prompt of its own. These are EVERY + /// event it handed to the replacement pipeline. + Events(Vec), + /// The resolver parked — an intrinsic prompt, or a replacement that already + /// prompts on THIS board, or a budget/accounting refusal. Fail-closed. + Prompted, +} + +/// CR 616.1 + CR 614.1a: run `a`'s whole chain on a clone of `state` and report +/// what it asked of the player. +/// +/// `state` must be the RESOLUTION BOARD the caller built — the entry removed and +/// `stack::bind_resolution_scope(..)` run on it, so CR 608.2k / CR 603.2c / +/// CR 706.2 resolution scope is bound and the CR 603.4 intervening-if has +/// already been re-checked. Handing this function a raw pre-resolution +/// `GameState` resolves every `EventContextAmount` / `Triggering*` reference +/// against an absent context and is FAIL-OPEN for the `> 0`-gated virtual +/// replacement arms. +pub(crate) fn probe_resolution( + state: &GameState, + a: &ResolvedAbility, + budget: &mut ProbeBudget, +) -> ResolutionProbe { + // CR 732.2a: BUDGET-EXCEEDED ⇒ `Prompted`. Cost is a COVERAGE knob, never a + // soundness knob — an unaffordable probe degrades to honest-red (no + // certificate, no offer), never to a wrong certificate and never to an + // unbounded stall. Same shape as the `is_empty ⇒ Prompted` arm below. + if !budget.try_charge_one() { + return ResolutionProbe::Prompted; + } + // The SAME re-entrancy guard `apply_confirmed_shortcut` holds across its own + // clone-and-drive. It suppresses ring accumulation and shortcut detection + // inside the probe, which is exactly the recursion hazard a speculative + // resolve would otherwise open. + let _probe = SimulationProbeGuard::enter(); + let mut work = state.clone(); + let events = replacement::record_proposed_events(|| { + let mut ev = Vec::new(); + // `resolve_ability_chain`, NOT `resolve_effect`: the chain is the + // documented production entry and is where `optional`, the chained + // sub-abilities and the `repeat_for` / `RepeatDecision` prompt sites + // live. `resolve_effect` is the reserved-for-tests entry and is blind to + // all of them. + let _ = effects::resolve_ability_chain(&mut work, a, &mut ev, 0); + }); + // CR 732.2a: an unanswered prompt is a choice the certificate cannot + // describe. + // + // KEYED ON "IS THERE A PROMPT AT ALL", not merely on "does it differ from the + // incoming variant". The struck form compared only `WaitingFor` DISCRIMINANTS: if + // the incoming board already carried a non-priority variant and the resolver parked + // a NEW prompt of that SAME variant, the discriminants matched and the resolution + // was reported CHOICE-FREE. That is fail-open in the one direction this function + // exists to close, and comparing against the incoming variant can never see it — + // the incoming variant is exactly what masks it. + // + // The incoming board is a RESOLUTION BOARD (see this function's contract above), so + // a non-priority `waiting_for` on entry is itself a reason to refuse rather than a + // baseline to compare against. The discriminant test is KEPT alongside, so a board + // that entered at priority and left at a different variant still refuses. + // + // Strictly stronger than the struck form on every input, so it can only ever cost + // COVERAGE (a missed offer), never soundness — the same direction as the + // budget-exceeded and empty-derivation arms around it. + if !matches!(work.waiting_for, WaitingFor::Priority { .. }) + || std::mem::discriminant(&work.waiting_for) != std::mem::discriminant(&state.waiting_for) + { + return ResolutionProbe::Prompted; + } + // FAIL-CLOSED on an empty set. Measured: every empty derivation observed was + // an entry whose targets were not yet announced, and the resolver still + // returned `Ok` — so the `Result` does NOT discriminate "proposes nothing" + // from "could not run". + if events.is_empty() { + return ResolutionProbe::Prompted; + } + // CR 732.2a: an event whose board effect the certificate cannot account for + // is a choice surface the certificate cannot describe. `event_is_accounted` + // is the SAME exhaustive, wildcard-free partition the completeness witness + // uses — one function, two callers — so the resolver and the witness cannot + // drift about which variants are accounted. + if events.iter().any(|ev| !replacement::event_is_accounted(ev)) { + return ResolutionProbe::Prompted; + } + ResolutionProbe::Events(events) +} + +/// The one adapter every allow-listed arm funnels through: `Prompted ⇒ +/// MayPrompt`, `Events(v) ⇒ FreeUnlessReplacements(v)`. +fn resolution_probe_verdict( + state: &GameState, + ability: &ResolvedAbility, + budget: &mut ProbeBudget, +) -> ResolutionChoiceFreedom { + match probe_resolution(state, ability, budget) { + ResolutionProbe::Prompted => ResolutionChoiceFreedom::MayPrompt, + ResolutionProbe::Events(events) => ResolutionChoiceFreedom::FreeUnlessReplacements(events), + } +} + +/// CR 608.2d: is this `QuantityExpr` a CR 608.2d "up to N" count — a magnitude +/// the resolving player picks rather than one the state determines? +/// +/// Recursive and wildcard-free for the same reason the classifier it serves is: +/// a NEW `QuantityExpr` variant must be classified before it compiles, so an +/// `UpTo` can never be smuggled in under a newly-added wrapper. +/// +/// MEASURED DISCREPANCY, and the reason this guard exists at all: +/// `game/quantity.rs` resolves `UpTo { max } => recurse(max)` — it SILENTLY +/// ANSWERS the CR 107.1c / CR 608.2d resolution-time count choice as the maximum +/// rather than surfacing it. Only the resolvers that call +/// `QuantityExpr::peel_up_to` honour the flag, and none of the six allow-listed +/// classes does. So an unguarded arm would probe choice-free on an ability whose +/// resolution opens a count prompt. +fn quantity_offers_up_to_choice(q: &QuantityExpr) -> bool { + match q { + QuantityExpr::UpTo { .. } => true, + QuantityExpr::Fixed { .. } | QuantityExpr::Ref { .. } => false, + QuantityExpr::DivideRounded { inner, .. } + | QuantityExpr::Offset { inner, .. } + | QuantityExpr::ClampMin { inner, .. } + | QuantityExpr::Multiply { inner, .. } => quantity_offers_up_to_choice(inner), + // `base` is an `i32`, not a `Box` (see `types/ability.rs`), so it + // is structurally incapable of carrying an `UpTo` and needs no recursion. Stated + // because the asymmetry with `Difference`/`Sum`/`Max` reads like an omission. + QuantityExpr::Power { exponent, base: _ } => quantity_offers_up_to_choice(exponent), + QuantityExpr::Difference { left, right } => { + quantity_offers_up_to_choice(left) || quantity_offers_up_to_choice(right) + } + QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => { + exprs.iter().any(quantity_offers_up_to_choice) + } + } +} + +/// CR 608.2d: can resolving this single `Effect` ever offer a resolution-time +/// player choice? Exhaustive `match` with NO wildcard catch-all arm — a NEW +/// `Effect` variant fails to compile here until it is classified. +/// +/// THE ALLOW LIST IS A SCOPE/COST FILTER, NOT A SOUNDNESS CARRIER. At HEAD each +/// arm carried a hand-audited "its only prompt is `ReplacementResult::NeedsChoice`" +/// claim in its doc comment. That is a hand enumeration, and it went stale the +/// moment a resolver gained a prompt. The soundness is now carried by +/// `ResolutionProbe::Prompted`, which OBSERVES the real resolver opening a real +/// prompt on the real board. The arm only decides which classes are worth paying +/// a clone for. +fn effect_offers_choice(e: &Effect) -> bool { + match e { + // ---- SCOPE FILTER. DESTRUCTURED WITHOUT `..` on every arm, exactly as + // HEAD's three allow arms are, so a new field on any of them forces + // a re-audit of whether the class is still in scope. + // ⚠ `..` IS FORBIDDEN ON EVERY ARM BELOW. If a new `Effect` field + // makes one of them stop compiling, THAT E0027 IS THE GUARD FIRING + // — it is not a compile error to silence. rustc's own diagnostic + // prints a `help:` suggesting `..`, which silently disarms the + // guard. Classify the new field against the scope filter, then name + // it `_`. The 14-field `Effect::Token` arm is the one that maximally + // invites the `..`. + // + // CR 107.1c + CR 608.2d: THE `UpTo` GUARD IS ON *EVERY* + // QUANTITY-CARRYING ARM, not only `Draw`. All six allow-listed arms + // carry a `QuantityExpr` at their count/amount field, and + // `game/quantity.rs` answers `UpTo` as the maximum instead of + // prompting — so an unguarded arm would report choice-free on an + // ability whose resolution opens a count choice. DIRECTION: + // fail-closed. It can only turn a probe verdict into `MayPrompt`, + // never the reverse. ---- + Effect::GainLife { amount, player: _ } + | Effect::LoseLife { amount, target: _ } + | Effect::DealDamage { + amount, + target: _, + damage_source: _, + excess: _, + } => { + quantity_offers_up_to_choice(amount) + } + Effect::PutCounter { + target: _, + counter_type: _, + count, + } + | Effect::Token { + name: _, + power: _, + toughness: _, + types: _, + colors: _, + keywords: _, + tapped: _, + count, + owner: _, + attach_to: _, + enters_attacking: _, + supertypes: _, + static_abilities: _, + enter_with_counters: _, + } => { + quantity_offers_up_to_choice(count) + } + // HEAD's own arm shape, kept verbatim (single arm + inner `if`, no match + // guard). CR 608.2d: an "up to N" draw is a resolution-time COUNT choice + // the probe would ANSWER rather than surface, because the count is read, + // not prompted, inside `draw::resolve`. + Effect::Draw { count, target: _ } => { + quantity_offers_up_to_choice(count) + } + // ---- everything else: fail-closed MayPrompt. HEAD's named list + // VERBATIM, minus the three variants promoted above + // (`DealDamage`, `PutCounter`, `Token`), and with NO wildcard, so + // the compiler still enforces exhaustiveness and a new `Effect` + // variant fails to compile until it is classified. ---- + Effect::StartYourEngines { .. } + | Effect::ChangeSpeed { .. } + | Effect::ApplyPostReplacementDamage { .. } + | Effect::EachDealsDamageEqualToPower { .. } + | Effect::OpponentGuess { .. } + | Effect::SwapChosenLabels { .. } + | Effect::Pump { .. } + | Effect::PairWith { .. } + | Effect::Destroy { .. } + | Effect::Regenerate { .. } + | Effect::RemoveAllDamage { .. } + | Effect::Counter { .. } + | Effect::CounterAll { .. } + | Effect::SetTapState { .. } + | Effect::RemoveCounter { .. } + | Effect::ChooseCounterKind { .. } + | Effect::PutChosenCounter { .. } + | Effect::Sacrifice { .. } + | Effect::DiscardCard { .. } + | Effect::Mill { .. } + | Effect::Scry { .. } + | Effect::PumpAll { .. } + | Effect::DamageAll { .. } + | Effect::DamageEachPlayer { .. } + | Effect::EachPlayerCopyChosen { .. } + | Effect::DestroyAll { .. } + | Effect::ChangeZone { .. } + | Effect::ChangeZoneAll { .. } + | Effect::Dig { .. } + | Effect::GainControl { .. } + | Effect::GainControlAll { .. } + | Effect::ControlNextTurn { .. } + | Effect::Attach { .. } + | Effect::UnattachAll { .. } + | Effect::Surveil { .. } + | Effect::Fight { .. } + | Effect::Bounce { .. } + | Effect::BounceAll { .. } + | Effect::Explore + | Effect::ExploreAll { .. } + | Effect::Investigate + | Effect::Tribute { .. } + | Effect::TimeTravel + | Effect::BecomeMonarch + | Effect::NoOp + | Effect::Proliferate + | Effect::ProliferateTarget { .. } + | Effect::Populate + | Effect::Clash + // CR 701.4a + CR 608.2d: behold may prompt (`WaitingFor::BeholdChoice` + // when 2+ candidates) — fail-closed MayPrompt. + | Effect::Behold { .. } + | Effect::EndTheTurn + | Effect::EndCombatPhase + | Effect::Vote { .. } + | Effect::SeparateIntoPiles { .. } + | Effect::SwitchPT { .. } + | Effect::CopySpell { .. } + | Effect::EpicCopy { .. } + | Effect::CastCopyOfCard { .. } + | Effect::CopyTokenOf { .. } + | Effect::CreateTokenCopyFromPool { .. } + | Effect::Myriad + | Effect::Encore + | Effect::CombineHost { .. } + | Effect::ChooseAugmentAndCombineWithHost { .. } + | Effect::Meld { .. } + | Effect::ExileHaunting { .. } + | Effect::HideawayConceal { .. } + | Effect::CopyTokenBlockingAttacker { .. } + | Effect::BecomeCopy { .. } + // CR 707.6: an object entering as a copy of a permanent does NOT inherit the + // original's choices — its controller makes the "as [this] enters" choices + // anew. That fresh choice is what raises `WaitingFor::CopyTargetChoice`, so + // this is a fail-closed MayPrompt (never resolved through the normal chain, + // but classified here to keep the match exhaustive). + | Effect::ChoosePermanent { .. } + | Effect::GainActivatedAbilitiesOfTarget { .. } + | Effect::ChooseCard { .. } + | Effect::PutCounterAll { .. } + | Effect::MultiplyCounter { .. } + | Effect::DoublePT { .. } + | Effect::DoublePTAll { .. } + | Effect::MoveCounters { .. } + | Effect::Animate { .. } + | Effect::ReturnAsAura { .. } + | Effect::RegisterBending { .. } + | Effect::GenericEffect { .. } + | Effect::Cleanup { .. } + | Effect::Mana { .. } + | Effect::Discard { .. } + | Effect::Shuffle { .. } + | Effect::Transform { .. } + // CR 710.4: `flip_permanent` offers no resolution-time choice (it is a + // status change or a silent no-op), exactly like `Transform`. + | Effect::FlipPermanent { .. } + | Effect::SearchLibrary { .. } + | Effect::SearchOutsideGame { .. } + | Effect::RevealHand { .. } + | Effect::RevealFromHand { .. } + | Effect::Reveal { .. } + | Effect::RevealTop { .. } + | Effect::ExileTop { .. } + | Effect::ExileFaceDownPile { .. } + | Effect::TargetOnly { .. } + | Effect::Choose { .. } + | Effect::ChooseDamageSource { .. } + | Effect::Suspect { .. } + | Effect::Unsuspect { .. } + | Effect::Connive { .. } + | Effect::PhaseOut { .. } + | Effect::PhaseIn { .. } + | Effect::ForceBlock { .. } + | Effect::ForceAttack { .. } + | Effect::SolveCase + | Effect::BecomePrepared { .. } + | Effect::BecomeUnprepared { .. } + | Effect::BecomeSaddled { .. } + | Effect::BecomeBlocked { .. } + | Effect::SetClassLevel { .. } + | Effect::CreateDelayedTrigger { .. } + | Effect::AddTargetReplacement { .. } + | Effect::AddRestriction { .. } + | Effect::ReduceNextSpellCost { .. } + | Effect::GrantNextSpellAbility { .. } + | Effect::AddPendingETBCounters { .. } + | Effect::AddPendingEntersModifications { .. } + | Effect::CreateEmblem { .. } + | Effect::PayCost { .. } + | Effect::CastFromZone { .. } + | Effect::FreeCastFromZones { .. } + | Effect::ExileResolvingSpellInsteadOfGraveyard { .. } + | Effect::PreventDamage { .. } + | Effect::CreateDamageReplacement { .. } + | Effect::CreateDrawReplacement { .. } + | Effect::LoseTheGame { .. } + | Effect::WinTheGame { .. } + | Effect::RollDie { .. } + | Effect::FlipCoin { .. } + | Effect::FlipCoins { .. } + | Effect::FlipCoinUntilLose { .. } + | Effect::RingTemptsYou + | Effect::VentureIntoDungeon + | Effect::VentureInto { .. } + | Effect::TakeTheInitiative + | Effect::ArrangePlanarDeckTop { .. } + | Effect::Planeswalk + | Effect::OpenAttractions { .. } + | Effect::RollToVisitAttractions + | Effect::AssembleContraptions { .. } + | Effect::AssembleContraptionsFromRollDifference + | Effect::CrankContraptions { .. } + | Effect::ReassembleContraption { .. } + | Effect::AssembleContraptionOnSprocket { .. } + | Effect::ReassembleContraptionOnSprocket { .. } + | Effect::PutSticker { .. } + | Effect::ApplySticker { .. } + | Effect::ProcessRadCounters + | Effect::GrantCastingPermission { .. } + | Effect::ChooseFromZone { .. } + | Effect::RememberCard { .. } + | Effect::ForEachCategory { .. } + | Effect::ChooseObjectsIntoTrackedSet { .. } + | Effect::ChooseAndSacrificeRest { .. } + | Effect::Exploit { .. } + | Effect::GainEnergy { .. } + | Effect::GivePlayerCounter { .. } + | Effect::LoseAllPlayerCounters { .. } + | Effect::ExileFromTopUntil { .. } + | Effect::RevealUntil { .. } + | Effect::Discover { .. } + | Effect::Heist { .. } + | Effect::HeistExile + | Effect::Cascade + | Effect::Ripple { .. } + | Effect::MiracleCast { .. } + | Effect::MadnessCast { .. } + | Effect::PutAtLibraryPosition { .. } + | Effect::ChooseDrawnThisTurnPayOrTopdeck { .. } + | Effect::PutOnTopOrBottom { .. } + | Effect::GiftDelivery { .. } + | Effect::Goad { .. } + | Effect::GoadAll { .. } + | Effect::Detain { .. } + | Effect::SetRoomDoorLock { .. } + | Effect::ExchangeControl { .. } + | Effect::ChangeTargets { .. } + | Effect::Manifest { .. } + | Effect::ManifestDread + | Effect::Cloak { .. } + | Effect::TurnFaceUp { .. } + | Effect::TurnFaceDown { .. } + | Effect::ExtraTurn { .. } + | Effect::GrantExtraLoyaltyActivations { .. } + | Effect::SkipNextTurn { .. } + | Effect::SkipNextStep { .. } + | Effect::AdditionalPhase { .. } + | Effect::Double { .. } + | Effect::EachSourceDealsDamage { .. } + | Effect::RuntimeHandled { .. } + | Effect::Incubate { .. } + | Effect::Amass { .. } + | Effect::Monstrosity { .. } + | Effect::Specialize + | Effect::Renown { .. } + | Effect::Bolster { .. } + | Effect::Adapt { .. } + | Effect::Learn + | Effect::Forage + | Effect::Harness + | Effect::CollectEvidence { .. } + | Effect::Endure { .. } + | Effect::BlightEffect { .. } + | Effect::Seek { .. } + | Effect::SetLifeTotal { .. } + | Effect::ExchangeLifeWithStat { .. } + | Effect::ExchangeLifeTotals { .. } + | Effect::SetDayNight { .. } + | Effect::GiveControl { .. } + | Effect::RemoveFromCombat { .. } + | Effect::Conjure { .. } + | Effect::ApplyPerpetual { .. } + | Effect::Intensify { .. } + | Effect::DraftFromSpellbook { .. } + | Effect::ChooseCounterAdjustment { .. } + | Effect::CreatePlaneswalkReplacement { .. } + | Effect::ChaosEnsues + | Effect::RedistributeLifeTotals + | Effect::ReverseTurnOrder + | Effect::ChooseOneOf { .. } + | Effect::Unimplemented { .. } => true, + } +} + +/// CR 608.2d + CR 732.2a: can resolving this ability's whole chain enter a +/// resolution-time player choice? +/// +/// PURE AST WALK — no `GameState`, no clone, no budget. That is what lets the +/// recursion stay exhaustive over `sub_ability` / `else_ability` while the +/// expensive probe runs exactly ONCE, at the chain root, in +/// [`ability_resolution_choice_freedom`]. +/// +/// The `ResolvedAbility` destructure is EXHAUSTIVE with no `..` — +/// `ability_scan::resolved_ability_axes`'s classifications are deliberately NOT +/// reused (this is a different question: e.g. `optional` is read-free yet +/// choice-bearing). A FUTURE field fails to compile here until classified for the +/// choice question. +pub(crate) fn chain_offers_choice(a: &ResolvedAbility) -> bool { + let ResolvedAbility { + // ---- choice-bearing: folded into the verdict below ---- + effect, + sub_ability, + else_ability, + optional, + optional_for, + optional_targeting, + unless_pay, + target_chooser, + target_choice_timing, + modal, + mode_abilities, + repeat_until, + repeat_for, + // ---- choice-free: bound `_` with a one-line justification ---- + condition: _, // resolution branch selector, pure eval (both branches gate-checked below) + duration: _, // continuous-effect lifetime, no prompt + player_scope: _, // iteration fan-out, pure player-filter eval + starting_with: _, // APNAP start override, no prompt + announced_x: _, // CR 601.2b announce-time count, pure quantity eval, no prompt + multi_target: _, // announce-time variable-count bounds (Resolution case caught by timing) + target_constraints: _, // announce-time cross-target legality, no resolution prompt + distribution: _, // CR 601.2d concrete pre-assigned portions (announce-time) + targets: _, // concrete announced target refs (already resolved) + source_id: _, // object id + source_incarnation: _, // self-transform epoch latch, no resolution-time choice + trigger_source: _, // exact triggered-source authority, no choice + trigger_definition_ref: _, // exact trigger occurrence, no choice + force_block_attacker: _, // exact force-block referent, no choice + controller: _, // player id + original_controller: _, // player id + scoped_player: _, // player id (iteration binding) + kind: _, // AbilityKind tag (no payload) + context: _, // SpellContext: cast-time fact snapshot, not a live choice + description: _, // display string + selected_mode_labels: _, // display strings, no resolution-time choice + min_x_value: _, // u32 + cant_be_copied: _, // bool + copy_count_status: _, // status tag + forward_result: _, // bool + chosen_x: _, // concrete cast-time X (chosen at announcement, not resolution) + cost_paid_object: _, // concrete captured-object snapshot + cost_paid_object_ids: _, // concrete captured-object ids (issue #4948) + effect_context_object: _, // concrete captured-object snapshot + amassed_army_object: _, // concrete captured-object snapshot + ability_index: _, // usize provenance + may_trigger_origin: _, // provenance tag + target_selection_mode: _, // Chosen/Random tag (announce-time) + chosen_players: _, // concrete chosen player ids (already selected) + replacement_applied: _, // replacement provenance set, no prompt + sub_link: _, // SubAbilityLink kind tag + sibling_condition: _, // SiblingCondition replication marker, no resolution-time choice + parent_target_missing_reason: _, // seam flag + } = a; + + // CR 603.5 + CR 608.2d: an optional effect / optional targeting / + // opponent-may effect prompts the controller (or opponent) before execution. + if *optional || *optional_targeting || optional_for.is_some() { + return true; + } + // CR 118.12: "unless a player pays {cost}" is a resolution-time pay prompt. + if unless_pay.is_some() { + return true; + } + // CR 601.2c + CR 603.3d: a resolution-time target chooser announces targets. + if target_chooser.is_some() { + return true; + } + // CR 608.2d: resolution-timed target selection is a resolution-time choice + // even though `targets` is empty on the stack. + if matches!(target_choice_timing, TargetChoiceTiming::Resolution) { + return true; + } + // CR 700.2b + CR 603.3c: a modal header / reflexive per-mode abilities open a + // mode choice at resolution (conservative — rejected even when the mode is + // baked). + if modal.is_some() || !mode_abilities.is_empty() { + return true; + } + // CR 608.2c + CR 107.1c: only the controller-prompted repeat variant is a + // player choice; while / until-stop predicates are pure re-evaluation. + if matches!(repeat_until, Some(RepeatContinuation::ControllerChoice)) { + return true; + } + // CR 608.2d + CR 107.1c: an "up to N" REPEAT COUNT is a resolution-time choice + // exactly like an "up to N" damage/draw/counter count, and it is answered in the + // same silent way — `game/quantity.rs` resolves `UpTo { max } => recurse(max)`, + // taking the maximum, and none of the six allow-listed classes calls + // `QuantityExpr::peel_up_to`. This field was previously bound `_` and justified as + // "pure quantity eval (game/quantity.rs)", which cites the very mechanism + // `quantity_offers_up_to_choice`'s own doc comment exists to distrust: the count is + // READ, not prompted. An allow-listed repeated ability carrying an `UpTo` repeat + // count was therefore probed choice-free and admitted to a loop certificate while + // its resolution opens a count prompt. Guarded with the SAME single authority the + // per-effect quantity positions use, so a new `QuantityExpr` wrapper is classified + // in one place rather than three. + if repeat_for + .as_ref() + .is_some_and(quantity_offers_up_to_choice) + { + return true; + } + + // CR 608.2c: gate-check the whole chain — this node's effect, plus the + // sub_ability / else_ability branches. Recursion is retained HERE, where it is a + // pure AST walk costing nothing, and dropped from the probe below. + effect_offers_choice(effect) + || sub_ability.as_deref().is_some_and(chain_offers_choice) + || else_ability.as_deref().is_some_and(chain_offers_choice) +} + +/// CR 608.2d + CR 732.2a: the resolution-choice verdict for a whole ability chain. +/// +/// TWO PHASES, and the split is the point. [`chain_offers_choice`] walks the entire +/// chain as pure AST — free, so it stays exhaustive over both branches. Only if the +/// whole chain is gate-clean does the expensive half run, and then exactly once, at +/// the ROOT: `probe_resolution` drives `resolve_ability_chain`, which is the +/// production entry and already resolves the taken `sub_ability` / `else_ability` +/// branch itself. The previous shape probed the root AND recursed a probe into each +/// branch, so a chain of depth N paid N whole-`GameState` clones and re-resolved every +/// subchain the root resolution had already walked. +/// +/// ⚠ ONE MEASURABLE CONSEQUENCE, stated rather than buried. The old form `join`ed the +/// branch verdicts, and `join` UNIONS event sets, so the accumulated set included +/// events from the branch NOT taken on this board. The single root probe reports only +/// what the resolution actually proposes. That is a smaller, more accurate set — the +/// old one described a resolution that cannot happen — but it IS a different set, so +/// any change in certification is a real delta and is reported with the change rather +/// than absorbed silently. +pub(crate) fn ability_resolution_choice_freedom( + state: &GameState, + a: &ResolvedAbility, + budget: &mut ProbeBudget, +) -> ResolutionChoiceFreedom { + if chain_offers_choice(a) { + return ResolutionChoiceFreedom::MayPrompt; + } + resolution_probe_verdict(state, a, budget) +} + +/// The RECORDER-FREE half of the completeness witness (R10′). +/// +/// Every axis below is written by the engine whether or not any recorder +/// exists, which is the whole point: a witness built from the recorder would +/// share its deepest dependency with the thing it checks, and the check would be +/// an identity. Two of them are the delegating legs' own turn ledgers — +/// `Damage`'s player branch delegates its life write to a companion `LifeLoss` +/// (CR 120.3a) and `Draw` delegates every card to the zone pipeline (CR 121.1), +/// so a board-resource-only axis set is BLIND on both. +#[cfg(test)] +#[derive(Debug, Default, PartialEq, Eq, Clone)] +pub(crate) struct BoardAxes { + life: std::collections::BTreeMap, + poison: std::collections::BTreeMap, + counters: i64, + battlefield: i64, + cards_drawn: std::collections::BTreeMap, + damage_records: i64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::zones::create_object; + use crate::types::ability::{ + AbilityCost, AbilityDefinition, AbilityKind, EffectScope, ModalChoice, OpponentMayScope, + PtValue, QuantityExpr, TapStateChange, TargetFilter, TargetRef, UnlessPayModifier, + }; + use crate::types::counter::CounterType; + use crate::types::identifiers::{CardId, ObjectId}; + use crate::types::player::PlayerId; + use crate::types::proposed_event::CounterPlacement; + use crate::types::zones::Zone; + use std::collections::BTreeMap; + + use crate::analysis::resource::{ProbeBudget, PROBE_BUDGET}; + + fn budget() -> ProbeBudget { + ProbeBudget::for_test(PROBE_BUDGET) + } + + /// Snapshot every axis the witness reads. + fn board_axes(state: &GameState) -> BoardAxes { + BoardAxes { + life: state + .players + .iter() + .map(|p| (p.id, i64::from(p.life))) + .collect(), + poison: state + .players + .iter() + .map(|p| (p.id, i64::from(p.poison_counters))) + .collect(), + counters: state + .objects + .values() + .map(|o| o.counters.values().map(|n| i64::from(*n)).sum::()) + .sum(), + battlefield: state.battlefield.len() as i64, + cards_drawn: state + .players + .iter() + .map(|p| (p.id, i64::from(p.cards_drawn_this_turn))) + .collect(), + damage_records: state.damage_dealt_this_turn.len() as i64, + } + } + + fn axis_delta(before: &BoardAxes, after: &BoardAxes) -> BoardAxes { + let map_delta = |b: &BTreeMap, a: &BTreeMap| { + let mut out = BTreeMap::new(); + for (id, av) in a { + let d = av - b.get(id).copied().unwrap_or(0); + if d != 0 { + out.insert(*id, d); + } + } + out + }; + BoardAxes { + life: map_delta(&before.life, &after.life), + poison: map_delta(&before.poison, &after.poison), + counters: after.counters - before.counters, + battlefield: after.battlefield - before.battlefield, + cards_drawn: map_delta(&before.cards_drawn, &after.cards_drawn), + damage_records: after.damage_records - before.damage_records, + } + } + + /// The RECORDER half's prediction. `None` ⇒ the derived set contains a + /// variant whose board effect this witness cannot account for, which + /// `probe_resolution` turns into `Prompted`. + /// + /// The accounted/unaccounted partition is NOT re-derived here: it is + /// `replacement::event_is_accounted`, the same function `probe_resolution` + /// reads, so the resolver and the witness cannot drift. + fn predicted_axes(events: &[ProposedEvent]) -> Option { + let mut axes = BoardAxes::default(); + for ev in events { + if !replacement::event_is_accounted(ev) { + return None; + } + match ev { + ProposedEvent::LifeGain { + player_id, amount, .. + } => *axes.life.entry(*player_id).or_default() += i64::from(*amount), + ProposedEvent::LifeLoss { + player_id, amount, .. + } => *axes.life.entry(*player_id).or_default() -= i64::from(*amount), + ProposedEvent::ZoneChange { from, to, .. } => { + axes.battlefield += + i64::from(*to == Zone::Battlefield) - i64::from(*from == Zone::Battlefield); + } + ProposedEvent::AddCounter { + placement, count, .. + } => match placement { + CounterPlacement::Object { .. } => axes.counters += i64::from(*count), + CounterPlacement::Player { + player_id, + counter_kind, + .. + } => { + if matches!( + counter_kind, + crate::types::player::PlayerCounterKind::Poison + ) { + *axes.poison.entry(*player_id).or_default() += i64::from(*count); + } + } + CounterPlacement::Energy { .. } => {} + }, + ProposedEvent::CreateToken { count, .. } => axes.battlefield += i64::from(*count), + // CR 120.3a: the life write is the companion `LifeLoss`'s; this + // variant's own axis is the damage ledger. + ProposedEvent::Damage { .. } => axes.damage_records += 1, + // CR 121.1: the zone write is the companion `ZoneChange`'s; this + // variant's own axis is the draw ledger. + ProposedEvent::Draw { + player_id, count, .. + } => *axes.cards_drawn.entry(*player_id).or_default() += i64::from(*count), + other => unreachable!( + "accounted variant with no axis arm — the partition and this witness \ + have drifted: {other:?}" + ), + } + } + // Drop zeroed map entries so the prediction is delta-shaped like the + // observation. + axes.life.retain(|_, v| *v != 0); + axes.poison.retain(|_, v| *v != 0); + axes.cards_drawn.retain(|_, v| *v != 0); + Some(axes) + } + + /// A 2p board with a P0 source creature on the battlefield, one card in each + /// library, and a P1 creature to damage / counter. + fn probe_board() -> GameState { + let mut state = GameState::new_two_player(7); + create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + create_object( + &mut state, + CardId(2), + PlayerId(1), + "Victim".to_string(), + Zone::Battlefield, + ); + for owner in [PlayerId(0), PlayerId(1)] { + create_object( + &mut state, + CardId(3), + owner, + "Deck Card".to_string(), + Zone::Library, + ); + } + state + } + + fn source_id(state: &GameState) -> ObjectId { + *state.battlefield.front().expect("source on battlefield") + } + + fn victim_id(state: &GameState) -> ObjectId { + *state.battlefield.get(1).expect("victim on battlefield") + } + + fn ability(state: &GameState, effect: Effect, targets: Vec) -> ResolvedAbility { + ResolvedAbility::new(effect, targets, source_id(state), PlayerId(0)) + } + + fn fixed(n: i32) -> QuantityExpr { + QuantityExpr::Fixed { value: n } + } + + fn up_to(n: i32) -> QuantityExpr { + QuantityExpr::UpTo { + max: Box::new(fixed(n)), + } + } + + fn token_effect(count: QuantityExpr) -> Effect { + Effect::Token { + name: "Servo".to_string(), + power: PtValue::Fixed(1), + toughness: PtValue::Fixed(1), + types: vec!["Creature".to_string()], + colors: vec![], + keywords: vec![], + tapped: false, + count, + owner: TargetFilter::Controller, + attach_to: None, + enters_attacking: false, + supertypes: vec![], + static_abilities: vec![], + enter_with_counters: vec![], + } + } + + /// The six allow-listed classes, each with a board that exercises it and the + /// name the population conjunct reports. + fn six_allow_listed_arms(state: &GameState) -> Vec<(&'static str, ResolvedAbility)> { + vec![ + ( + "GainLife", + ability( + state, + Effect::GainLife { + amount: fixed(3), + player: TargetFilter::Controller, + }, + vec![], + ), + ), + ( + "LoseLife", + ability( + state, + Effect::LoseLife { + amount: fixed(2), + target: Some(TargetFilter::Controller), + }, + vec![], + ), + ), + ( + "DealDamage", + ability( + state, + Effect::DealDamage { + amount: fixed(1), + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + vec![TargetRef::Player(PlayerId(1))], + ), + ), + ( + "PutCounter", + ability( + state, + Effect::PutCounter { + target: TargetFilter::Any, + counter_type: CounterType::Plus1Plus1, + count: fixed(2), + }, + vec![TargetRef::Object(victim_id(state))], + ), + ), + ("Token", ability(state, token_effect(fixed(1)), vec![])), + ( + "Draw", + ability( + state, + Effect::Draw { + count: fixed(1), + target: TargetFilter::Controller, + }, + vec![], + ), + ), + ] + } + + /// CR 732.2a — a prompt already parked on the incoming board must REFUSE the probe, + /// even though the resolution leaves the variant unchanged. + /// + /// The struck guard compared only `WaitingFor` DISCRIMINANTS between the probed clone + /// and the incoming board. When the incoming board already carries a non-priority + /// variant, the two discriminants are equal for a resolution that re-parks the SAME + /// variant (and, in the reduced form asserted here, for one that simply leaves the + /// standing prompt in place) — so the probe reported CHOICE-FREE while an unanswered + /// choice sat on the board. Fail-open, in the one direction this function closes. + /// + /// MATCHED PAIR, which is what makes this non-vacuous: + /// * NEGATIVE arm — the same ability, same board, `waiting_for` parked at a real + /// resolution-time prompt (`ReplacementChoice`, CR 616.1) ⇒ MUST be `Prompted`. + /// * POSITIVE arm — the same ability on the same board at `Priority` ⇒ MUST still + /// reach `Events`. Without it, a probe that refused EVERYTHING would pass the + /// negative arm, and the row would prove nothing. + /// + /// REVERT-PROBE (run, recorded): restore the guard to the bare + /// `discriminant(&work.waiting_for) != discriminant(&state.waiting_for)` ⇒ the + /// NEGATIVE arm FLIPS TO FAIL (the probe returns `Events`) while the POSITIVE arm + /// stays green — i.e. the new conjunct, not the old one, is what carries this row. + #[test] + fn a_prompt_standing_on_the_incoming_board_refuses_the_probe() { + let base = probe_board(); + let parked = WaitingFor::ReplacementChoice { + player: PlayerId(0), + candidate_count: 2, + candidates: Vec::new(), + }; + assert!( + !matches!(base.waiting_for, WaitingFor::ReplacementChoice { .. }), + "reach-guard: the parked variant must DIFFER from the board's own starting \ + variant, or the negative arm could pass for the wrong reason" + ); + + let mut refused = 0usize; + for (name, a) in six_allow_listed_arms(&base) { + // POSITIVE — at priority, this arm is known to reach the recorder. + match probe_resolution(&base, &a, &mut budget()) { + ResolutionProbe::Events(events) => assert!( + !events.is_empty(), + "{name}: positive control must derive a non-empty event set" + ), + ResolutionProbe::Prompted => panic!( + "{name}: POSITIVE CONTROL FAILED — this arm must still be probeable \ + from a priority board, otherwise the negative arm below is vacuous" + ), + } + + // NEGATIVE — same ability, same board, a prompt already standing. + let mut stalled = base.clone(); + stalled.waiting_for = parked.clone(); + // WHAT ESTABLISHES THE SAME-DISCRIMINANT CONDITION: the REVERT-PROBE, not an + // assertion here. Comparing `stalled.waiting_for` against `parked` at this + // point would compare a value to itself and prove nothing, and the probe's + // internal `work` board is not observable from outside `probe_resolution`. + // The probe supplies it empirically instead — under the struck guard this arm + // returns `Events` rather than `Prompted`, which can only happen when the two + // discriminants compared EQUAL, i.e. the resolution left the standing prompt + // in place. That is exactly the masking condition this row is about. + assert!( + matches!( + probe_resolution(&stalled, &a, &mut budget()), + ResolutionProbe::Prompted + ), + "{name}: an UNANSWERED prompt on the incoming board is a choice the \ + certificate cannot describe — the probe must refuse it, not read the \ + equal discriminants as `unchanged`" + ); + refused += 1; + } + assert_eq!( + refused, 6, + "reach-guard: the row must range over all six allow-listed arms" + ); + } + + /// R10′ — THE AXIS-COMPLETENESS WITNESS, over ALL SIX allow-listed arms. + /// + /// Two INDEPENDENT clone-and-resolves of the same ability on the same board: + /// ARM 1 derives the event set through the recorder; ARM 2 reads the axes + /// before/after and touches no recorder, no `ProposedEvent`, no + /// `replace_event`, no `pipeline_loop`. The two arms share + /// `resolve_ability_chain` + `GameState` — the SUBJECT under test — and + /// share NOTHING on the CHECKING side. That is the whole point: a check that + /// shares its deepest dependency with the thing checked is an identity. + /// + /// CONJUNCT 2 (the population gate) is asserted as a conjunct that FAILS the + /// test, not promised in prose: the set of arms observed must equal all six. + #[test] + fn derived_event_set_accounts_for_every_board_axis_on_all_six_allow_listed_arms() { + let state = probe_board(); + let mut observed_arms: Vec<&'static str> = Vec::new(); + + for (name, a) in six_allow_listed_arms(&state) { + // ARM 1 — the recorder. + let events = match probe_resolution(&state, &a, &mut budget()) { + ResolutionProbe::Events(events) => events, + ResolutionProbe::Prompted => { + panic!("{name}: the probe must reach the recorder arm on this board") + } + }; + assert!(!events.is_empty(), "{name}: derivation must be non-empty"); + + // ARM 2 — recorder-free. A separate clone, resolved directly. + let before = board_axes(&state); + let mut work = state.clone(); + let mut ev = Vec::new(); + let _ = effects::resolve_ability_chain(&mut work, &a, &mut ev, 0); + let observed = axis_delta(&before, &board_axes(&work)); + + let predicted = predicted_axes(&events) + .unwrap_or_else(|| panic!("{name}: derived set contains an Unaccounted variant")); + assert_eq!( + predicted, observed, + "{name}: the derived set must ACCOUNT FOR what the resolution did to the board \ + (events {events:?})" + ); + observed_arms.push(name); + } + + observed_arms.sort_unstable(); + let mut expected = [ + "DealDamage", + "Draw", + "GainLife", + "LoseLife", + "PutCounter", + "Token", + ]; + expected.sort_unstable(); + assert_eq!( + observed_arms, expected, + "the witness must range over ALL SIX allow-listed arms — an unexercised arm is an \ + unmeasured population, not a passing row" + ); + } + + /// R10′'s own discriminating control: a witness blind on a delegating leg + /// scores CLEAN on a recorder that lost that leg's variant. Cripple the + /// recorded set by dropping `Damage`, then `Draw`, and assert the SIX-axis + /// witness catches both — while the four board-resource axes alone do not. + #[test] + fn the_two_ledger_axes_are_load_bearing_not_decorative() { + let state = probe_board(); + let arms = six_allow_listed_arms(&state); + + for (name, dropped) in [("DealDamage", "Damage"), ("Draw", "Draw")] { + let (_, a) = arms + .iter() + .find(|(n, _)| *n == name) + .expect("arm present in the six"); + let ResolutionProbe::Events(events) = probe_resolution(&state, a, &mut budget()) else { + panic!("{name}: reach-guard — the probe must produce events here"); + }; + let before = board_axes(&state); + let mut work = state.clone(); + let mut ev = Vec::new(); + let _ = effects::resolve_ability_chain(&mut work, a, &mut ev, 0); + let observed = axis_delta(&before, &board_axes(&work)); + + // HEALTHY: the full six-axis witness agrees. + assert_eq!( + predicted_axes(&events).expect("accounted"), + observed, + "{name}: healthy control must be CLEAN" + ); + + // CRIPPLED: drop the delegating variant from the RECORDED set only. + let crippled: Vec = events + .iter() + .filter(|e| { + !matches!( + (dropped, e), + ("Damage", ProposedEvent::Damage { .. }) + | ("Draw", ProposedEvent::Draw { .. }) + ) + }) + .cloned() + .collect(); + assert!( + crippled.len() < events.len(), + "{name}: reach-guard — the cripple must actually remove a {dropped} event \ + (events {events:?})" + ); + let crippled_prediction = predicted_axes(&crippled).expect("accounted"); + assert_ne!( + crippled_prediction, observed, + "{name}: the six-axis witness must MISMATCH once {dropped} is dropped" + ); + + // And the exact defect the two ledger axes close: with them deleted + // (round 5's board-resource-only axis set) the same cripple scores + // CLEAN — the blindness, reproduced in the same test. + let blind = + |x: &BoardAxes| (x.life.clone(), x.poison.clone(), x.counters, x.battlefield); + assert_eq!( + blind(&crippled_prediction), + blind(&observed), + "{name}: a board-resource-only axis set is BLIND on this delegating leg — this \ + is why `cards_drawn` and `damage_records` are axes" + ); + } + } + + /// R11 — AN EMPTY DERIVATION IS FAIL-CLOSED. + /// + /// Measured: an entry whose targets are not yet announced derives ZERO + /// events while the resolver still returns `Ok`, so the `Result` does NOT + /// discriminate "proposes nothing" from "could not run". Without the + /// `is_empty ⇒ Prompted` arm the caller's `any()` discharges vacuously and + /// an entry that has made no choices yet certifies as choice-free. + #[test] + fn an_unannounced_target_derives_nothing_and_is_prompted() { + let state = probe_board(); + let unannounced = ability( + &state, + Effect::DealDamage { + amount: fixed(1), + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + vec![], + ); + assert_eq!( + probe_resolution(&state, &unannounced, &mut budget()), + ResolutionProbe::Prompted, + "an entry with no announced target proposes nothing, and nothing is never 'safe'" + ); + // PAIRED POSITIVE, differing in exactly the announced target: the same + // ability one announcement later derives {Damage, LifeLoss}. Without it + // the negative above could pass on a board where nothing resolves at all. + let announced = ability( + &state, + Effect::DealDamage { + amount: fixed(1), + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + vec![TargetRef::Player(PlayerId(1))], + ); + let ResolutionProbe::Events(events) = probe_resolution(&state, &announced, &mut budget()) + else { + panic!("reach-guard: the announced twin must derive events"); + }; + assert!( + events + .iter() + .any(|e| matches!(e, ProposedEvent::Damage { .. })), + "CR 120.3a: the announced twin derives the damage event ({events:?})" + ); + assert!( + events + .iter() + .any(|e| matches!(e, ProposedEvent::LifeLoss { .. })), + "CR 120.3a: damage to a player delegates a companion LifeLoss ({events:?})" + ); + } + + /// R12 — INTRINSIC-PROMPT DETECTION, a matched pair on one board. + /// + /// This is the row that turns the allow-list's old *"its only prompt is + /// `NeedsChoice`"* doc claim into a measurement: the probe OBSERVES the real + /// resolver opening a real prompt instead of trusting a comment that was + /// true when it was written. + #[test] + fn an_intrinsically_prompting_resolver_is_prompted_and_the_six_arms_are_not() { + let state = probe_board(); + let scry = ability( + &state, + Effect::Scry { + count: fixed(1), + target: TargetFilter::Controller, + }, + vec![], + ); + assert_eq!( + probe_resolution(&state, &scry, &mut budget()), + ResolutionProbe::Prompted, + "CR 732.2a: a resolver that parks on its own is a choice the certificate cannot \ + describe" + ); + // The same instrument, the same board, the same axis: none of the six + // allow-listed classes parks. Without this half the row would pass by + // refusing everything. + for (name, a) in six_allow_listed_arms(&state) { + assert!( + matches!( + probe_resolution(&state, &a, &mut budget()), + ResolutionProbe::Events(_) + ), + "{name} must NOT be Prompted on the same board that makes Scry Prompted" + ); + } + } + + /// R19a — `Unaccounted ⇒ Prompted` FIRES IN THE RESOLVER, not only in the + /// witness. A gate that ships only inside a test is not a gate. + /// + /// KEYED on a board that ACTUALLY DERIVES an `Unaccounted` variant: a draw + /// chained with a tap. `ProposedEvent::Tap` writes none of the six axes, so + /// the certificate cannot account for what that resolution did to the board + /// even though every other event in the set is accounted. + /// + /// TWO ARM-ATTRIBUTION CONJUNCTS, because `probe_resolution`'s `Prompted` + /// arms are ordered budget → `waiting_for` discriminant → `is_empty` → + /// `event_is_accounted`: without them an upstream arm could dominate, the + /// row would pass for the wrong reason, and deleting the accounting arm + /// could not flip it. + #[test] + fn an_unaccounted_derived_event_is_prompted_in_the_resolver() { + let state = probe_board(); + let victim = victim_id(&state); + let mut chained = ability( + &state, + Effect::Draw { + count: fixed(1), + target: TargetFilter::Controller, + }, + vec![], + ); + chained.sub_ability = Some(Box::new(ResolvedAbility::new( + Effect::SetTapState { + target: TargetFilter::Any, + scope: EffectScope::Single, + state: TapStateChange::Tap, + }, + vec![TargetRef::Object(victim)], + source_id(&state), + PlayerId(0), + ))); + + // ATTRIBUTION (i)+(ii): re-derive the same run's inputs to the verdict so + // the outcome is pinned to the accounting arm and to no other. + let events = replacement::record_proposed_events(|| { + let _probe = SimulationProbeGuard::enter(); + let mut work = state.clone(); + let mut ev = Vec::new(); + let _ = effects::resolve_ability_chain(&mut work, &chained, &mut ev, 0); + // The production guard has TWO legs (`!matches!(.., Priority)` OR the + // discriminants differ), and the attribution claim above names the whole + // arm rather than one leg of it, so both legs are asserted. + // + // COMPLETENESS GUARD, NOT A LIVE FIX — stated from measurement so it is not + // oversold. Deleting the accounting arm flips this row red WITH the leg + // assert and WITHOUT it, so the row was never vacuous as it stood. The + // divergence the second leg would catch (probe board at a non-priority + // `waiting_for` whose variant still matches `state`'s) is not expressible on + // this fixture: parking the board at `ReplacementChoice` changes what the + // chain proposes — the sub-ability's `Tap` stops being derived — so the row + // dies at its own reach-guard before any arm attribution is reached. This + // assert buys future-drift coverage, not a currently-reachable bug. + assert!( + matches!(work.waiting_for, WaitingFor::Priority { .. }), + "attribution (i): the non-priority leg of the waiting_for arm must NOT \ + be what fires here" + ); + assert_eq!( + std::mem::discriminant(&work.waiting_for), + std::mem::discriminant(&state.waiting_for), + "attribution (i): the discriminant leg of the waiting_for arm must NOT \ + be what fires here" + ); + }); + assert!( + !events.is_empty(), + "attribution (ii): the is_empty arm must NOT be what fires here" + ); + assert!( + events + .iter() + .any(|e| matches!(e, ProposedEvent::Tap { .. })), + "reach-guard: the derived set must actually carry the Unaccounted variant ({events:?})" + ); + assert!( + events.iter().any(replacement::event_is_accounted), + "reach-guard: the set must ALSO carry accounted events, so the refusal is \ + attributable to the one unaccounted member and not to an all-unknown set" + ); + + assert_eq!( + probe_resolution(&state, &chained, &mut budget()), + ResolutionProbe::Prompted, + "CR 732.2a: an event whose board effect the certificate cannot account for is a \ + choice surface the certificate cannot describe" + ); + + // PAIRED POSITIVE, differing in exactly the chained tap: the same draw + // without it is fully accounted and is NOT Prompted, so the row cannot + // pass by refusing everything. + let mut plain = chained.clone(); + plain.sub_ability = None; + assert!( + matches!( + probe_resolution(&state, &plain, &mut budget()), + ResolutionProbe::Events(_) + ), + "the same draw without the chained tap is accounted" + ); + + // The zero-payload guards are the other members of this class, pinned on + // the partition itself: a zero-valued event writes no axis while still + // being drawn as a candidate under a `> 0` gate. + assert!(!replacement::event_is_accounted( + &ProposedEvent::TokenEntry { + entry_ref: victim, + enter_tapped: Default::default(), + enter_with_counters: Vec::new(), + applied: Default::default(), + } + )); + for (accounted, count) in [(false, 0u32), (true, 1u32)] { + assert_eq!( + replacement::event_is_accounted(&ProposedEvent::AddCounter { + placement: CounterPlacement::Object { + actor: PlayerId(0), + object_id: victim, + counter_type: CounterType::Plus1Plus1, + }, + count, + applied: Default::default(), + }), + accounted, + "CR 702.150a: the Compleated virtual candidate is gated on `count > 0`, so a \ + zero-count placement must be honest-red rather than silently candidate-free" + ); + assert_eq!( + replacement::event_is_accounted(&ProposedEvent::Damage { + source_id: source_id(&state), + target: TargetRef::Player(PlayerId(1)), + amount: count, + is_combat: false, + applied: Default::default(), + }), + accounted, + "the shield-damage virtual candidate is gated on `amount > 0`" + ); + assert_eq!( + replacement::event_is_accounted(&ProposedEvent::Draw { + player_id: PlayerId(0), + count, + applied: Default::default(), + }), + accounted + ); + } + } + + /// CR 107.1c + CR 608.2d: THE `UpTo` GUARD IS ON EVERY QUANTITY-CARRYING + /// ARM, not only `Draw`. + /// + /// `game/quantity.rs` resolves `UpTo { max }` as the maximum instead of + /// prompting, and none of the six allow-listed classes calls + /// `QuantityExpr::peel_up_to`. So an unguarded arm reports choice-free on an + /// ability whose resolution opens a count choice — a fail-OPEN, not a + /// coverage gap. Both directions are asserted per arm so neither can go + /// vacuous. + #[test] + fn an_up_to_count_is_may_prompt_on_every_quantity_carrying_arm() { + let state = probe_board(); + let victim = victim_id(&state); + let cases: Vec<(&str, Effect, Effect, Vec)> = vec![ + ( + "GainLife", + Effect::GainLife { + amount: up_to(3), + player: TargetFilter::Controller, + }, + Effect::GainLife { + amount: fixed(3), + player: TargetFilter::Controller, + }, + vec![], + ), + ( + "LoseLife", + Effect::LoseLife { + amount: up_to(2), + target: Some(TargetFilter::Controller), + }, + Effect::LoseLife { + amount: fixed(2), + target: Some(TargetFilter::Controller), + }, + vec![], + ), + ( + "DealDamage", + Effect::DealDamage { + amount: up_to(1), + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + Effect::DealDamage { + amount: fixed(1), + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + vec![TargetRef::Player(PlayerId(1))], + ), + ( + "PutCounter", + Effect::PutCounter { + target: TargetFilter::Any, + counter_type: CounterType::Plus1Plus1, + count: up_to(2), + }, + Effect::PutCounter { + target: TargetFilter::Any, + counter_type: CounterType::Plus1Plus1, + count: fixed(2), + }, + vec![TargetRef::Object(victim)], + ), + ( + "Token", + token_effect(up_to(1)), + token_effect(fixed(1)), + vec![], + ), + ( + "Draw", + Effect::Draw { + count: up_to(1), + target: TargetFilter::Controller, + }, + Effect::Draw { + count: fixed(1), + target: TargetFilter::Controller, + }, + vec![], + ), + ]; + for (name, up_to_effect, fixed_effect, targets) in cases { + let guarded = ability(&state, up_to_effect, targets.clone()); + assert_eq!( + ability_resolution_choice_freedom(&state, &guarded, &mut budget()), + ResolutionChoiceFreedom::MayPrompt, + "{name}: an `up to N` count is a CR 608.2d resolution-time choice the probe \ + would ANSWER rather than surface" + ); + // MATCHED POSITIVE: the same arm at a fixed count is probe-backed, + // so the `MayPrompt` above is attributable to the guard and not to + // the arm being out of scope. + let unguarded = ability(&state, fixed_effect, targets); + assert!( + matches!( + ability_resolution_choice_freedom(&state, &unguarded, &mut budget()), + ResolutionChoiceFreedom::FreeUnlessReplacements(_) + ), + "{name}: the same arm at a fixed count must be probe-backed" + ); + } + } + + /// The reject side stays fail-closed, and the ability-level wrapper flips. + /// + /// Successor to `ability_scan::resolution_choice_verdicts_are_exactly_pinned` + /// for the halves that survive the payload change; the per-arm obligation + /// pin it carried is superseded by the derived-event witness above, which + /// pins the EVENTS rather than a class name and cannot go stale. + #[test] + fn the_reject_side_and_the_ability_level_gates_are_pinned_in_both_directions() { + let state = probe_board(); + let rejects = [ + Effect::Proliferate, + Effect::Populate, + Effect::Clash, + Effect::Explore, + Effect::Scry { + count: fixed(1), + target: TargetFilter::Controller, + }, + Effect::Sacrifice { + target: TargetFilter::Any, + count: fixed(1), + min_count: 0, + }, + Effect::DiscardCard { + count: 1, + target: TargetFilter::Any, + }, + ]; + for e in rejects { + let a = ability(&state, e.clone(), vec![]); + assert_eq!( + ability_resolution_choice_freedom(&state, &a, &mut budget()), + ResolutionChoiceFreedom::MayPrompt, + "{e:?} must be MayPrompt" + ); + } + + // Base ⇒ probe-backed (the paired positive reach-guard); each single-field + // mutation ⇒ MayPrompt, proving the FLIP causes the rejection and not + // something upstream. + let base = ability( + &state, + Effect::GainLife { + amount: fixed(1), + player: TargetFilter::Controller, + }, + vec![], + ); + assert!( + matches!( + ability_resolution_choice_freedom(&state, &base, &mut budget()), + ResolutionChoiceFreedom::FreeUnlessReplacements(_) + ), + "reach-guard: the unmutated base is probe-backed" + ); + + let mut mutations: Vec<(&str, ResolvedAbility)> = Vec::new(); + let mut push = |label: &'static str, f: &dyn Fn(&mut ResolvedAbility)| { + let mut a = base.clone(); + f(&mut a); + mutations.push((label, a)); + }; + push("optional", &|a| a.optional = true); + push("optional_targeting", &|a| a.optional_targeting = true); + push("unless_pay", &|a| { + a.unless_pay = Some(UnlessPayModifier { + cost: AbilityCost::Tap, + payer: TargetFilter::Controller, + }) + }); + push("target_chooser", &|a| { + a.target_chooser = Some(TargetFilter::Controller) + }); + push("target_choice_timing", &|a| { + a.target_choice_timing = TargetChoiceTiming::Resolution + }); + push("mode_abilities", &|a| { + a.mode_abilities = vec![AbilityDefinition::new(AbilityKind::Spell, Effect::NoOp)] + }); + push("repeat_until", &|a| { + a.repeat_until = Some(RepeatContinuation::ControllerChoice) + }); + push("modal", &|a| a.modal = Some(ModalChoice::default())); + // The gate at the top of the classifier is + // `*optional || *optional_targeting || optional_for.is_some()`, and only the + // first two disjuncts had a row. A row per DISJUNCT, not per gate: with two of + // three covered, `optional_for` could have been dropped from the condition and + // every existing row would still pass. + push("optional_for", &|a| { + a.optional_for = Some(OpponentMayScope::AnyOpponent) + }); + // CR 608.2d + CR 107.1c: an `UpTo` REPEAT COUNT is a resolution-time choice. + // `repeat_for` used to be bound `_` as "pure quantity eval", so this class of + // ability was certified choice-free while its resolution opens a count prompt. + push("repeat_for_up_to", &|a| { + a.repeat_for = Some(QuantityExpr::UpTo { + max: Box::new(fixed(3)), + }) + }); + for (label, a) in mutations { + assert_eq!( + ability_resolution_choice_freedom(&state, &a, &mut budget()), + ResolutionChoiceFreedom::MayPrompt, + "{label} is a resolution-time choice the probe must not swallow" + ); + } + + // REACH-GUARD for the `repeat_for` row above, and the half that makes it + // DISCRIMINATING rather than merely red-on-mutation: a `repeat_for` that is NOT + // `UpTo` must still be probe-backed. Without this arm the row would pass just as + // well against a guard that rejected EVERY `repeat_for`, which would be a + // coverage loss dressed up as a fix — "for each" loops over a fixed or + // state-derived count really are pure evaluation, and that is the common case. + let mut fixed_repeat = base.clone(); + fixed_repeat.repeat_for = Some(fixed(3)); + assert!( + matches!( + ability_resolution_choice_freedom(&state, &fixed_repeat, &mut budget()), + ResolutionChoiceFreedom::FreeUnlessReplacements(_) + ), + "a non-`UpTo` repeat count is pure evaluation and must stay probe-backed — \ + rejecting it too would trade the fail-open bug for lost certification" + ); + // And the guard must see through a WRAPPER, or "up to N, doubled" would evade it. + // This is the composability the shared `quantity_offers_up_to_choice` authority + // buys; a bespoke `matches!(.., UpTo { .. })` here would pass the row above and + // fail this one. + let mut wrapped = base.clone(); + wrapped.repeat_for = Some(QuantityExpr::Multiply { + inner: Box::new(QuantityExpr::UpTo { + max: Box::new(fixed(3)), + }), + factor: 2, + }); + assert_eq!( + ability_resolution_choice_freedom(&state, &wrapped, &mut budget()), + ResolutionChoiceFreedom::MayPrompt, + "an `UpTo` nested under an arithmetic wrapper is still a resolution-time count choice" + ); + + // ── THE RECURSION IS RETAINED FOR GATES, which is the premise that lets the + // probe run once at the chain ROOT. + // + // ⚠ MEASURED ASYMMETRY between the two branch sites, recorded because the + // obvious reading of these two rows is WRONG. Each recursion site was + // revert-probed independently: + // + // * deleting the `else_ability` recursion FLIPS its row. The else branch is + // not the branch this board resolves, so the root probe never executes it + // and the AST walk is the ONLY thing that can see a gate there. + // * deleting the `sub_ability` recursion does NOT flip its row — measured, + // 9 of 9 still pass. `resolve_ability_chain` resolves the TAKEN + // sub-ability, so the root probe observes that prompt directly and the + // upstream conjunct dominates the discriminator. + // + // So the `sub_ability` row is a REGRESSION GUARD, not a proof of that + // recursion, and says so rather than implying coverage it does not carry. + // That recursion still earns its place on COST — `analysis::resource` calls + // `chain_offers_choice` before cloning the board, where no probe has run yet + // — but the soundness there rests on the probe, not on this row. + for (label, attach, why) in [ + ( + "sub_ability", + (&|a: &mut ResolvedAbility, branch: ResolvedAbility| { + a.sub_ability = Some(Box::new(branch)) + }) as &dyn Fn(&mut ResolvedAbility, ResolvedAbility), + // REGRESSION GUARD ONLY — measured non-discriminating for the recursion: + // the root probe resolves the taken sub-ability and sees this prompt itself. + "the root probe also sees this one, so this row guards against regression \ + rather than proving the recursion", + ), + ( + "else_ability", + &|a, branch| a.else_ability = Some(Box::new(branch)), + // The discriminating half: this branch is never resolved on this board. + "the root probe never resolves this branch, so the AST recursion is the \ + ONLY thing that can see it", + ), + ] { + let mut branch = base.clone(); + branch.optional = true; // a gate that only the recursion can see + let mut with_branch = base.clone(); + attach(&mut with_branch, branch); + assert_eq!( + ability_resolution_choice_freedom(&state, &with_branch, &mut budget()), + ResolutionChoiceFreedom::MayPrompt, + "a choice gate on `{label}` must reject the whole chain ({why})" + ); + + // NEGATIVE CONTROL for the pair above: the identical chain SHAPE with a + // gate-free branch must still certify. Without it, a `chain_offers_choice` + // that rejected any ability merely for HAVING a branch would pass both rows + // and silently stop certifying every chained ability in the corpus. + let mut clean_branch = base.clone(); + clean_branch.optional = false; + let mut with_clean = base.clone(); + attach(&mut with_clean, clean_branch); + assert!( + matches!( + ability_resolution_choice_freedom(&state, &with_clean, &mut budget()), + ResolutionChoiceFreedom::FreeUnlessReplacements(_) + ), + "a gate-free `{label}` branch must remain probe-backed, or the recursion \ + is rejecting chain SHAPE rather than chain CONTENT" + ); + } + } + + /// STRUCTURAL INVARIANT: `game/ability_scan.rs` holds NO `GameState`. + /// + /// The module header defines it as a pure AST walk, and that contract is + /// exactly why the resolution-choice classifier moved out of it. Pinned at + /// the WIDEST form — a word-bounded `GameState` token anywhere in the file — + /// because the narrow `state: &GameState` spelling is evaded by `st:` or + /// `&mut`. Keyed by this file, which contains many. + #[test] + fn ability_scan_holds_no_game_state() { + let count_tokens = |path: &str| -> usize { + let src = std::fs::read_to_string(path).expect("source file readable"); + src.split(|c: char| !(c.is_alphanumeric() || c == '_')) + .filter(|tok| *tok == "GameState") + .count() + }; + let dir = env!("CARGO_MANIFEST_DIR"); + let scanner = format!("{dir}/src/game/ability_scan.rs"); + let here = format!("{dir}/src/game/resolution_prompt.rs"); + assert!( + count_tokens(&here) > 0, + "positive control: this file names GameState, so the instrument can return non-zero" + ); + assert_eq!( + count_tokens(&scanner), + 0, + "ability_scan.rs is a pure AST walk and must hold no board — probing a resolution \ + needs one, which is why that classifier lives here instead" + ); + } +} diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index 3886b06e24..343b418da3 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -1917,8 +1917,17 @@ fn check_battle_protector( } // Compute legal choices. + // CR 310.11a: a Siege's controller "must choose its protector from among their + // opponents", and CR 704.5w's SBA phrasing — "no player IN THE GAME designated as + // its protector ... chooses an appropriate player" — seats CR 102.1 directly on + // this seam. A CHOICE, not a target (CR 115.10a), so the candidate list is the + // CHOOSABLE opponents. The pre-existing `eliminated_players` filter is LEFT IN + // PLACE: `is_alive` reads `Player::is_eliminated` while this reads + // `GameState::eliminated_players` — two different stores whose equivalence is not + // measured here, so deleting the redundant filter would smuggle an unmeasured + // behaviour change into a one-token routing. let legal_choices: Vec = if is_siege { - crate::game::players::opponents(state, controller) + crate::game::players::choosable_opponents(state, controller) .into_iter() .filter(|p| !state.eliminated_players.contains(p)) .collect() diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 29402e7b38..401f1cdc9d 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -901,6 +901,95 @@ fn pending_spell_resolution_snapshot( } } +/// CR 603.4 + CR 608.2k + CR 603.2c + CR 706.2: bind the resolution scope +/// [`resolve_top`] hands to `resolve_ability_chain`, for an entry ALREADY off +/// the stack. +/// +/// Returns `false` iff the CR 603.4 intervening-if re-check fails — i.e. the +/// live resolution proposes NOTHING. The caller owns the consequence: +/// [`resolve_top`] pushes `GameEvent::StackResolved` and returns; an analysis +/// caller returns its fail-closed verdict. The event is deliberately NOT pushed +/// here — this function takes no event sink, which is what keeps it callable +/// from the analysis crate. +/// +/// CR 608.2k is the rule for the `current_trigger_event` lift: *"If an ability's +/// effect refers to a specific untargeted object that has been previously +/// referred to by that ability's cost or trigger condition, it still affects +/// that object even if the object has changed characteristics."* The +/// `Triggering*` anaphors are exactly untargeted back-references to the object +/// the TRIGGER CONDITION matched (carried on the entry as `trigger_event`); the +/// lift is the mechanism that keeps that object reachable while the ability +/// resolves, and it CLONES the recorded event rather than re-evaluating the +/// condition, so the binding survives characteristic change by construction. +/// CR 608.2h is why a clone rather than a re-derivation is right: the answer is +/// determined only once, when the effect is applied. +/// +/// The in-order-written execution of those anaphor arms is CR 608.2c; the +/// batched-subject-count re-stamp is CR 603.2c; the die-roll re-stamp is +/// CR 706.2. +pub(crate) fn bind_resolution_scope( + state: &mut GameState, + entry: &StackEntry, + trigger_event_batch: Option>, +) -> bool { + // CR 603.4: Intervening-if condition rechecked at resolution time. + if let StackEntryKind::TriggeredAbility { + condition: Some(ref condition), + source_id: _, + ref trigger_event, + .. + } = &entry.kind + { + let trigger_source = entry + .ability() + .and_then(|ability| ability.trigger_source.as_ref()); + if !super::triggers::check_trigger_condition_with_source( + state, + condition, + entry.controller, + trigger_source, + trigger_event.as_ref(), + ) { + return false; + } + } + + // CR 608.2k: Set trigger event context for event-context target resolution. + // TriggeringSpellController, TriggeringSource, etc. read this during resolution. + if let StackEntryKind::TriggeredAbility { + trigger_event: Some(ref te), + .. + } = entry.kind + { + state.current_trigger_event = Some(te.clone()); + state.current_trigger_events = trigger_event_batch.unwrap_or_else(|| vec![te.clone()]); + } else if let Some(trigger_events) = trigger_event_batch { + state.current_trigger_event = trigger_events.first().cloned(); + state.current_trigger_events = trigger_events; + } + + // CR 603.2c: Lift the filtered subject count of a batched trigger into + // resolution scope so `QuantityRef::EventContextAmount` resolves "that + // many" against the count, not against zero. Set in lockstep with + // `current_trigger_event` and cleared at every reset site below. + if let StackEntryKind::TriggeredAbility { + subject_match_count, + die_result, + .. + } = entry.kind + { + state.current_trigger_match_count = subject_match_count; + // CR 706.2 + CR 706.4 + CR 603.12: re-stamp the carried die-roll result + // into resolution scope so a reflexive "When you do … the result" + // sub-ability resolving on its own stack entry (a later apply(), after + // the original roll's resolution scope cleared) reads the rolled value + // via the `QuantityRef::EventContextAmount` cascade. + state.die_result_this_resolution = die_result; + } + + true +} + /// CR 608.2: Resolve the top object on the stack. pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // CR 603.3c + CR 603.3d: The top of the stack may be a trigger entry that @@ -982,67 +1071,22 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { return; } - // CR 603.4: Intervening-if condition rechecked at resolution time. - if let StackEntryKind::TriggeredAbility { - condition: Some(ref condition), - source_id: _, - ref trigger_event, - .. - } = &entry.kind - { - let trigger_source = entry - .ability() - .and_then(|ability| ability.trigger_source.as_ref()); - if !super::triggers::check_trigger_condition_with_source( + // CR 603.4: the intervening-if recheck lives inside `bind_resolution_scope`; a `false` + // return means the condition failed and this entry resolves with no effect. The + // SETTLEMENT stays HERE, at the caller, and must never move into the helper: + // `analysis/resource.rs` calls `bind_resolution_scope` on CLONED PROBE BOARDS (five + // sites), where running terminal delayed-trigger disposition would mutate lifecycle + // state for a board that is only being measured. + if !bind_resolution_scope(state, &entry, trigger_event_batch) { + events.push(GameEvent::StackResolved { + object_id: entry.id, + }); + finish_resolving_stack_entry( state, - condition, - entry.controller, - trigger_source, - trigger_event.as_ref(), - ) { - events.push(GameEvent::StackResolved { - object_id: entry.id, - }); - finish_resolving_stack_entry( - state, - super::lifecycle::DelayedTerminalDisposition::InterveningIfFalse, - ); - state.resolution_source_relatch = None; - return; - } - } - - // CR 603.7c: Set trigger event context for event-context target resolution. - // TriggeringSpellController, TriggeringSource, etc. read this during resolution. - if let StackEntryKind::TriggeredAbility { - trigger_event: Some(ref te), - .. - } = entry.kind - { - state.current_trigger_event = Some(te.clone()); - state.current_trigger_events = trigger_event_batch.unwrap_or_else(|| vec![te.clone()]); - } else if let Some(trigger_events) = trigger_event_batch { - state.current_trigger_event = trigger_events.first().cloned(); - state.current_trigger_events = trigger_events; - } - - // CR 603.2c: Lift the filtered subject count of a batched trigger into - // resolution scope so `QuantityRef::EventContextAmount` resolves "that - // many" against the count, not against zero. Set in lockstep with - // `current_trigger_event` and cleared at every reset site below. - if let StackEntryKind::TriggeredAbility { - subject_match_count, - die_result, - .. - } = entry.kind - { - state.current_trigger_match_count = subject_match_count; - // CR 706.2 + CR 706.4 + CR 603.12: re-stamp the carried die-roll result - // into resolution scope so a reflexive "When you do … the result" - // sub-ability resolving on its own stack entry (a later apply(), after - // the original roll's resolution scope cleared) reads the rolled value - // via the `QuantityRef::EventContextAmount` cascade. - state.die_result_this_resolution = die_result; + super::lifecycle::DelayedTerminalDisposition::InterveningIfFalse, + ); + state.resolution_source_relatch = None; + return; } // Extract the resolved ability from the stack entry. `KeywordAction` is @@ -13007,6 +13051,231 @@ mod tests { assert_eq!(obj.defense, Some(5)); } + /// **§6 R26 — `resolve_top`'s BEHAVIOUR IS UNCHANGED ACROSS THE + /// `bind_resolution_scope` EXTRACTION.** + /// + /// U1 moves the CR 603.4 re-check and the CR 608.2k / CR 603.2c / CR 706.2 + /// resolution-scope binding out of the universal resolution chokepoint into + /// a shared function the analysis probe can also call. Three matched pairs, + /// each keyed to one thing a WIDER extraction boundary would have broken — + /// the boundary this plan struck, which would have pulled the pop, the + /// keyword-action branch and the `StackResolved` pushes across with it: + /// + /// * **(a) CR 113.3b keyword actions still resolve.** The `KeywordAction` + /// early return sits ABOVE the extracted region and must stay in + /// `resolve_top` (it needs `&mut Vec`, which the shared + /// function deliberately does not take). Equip attaches, and + /// `StackResolved` is emitted exactly once. + /// * **(b) CR 603.4 false ⇒ removed from the stack, does nothing, + /// `StackResolved` STILL emitted.** The event is pushed by the CALLER — + /// the extracted function returns a bare `bool` and has no event sink, so + /// this is the seam the struck `Option` signature had no + /// channel for. Matched against the condition-TRUE twin, which resolves. + /// * **(c) CR 107.3m + CR 707.10 `paid_facts` survives.** The pop and its + /// `paid_snapshot` binding stayed in `resolve_top`: a permanent spell with + /// printed loyalty `X` enters with the snapshot's `x_value` in loyalty + /// counters. Matched against the same spell with NO snapshot, which enters + /// with none. + /// + /// REACH-GUARD on all three: the stack depth decreased by exactly 1, so an + /// entry that never resolved cannot satisfy a "did nothing" arm vacuously. + /// + /// REVERT-PROBES (the plan's, each a single edit): (a) move the + /// `KeywordAction` branch into `bind_resolution_scope` and return `false` + /// for it ⇒ the equipment never attaches; (b) delete the + /// `events.push(StackResolved)` from `resolve_top`'s `false` arm ⇒ the event + /// assertion flips; (c) move the pop into the shared function so + /// `paid_snapshot` is dropped ⇒ the spell enters at `cost_x_paid`/0 loyalty. + #[test] + fn resolve_top_behaviour_is_unchanged_across_the_bind_resolution_scope_extraction() { + let resolved_once = |events: &[GameEvent], id: ObjectId| { + events + .iter() + .filter(|e| matches!(e, GameEvent::StackResolved { object_id } if *object_id == id)) + .count() + }; + + // ── (a) CR 113.3b: the keyword-action early return ── + { + let mut state = setup(); + let equipment = create_object( + &mut state, + CardId(701), + PlayerId(0), + "Test Equipment".to_string(), + Zone::Battlefield, + ); + let creature = create_object( + &mut state, + CardId(702), + PlayerId(0), + "Test Bearer".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&creature) + .unwrap() + .card_types + .core_types + .push(CoreType::Creature); + let entry_id = ObjectId(7010); + state.stack.push_back(StackEntry { + id: entry_id, + source_id: equipment, + controller: PlayerId(0), + kind: StackEntryKind::KeywordAction { + action: KeywordAction::Equip { + equipment_id: equipment, + target_creature_id: creature, + }, + }, + }); + let depth = state.stack.len(); + + let mut events = Vec::new(); + resolve_top(&mut state, &mut events); + + assert_eq!(state.stack.len(), depth - 1, "reach-guard (a): it resolved"); + assert_eq!( + state.objects[&equipment].attached_to, + Some(crate::game::game_object::AttachTarget::Object(creature)), + "CR 702.6a: the equip keyword action must still attach — its branch \ + returns EARLY, above the extracted region" + ); + assert_eq!( + resolved_once(&events, entry_id), + 1, + "CR 405.5: exactly one StackResolved for the keyword action" + ); + } + + // ── (b) CR 603.4: a FALSE intervening-if still emits StackResolved ── + // `setup()` is a standard-format board (20 starting life), so the + // intervening-if `LifeTotalGE 5` is TRUE and `LifeTotalGE 99` FALSE. + for (label, minimum, expect_gain) in [("TRUE", 5, 3i32), ("FALSE", 99, 0)] { + let mut state = setup(); + let source = create_object( + &mut state, + CardId(703), + PlayerId(0), + "Conditional Trigger".to_string(), + Zone::Battlefield, + ); + let entry_id = ObjectId(7020); + state.stack.push_back(StackEntry { + id: entry_id, + source_id: source, + controller: PlayerId(0), + kind: StackEntryKind::TriggeredAbility { + source_id: source, + ability: Box::new(ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 3 }, + player: TargetFilter::Controller, + }, + vec![], + source, + PlayerId(0), + )), + condition: Some(TriggerCondition::LifeTotalGE { minimum }), + trigger_event: None, + description: None, + source_name: "Conditional Trigger".to_string(), + subject_match_count: None, + die_result: None, + }, + }); + let depth = state.stack.len(); + let life_before = state.players[0].life; + + let mut events = Vec::new(); + resolve_top(&mut state, &mut events); + + assert_eq!( + state.stack.len(), + depth - 1, + "reach-guard (b/{label}): the entry left the stack either way" + ); + assert_eq!( + state.players[0].life - life_before, + expect_gain, + "CR 603.4 ({label}): the effect runs only when the intervening-if holds" + ); + assert_eq!( + resolved_once(&events, entry_id), + 1, + "CR 405.5 ({label}): the CALLER pushes StackResolved on BOTH sides of \ + the extracted check — the shared function takes no event sink" + ); + } + + // ── (c) CR 107.3m + CR 707.10: the popped paid snapshot survives ── + for (label, snapshot_x, expected_loyalty) in + [("snapshot X=3", Some(3u32), 3u32), ("no snapshot", None, 0)] + { + let mut state = setup(); + let spell_id = create_object( + &mut state, + CardId(704), + PlayerId(0), + "X Loyalty Walker".to_string(), + Zone::Stack, + ); + { + let obj = state.objects.get_mut(&spell_id).unwrap(); + obj.card_types.core_types.push(CoreType::Planeswalker); + obj.printed_loyalty = Some(crate::types::card::PrintedLoyalty::X); + obj.loyalty = None; + } + if let Some(x_value) = snapshot_x { + state.stack_paid_facts.insert( + spell_id, + StackPaidSnapshot { + x_value: Some(x_value), + ..Default::default() + }, + ); + } + state.stack.push_back(StackEntry { + id: spell_id, + source_id: spell_id, + controller: PlayerId(0), + kind: StackEntryKind::Spell { + card_id: CardId(704), + ability: None, + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + let depth = state.stack.len(); + + let mut events = Vec::new(); + resolve_top(&mut state, &mut events); + + assert_eq!( + state.stack.len(), + depth - 1, + "reach-guard (c/{label}): the spell resolved" + ); + assert_eq!( + state.objects[&spell_id].zone, + Zone::Battlefield, + "reach-guard (c/{label}): the permanent spell entered the battlefield" + ); + assert_eq!( + state.objects[&spell_id] + .counters + .get(&CounterType::Loyalty) + .copied() + .unwrap_or(0), + expected_loyalty, + "CR 107.3m ({label}): the ETB counter count comes from the POPPED \ + payment snapshot, which stays bound in `resolve_top`" + ); + } + } + // ----------------------------------------------------------------------- // C2: resolution-default moves route through the zone pipeline so Moved // graveyard→exile redirects (Rest in Peace / Leyline of the Void class) diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index cb72f4425c..065bd044f0 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -99,6 +99,37 @@ pub(crate) fn find_legal_object_targets_for_ability_with_filter_controller( .collect() } +/// CR 115.1: may this seat be chosen as a TARGET of this source? +/// +/// Existence ([`crate::game::players::player_exists_for_choice`], which owns +/// CR 800.4 + CR 102.1 plus the CR 702.26b phasing MIRROR) PLUS the targeting-only +/// exclusions — CR 702.11c hexproof (opponent-scoped), CR 702.18a shroud +/// (source-agnostic), CR 702.16b protection. Every player-target legal-set producer calls +/// THIS, so the enumerating sides cannot drift. +/// +/// NOT the predicate for a non-targeted choice. CR 115.10a draws that boundary — "unless +/// that object or player is identified by the word 'target' ... it's not a target" — so a +/// merely *chosen* seat is judged by [`crate::game::players::player_exists_for_choice`] +/// alone, and its consumers must NOT call this function. Doing so would refuse legal +/// choices. +/// +/// Parameter order deliberately matches `static_abilities::player_cannot_be_targeted_by` +/// so a silent transposition of `source_id` and `source_controller` is unrepresentable. +pub fn player_is_legal_target( + state: &GameState, + player: PlayerId, + source_id: ObjectId, + source_controller: PlayerId, +) -> bool { + crate::game::players::player_exists_for_choice(state, player) + && !super::static_abilities::player_cannot_be_targeted_by( + state, + player, + source_id, + source_controller, + ) +} + fn find_legal_targets_with_context( state: &GameState, filter: &TargetFilter, @@ -193,22 +224,10 @@ fn find_legal_targets_with_context( if tf.type_filters.is_empty() && tf.properties.is_empty() && !is_any_other_target { let controller = &tf.controller; for player in &state.players { - // Player-phasing exclusion (mirrors CR 702.26b for permanents). - if player.is_phased_out() { - continue; - } - // CR 800.4a: Eliminated players are not legal targets. - if player.is_eliminated { - continue; - } - // CR 702.11c + CR 702.18a + CR 702.16b: Player-scope hexproof, - // shroud, and protection exclude illegal player targets. - if super::static_abilities::player_cannot_be_targeted_by( - state, - player.id, - source_id, - source_controller, - ) { + // CR 115.1: one authority for player-target legality — existence + // (CR 800.4 + CR 102.1, phasing per the CR 702.26b MIRROR) plus the + // targeting-only exclusions (CR 702.11c / CR 702.18a / CR 702.16b). + if !player_is_legal_target(state, player.id, source_id, source_controller) { continue; } let include = match controller { @@ -2132,28 +2151,14 @@ fn add_players( source_id: ObjectId, source_controller: PlayerId, ) { - // Player-phasing exclusion: a phased-out player is treated as though they - // don't exist for targeting purposes (mirrors CR 702.26b for permanents, - // applied to players via card Oracle text like "you phase out"). + // CR 115.1: one authority for player-target legality — existence (CR 800.4: + // multiplayer games continue after players leave, + CR 102.1: a player is one of the + // people in the game; player phasing per the CR 702.26b MIRROR) plus the + // targeting-only exclusions (CR 702.11c hexproof / CR 702.18a shroud / + // CR 702.16b protection). CR 608.2b's illegal-target fizzle still applies on + // resolution; this is the announcement-time legal set. for player in &state.players { - if player.is_phased_out() { - continue; - } - // CR 800.4a: When a player leaves the game in a multiplayer game, all - // objects they own/control leave the game and the player ceases to be - // a valid target. Eliminated players cannot be targeted by any spell - // or ability (CR 608.2b illegal-target fizzle applies on resolution). - if player.is_eliminated { - continue; - } - // CR 702.11c + CR 702.18a + CR 702.16b: Player-scope hexproof, shroud, - // and protection exclude illegal player targets. - if super::static_abilities::player_cannot_be_targeted_by( - state, - player.id, - source_id, - source_controller, - ) { + if !player_is_legal_target(state, player.id, source_id, source_controller) { continue; } targets.push(TargetRef::Player(player.id)); @@ -2167,21 +2172,13 @@ fn add_specific_player( source_id: ObjectId, source_controller: PlayerId, ) { - let Some(player) = state.players.iter().find(|player| player.id == player_id) else { - return; - }; - if player.is_phased_out() || player.is_eliminated { - return; - } - if super::static_abilities::player_cannot_be_targeted_by( - state, - player.id, - source_id, - source_controller, - ) { + // CR 115.1: same single authority as `add_players`. The former `find` membership + // guard is subsumed — `player_exists_for_choice` begins with `is_alive`, which is + // itself a membership test, so a nonexistent id is rejected identically. + if !player_is_legal_target(state, player_id, source_id, source_controller) { return; } - targets.push(TargetRef::Player(player.id)); + targets.push(TargetRef::Player(player_id)); } /// CR 702.16b: Protection prevents targeting from sources with the relevant quality. @@ -3651,7 +3648,9 @@ mod tests { assert!(targets.is_empty()); } - /// CR 800.4a: Eliminated players are not legal targets in multiplayer. + /// CR 800.4 + CR 102.1: a seat that has left the game is no longer one of the people + /// in the game, so nothing may choose it — which is why `find_legal_targets` omits it + /// in multiplayer. /// Regression: AI was targeting dead opponents in commander multiplayer. #[test] fn find_legal_targets_excludes_eliminated_player() { @@ -3659,22 +3658,49 @@ mod tests { state.players[1].is_eliminated = true; state.eliminated_players.push(PlayerId(1)); + // EVERY negative below is PAIRED with a positive reach-guard. Without them a + // `find_legal_targets` that returned nothing at all — because it bailed before it + // ever evaluated player legality — would satisfy all three "must not contain" + // assertions and the row would certify an unreached code path. let player_targets = find_legal_targets(&state, &TargetFilter::Player, PlayerId(0), ObjectId(99)); + assert!( + player_targets.contains(&TargetRef::Player(PlayerId(0))), + "reach-guard: the LIVING player must still be a legal `Player` target, or the \ + exclusion below proves nothing about elimination" + ); assert!( !player_targets.contains(&TargetRef::Player(PlayerId(1))), "eliminated player must not appear in legal targets" ); let any_targets = find_legal_targets(&state, &TargetFilter::Any, PlayerId(0), ObjectId(99)); + assert!( + any_targets.contains(&TargetRef::Player(PlayerId(0))), + "reach-guard: the LIVING player must still be reachable under `Any`" + ); assert!( !any_targets.contains(&TargetRef::Player(PlayerId(1))), "eliminated player must not appear under TargetFilter::Any either" ); + // The opponent arm needs a THIRD seat. In the 2p fixture, eliminating P1 removes + // P0's only opponent, so "the eliminated opponent is absent" would hold for a + // filter that simply never yields players — the assertion would be vacuous by + // construction. A live opponent alongside the eliminated one is what makes the + // exclusion attributable to elimination. + use crate::types::format::FormatConfig; + let mut three = GameState::new(FormatConfig::standard(), 3, 42); + three.players[1].is_eliminated = true; + three.eliminated_players.push(PlayerId(1)); let opponent_filter = TargetFilter::Typed(TypedFilter::default().controller(ControllerRef::Opponent)); - let opp_targets = find_legal_targets(&state, &opponent_filter, PlayerId(0), ObjectId(99)); + let opp_targets = find_legal_targets(&three, &opponent_filter, PlayerId(0), ObjectId(99)); + assert!( + opp_targets.contains(&TargetRef::Player(PlayerId(2))), + "reach-guard: the LIVING opponent must match 'target opponent', or the \ + exclusion below is vacuous — got {opp_targets:?}" + ); assert!( !opp_targets.contains(&TargetRef::Player(PlayerId(1))), "eliminated opponent must not match 'target opponent'" diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index cb3f270ce8..2a5611424c 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -1891,7 +1891,11 @@ fn legal_aura_attachment_targets( .collect(); targets.extend(state.players.iter().filter_map(|player| { - if player.is_eliminated || player.is_phased_out() { + // Hygiene routing, behaviour-neutral by construction: `is_eliminated || + // is_phased_out()` on an iterated member is the negation of what + // `players::player_exists_for_choice` spells for a member already known to be in + // `state.players`. Routed so an existence fix propagates here for free. + if !crate::game::players::player_exists_for_choice(state, player.id) { return None; } if crate::game::filter::player_matches_target_filter_in_state( diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 41cf2a04ff..d3543664b6 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -8690,6 +8690,100 @@ fn delayed_trigger_install_command_mut( .as_object_mut() } +/// CR 732.2a: a shortcut proposal describes "a sequence of game choices ... that may be +/// legally taken based on the current game state". A restored `LoopShortcut` OFFER whose +/// bound is `0` can describe no such sequence — it admits only the empty one — so the +/// saved offer is corrupt and the load fails closed rather than reviving it. +/// +/// WHY THIS SEAM AND NOT THE DECLARE SEAM. `max_iterations >= 1` holds for every schema +/// minted IN-PROCESS (`decision_template`'s `Default` via `default_max_iterations`, its +/// `MAX_SHORTCUT_CYCLES` literal, and `game::engine::build_shortcut_schema`'s clamped +/// parameter). A WIRE-sourced offer has no such producer, so the guarantee holds +/// everywhere except across deserialization — which is exactly here. This must NEVER be +/// "fixed" instead by re-refusing the DECLARED count at `handle_declare_shortcut`: a +/// declared `IterationCount::Fixed(0)` is a legal zero-repetition proposal (CR 732.2a +/// admits "a non-repetitive series of choices"), and refusing it there re-creates the +/// over-refusal this phase exists to remove. The zero in the OFFER'S BOUND and the zero +/// in a DECLARED COUNT are different values at different seams. +/// +/// SCOPED TO `LoopShortcut` ONLY, deliberately. `WaitingFor::RespondToShortcut` carries a +/// `ShortcutProposal`, which has no `schema`/`max_iterations` field at all — there is no +/// bound to check. Its only reachable zero is `proposal.count`, the ALREADY-DECLARED +/// count, where `Fixed(0)` is legal; re-refusing it here would re-break the same +/// over-refusal at a seam no row can see. +/// +/// `== 0` is the COMPLETE rejection predicate and must not be widened: `max_iterations` +/// is `u32`, so negatives fail serde before this runs; values at or above +/// `MAX_SHORTCUT_CYCLES` mean "unbounded", a legitimate state; and an ABSENT wire key +/// decodes to `MAX_SHORTCUT_CYCLES` through `#[serde(default = "default_max_iterations")]`, +/// so every legacy save predating the field is untouched. +/// CR 732.2a: a certified period spanning ZERO frames is not a period. +/// +/// Single authority for the field so both wire hosts enforce identically — the offer's +/// `LoopCertificate::per_cycle` and the proposal's `ShortcutProposal::per_cycle` are different +/// structs carrying the same `PeriodicDelta`, and only the second is what the drive reads. +fn reject_zero_frames_per_period( + period: &Option, + host: &str, +) -> Result<(), String> { + if period.as_ref().is_some_and(|pd| pd.frames_per_period == 0) { + return Err(format!( + "persisted {host} states frames_per_period 0, which delimits every committed cycle \ + at the first priority beat rather than at the certified CR 732.2a period" + )); + } + Ok(()) +} + +fn reject_zero_bound_shortcut_offer(state: &GameState) -> Result<(), String> { + if let WaitingFor::LoopShortcut { + schema, + certificate, + .. + } = &state.waiting_for + { + if schema.max_iterations == 0 { + return Err( + "persisted LoopShortcut offer states max_iterations 0, which CR 732.2a admits \ + no legally takeable sequence for" + .to_string(), + ); + } + // The SIBLING wire zero. `max_iterations` says how many repetitions there are; + // `frames_per_period` says what one repetition IS, and a wire-supplied 0 corrupts the + // second question exactly as a 0 bound corrupts the first. + // + // ⚠ THIS CALL COVERS ONE OF THE FIELD'S TWO HOSTS. `frames_per_period` rides + // `PeriodicDelta`, which hangs off BOTH `LoopCertificate` (here) and `ShortcutProposal` + // (the `RespondToShortcut` arm below) — and the second is the one the drive reads. Neither + // call is complete alone; `reject_zero_frames_per_period` is the shared authority so the + // two can never drift apart. + // + // Mechanism, not a symmetry argument: `drive_one_shortcut_cycle` delimits a committed + // cycle with `frames_per_period.is_some_and(|k| frames_this_cycle >= k)` + // (`game/engine.rs`). `frames_this_cycle` is a `u32`, so `>= 0` is a TAUTOLOGY — that + // disjunct fires at the first active-player priority beat, collapsing the cycle boundary + // to one beat instead of the certified span, and every per-cycle conformance check then + // measures a truncated cycle against a whole-period delta. + // + // Production never mints 0 — both certification bases derive it measured (`span as u32` + // on basis A, `k` on basis B) — which is exactly why a 0 arriving on the wire is a + // corrupted or hand-edited save rather than a live shape. + reject_zero_frames_per_period(&certificate.per_cycle, "LoopShortcut offer")?; + } + // THE SECOND WIRE HOST FOR THE SAME FIELD, and the one the drive actually reads. + // `frames_per_period` is not a `LoopCertificate` field — it lives on `PeriodicDelta`, and + // `ShortcutProposal` carries its own `per_cycle` too. `materialize_fixed_shortcut` feeds + // `drive_one_shortcut_cycle` from `proposal.per_cycle.as_ref().map(|pd| pd.frames_per_period)` + // (`game/engine.rs`), i.e. from THIS host, not from the offer's certificate. Guarding only + // the offer would leave the consumed path open — the restored `RespondToShortcut` state is + // reached without ever re-entering `LoopShortcut`. + if let WaitingFor::RespondToShortcut { proposal, .. } = &state.waiting_for { + reject_zero_frames_per_period(&proposal.per_cycle, "RespondToShortcut proposal")?; + } + Ok(()) +} + impl Serialize for TrustedGameStateEnvelope { fn serialize(&self, serializer: S) -> Result where @@ -8783,7 +8877,11 @@ impl GameState { /// Decodes both current trusted snapshots and historical raw `GameState` /// snapshots. The raw form has no pre-cast route authority, so restoring it -/// always drops any protocol wait before it reaches a live game session. +/// routes through `precast_copy_shortcut::normalize_untrusted_restore`, which +/// rewrites only the PRE-CAST COPY waits. Other protocol waits survive intact — +/// a `WaitingFor::LoopShortcut` offer restores as itself — and what validates a +/// restored offer's bound is `reject_zero_bound_shortcut_offer` on the decode +/// path, not a blanket drop here. #[derive(Debug, Clone)] pub enum PersistedGameState { Raw(Box), @@ -12820,6 +12918,32 @@ impl<'de> Deserialize<'de> for PendingLiminalEntryResume { } } +/// CR 104.4b + CR 732.2a: ONE retained loop-detection sample, with its two roles +/// separated at the type level. +/// +/// `normalized` is the **CR 104.4b comparand** — the only half +/// `loop_states_equal_modulo_resources` / `loop_states_cover_modulo_growth*` / +/// `ring_delta_signature` may read. It is byte-identical to the frame the ring held +/// before the split, because [`GameState::normalize_for_loop`] is unchanged. +/// +/// `live` is the **CR 732.2a evaluable** — the same beat un-normalized, and the only +/// half the period-touch consumers may read. Normalization zeroes `next_object_id` and +/// runs `clear_trigger_identity_recursive`, so evaluating a resolution against a +/// normalized frame allocates `ObjectId(0)` over a live object and loses the trigger +/// source identity; those are inputs to a mint and to a resolution probe, never to an +/// equality comparand. +/// +/// `Clone` is load-bearing, not decoration: ring elements are written through +/// `Arc::make_mut` (two sites in `analysis/resource.rs`), which is bounded +/// `T: CloneToUninit` ⇐ `T: Clone`. `PartialEq`/`Serialize`/`Default` are deliberately +/// absent — the field is `#[serde(skip, default)]` and excluded from `impl PartialEq for +/// GameState`, so no site needs them. +#[derive(Debug, Clone)] +pub struct LoopDetectSample { + pub normalized: GameState, + pub live: GameState, +} + /// Declares the runtime state and its private serde-only raw mirror from one /// field list. Keeping the field declaration single-sourced makes persistence /// ingress exhaustive whenever `GameState` evolves. @@ -13144,7 +13268,9 @@ declare_game_state! { )] pub static_mode_presence: crate::types::statics::StaticModePresence, /// CR 732.2a loop-shortcut detection ring (PR-3). A bounded FIFO of recent - /// post-resolution NORMALIZED board snapshots, captured at the post-pipeline frame + /// post-resolution [`LoopDetectSample`]s — each carrying BOTH the CR 104.4b + /// `normalized` comparand (what this ring held before the two roles were split) and + /// the CR 732.2a `live` evaluable — captured at the post-pipeline frame /// of `game::engine::pass_priority_once_with_pipeline` (after /// `run_post_action_pipeline` places refilling triggers, CR 603.3) and scanned at /// the SBA-reconciliation seam (`game::engine::reconcile_terminal_result`). A @@ -13164,7 +13290,7 @@ declare_game_state! { /// `layers_dirty`, which are `serde(skip)` but ARE compared in `eq`) so AI-search /// dedup on semantically-identical positions is unaffected. #[serde(skip, default)] - pub loop_detect_ring: std::collections::VecDeque>, + pub loop_detect_ring: std::collections::VecDeque>, /// Live-only authority for the finite pre-cast shortcut. It is absent from /// raw/public serialization; trusted persistence uses the explicit codec /// envelope in `game::precast_copy_shortcut`. @@ -14871,6 +14997,7 @@ impl GameStateDecode { .map_err(|error| error.to_string())?; normalize_delayed_trigger_allocators(&mut state)?; validate_trigger_firing_coherence(&state)?; + reject_zero_bound_shortcut_offer(&state)?; #[cfg(debug_assertions)] debug_assert_runtime_resolution_invariants(&state); Ok(state) @@ -14894,6 +15021,12 @@ impl GameStateDecode { let mut state = Self::materialize_prepared(value)?; normalize_delayed_trigger_allocators(&mut state)?; validate_trigger_firing_coherence(&state)?; + // Both decode entry points guard, because they are genuinely two ingresses: + // `decode_persisted_resolution_state` above deserializes `ResolutionStateWire` + // itself and never routes through `decode`. Hosting the CR 732.2a bound check on + // only one of them leaves the other — the one a bare-`GameState` `impl Deserialize` + // reaches — able to revive a zero-bound offer. + reject_zero_bound_shortcut_offer(&state)?; #[cfg(debug_assertions)] debug_assert_runtime_resolution_invariants(&state); Ok(state) @@ -19380,10 +19513,27 @@ impl GameState { if self.loop_detect_ring.len() == LOOP_DETECT_RING_CAP { self.loop_detect_ring.pop_front(); } - let snapshot = std::sync::Arc::new(self.normalize_for_loop()); + let snapshot = std::sync::Arc::new(LoopDetectSample { + normalized: self.normalize_for_loop(), + live: self.loop_detect_live_sample(), + }); self.loop_detect_ring.push_back(snapshot); } + /// CR 732.2a: the un-normalized half of a [`LoopDetectSample`] — this beat exactly as + /// it stood, so a period-touch consumer evaluates against real object ids and real + /// trigger-source identity. + /// + /// The ring clear is mandatory and is `normalize_for_loop`'s own reason: samples are + /// produced from the live state, so without it each stored sample would carry a clone + /// of the live ring ⇒ recursive/quadratic growth. **Nothing else is touched** — every + /// other field is what makes this half the evaluable one. + pub(crate) fn loop_detect_live_sample(&self) -> GameState { + let mut clone = self.clone(); + clone.loop_detect_ring.clear(); + clone + } + /// CR 510.2 + CR 704.3 + CR 704.5a + CR 732.2a: drop the loop-detection ring when a /// turn-based action moved a life total with NO intervening priority. /// @@ -20647,6 +20797,7 @@ mod forced_cascade_window_tests { win_kind: crate::analysis::loop_check::WinKind::LethalDamage, mandatory: false, residual_board_delta: crate::analysis::resource::BoardDelta::default(), + per_cycle: None, } } @@ -20918,6 +21069,7 @@ mod forced_cascade_window_tests { unbounded: vec![crate::analysis::resource::ResourceAxis::Life(PlayerId(1))], win_kind: crate::analysis::loop_check::WinKind::LethalDamage, template: None, + per_cycle: None, }, }, ), diff --git a/crates/engine/src/types/mod.rs b/crates/engine/src/types/mod.rs index a30fd16ab0..0dfb31a420 100644 --- a/crates/engine/src/types/mod.rs +++ b/crates/engine/src/types/mod.rs @@ -46,10 +46,10 @@ pub use events::GameEvent; pub use format::{DeckCopyLimit, FormatConfig, GameFormat}; pub use game_state::{ ActionResult, BattlefieldEntryRecord, CommanderDamageEntry, CostResume, GameState, LKISnapshot, - LandPlayRecord, NextSpellModifier, PayCostKind, PendingNextSpellModifier, PendingReplacement, - PendingSpellCostReduction, PlayerDeckPool, PriorityPassingMode, ScheduledTurnControl, - SpellCastRecord, StackEntry, StackEntryKind, TransientContinuousEffect, WaitingFor, - ZoneChangeRecord, + LandPlayRecord, LoopDetectSample, NextSpellModifier, PayCostKind, PendingNextSpellModifier, + PendingReplacement, PendingSpellCostReduction, PlayerDeckPool, PriorityPassingMode, + ScheduledTurnControl, SpellCastRecord, StackEntry, StackEntryKind, TransientContinuousEffect, + WaitingFor, ZoneChangeRecord, }; pub use identifiers::{ CardId, ObjectId, ObjectIdentityBinding, ObjectIncarnationRef, ObjectProvenance, diff --git a/crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz b/crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz index 8e65bac052..01c525e0c0 100644 Binary files a/crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz and b/crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz differ diff --git a/crates/engine/tests/fixtures/dina_conqueror_4p.json.gz b/crates/engine/tests/fixtures/dina_conqueror_4p.json.gz index ea7e095b5f..4b0a30548c 100644 Binary files a/crates/engine/tests/fixtures/dina_conqueror_4p.json.gz and b/crates/engine/tests/fixtures/dina_conqueror_4p.json.gz differ diff --git a/crates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz b/crates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz new file mode 100644 index 0000000000..c082045c56 Binary files /dev/null and b/crates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz differ diff --git a/crates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gz b/crates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gz index cde75b40a9..b56d4aaf17 100644 Binary files a/crates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gz and b/crates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gz differ diff --git a/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gz b/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gz index 7a0afdc831..c4cc26b733 100644 Binary files a/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gz and b/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gz differ diff --git a/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz b/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz index bdabe77972..1796c59b44 100644 Binary files a/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz and b/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_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 new file mode 100644 index 0000000000..43fea5beed --- /dev/null +++ b/crates/engine/tests/integration/fantastic_four_bounded_loop.rs @@ -0,0 +1,1427 @@ +//! 5d U5 — the Fantastic Four bounded-loop acceptance rows, driven from the REAL 4-player +//! playtest dump through the production `apply()` boundary. +//! +//! This module is the first commit that TRACKS +//! `crates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz`; it ships with its +//! loader in the same change (a tracked fixture with no tracked loader is residue). +//! +//! # The board (CR 732.2a) +//! +//! Four Fantastic Four permanents, all P0-controlled, chained into one self-sustaining cycle: +//! +//! * **Human Torch, Johnny Storm** (`403`) — *"Whenever you draw a card, if you control another +//! Hero, ~ deals 1 damage to target opponent."* — a CR 608.2b TARGET choice, three legal +//! opponents. +//! * **The Thing, Ben Grimm** (`404`) — mandatory `PutCounter`, no choice. +//! * **Invisible Woman, Sue Storm** (`402`) — an `optional` (CR 603.5 "may") token creation. +//! * **Mister Fantastic, Reed Richards** (`401`) — *"Whenever one or more tokens you control +//! enter, you may draw a card."* — a second CR 603.5 "may", whose draw re-triggers Torch. +//! +//! Per cycle: P1 loses 1 life, P0's library loses 1 card, two `+1/+1` counters are added. +//! +//! # MEASURED SCOPE OF THIS MODULE — read this before adding a row +//! +//! The bounded offer FIRES on this dump (that is 5d's headline and [`r1_the_bounded_offer_fires_ +//! on_the_real_f4_dump`] is the row). It publishes exactly **one** decision point — Sue's +//! `MayChoice`. Torch's `Targets` point and Reed's `MayChoice` point are **NOT** published, and +//! the mechanism is measured and pinned by +//! [`r1b_the_published_point_set_is_exactly_what_the_retained_window_announces`]: the CR 732.2a +//! ring sampler fires only at `Priority { player == active_player }` after a non-shrinking +//! resolution, so on this board the retained frames alternate strictly between the `404` and +//! `402` stack entries. `certified_period_touch`'s `announced` set is "entries in a frame's +//! stack that were absent from the previous frame's", so the `403` and `401` entries are +//! structurally invisible to conjunct (6) and to `bounded_cycle_pin_slots_for_window`. +//! +//! CONSEQUENCE, also measured and pinned +//! ([`r2_an_accepted_declaration_commits_zero_cycles_because_reeds_may_is_unannounced`]): an +//! accepted `Fixed(n)` declaration carrying the FULL published pin set drives cycle 0, answers +//! Sue's "may" from the pin (U4's arm, on the real dump), and then **aborts** on Reed's +//! unpinned "may" ⇒ whole-cycle rollback, zero commit, manual handback. That is fail-CLOSED and +//! rules-safe, but it is not a grant — so the plan's R2a/R2b/R3/R5 (pass ⇒ grant, respond ⇒ +//! no-grant, Sue-Decline rollback, `victim_slot` keyed by Torch) have no non-vacuous form on +//! this tree and are NOT written here. They are handed back with the mechanism above. + +use engine::analysis::decision_template::{DecisionKind, DecisionPointKind, IterationCount}; +use engine::game::engine::apply; +use engine::types::ability::{ReplacementMode, TargetRef}; +use engine::types::actions::GameAction; +use engine::types::game_state::{GameState, PersistedGameState, StackEntryKind, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::player::PlayerId; +use engine::types::replacements::ReplacementEvent; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); + +/// The four F4 permanents, by their **comma printings** — verified verbatim against the card +/// faces in the dump itself (`objects[401..404].name`). The plain names ("Mister Fantastic", +/// "Human Torch", …) are DIFFERENT cards with different text. +const REED: &str = "Mister Fantastic, Reed Richards"; +const SUE: &str = "Invisible Woman, Sue Storm"; +const TORCH: &str = "Human Torch, Johnny Storm"; +const THING: &str = "The Thing, Ben Grimm"; + +/// `game::engine::MAX_SHORTCUT_CYCLES`, mirrored because it is `pub(crate)` and this binary +/// cannot name it. Only ever used as the "the bound was NARROWED" ceiling; the row's real +/// assertion is the re-derived arithmetic below it, so a drift in the constant cannot make the +/// row pass wrongly — it can only weaken the ceiling half. +const MAX_SHORTCUT_CYCLES_MIRROR: u32 = 1_000; + +fn gunzip(gz: &[u8]) -> String { + use std::io::Read; + let mut json = String::new(); + flate2::read::GzDecoder::new(gz) + .read_to_string(&mut json) + .expect("fixture .json.gz must inflate to UTF-8 JSON"); + json +} + +/// Load the tracked F4 dump's `["gameState"]` through the REAL production restore chokepoint +/// `PersistedGameState::into_game_state` (both the server's `from_persisted` and WASM's +/// `decode_restored_game_state` funnel through it) — never a bare `GameState` decode, which +/// would skip `reject_legacy_raw_prompt_authority` and `decode_persisted_resolution_state`. +/// +/// The dump was captured with the detector OFF; every row here is about the CR 732.2a +/// interactive offer, so the mode is set to `Interactive` at load — the same thing the user's +/// own toggle does. +fn load_f4() -> GameState { + let json = gunzip(include_bytes!( + "../fixtures/fantastic_four_bounded_loop_4p.json.gz" + )); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + let mut state = serde_json::from_value::(envelope["gameState"].clone()) + .expect("gameState deserializes through the production decoder") + .into_game_state(); + state.loop_detection = engine::types::game_state::LoopDetectionMode::Interactive; + state +} + +/// R18 / §3 D6 TARGET A — resolve a fixture object by CARD NAME, never by literal `ObjectId`. +/// +/// The user has announced a re-dump of this same board (*"I will then provide a new F4 .zip +/// game state"*), and a fresh dump RENUMBERS every `ObjectId`. A silent first-match would then +/// bind the acceptance rows to the wrong object and still go green; this fails LOUD on both +/// ambiguity and absence instead. [`r18_the_name_resolver_fails_loud_in_both_directions`] is +/// the row that proves it. +fn resolve_by_name(state: &GameState, name: &str) -> ObjectId { + let hits: Vec = state + .battlefield + .iter() + .filter(|id| state.objects.get(id).is_some_and(|o| o.name == name)) + .copied() + .collect(); + match hits.as_slice() { + [one] => *one, + [] => panic!("fixture name resolution: NO battlefield object named {name:?}"), + many => panic!( + "fixture name resolution: AMBIGUOUS — {} battlefield objects named {name:?}: {many:?}", + many.len() + ), + } +} + +/// One beat of the F4 drive policy, every beat crossing the public `apply()` boundary. +/// +/// At `Priority` ALWAYS pass: the mandatory chain resolves and re-triggers, and that IS the +/// loop — casting here wanders off it. At Torch's CR 608.2b target choice aim **P1** (a +/// CONSTANT seat, so the cycle is board-stable and the detector can certify it); at either +/// CR 603.5 "may" prompt TAKE (declining Sue's token breaks the chain to Reed). +/// +/// ⚠ This is deliberately NOT `loop_shortcut.rs`'s shared `dump_drive_one_beat`: that helper's +/// victim preference matches `GameAction::SelectTargets`, and this dump raises +/// `GameAction::ChooseTarget`, so its pin is inert here and its "first legal non-terminal +/// action" fallback answers Sue's "may" with whichever `DecideOptionalEffect` is enumerated +/// first. MEASURED: under that policy this dump reaches no offering beat at all. +fn f4_drive_one_beat(state: &mut GameState) -> Result<(), String> { + let who = state + .waiting_for + .acting_player() + .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::ChooseTarget { target: Some(TargetRef::Player(p)) } if *p == P1 + ) + }) + .or_else(|| { + actions + .iter() + .find(|a| matches!(a, GameAction::DecideOptionalEffect { accept: true })) + }) + .cloned() + }; + let action = chosen.ok_or_else(|| { + format!( + "the F4 policy answers every beat this drive reaches; unhandled {:?}", + state.waiting_for + ) + })?; + apply(state, who, action.clone()) + .map(|_| ()) + .map_err(|e| format!("apply err ({action:?}): {e:?}")) +} + +/// Drive the loaded dump until the ENGINE raises the CR 732.2a bounded offer, returning that +/// beat index. The beat is SEARCHED, never hardcoded — a hardcoded index is a fixture that +/// drifts silently when the drive policy moves. +fn drive_f4_to_offer(state: &mut GameState, cap: u32) -> Option { + for beat in 0..cap { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + return Some(beat); + } + f4_drive_one_beat(state).ok()?; + } + None +} + +fn offer_parts( + state: &GameState, +) -> ( + PlayerId, + &engine::analysis::loop_check::LoopCertificate, + &engine::analysis::decision_template::ShortcutDecisionSchema, +) { + match &state.waiting_for { + WaitingFor::LoopShortcut { + proposer, + certificate, + schema, + .. + } => (*proposer, certificate, schema), + other => panic!("expected the CR 732.2a bounded offer, got {other:?}"), + } +} + +/// Build the CONFORMANT declaration template for a published schema: one pin per published +/// point, `owner` and `count` supplied by the caller. +/// +/// This is the shape `handle_declare_shortcut` ACCEPTS (measured in +/// [`u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_shape_is_one_it_never_builds`]), +/// so every row that needs either an accepted declaration or a one-axis hostile variant of one +/// builds it here rather than re-deriving the mapping. Keyed off `schema.points` — never off a +/// hard-coded slot — so a re-dump that renumbers objects, or a remedy that widens the announced +/// set, flows through without edit. +/// +/// The per-kind mapping is deliberately total and LOUD on the kinds F4 cannot produce: a +/// silently-skipped point would build a template that `predictability_gate` refuses, and the +/// refusal would be read as the row's subject rather than as the fixture's own gap. +fn f4_pin_template( + schema: &engine::analysis::decision_template::ShortcutDecisionSchema, + owner: PlayerId, + count: u32, +) -> engine::analysis::decision_template::DecisionTemplate { + use engine::analysis::decision_template::{ + DecisionGroupKey, DecisionTemplate, MayChoiceOption, PinnedDecision, ReplayMode, TargetPin, + }; + DecisionTemplate { + owner, + decisions: schema + .points + .iter() + .map(|p| match &p.kind { + DecisionPointKind::MayChoice => PinnedDecision::MayChoice { + slot: p.slot.clone(), + take: MayChoiceOption::Take, + }, + // CR 603.3d + CR 608.2b: F4's only target point is Torch's "target opponent", + // chosen when the trigger goes on the stack and re-checked for legality at + // each resolution. P1 is the constant seat `f4_drive_one_beat` aims at and is + // living on this board, so the pin stays legal for every driven cycle. + DecisionPointKind::Targets { .. } => PinnedDecision::Targets { + slot: p.slot.clone(), + targets: vec![TargetPin::Player(P1)], + }, + other => panic!("unexpected point kind {other:?}"), + }) + .collect(), + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(count), + }, + key: DecisionGroupKey::from_sources( + &schema + .points + .iter() + .map(|p| p.slot.source.clone()) + .collect::>(), + DecisionKind::LoopChoice, + ), + } +} + +/// Restore the `Priority` window the reconcile bridge consumed when it raised the offer, so +/// the mint can be re-run on the offer beat's OWN board. Every caller proves the +/// reconstruction faithful by requiring the same outcome the production path produced. +fn replay_at_priority(state: &GameState, proposer: PlayerId) -> GameState { + let mut replay = state.clone(); + replay.waiting_for = WaitingFor::Priority { player: proposer }; + replay +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// R18 — fail-loud fixture name resolution +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// §6 R18 (a)/(b)/(c) — the resolver every acceptance row's identity flows through. +/// +/// * **(c) the paired positive reach-guard, asserted FIRST**: on the UNMODIFIED dump all four +/// comma printings resolve, to four DISTINCT `ObjectId`s. Without this, (a)/(b) could pass +/// over a resolver that never resolves anything. +/// * **(a)** two battlefield objects sharing the resolved name ⇒ PANIC, not first-match. +/// * **(b)** zero matches ⇒ PANIC, not a `None`-swallow. +/// +/// REVERT-PROBES (both RUN, see the journal): replace the unique-match with a `.find(..)` +/// first-match ⇒ (a) stops panicking ⇒ FLIPS; delete the empty-slice panic arm ⇒ (b) FLIPS. +#[test] +fn r18_the_name_resolver_fails_loud_in_both_directions() { + use std::panic::{catch_unwind, AssertUnwindSafe}; + + let state = load_f4(); + + // ── (c) the anti-vacuity leg: four printings, four DISTINCT ids ── + let ids: Vec = [REED, SUE, TORCH, THING] + .iter() + .map(|n| resolve_by_name(&state, n)) + .collect(); + let mut sorted = ids.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + sorted.len(), + 4, + "(c) the unmodified F4 dump must resolve all four comma printings to four DISTINCT \ + ObjectIds — otherwise (a)/(b) are asserted over a resolver that never resolves \ + anything; got {ids:?}" + ); + + // ── (a) AMBIGUITY ⇒ panic. A second battlefield object is given Torch's exact name; the + // id-literal precedent would have silently taken the first. ── + let ambiguous = { + let mut s = state.clone(); + let clone_target = *s + .battlefield + .iter() + .find(|id| **id != ids[2]) + .expect("the dump's battlefield holds more than one permanent"); + s.objects + .get_mut(&clone_target) + .expect("battlefield ids index live objects") + .name = TORCH.to_string(); + s + }; + let ambiguous_err = catch_unwind(AssertUnwindSafe(|| resolve_by_name(&ambiguous, TORCH))) + .expect_err( + "(a) CR-neutral fixture hygiene: two battlefield objects sharing the resolved name \ + must PANIC, not silently first-match — a re-dump that duplicates a name would \ + otherwise bind the acceptance rows to the wrong object and still go green", + ); + assert!( + panic_message(&ambiguous_err).contains("AMBIGUOUS"), + "(a) the panic must NAME the failure mode so a re-dump reads as a fixture problem, \ + got {:?}", + panic_message(&ambiguous_err) + ); + + // ── (b) ABSENCE ⇒ panic. ── + let absent_err = catch_unwind(AssertUnwindSafe(|| { + resolve_by_name(&state, "Doctor Doom, Victor Von Doom") + })) + .expect_err("(b) a name with zero battlefield matches must PANIC, not be swallowed"); + assert!( + panic_message(&absent_err).contains("NO battlefield object"), + "(b) the panic must name the failure mode, got {:?}", + panic_message(&absent_err) + ); +} + +fn panic_message(payload: &Box) -> String { + payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string())) + .unwrap_or_default() +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// R1 — the offer fires on the real dump, with an independently re-derived bound +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// §6 R1 — the CR 732.2a bounded offer FIRES on the REAL 4-player F4 dump, driven through +/// `apply()`, and its `max_iterations` equals the bound re-derived by this row from the +/// offer-beat board. +/// +/// **STATUS: PARTIAL — pending the (A)/(B) ruling.** §6 R1 as planned also expected the offer to +/// publish three decision points and to be TAKEABLE (commit ≥ 1 cycle). Measured on this tree it +/// publishes ONE point and commits ZERO cycles (see `r1b` and `r2` for the pinned measurements, +/// and the module header for the mechanism). This row therefore ships the half of R1 that the +/// measurement supports — the offer fires, and its bound arithmetic is correct — and pins the +/// other half AS MEASURED rather than asserting the falsified prediction. R2a/R2b/R3/R4/R5 and +/// the interruptibility pair stay unwritten until the ruling lands. +/// +/// # What the assertion is bound to, and why it is not `f(x) == f(x)` +/// +/// The expectation is computed HERE from (i) each living seat's life and library on the +/// offer-beat board and (ii) the per-period delta the ENGINE published on the certificate — it +/// never calls `elimination_bounds`, which is the function under test. Per §6 R1's ROUND-38 +/// (F3) ruling the row is anchored to the **in-tree MAX form** (`resource.rs` +/// `observed_life_loss.max(declared_life_magnitude)` under the `declarable_victims` guard); +/// the additive per-victim form is a tracked follow-up (R1-fu), not a prerequisite. Measured on +/// this board `victim_slot` is EMPTY (see `r5`'s handback in the module header), so the two +/// forms coincide here and the row states which one it assumes. +/// +/// # Reach-guards (each excludes a way this could pass degenerately) +/// +/// * the pre-offer beats really ran the cycle — P1's life FELL and P0's library SHRANK; +/// * the published per-period delta is non-zero on both axes, so the division is not by zero +/// and the `min` is not taken over an empty set; +/// * the bound is NARROWED (`< MAX_SHORTCUT_CYCLES`), so the row is not satisfied by the +/// unnarrowed default every pre-bounded offer carries. +#[test] +fn r1_the_bounded_offer_fires_on_the_real_f4_dump() { + let mut state = load_f4(); + let life_before: Vec = state.players.iter().map(|p| p.life as i64).collect(); + let libs_before: Vec = state.players.iter().map(|p| p.library.len()).collect(); + + let beat = drive_f4_to_offer(&mut state, 400).expect( + "CR 732.2a: the bounded offer must FIRE on this real 4p board. A failure here is the \ + offer never being raised, not a fixture accident — the pre-5d baseline drove 400 \ + beats on this same dump and reached zero LoopShortcut beats", + ); + let (proposer, certificate, schema) = offer_parts(&state); + + assert_eq!( + proposer, P0, + "the proposer is the seat holding priority in the cycle it controls" + ); + + // ── reach-guard: the cycle really ran before the offer ── + let life_now: Vec = state.players.iter().map(|p| p.life as i64).collect(); + let libs_now: Vec = state.players.iter().map(|p| p.library.len()).collect(); + assert!( + life_now[1] < life_before[1] && libs_now[0] < libs_before[0], + "reach-guard: the pre-offer beats must show the cycle RUNNING (P1 life falls, P0 \ + library shrinks). life {life_before:?} -> {life_now:?}, libs {libs_before:?} -> \ + {libs_now:?} over {beat} beats" + ); + + let per_cycle = certificate + .per_cycle + .clone() + .expect("a bounded offer publishes the per-period signature its bound was divided by"); + let life_loss_p1 = -per_cycle.delta.life.get(&P1).copied().unwrap_or(0); + let library_drain_p0 = -per_cycle.delta.library_delta.get(&P0).copied().unwrap_or(0); + assert!( + life_loss_p1 > 0 && library_drain_p0 > 0, + "reach-guard: both live axes must carry a strictly positive per-cycle consumption, \ + else the divisions below are vacuous; delta {:?}", + per_cycle.delta + ); + + // ── the expectation, re-derived independently of `elimination_bounds` ── + // CR 704.5a headroom is `life - 1`: a seat at exactly 0 has LOST, so a legal shortcut must + // stop one point above it. CR 104.3c: an empty library is only lethal on the next draw, so + // the library axis divides the whole remaining library. + let mut bounds: Vec = vec![]; + for player in state.players.iter().filter(|p| !p.is_eliminated) { + let loss = -per_cycle.delta.life.get(&player.id).copied().unwrap_or(0); + if loss > 0 { + bounds.push((player.life as i64 - 1) / loss); + } + let drain = -per_cycle + .delta + .library_delta + .get(&player.id) + .copied() + .unwrap_or(0); + if drain > 0 { + bounds.push(player.library.len() as i64 / drain); + } + } + let expected = bounds + .iter() + .copied() + .min() + .expect("at least one consumed axis, guaranteed by the reach-guard above") + .clamp(0, i64::from(MAX_SHORTCUT_CYCLES_MIRROR)); + + assert_eq!( + i64::from(schema.max_iterations), + expected, + "CR 732.2a + CR 704.5a: `max_iterations` is the MIN over every living seat's \ + elimination headroom, divided by the per-period consumption the certificate itself \ + published. Re-derived here as {bounds:?} -> {expected}; the offer published {}. \ + (This row assumes the IN-TREE max form; see R1-fu.)", + schema.max_iterations + ); + assert!( + schema.max_iterations < MAX_SHORTCUT_CYCLES_MIRROR, + "the bound must be NARROWED, else this row is satisfied by the unnarrowed default \ + every pre-bounded offer carries" + ); +} + +/// §6 R1, SECOND HALF — **a MEASURED CORRECTION to the plan, pinned so it cannot drift +/// silently. STATUS: PARTIAL — this row pins the CURRENT truth of the published point set, not +/// the planned one, pending the (A)/(B) ruling.** +/// +/// R1 as written expects `points ≡ {Targets(403 Torch), MayChoice(401 Reed), +/// MayChoice(402 Sue)}`. That expectation is a HEAD-era SNAPSHOT-mint reading (§2: *"returns 1 +/// point when 403 is up"*), and it does not survive U3's WINDOW mint. Measured on this tree: +/// +/// * the retained ring frames on this board alternate strictly between the `404` and `402` +/// stack entries — the CR 732.2a sampler fires only at `Priority { player == active_player }` +/// after a non-shrinking resolution, and the `403` / `401` entries only ever sit on the stack +/// across a `TriggerTargetSelection` / `OptionalEffectChoice` window; +/// * `certified_period_touch`'s `announced` set is exactly "entries in a frame's stack absent +/// from the previous frame's", so `403` and `401` are never announced; +/// * therefore `bounded_cycle_pin_slots_for_window` publishes exactly ONE point — Sue's +/// `MayChoice`. +/// +/// The row asserts the MEASUREMENT, with the sources named, and the frame census as its own +/// reach-guard. **If a future change widens the announced set this row FAILS LOUDLY and must be +/// re-keyed — which is the point: R2a/R2b/R3/R5 become writable at exactly that moment.** +#[test] +fn r1b_the_published_point_set_is_exactly_what_the_retained_window_announces() { + let mut state = load_f4(); + let (torch, sue, reed, thing) = ( + resolve_by_name(&state, TORCH), + resolve_by_name(&state, SUE), + resolve_by_name(&state, REED), + resolve_by_name(&state, THING), + ); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (_proposer, _certificate, schema) = offer_parts(&state); + + // ── reach-guard: the ring really is populated, and its frames really do alternate over + // exactly {THING, SUE} — this is the fact that EXPLAINS the point set ── + assert!( + state.loop_detect_ring.len() >= 2, + "reach-guard: a window needs at least two retained samples; ring = {}", + state.loop_detect_ring.len() + ); + let framed_sources: std::collections::BTreeSet = state + .loop_detect_ring + .iter() + .flat_map(|f| f.live.stack.iter().map(|e| e.source_id)) + .collect(); + assert_eq!( + framed_sources, + [thing, sue].into_iter().collect(), + "MEASURED: every retained sample's stack holds a {THING:?} ({thing:?}) or {SUE:?} \ + ({sue:?}) entry and NEVER a {TORCH:?} ({torch:?}) or {REED:?} ({reed:?}) one, because \ + those two resolve across a prompt window and the sampler only fires at an \ + active-player `Priority` settle. This is the reach-guard for the point-set assertion \ + below" + ); + assert!( + !framed_sources.contains(&torch) && !framed_sources.contains(&reed), + "stated as its own conjunct because it is the load-bearing half: the two sources whose \ + choices go unpublished are exactly the two the sampler never retains" + ); + + let published: Vec<(ObjectId, &'static str)> = schema + .points + .iter() + .map(|p| { + let source = match &p.slot.source { + engine::types::game_state::YieldTarget::ThisObject { source_id, .. } => *source_id, + other => panic!("unexpected decision source {other:?}"), + }; + let kind = match &p.kind { + DecisionPointKind::MayChoice => "MayChoice", + DecisionPointKind::Targets { .. } => "Targets", + other => panic!("unexpected point kind {other:?}"), + }; + (source, kind) + }) + .collect(); + assert_eq!( + published, + vec![(sue, "MayChoice")], + "MEASURED PLAN CORRECTION (§6 R1): the window mint publishes ONE point — Sue's \ + CR 603.5 `may`. Torch's CR 608.2b `Targets` point and Reed's CR 603.5 `may` are NOT \ + published because their stack entries are never ANNOUNCED (see the frame census \ + above). If this assertion fails because the set GREW, the announced-set derivation \ + changed and R2a/R2b/R3/R5 must be written in the same change" + ); +} + +/// §6 R2, **as measured** — the consequence of the unannounced choices, driven end to end. +/// +/// A `Fixed(n)` declaration carrying the FULL published pin set is ACCEPTED at declare +/// (`predictability_gate` + `validate_pins` both pass — the published set is covered), every +/// living opponent Accepts (CR 732.2c), and then the drive **commits nothing**: cycle 0 answers +/// Sue's `OptionalEffectChoice` from the pin (U4's `inject_pinned_answer` arm, on the real +/// dump), reaches Reed's `OptionalEffectChoice`, finds no pin for it, and returns +/// `CycleOutcome::Abort` ⇒ whole-cycle rollback ⇒ CR 800.4a priority handback. +/// +/// This is FAIL-CLOSED and rules-safe; it is also NOT a grant, so §6 R2a's *"exactly N cycles +/// commit"* has no non-vacuous form here and is handed back rather than weakened. The row pins +/// the zero-commit **together with its cause**, so it cannot be read as "the drive works": +/// +/// * the same `n` is run at 1 and at 3 and BOTH commit zero (a partial commit would separate +/// them, which is the discriminator `bounded_fixed_count_commits_exactly_n_periods` uses); +/// * the declaration is asserted to have been ACCEPTED (`RespondToShortcut` raised), so the +/// zero is the DRIVE's and not a declare-time refusal — that distinction is the whole row; +/// * Reed's "may" is asserted UNPUBLISHED on the same offer, naming the cause. +#[test] +fn r2_an_accepted_declaration_commits_zero_cycles_because_reeds_may_is_unannounced() { + use engine::analysis::loop_check::ShortcutResponse; + + let mut committed_per_n = vec![]; + for n in [1u32, 3] { + let mut state = load_f4(); + let reed = resolve_by_name(&state, REED); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + + assert!( + !schema.points.iter().any(|p| matches!(&p.slot.source, + engine::types::game_state::YieldTarget::ThisObject { source_id, .. } + if *source_id == reed)), + "the CAUSE this row is about: Reed's CR 603.5 `may` is NOT among the published \ + points, so no legal declaration can pin it" + ); + + let template = f4_pin_template(&schema, proposer, n); + + let life_before: Vec = state.players.iter().map(|p| p.life as i64).collect(); + let libs_before: Vec = state.players.iter().map(|p| p.library.len()).collect(); + + apply( + &mut state, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(n), + template: Some(template), + }, + ) + .expect("the declaration is dispatched"); + // THE DISCRIMINATOR between "declare refused it" and "the drive aborted": a refused + // declaration hands priority straight back and never opens the APNAP window. + assert!( + matches!(state.waiting_for, WaitingFor::RespondToShortcut { .. }), + "n={n}: the declaration carrying the FULL published pin set must be ACCEPTED and \ + open the CR 732.2b APNAP window — a `Priority` here would mean the zero-commit \ + below is a declare-time refusal, not the drive's abort. got {:?}", + state.waiting_for + ); + while let WaitingFor::RespondToShortcut { player, .. } = state.waiting_for.clone() { + apply( + &mut state, + player, + GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }, + ) + .expect("each living opponent accepts (CR 732.2c)"); + } + + let life_after: Vec = state.players.iter().map(|p| p.life as i64).collect(); + let libs_after: Vec = state.players.iter().map(|p| p.library.len()).collect(); + assert_eq!( + (&life_after, &libs_after), + (&life_before, &libs_before), + "n={n}: MEASURED — the accepted shortcut commits NOTHING. Cycle 0 answers Sue's \ + `may` from the pin and then aborts on Reed's UNPINNED `may`, and the whole cycle \ + is rolled back (CR 732.2a: an unpinned per-iteration choice is not a describable \ + predictable sequence). If this ever fails because a delta APPEARED, the announced \ + set widened and §6 R2a/R2b/R3/R5 must be written in the same change" + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "n={n}: CR 800.4a — the aborted drive hands back to ordinary priority, got {:?}", + state.waiting_for + ); + committed_per_n.push((life_after, libs_after)); + } + assert_eq!( + committed_per_n[0], committed_per_n[1], + "n=1 and n=3 must be INDISTINGUISHABLE: a partial commit would separate them, and a \ + partial commit is the one outcome CR 732.2a forbids outright" + ); +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// R23 conjunct (5-reach) — the beat guard's reachability on the real dump +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// §6 R23, the (5-reach) arm — **U4's CR 603.3c beat guard never fires on the acceptance +/// fixture, and the fire population it guards against is measurably NON-EMPTY on the same +/// drive.** +/// +/// The guard is `if work.pending_trigger.is_some() { return Err(RecastAbort) }` at the head of +/// `inject_pinned_answer`'s `OptionalEffectChoice` arm: a live CR 603.3c construction cursor +/// means the prompt in hand may be the ANNOUNCEMENT-time optional-modal question rather than +/// the resolution-time "may" the pin answers, and `slot_source_prompted` matches only the +/// SOURCE OBJECT, which both questions share. +/// +/// ⚠ DISCLOSED INSTRUMENT LIMIT: the guard reads the drive's private `work` board, which no +/// test can observe. This row asserts the same property on the OUTER drive — every +/// `OptionalEffectChoice` beat this dump reaches carries `pending_trigger == None` — which is +/// the beat structure the drive replays. **Its non-vacuity is the paired positive**: the same +/// drive DOES visit beats carrying a live cursor (`pending_trigger == Some(TORCH)`), so the +/// instrument demonstrably can report one. +/// +/// **If the `is_none()` assertion ever fires, the remedy is NOT to weaken it**: it is to scope +/// the guard to the prompt's own `source_id` (§5 U2's alternative placement), which changes +/// what the guard MEANS and is an escalation, not a local edit. +#[test] +fn r23_5_reach_no_may_beat_of_the_f4_drive_carries_a_construction_cursor() { + let mut state = load_f4(); + let mut may_beats = 0usize; + let mut cursor_beats = 0usize; + for beat in 0..400u32 { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + break; + } + if let WaitingFor::OptionalEffectChoice { source_id, .. } = &state.waiting_for { + may_beats += 1; + assert!( + state.pending_trigger.is_none(), + "R23 (5-reach): a CR 603.5 `may` beat carrying a LIVE CR 603.3c construction \ + cursor is exactly the configuration U4's beat guard fail-closes on, and it \ + must not occur on the acceptance fixture. beat {beat}, prompt source \ + {source_id:?}, cursor source {:?}. REMEDY IS AN ESCALATION (scope the guard \ + to the prompt's own source_id), NEVER a weakening of this assertion", + state.pending_trigger.as_ref().map(|t| t.source_id) + ); + } + if state.pending_trigger.is_some() { + cursor_beats += 1; + } + if f4_drive_one_beat(&mut state).is_err() { + break; + } + } + // ── the paired positive: both populations are non-empty, so neither half is vacuous ── + assert!( + may_beats > 0, + "reach-guard: the drive must actually REACH CR 603.5 `may` beats, else the assertion \ + above quantifies over nothing" + ); + assert!( + cursor_beats > 0, + "reach-guard: the same drive must visit beats that DO carry a live construction \ + cursor (this dump ships `pending_trigger` on Torch), else `is_none()` is satisfied by \ + an instrument that can never report `Some`" + ); +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// R9 — the environmental discharge, on the production path +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// §6 R9 — **the environmental-discharge row round 2's def-scan design could not fail** — with +/// its keying RE-DERIVED from measurement. +/// +/// # What the row asserts +/// +/// On the offer-beat board, ONE CR 614.1a replacement definition that the resolver's OWN +/// derivation draws turns the OFFER into `UnspecifiedChoiceWindow`; six definitions the +/// resolver's derivation does NOT draw leave the offer standing. That contrast IS the claim: +/// the obligation is **event-derived**, read off what the resolution proposes through +/// `find_applicable_replacements`, and is NOT a scan over `def.event` NAMES. A name scan +/// cannot distinguish the seven definitions below — they differ only in their `event` name. +/// +/// # MEASURED PLAN CORRECTION — the plan's ChangeZone/token keying does not fire +/// +/// §6 R9 keys this row on *"a def carrying `event: ReplacementEvent::ChangeZone` … because +/// Sue's `ProposedEvent::CreateToken` draws it via the `ChangeZone` registry key"*. Measured on +/// this board, that def leaves the offer standing — and the reason is already on record in this +/// lane: U1-fin measured that `Effect::Token` never sets `CreateToken.copy`, and +/// `apply_create_token_after_replacement_with_created_ids` gates the whole `TokenEntry` route +/// on `if let Some(copy) = copy`, so **an `Effect::Token` resolution derives no token-entry +/// event at all** (the same fact that re-keyed R19a). Sue's trigger IS an `Effect::Token` +/// (`Wall` 0/4), so the plan's board cannot reach its own stated mechanism. +/// +/// The row is therefore re-keyed onto the announced entry whose derivation the resolver DOES +/// produce: **The Thing's mandatory `PutCounter P1P1 ×2`, deriving `ProposedEvent::AddCounter`** +/// — same class, same seam, same conjunct, on the same real board. The falsified keys are not +/// dropped: they ship as arm (b), where their NON-firing is the discriminator. +/// +/// # Arms +/// +/// * **(pos)** the UNMODIFIED offer-beat board OFFERS through the metered seam — asserted +/// FIRST, so every refusal below is attributable to the definition and not to the replay. +/// * **(a)** one OPTIONAL `AddCounter` definition ⇒ `UnspecifiedChoiceWindow` (CR 614.1a: an +/// optional replacement is a genuine resolution-time choice ⇒ the period is not choice-free). +/// * **(a′)** the SAME definition, MANDATORY ⇒ still OFFERS. CR 616.1: a lone quantity +/// modification commutes with nothing, so there is no ordering choice to make. This is what +/// keeps (a) keyed to OPTIONALITY rather than to "a definition exists". +/// * **(b)** six definitions whose events this board's resolutions never propose +/// (`ChangeZone`, `Moved`, `CreateToken`, `Draw`, `DamageDone`, `RemoveCounter`), each +/// OPTIONAL ⇒ all still OFFER. A `def.event`-name scan would have to refuse these too. +/// +/// # Reach-guard +/// +/// The live candidate authority is asked directly for the `ProposedEvent::AddCounter` The +/// Thing's resolution proposes, and must return a non-empty set — otherwise (a)'s refusal +/// could belong to some other conjunct. +/// +/// # REVERT-PROBES — RUN, and the FIRST FOUR MEASURED **NOT** TO FLIP, which is the finding +/// +/// The refusal is carried by **two independent authorities**, and each one alone is sufficient: +/// +/// | probe (one production edit) | (a) | +/// |---|---| +/// | delete `resolution_events_are_discharged`'s `!causes.is_empty()` conjunct | still REFUSES | +/// | disable `probe_resolution`'s `waiting_for`-discriminant arm | still REFUSES | +/// | … + its `events.is_empty()` arm | still REFUSES | +/// | … + its `event_is_accounted` arm (all three prompt arms) | still REFUSES | +/// | **all three prompt arms AND the discharge conjunct** | **OFFERS ⇒ (a) FAILS** | +/// +/// Measured at the seam with a throwaway instrument (run, read, deleted): on the unprobed tree +/// The Thing's entry classifies `MayPrompt` — the resolver's OWN probe detects the pending +/// optional replacement — and a MANDATORY entry publishes no `may`, so +/// `pinned_may_choice_relief` returns `None` and conjunct (6) refuses there. Disable that +/// detection and the entry classifies `FreeUnlessReplacements([AddCounter])`, whereupon the +/// CR 614.1a discharge conjunct refuses instead. Defence in depth is the property; a row that +/// flipped on either single edit would have been asserting over only one of the two. +/// +/// ⚠ §6 R9's stated probe (*"swap `proposed_event_prompt_cause` back to a def-scan over +/// `def.event` names"*) is not runnable — that scan and its class map were DELETED at U1 — and +/// its predicted single-edit flip is refuted by the table above. Arm (b) covers what that probe +/// was for: it exhibits six definitions a name scan could not distinguish from (a)'s. +#[test] +fn r9_the_offer_refuses_on_a_derived_replacement_obligation_not_on_a_definition_name() { + use engine::game::engine::{try_offer_bounded_cycle_shortcut_metered, ProbeCap}; + use engine::types::ability::{QuantityModification, ReplacementDefinition}; + use engine::types::counter::CounterType; + use engine::types::proposed_event::{CounterPlacement, ProposedEvent}; + + let mut state = load_f4(); + let thing = resolve_by_name(&state, THING); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, _schema) = offer_parts(&state); + + // ── (pos) the matched positive, asserted first ── + let healthy = replay_at_priority(&state, proposer); + let (healthy_out, healthy_meter) = + try_offer_bounded_cycle_shortcut_metered(&healthy, false, ProbeCap::Shipped); + assert!( + healthy_out.is_ok(), + "matched positive: the UNMODIFIED offer-beat board must still OFFER through the \ + metered seam, else every negative below is asserted over a board that refuses \ + anyway. got {healthy_out:?}, meter {healthy_meter:?}" + ); + + // One definition, installed on an EXISTING P0-controlled permanent (never a new object), + // so board membership — and therefore every certification premise — is untouched. + // CR 614.1a scopes a definition to its controller's events, and The Thing is P0's. + let with_def = |event: ReplacementEvent, optional: bool| -> GameState { + let mut hostile = healthy.clone(); + let mut def = ReplacementDefinition::new(event.clone()); + if optional { + def.mode = ReplacementMode::Optional { decline: None }; + } + if matches!(event, ReplacementEvent::Draw) { + // CR 121.2: a Draw definition must declare its stage or the pipeline debug-asserts. + def.draw_scope = Some(engine::types::ability::DrawReplacementScope::IndividualDraw); + } + def.quantity_modification = Some(QuantityModification::Plus { value: 1 }); + hostile + .objects + .get_mut(&thing) + .expect("The Thing is on the battlefield") + .replacement_definitions + .push(def); + hostile + }; + let outcome = |board: &GameState| { + try_offer_bounded_cycle_shortcut_metered(board, false, ProbeCap::Shipped) + }; + + // ── reach-guard: the LIVE candidate authority draws the optional AddCounter definition + // for the very event The Thing's announced resolution proposes ── + let optional_counter_board = with_def(ReplacementEvent::AddCounter, true); + let proposed = ProposedEvent::AddCounter { + placement: CounterPlacement::Object { + actor: proposer, + object_id: thing, + counter_type: CounterType::Plus1Plus1, + }, + count: 2, + applied: Default::default(), + }; + let candidates = engine::game::replacement::find_applicable_replacements( + &optional_counter_board, + &proposed, + engine::game::replacement::replacement_registry(), + ); + assert!( + !candidates.is_empty(), + "reach-guard: the live candidate authority must draw the definition for the \ + `ProposedEvent::AddCounter` The Thing's `PutCounter P1P1 x2` proposes — a refusal \ + over an EMPTY candidate set would belong to some other conjunct entirely" + ); + + // ── (a) the optional definition refuses ── + let (a_out, a_meter) = outcome(&optional_counter_board); + assert!( + matches!( + a_out, + Err(engine::game::engine::BoundedOfferRefusal::UnspecifiedChoiceWindow) + ), + "(a) CR 614.1a + CR 732.2a: an OPTIONAL replacement candidate applicable to an \ + ANNOUNCED entry's DERIVED event is a real resolution-time choice, so the period is \ + not choice-free and the offer must be refused. got {a_out:?}, meter {a_meter:?}" + ); + + // ── (a′) the same definition, mandatory, still offers ── + let (a2_out, a2_meter) = outcome(&with_def(ReplacementEvent::AddCounter, false)); + assert!( + a2_out.is_ok(), + "(a′) CR 616.1: a LONE mandatory quantity modification commutes with nothing, so it \ + opens no ordering choice and the offer stands. Without this arm (a) would be keyed \ + to `a definition exists` rather than to OPTIONALITY. got {a2_out:?}, meter {a2_meter:?}" + ); + + // ── (b) the def-NAME discriminator: six optional definitions the resolver never draws ── + for event in [ + ReplacementEvent::ChangeZone, + ReplacementEvent::Moved, + ReplacementEvent::CreateToken, + ReplacementEvent::Draw, + ReplacementEvent::DamageDone, + ReplacementEvent::RemoveCounter, + ] { + let (b_out, b_meter) = outcome(&with_def(event.clone(), true)); + assert!( + b_out.is_ok(), + "(b) {event:?}: this board's announced resolutions never PROPOSE this event, so \ + an event-derived obligation must ignore the definition entirely and the offer \ + must stand. A scan over `def.event` NAMES — round 2's design — would refuse here \ + exactly as it refuses in (a), which is what makes this arm the discriminator. \ + (`ChangeZone`/`CreateToken` are §6 R9's own stated keying; see this row's doc for \ + why `Effect::Token` derives no token-entry event.) got {b_out:?}, meter {b_meter:?}" + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// R16 — the probe budget does not starve the F4 acceptance fixture +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// §6 R16 (i) + the exact-demand pin, on the newly tracked F4 fixture. +/// +/// The shipped `PROBE_BUDGET` was re-derived at U3 from dina's offering beat (demand 13). F4 +/// was UNTRACKED then, so the acceptance fixture this whole lane exists for had never been +/// measured against the budget at all. Measured here, at F4's own offering beat, through the +/// metered seam: +/// +/// * the demand is EXACT — `Lowered(d)` offers and every `Lowered(n < d)` refuses, over the +/// seam's closed cap domain. That is a sweep, not a single reading, so the number cannot be +/// an artifact of one call; +/// * `denied == false` at the shipped cap — the budget is not binding on this fixture; +/// * the certification basis at that beat is recorded (`ResourceSignatureOnly`, basis B), +/// because the meter is the only surface on which the basis is observable. +/// +/// # (iii-b) THE ORDERING PIN, re-keyed onto an instrument that exists +/// +/// §6 R16 (iii-b) asks for *"the honest count is 1"* at a beat carrying a non-exempt `optional` +/// entry, with the revert *"move `try_charge_one` above the `optional` gate ⇒ the entry burns a +/// charge in its primary pass as well as its residual pass ⇒ the count rises 1 → 2"*. The +/// count-per-entry is not a `MintMeter` field, but the property is: the meter carries BOTH +/// `spent` and `conjunct6_asks`, so **`spent == conjunct6_asks`** says exactly "every ask +/// charged once", which is the invariant the ordering protects. Under the stated revert an +/// `optional` ask charges twice and `spent > asks`. +/// +/// Its reach-guard is the population the plan asks for: at least one entry the door is asked +/// about must be CR 603.5 `optional` — asserted on the retained window, since Sue's announced +/// entries are the optional ones (the current stack's single entry is The Thing's mandatory +/// `PutCounter`). +/// +/// # (iii-a) — DISCLOSED, the plan's instrument does not exist on this tree +/// +/// §6 R16 (iii-a) pins the *"CURRENT-FRAME charge subcount"* at 1. `MintMeter` has no +/// current-frame subcount, and adding one is a production change with no other consumer. What +/// this row establishes instead, and states as a derivation rather than a reading: the offering +/// beat's `current.stack` holds exactly ONE entry (asserted) and every ask charges exactly once +/// (asserted above) ⇒ the current frame contributes exactly one charge. The unqualified TOTAL +/// is (ii-a)'s figure and is measured directly by the sweep below. +#[test] +fn r16_the_f4_offering_beats_probe_demand_is_exactly_measured() { + use engine::game::engine::{try_offer_bounded_cycle_shortcut_metered, ProbeCap}; + + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, _schema) = offer_parts(&state); + let replay = replay_at_priority(&state, proposer); + + let (shipped_out, shipped) = + try_offer_bounded_cycle_shortcut_metered(&replay, false, ProbeCap::Shipped); + assert!( + shipped_out.is_ok(), + "reach-guard: the replay must reproduce the production OFFER, else every figure below \ + is measured on a different board. got {shipped_out:?}" + ); + assert!( + !shipped.denied, + "R16 (i): the shipped budget must not STARVE the acceptance fixture — a denied budget \ + at the one beat this corpus offers on is the defect U3's re-derivation fixed for \ + dina, measured here for F4. meter {shipped:?}" + ); + assert_eq!( + shipped.certification, + Some(engine::analysis::resource::PeriodCertification::ResourceSignatureOnly), + "the F4 offering beat certifies through BASIS B; the meter is the only surface on \ + which that is observable (both bases publish `frames_per_period`)" + ); + + // ── (iii-b) the ordering pin: every ask charges EXACTLY ONE ── + let optional_in_window = state + .loop_detect_ring + .iter() + .map(|f| optional_entries(&f.live)) + .sum::(); + assert!( + optional_in_window > 0, + "(iii-b) reach-guard: the door must be asked about at least one CR 603.5 `optional` \ + entry, else the ordering property below is asserted over a population that never \ + reaches the `optional` gate at all — the exact defect §6 R16's ROUND-10 (MED-2) \ + re-keying was about" + ); + assert!( + shipped.conjunct6_asks > 0, + "(iii-b) reach-guard: conjunct (6) must actually ASK, else `spent == asks` is `0 == 0`" + ); + assert_eq!( + shipped.spent, shipped.conjunct6_asks, + "(iii-b) CR 603.5: `try_charge_one` sits BELOW the `optional` gate, so an entry pays \ + for its residual pass and never additionally for a primary pass it exits early. \ + Hoisting the charge above that gate makes every optional ask charge TWICE and \ + `spent` exceed `asks`. meter {shipped:?}" + ); + assert_eq!( + state.stack.len(), + 1, + "(iii-a) the derivation's premise: the offering beat's current frame holds exactly ONE \ + entry, so with `spent == asks` the current frame contributes exactly one charge. \ + (`MintMeter` has no current-frame subcount — see this row's doc.)" + ); + + // ── the exact-demand sweep over the seam's closed cap domain ── + let demand = shipped.spent; + assert!( + demand > 0, + "reach-guard: a zero-demand beat would make every `Lowered(n)` below identical to \ + `Lowered(0)` and the sweep vacuous" + ); + let (at_demand, _) = + try_offer_bounded_cycle_shortcut_metered(&replay, false, ProbeCap::Lowered(demand)); + assert!( + at_demand.is_ok(), + "the measured demand {demand} must be SUFFICIENT — `Lowered(demand)` still offers" + ); + for n in 0..demand { + let (out, meter) = + try_offer_bounded_cycle_shortcut_metered(&replay, false, ProbeCap::Lowered(n)); + assert!( + matches!( + out, + Err(engine::game::engine::BoundedOfferRefusal::UnspecifiedChoiceWindow) + ) && meter.denied, + "every cap BELOW the measured demand must exhaust and refuse FAIL-CLOSED, so the \ + demand figure is a boundary and not one lucky reading. cap {n} gave {out:?}, \ + meter {meter:?}" + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// R27 (a1) — the F4 arm of the split-sample row +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// §6 R27 (a1), the F4 arm the plan sites at U5 (*"on BOTH real dumps"*; U0 landed the dellian +/// arm only, because this fixture was untracked until now). +/// +/// CR 104.4b: the loop-detection COMPARAND is `normalize_for_loop`d, which zeroes the object +/// allocator so two structurally identical boards compare equal. CR 732.2a: the EVALUATION +/// board must keep the live allocator cursor, or every downstream consumer is reading a board +/// the game was never in. `LoopDetectSample` splits the two, and this row asserts the split on +/// the real F4 dump, on the allocator axis, at a sample the PRODUCTION sampler wrote. +#[test] +fn r27_a1_the_f4_dumps_recorded_sample_keeps_a_live_half_normalization_would_have_erased() { + let mut state = load_f4(); + assert!( + state.loop_detect_ring.is_empty(), + "reach-guard: the restored dump starts with an EMPTY ring, so the sample asserted on \ + below is one THIS drive's production sampler wrote" + ); + let mut witness = None; + for _ in 0..400u32 { + let before = state.next_object_id; + let ring_before = state.loop_detect_ring.len(); + if f4_drive_one_beat(&mut state).is_err() { + break; + } + if state.loop_detect_ring.len() > ring_before { + witness = Some((before, state.next_object_id)); + break; + } + } + let (before_beat, after_beat) = + witness.expect("the production sampler must grow the ring within the drive's cap"); + let sample = state + .loop_detect_ring + .back() + .expect("the ring just grew, so it has a newest sample"); + + assert!( + before_beat > 0, + "reach-guard: the allocator cursor must be non-zero before the sampled beat, else the \ + inequality below is `0 != 0`" + ); + assert_eq!( + sample.normalized.next_object_id, 0, + "CR 104.4b: the COMPARAND half is normalized — `normalize_for_loop` zeroes the object \ + allocator so two structurally identical boards compare equal" + ); + assert!( + sample.live.next_object_id >= before_beat && sample.live.next_object_id <= after_beat, + "CR 732.2a: the EVALUATION half carries the LIVE allocator cursor, inside the beat's \ + own bracket [{before_beat}, {after_beat}]; got {}", + sample.live.next_object_id + ); + assert_ne!( + sample.live, sample.normalized, + "the two halves must be genuinely different boards — an equal pair would make the \ + split a distinction without a difference" + ); +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// helpers used by more than one row +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// Count the stack entries whose triggered ability is CR 603.5 `optional`. Used as a reach +/// guard where a row's claim is about the optional gate. +fn optional_entries(state: &GameState) -> usize { + state + .stack + .iter() + .filter(|e| match &e.kind { + StackEntryKind::TriggeredAbility { ability, .. } => ability.optional, + _ => false, + }) + .count() +} + +// ───────────────────────────────────────────────────────────────────────────────────────── +// U6 — the AI's candidate set at the real F4 offer, and what the engine does with it +// +// Reachability of the seam under test: `phase_ai::search::choose_action` dispatches +// `WaitingFor::LoopShortcut { .. } => engine::ai_support::legal_actions(state)` +// (`crates/phase-ai/src/search.rs`), and `legal_actions` funnels into the +// `WaitingFor::LoopShortcut` arm of `engine::ai_support::candidates`. These rows drive the +// REAL dump to the REAL offer and measure that arm's output there, plus what +// `handle_declare_shortcut` does with each member of it. +// +// ⚠ MEASURED SCOPE. §5 U6 as planned expects a declare candidate "whose template pins all +// three F4 slots (or declines)". F4 publishes ONE point, not three (see `r1b`), and the +// measured answer to the underlying question is the second branch: the AI DECLINES, because +// the only declaration it can emit is one the engine refuses outright. These rows pin that, +// name the two independent reasons, and pin the accepted shape the generator never emits — +// they do not assert the planned prediction. +// ───────────────────────────────────────────────────────────────────────────────────────── + +/// §5 U6 (i) — MEASURED: at the real F4 bounded offer the engine's AI candidate generator +/// emits exactly ONE action, `DeclineShortcut`. It offers no declaration at all. +/// +/// Both declare candidates are excluded, each by a different conjunct, and this board trips +/// both at once: +/// +/// * `UntilLethal` is gated on `!schema.is_bounded()`. CR 732.2a: a count-free declaration +/// names no legal repetition number against an offer that narrowed the bound, and +/// `handle_declare_shortcut` refuses it — measured in +/// [`u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_shape_is_one_it_never_builds`]. +/// * `Fixed(max_iterations)` is gated on `schema.points.is_empty()` — it carries +/// `template: None`, and a published pin set fail-closes on that. F4 publishes one point. +/// +/// So the AI declines because it has nothing else it can legally say, not because it emitted a +/// declaration the engine then accepted-and-discarded. +/// +/// # Reach-guards (each excludes a way this could pass degenerately) +/// +/// * the offer is BOUNDED (`is_bounded()`, bound narrowed below the ceiling) — that is the +/// `UntilLethal` gate's conjunct; on an unbounded offer that candidate would be PRESENT, so +/// without this guard the row could pass on a board where it was never at issue; +/// * `schema.points` is NON-empty — that is the `Fixed` gate's conjunct, and symmetrically the +/// row would otherwise pass on a board where `Fixed` was never at issue; +/// * `predicted_winner` is `None`. Recorded as a measured property of this board, NOT as +/// reachability for `phase_ai::policies::loop_shortcut::LoopShortcutPolicy`'s +/// `(None, UntilLethal) => reject` arm: since the bounded gate landed, this generator can no +/// longer put that pair in front of the policy from a bounded offer, and +/// `declare_until_lethal_with_no_predicted_winner_is_rejected` covers the arm directly. +/// +/// REVERT-PROBE — one per excluded candidate, because a single probe would leave the OTHER +/// exclusion holding the assertion up and report a false pass: +/// +/// * drop `!schema.is_bounded()` from the `UntilLethal` push in `ai_support/candidates.rs` +/// ⇒ `DeclareShortcut { UntilLethal, None }` reappears ⇒ this row FLIPS on the equality; +/// * drop `schema.points.is_empty() &&` from the `Fixed` push ⇒ `Fixed(max_iterations)` +/// appears ⇒ this row FLIPS on the equality. +#[test] +fn u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + let WaitingFor::LoopShortcut { + predicted_winner, .. + } = &state.waiting_for + else { + unreachable!("offer_parts would have panicked") + }; + + assert!( + schema.is_bounded() && schema.max_iterations < MAX_SHORTCUT_CYCLES_MIRROR, + "reach-guard: the generator's `Fixed` candidate is gated on `is_bounded()` too, so an \ + unbounded offer would exclude it for the wrong reason. bounded={} max_it={}", + schema.is_bounded(), + schema.max_iterations + ); + assert!( + !schema.points.is_empty(), + "reach-guard: a NON-empty published pin set is the conjunct this row is about" + ); + assert_eq!( + *predicted_winner, None, + "reach-guard + REACHABILITY for `phase_ai::policies::loop_shortcut`: the F4 offer \ + latches NO predicted winner, which is what routes its `(None, UntilLethal)` reject arm" + ); + + // ── the seam: `phase-ai/src/search.rs` `WaitingFor::LoopShortcut { .. } =>` calls this ── + let actions = engine::ai_support::legal_actions(&state); + assert_eq!( + actions, + vec![GameAction::DeclineShortcut], + "MEASURED: exactly one candidate. No `UntilLethal` declaration (gated on \ + `!schema.is_bounded()`, and this offer narrowed its bound to {}), no `Fixed` \ + declaration (gated on `schema.points.is_empty()`, and this schema publishes {} \ + point(s)), and no declaration carrying a template at all — so the AI cannot pin the \ + point the offer DID publish", + schema.max_iterations, + schema.points.len() + ); + + // Stated separately from the equality above so a future generator change that adds an + // unrelated candidate reports the interesting fact rather than a diff of two long vectors. + assert!( + !actions.iter().any(|a| matches!( + a, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(_), + .. + } + )), + "no `Fixed` candidate is generated against a points-carrying offer" + ); + assert!( + !actions.iter().any(|a| matches!( + a, + GameAction::DeclareShortcut { + template: Some(_), + .. + } + )), + "the generator never builds a pin template — that is the capability §5 U6 asks about" + ); + assert_eq!( + proposer, P0, + "every candidate is the proposer's own action (`ActionMetadata.actor`)" + ); +} + +/// §5 U6 (ii) — the branch that fires, and the MEASURED reason it fires. +/// +/// Every action the AI can take at this offer hands priority straight back; the accepted shape +/// is one the generator never emits. Four declarations are driven through `apply()` on the SAME +/// real offer board, differing one axis at a time: +/// +/// | declaration | measured | +/// |---|---| +/// | `UntilLethal` + `None` — **the shape the generator emitted before the bounded gate** | REFUSED ⇒ `Priority` | +/// | `UntilLethal` + a conformant template | REFUSED ⇒ `Priority` (so the refusal is keyed on the COUNT, not on the pins) | +/// | `Fixed(max)` + `None` | REFUSED ⇒ `Priority` (`template: None` against a non-empty schema fail-closes when `last_loop_action_sequence` is empty — measured empty here) | +/// | `Fixed(max)` + a conformant template | **ACCEPTED** ⇒ the CR 732.2b APNAP window opens | +/// +/// The last row is the ANTI-VACUITY control: without it, "everything reaches `Priority`" would +/// be satisfied by a board that refuses every declaration for some unrelated reason. With it, +/// the three refusals are proved to be refusals of *those* declarations. +/// +/// ⚠ This row deliberately does NOT assert that the accepted declaration accomplishes +/// anything — measured, it commits zero cycles ([`r2_an_accepted_declaration_commits_zero_cycles_because_reeds_may_is_unannounced`]). +/// Closing the generator gap would therefore ride the grant mechanism, which is why U6 reports +/// the gap rather than building the candidate. +/// +/// The `UntilLethal` rows are what justifies the generator's `!schema.is_bounded()` gate +/// ([`u6_the_ai_candidate_set_at_the_f4_offer_is_decline_only`]): the engine refuses that count +/// against a narrowed bound on a real board, so emitting it was offering the search layer an +/// action that is accepted-then-discarded. These rows keep measuring the ENGINE guard directly, +/// which is the fact the generator gate depends on and must not be allowed to rot. +/// +/// REVERT-PROBES, both RUN, and the measured result CORRECTS the obvious prediction — the +/// count-free declaration is refused by TWO INDEPENDENT guards, so disabling either alone +/// leaves it refused: +/// +/// * disable `IterationCount::UntilLethal if offer.schema.is_bounded()` in +/// `handle_declare_shortcut` ⇒ the *`UntilLethal` + conformant template* arm flips +/// (`Priority` → `RespondToShortcut`), while the AI's own `template: None` candidate stays +/// refused by the `None if last_loop_action_sequence.is_empty()` arm; +/// * disable BOTH ⇒ the AI-candidate loop itself flips — `UntilLethal` + `None` builds a +/// proposal and opens APNAP for `PlayerId(1)`. +/// +/// The row asserts both arms for exactly that reason: a single-guard probe would report the +/// AI's candidate as still-refused and hide the change. +#[test] +fn u6_no_declaration_the_generator_can_emit_opens_the_window_while_the_accepted_shape_is_one_it_never_builds( +) { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + let max = schema.max_iterations; + + assert!( + state.last_loop_action_sequence.is_empty(), + "the measured precondition for the `Fixed` + `None` arm below: with a NON-empty \ + sequence a template-free declaration is legitimately re-derivable and that arm would \ + be measuring something else. len={}", + state.last_loop_action_sequence.len() + ); + + // Every AI candidate, driven through the public boundary. Since the bounded gate landed this + // set is `[DeclineShortcut]` alone, so on its own the loop is a WEAK statement — it is the + // four one-axis drives below that carry this row. Kept because it is the only assertion here + // that re-derives the candidate set from the generator rather than naming shapes by hand: a + // future generator change that reintroduces a declaration at this node has to survive it. + let candidates = engine::ai_support::legal_actions(&state); + assert!( + !candidates.is_empty(), + "positive control: an EMPTY candidate set would satisfy the loop below vacuously" + ); + for action in candidates { + let mut probe = state.clone(); + apply(&mut probe, proposer, action.clone()).expect("dispatched — refusal is a HANDBACK"); + assert!( + matches!(probe.waiting_for, WaitingFor::Priority { .. }), + "CR 800.4a: the AI candidate {action:?} hands priority back. A \ + `RespondToShortcut` here would mean the AI CAN open the CR 732.2b window, which \ + is the capability this row measures absent. got {:?}", + probe.waiting_for + ); + } + + let outcome = |count: IterationCount, template: Option<_>| { + let mut probe = state.clone(); + apply( + &mut probe, + proposer, + GameAction::DeclareShortcut { count, template }, + ) + .expect("dispatched — refusal is a HANDBACK"); + probe.waiting_for.variant_name() + }; + + assert_eq!( + outcome( + IterationCount::UntilLethal, + Some(f4_pin_template(&schema, proposer, 1)) + ), + "Priority", + "CR 732.2a: the refusal of the AI's candidate is keyed on the COUNT — `UntilLethal` \ + against a narrowed bound — not on its missing pins. Carrying the very template the \ + positive control below has accepted changes nothing" + ); + assert_eq!( + outcome(IterationCount::Fixed(max), None), + "Priority", + "and 'just emit `Fixed`' is not a template-free remedy: a `template: None` declaration \ + against a non-empty schema fail-closes when `last_loop_action_sequence` is empty" + ); + // ── ANTI-VACUITY CONTROL: this board DOES accept a declaration ── + assert_eq!( + outcome( + IterationCount::Fixed(max), + Some(f4_pin_template(&schema, proposer, max)) + ), + "RespondToShortcut", + "the accepted shape is `Fixed(n)` + a template pinning every published point, owner == \ + proposer. Without this arm the three refusals above would be vacuous" + ); +} + +/// §5 U6 (iii) — the declare-time `template.owner` firewall, exercised on the REAL F4 offer. +/// +/// `loop_shortcut.rs`'s `r28_a_declared_template_owning_another_seat_is_refused_at_declare` +/// already covers this seam on a STAGED offer; this is the real-dump arm — a 4-player board +/// whose schema, pin slots and proposer all come from a captured game rather than from a +/// scenario built to reach the guard. The matched pair differs in exactly one field. +/// +/// Reach-guards: the published pin set is non-empty, so `predictability_gate` and +/// `validate_pins` really run and the accepting arm proves they PASS (a refusal on both arms +/// would otherwise be reported as a firewall hit); and the hostile owner names a LIVING seat +/// that is not the proposer, which is the only shape the guard can distinguish. +/// +/// REVERT-PROBE (shared with `r28_a`, and recorded as shared): delete +/// `if template.as_ref().is_some_and(|t| t.owner != offer.proposer)` from +/// `handle_declare_shortcut` ⇒ the hostile arm opens APNAP ⇒ this row FLIPS. +#[test] +fn u6_the_declare_owner_firewall_holds_on_the_real_f4_offer() { + let mut state = load_f4(); + drive_f4_to_offer(&mut state, 400).expect("the bounded offer fires (see R1)"); + let (proposer, _certificate, schema) = offer_parts(&state); + let schema = schema.clone(); + + assert!( + !schema.points.is_empty(), + "reach-guard: a non-empty schema means `predictability_gate` / `validate_pins` really \ + run, so the accepting arm below proves the pair is keyed to `owner`" + ); + let hostile = state + .players + .iter() + .find(|p| p.id != proposer && !p.is_eliminated) + .map(|p| p.id) + .expect("reach-guard: a living seat other than the proposer must exist on a 4p board"); + + let mut outcomes = vec![]; + for owner in [proposer, hostile] { + let template = f4_pin_template(&schema, owner, 1); + assert_eq!( + template.owner, owner, + "the two arms differ in exactly one field" + ); + let mut probe = state.clone(); + let result = apply( + &mut probe, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: Some(template), + }, + ) + .expect("dispatched either way — refusal is a HANDBACK"); + outcomes.push((probe.waiting_for.variant_name(), result.events.len())); + } + + assert_eq!( + outcomes, + vec![("RespondToShortcut", 0), ("Priority", 0)], + "CR 732.2a + CR 603.5: the declaration owned by the engine-issued proposer opens the \ + APNAP window; the byte-identical declaration owned by {hostile:?} is refused into the \ + CR 800.4a manual handback. `handle_declare_shortcut` pushes no events on either path, \ + so the event counts are exact rather than wildcards" + ); +} diff --git a/crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs b/crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs new file mode 100644 index 0000000000..9c3ff350c9 --- /dev/null +++ b/crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs @@ -0,0 +1,202 @@ +//! R4g — CR 702.174a: Gift is *"you may **choose** an opponent"*, a CHOICE and not a +//! target (CR 115.10a), so the recipient list is the seats that still exist to be chosen. +//! +//! Two arms, and the second one is the reason this file exists at all. Narrowing the +//! recipient list moves boards across the gift seam's low-cardinality branches, and one of +//! those branches is the only ERRORING branch in the whole choice-enumeration class: with +//! no choosable opponent, an accepted gift promise becomes an engine error. That branch was +//! previously reachable only by eliminating every opponent — which ends the game — and is +//! newly reachable at a LIVE table once phasing narrows the list. An erroring branch nobody +//! asserts is a branch that changes silently. +//! +//! The production entry chain is the `DecideOptionalCost` beat, NOT `handle_cast_spell`: +//! `GameAction::DecideOptionalCost { pay: true }` → `engine_casting::handle_optional_cost_ +//! choice` → `casting_costs::handle_decide_additional_cost` → `continue_after_gift_promised` +//! (which is private and is never called directly here). `handle_cast_spell` opens the cast +//! that publishes the `OptionalCostChoice`, one action earlier. + +use engine::game::engine::EngineError; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::types::ability::AdditionalCostOrigin; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::ObjectId; + +const PEERLESS_RECYCLING: &str = + "Gift a card (You may promise an opponent a gift as you cast this spell. \ +If you do, they draw a card before its other effects.)\n\ +Return target permanent card from your graveyard to your hand. If the gift was promised, \ +instead return two target permanent cards from your graveyard to your hand."; + +/// A 5-seat board with a castable Gift spell, `phase_out` seats transitioned through the +/// PRODUCTION phasing API, and P2 eliminated unless the caller asks otherwise. +/// +/// Both arms differ ONLY in which seats are phased out, so the pair is one narrowing +/// series rather than two unrelated boards. +fn gift_board( + phase_out: &[PlayerId], + eliminate: Option, +) -> (engine::game::scenario::GameRunner, ObjectId) { + let mut scenario = GameScenario::new_n_player(5, 42); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + (0..2) + .map(|_| ManaUnit::new(ManaType::Green, ObjectId(0), false, vec![])) + .collect(), + ); + scenario.add_creature_to_graveyard(P0, "Bear Cub", 2, 2); + scenario.add_creature_to_graveyard(P0, "Cougar Cub", 2, 2); + // MTGJSON supplies the bare "Gift" keyword hint; scenario inference cannot FromStr a + // "Gift a card (reminder…)" line, so pass the production hint. + let spell = scenario + .add_spell_to_hand(P0, "Peerless Recycling", true) + .from_oracle_text_with_keywords(&["Gift"], PEERLESS_RECYCLING) + .id(); + + let mut runner = scenario.build(); + let mut events = Vec::new(); + for seat in phase_out { + // Setup anti-vacuity: `phase_out_player` reports the seats it transitioned, so a + // silent no-op fails loudly here instead of quietly weakening the arm. + let transitioned = + engine::game::phasing::phase_out_player(runner.state_mut(), *seat, &mut events); + assert_eq!( + transitioned, + vec![*seat], + "phase_out_player must actually transition {seat:?}" + ); + } + if let Some(seat) = eliminate { + engine::game::elimination::eliminate_player(runner.state_mut(), seat, &mut events); + assert!( + runner.state().players[seat.0 as usize].is_eliminated, + "{seat:?} must read as eliminated" + ); + } + (runner, spell) +} + +/// Drive the cast up to and including the gift promise, asserting the two SHIPPED guards +/// on the way. Returns whatever the promise beat returned, so the caller can assert on +/// either the published prompt or the `Err`. +/// +/// GUARD 1 — the `OptionalCostChoice` carrying the Gift origin is published. That is the +/// in-test proof the promise path was entered at all, taken BEFORE the promise is +/// submitted, so a later `Err` cannot be an earlier rejection wearing a gift's name. +/// GUARD 2 — `players::opponents` (the UN-routed sibling the fix does not touch) is still +/// non-empty on this same board, so any emptiness the routed seam reports is attributable +/// to the routing rather than to a board with no opponents. +/// +/// Both guards are runnable in the SHIPPED tree by construction: the gift promise is +/// queued with no opponents gate, and `players::opponents` is not narrowed by this phase. +fn promise_the_gift( + runner: &mut engine::game::scenario::GameRunner, + spell: ObjectId, +) -> Result { + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast Peerless Recycling"); + + for _ in 0..50 { + match &runner.state().waiting_for { + WaitingFor::ManaPayment { .. } => { + runner + .act(GameAction::PassPriority) + .expect("complete mana payment"); + } + WaitingFor::OptionalCostChoice { origin, .. } => { + // GUARD 1. + assert_eq!( + *origin, + AdditionalCostOrigin::Gift, + "the promise path must be entered: the published optional cost is Gift" + ); + // GUARD 2. + assert!( + !engine::game::players::opponents(runner.state(), P0).is_empty(), + "the UN-routed opponent relation is still non-empty on this board, so \ + an empty CHOOSABLE list is the routing's doing and not the board's" + ); + return runner.act(GameAction::DecideOptionalCost { pay: true }); + } + other => panic!("unexpected beat before the gift promise: {other:?}"), + } + } + panic!("the cast never reached the Gift optional-cost choice"); +} + +/// R4g arm 1 — the offer arm. A phased-out seat (the CR 702.26b MIRROR) and a departed one +/// (CR 800.4 + CR 102.1) are both absent from the published recipient list, and the two +/// surviving opponents are both present. +/// +/// THE OFFER MUST STILL FIRE, which is both the reach-guard and the anti-vacuity control: +/// with a single choosable opponent the seam takes the sole-opponent auto-latch branch and +/// publishes nothing at all, so a `!contains` assertion would pass on a board where no +/// choice was ever offered. Hence total equality on the published variant. +/// +/// REVERT-PROBE: restore `players::opponents` at the gift candidate derivation ⇒ P1 +/// reappears ⇒ the equality FAILS. +#[test] +fn gift_recipient_offer_excludes_a_phased_out_opponent_and_still_offers_the_rest() { + let (mut runner, spell) = gift_board(&[P1], Some(PlayerId(2))); + promise_the_gift(&mut runner, spell).expect("the promise is accepted with two recipients"); + + match &runner.state().waiting_for { + WaitingFor::ChooseGiftRecipient { candidates, .. } => { + assert_eq!( + *candidates, + vec![PlayerId(3), PlayerId(4)], + "phased-out P1 and eliminated P2 are out; both valid opponents are in" + ); + } + other => panic!("expected ChooseGiftRecipient, got {other:?}"), + } +} + +/// R4g arm 2 — the `0`-crossing, this class's only ERROR branch. +/// +/// Every opponent is phased out and NONE is eliminated, so the table is still live: a board +/// that reaches zero choosable opponents by elimination would also end the game, which is a +/// different thing to assert. An accepted gift promise with no choosable recipient is an +/// invalid action, and 5c PINS that pre-existing `Err` rather than changing it — what 5c +/// changes is that the branch is newly reachable at a live table. +/// +/// The two shipped guards inside `promise_the_gift` are what make this arm non-vacuous: a +/// post-fix drive CANNOT reach `ChooseGiftRecipient` on this board, so a "the prompt is not +/// published" assertion would need the fix reverted to mean anything, and a revert-probe is +/// an executor action rather than a shipped guard. +/// +/// REVERT-PROBE: restore `players::opponents` at the gift candidate derivation ⇒ the +/// candidate list is non-empty again ⇒ the prompt publishes instead of erroring ⇒ FAILS. +#[test] +fn gift_promise_with_every_opponent_phased_out_is_an_invalid_action() { + let (mut runner, spell) = gift_board(&[P1, PlayerId(2), PlayerId(3), PlayerId(4)], None); + let outcome = promise_the_gift(&mut runner, spell); + + match outcome { + Err(EngineError::InvalidAction(message)) => { + assert!( + message.to_lowercase().contains("gift"), + "the rejection must name the gift it refused, got: {message}" + ); + } + other => panic!("expected InvalidAction naming the gift, got {other:?}"), + } + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::ChooseGiftRecipient { .. } + ), + "no recipient prompt may be published when no opponent is choosable" + ); +} diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 25e28a02fa..199e773e0c 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -2358,6 +2358,7 @@ fn loop_shortcut_zero_max_iterations_is_rejected_not_clamped() { win_kind: engine::analysis::loop_check::WinKind::LethalDamage, mandatory: false, residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: None, }, schema: engine::analysis::decision_template::ShortcutDecisionSchema { iteration_count: engine::analysis::decision_template::IterationCount::Fixed(2), @@ -2417,6 +2418,7 @@ fn loop_shortcut_narrowed_max_iterations_bounds_the_picker() { win_kind: engine::analysis::loop_check::WinKind::LethalDamage, mandatory: false, residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: None, }, schema: engine::analysis::decision_template::ShortcutDecisionSchema { // A NARROWED bound, i.e. what `elimination_bounds` produces on a real board. @@ -2465,6 +2467,7 @@ fn loop_shortcut_number_schema_accepts_a_fixed_count_above_one() { win_kind: engine::analysis::loop_check::WinKind::LethalDamage, mandatory: false, residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: None, }, schema: engine::analysis::decision_template::ShortcutDecisionSchema { iteration_count: engine::analysis::decision_template::IterationCount::Fixed(2), @@ -2519,10 +2522,11 @@ fn loop_shortcut_schema_and_materializer_cover_every_decision_point_kind() { win_kind: engine::analysis::loop_check::WinKind::Advantage, mandatory: false, residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: None, }, schema: ShortcutDecisionSchema { iteration_count: IterationCount::Fixed(2), - // No narrowed CR 732.2a bound — the global cap, as every offer states today. + // No narrowed CR 732.2a bound — `Default` carries the global cap. max_iterations: ShortcutDecisionSchema::default().max_iterations, points: vec![ DecisionPoint { diff --git a/crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs b/crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs index 95c15b59d7..61f9a482b9 100644 --- a/crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs +++ b/crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs @@ -477,3 +477,107 @@ fn controller_may_choose_different_announcing_opponents_per_effect() { let stack_entry = runner.state().stack.last().expect("spell is on the stack"); assert_eq!(stack_entry.controller, P0); } + +/// R4i — CR 601.2c + CR 115.10a: the announcing opponent is CHOSEN by the spell's +/// controller, not targeted, so the candidate list is the seats that still exist to be +/// chosen: a phased-out seat is out (the CR 702.26b MIRROR) and a departed one is out +/// (CR 800.4 + CR 102.1). +/// +/// ONE ROW, TWO MINTS. The engine mints `ChooseAnnouncingOpponent` in two different +/// places — the cast-time mint in `casting.rs` and the per-group re-prompt in +/// `casting_costs::begin_deferred_target_selection` — and a spell with TWO +/// opponent-choice groups traverses both in one drive. Asserting total equality at BOTH +/// prompts is what makes the row discriminating for two sites at once: restoring only the +/// first site flips the first assertion, restoring only the second flips the second. +/// +/// FIVE SEATS: each mint is gated on `candidates.len() >= 2`, so with fewer surviving +/// opponents the cast proceeds with no prompt at all and every exclusion assertion would +/// be unreachable. Asserting the published variant IS the reach-guard. +#[test] +fn announcer_offer_excludes_a_phased_out_opponent_at_both_mints() { + let p3 = PlayerId(3); + let p4 = PlayerId(4); + + let mut scenario = GameScenario::new_n_player(5, 7); + scenario.at_phase(Phase::PreCombatMain); + + // Both SURVIVING opponents control a nonbasic land and a creature, so every slot has + // a legal target whichever of them the controller picks. + let land_p3 = nonbasic_land(&mut scenario, p3, "P3 Land"); + let creature_p3 = scenario.add_creature(p3, "P3 Creature", 5, 5).id(); + let land_p4 = nonbasic_land(&mut scenario, p4, "P4 Land"); + let creature_p4 = scenario.add_creature(p4, "P4 Creature", 5, 5).id(); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Volcanic Offering", true, VOLCANIC_OFFERING) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + + // Setup anti-vacuity, asserted before the cast: the production APIs report what they + // transitioned, so a silent no-op fails loudly here. + let mut setup_events = Vec::new(); + let transitioned = + engine::game::phasing::phase_out_player(runner.state_mut(), P1, &mut setup_events); + assert_eq!( + transitioned, + vec![P1], + "phase_out_player must actually transition P1" + ); + assert!( + runner.state().players[1].is_phased_out(), + "P1 must read as phased out" + ); + engine::game::elimination::eliminate_player(runner.state_mut(), PlayerId(2), &mut setup_events); + assert!( + runner.state().players[2].is_eliminated, + "P2 must read as eliminated" + ); + + let spell_card = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id: spell_card, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting the free instant must succeed"); + + // BOTH mints: the cast-time one and the per-group re-prompt. + for effect in ["second land", "second creature"] { + match &runner.state().waiting_for { + WaitingFor::ChooseAnnouncingOpponent { + player, candidates, .. + } => { + assert_eq!( + *player, P0, + "the controller chooses the announcing opponent ({effect})" + ); + assert_eq!( + *candidates, + vec![p3, p4], + "phased-out P1 and eliminated P2 are out at the {effect} mint; both \ + valid opponents are in" + ); + } + other => panic!("expected ChooseAnnouncingOpponent for {effect}, got {other:?}"), + } + runner + .act(GameAction::ChooseAnnouncingOpponent { opponent: p4 }) + .expect("controller picks P4 as the announcer"); + } + + // Drive the four target slots to completion so the row proves the narrowed offer + // still produces a castable spell rather than a stuck prompt. + for target in [land_p3, land_p4, creature_p3, creature_p4] { + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }) + .expect("each slot announces its target"); + } + let stack_entry = runner.state().stack.last().expect("spell is on the stack"); + assert_eq!(stack_entry.controller, P0); +} diff --git a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs index e23e4d0b3a..5a812e05e2 100644 --- a/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs +++ b/crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs @@ -62,8 +62,12 @@ fn load_migrated_dump() -> GameState { )); let envelope: serde_json::Value = serde_json::from_str(&json).expect("dump envelope parses as JSON"); + // Decode AS `PersistedGameState` rather than decoding a bare `GameState` and wrapping + // it in `Raw`: only the former runs `reject_legacy_raw_prompt_authority` and + // `decode_persisted_resolution_state`, which is the rest of the production chokepoint. + // `.expect(..)`, not `?`: `into_game_state` returns `GameState`, not `Result`. serde_json::from_value::(envelope["gameState"].clone()) - .expect("the real 4p gameState restores through the persisted ingress") + .expect("gameState deserializes through the production decoder") .into_game_state() } @@ -165,6 +169,20 @@ fn drive_one_live_cycle(state: &mut GameState) { /// unreplayable. /// - FIX-2 (counter-growth cover disjunct) — the completed drive's +1-charge frames fail /// `loop_states_equal_modulo_resources`. +/// +/// R4c — NAMED ACCEPTANCE ARM for the player-choice legality authority (CR 115.10a). Routing +/// `resolve_target`'s `TargetPin::Player` arm through the CHOICE authority +/// (`players::player_exists_for_choice`) rather than the TARGET one must not suppress a +/// shipped offer on a REAL dump, and this row is what says so: if a later change routes that +/// arm through `targeting::player_is_legal_target`, the over-veto class returns and this row +/// must still pass — so it is the acceptance side of the pair whose refusal side is +/// `analysis::decision_template::tests::a_shrouded_seat_is_untargetable_yet_still_choosable_ +/// at_the_pin_recheck`. +/// +/// ⚠ WHAT THIS ROW DOES NOT WITNESS, stated so nobody credits it with more than it covers: +/// this dump's pins are `ByIdentity` and `ManaColor`, NOT `TargetPin::Player`, so the Player +/// arm is not on its path at all. It is an acceptance arm for the offer PIPELINE, not a +/// witness for the Player-pin seam; that witness is R2b. #[test] fn kilo_migrated_dump_fires_object_growth_offer() { let mut state = load_migrated_dump(); diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index b570eba2a0..42ea21d4aa 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -26,8 +26,7 @@ use engine::types::ability::{Effect, TargetRef}; use engine::types::actions::GameAction; use engine::types::events::GameEvent; use engine::types::game_state::{ - CastPaymentMode, GameState, LoopDetectionMode, PersistedGameState, StackEntryKind, WaitingFor, - YieldTarget, + CastPaymentMode, GameState, LoopDetectionMode, StackEntryKind, WaitingFor, YieldTarget, }; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; @@ -190,7 +189,9 @@ fn setup_3p_draw(mode: LoopDetectionMode) -> (GameRunner, ObjectId) { /// 3-player SUBSET-LETHAL loop: the SAME proven-detected constant-depth mutual drain as /// `setup_2p_drain` (P0's `DRAIN_CLERIC` + `BLOOD_SIPPER`), embedded in a 3p pod where P2 is -/// IMMUNE to life loss (CR 119.8 "you can't lose life"). So the cycle drains ONLY P1 (sole +/// IMMUNE to life loss (CR 101.2 — a "can't" effect takes precedence over the trigger's +/// life-loss instruction; cf. CR 119.8, which governs only life EXCHANGES, life +/// REDISTRIBUTION and pay-life COSTS, none of which happens here). So the cycle drains ONLY P1 (sole /// faller); P2 is a bystander with per-cycle life delta 0 (a second non-faller). Living /// partition each cycle: fallers = {P1}, non-fallers = {P0, P2} — so `live_mandatory_loop_winner` /// refuses to name a winner (CR 104.2a). P1 starts very high so it never dies inside the drive @@ -220,7 +221,8 @@ fn setup_3p_subset_lethal(mode: LoopDetectionMode) -> (GameRunner, ObjectId) { /// (EQUAL — required by `live_mandatory_loop_winner`'s CR 704.3 simultaneity floor: fallers die in /// ONE SBA event, so unequal lives are not a determinate single-winner shape), P2 = 0 /// (life-loss-immune: CR 101.2 — a "can't" effect takes precedence over the trigger's life-loss -/// instruction; cf. CR 119.8 for the same const elsewhere in this file). Living partition each +/// instruction; cf. CR 119.8, which governs only life EXCHANGES, life REDISTRIBUTION and +/// pay-life COSTS, none of which happens here). Living partition each /// cycle: fallers = {P0, P1}, nonfallers = {P2} ⇒ len == 1 ⇒ the engine NATURALLY latches /// `predicted_winner = Some(P2)` — a winner who controls no loop enabler and is not the proposer. /// No injection. @@ -304,10 +306,42 @@ fn drive_collect(runner: &mut GameRunner, cap: usize) -> (Vec, Waitin /// {1, 2, 3, 4, 8, 24} it holds at 2 and 8 and not at 1/3/4/24 (period 6), so inspecting only /// the terminal state would report `false` on a perfectly primed loop. The scan short-circuits /// at the first hit — measured 5.6–17.5 ms per drive, priming at beat 2. -fn drive_collect_primed(runner: &mut GameRunner, cap: usize) -> (Vec, WaitingFor, bool) { +/// +/// # PR-7 Phase 5b — the DECLINE arm, and why the guard would otherwise hollow out +/// +/// These boards now raise a natural bounded CR 732.2a offer. A `WaitingFor::LoopShortcut` is +/// neither `Priority` nor `OrderTriggers`, so without the arm below the break test fires at +/// the offer and the drive ends BEFORE recurrence can be witnessed — every caller's `primed` +/// reach-guard would then report `false` on a perfectly primed loop, i.e. the guard fails for +/// a reason that has nothing to do with what it guards. +/// +/// The remedy re-grounds each guard THROUGH the offer rather than around it: decline and keep +/// driving. Declining is a PASS-THROUGH, not an assertion — these rows' claims are about the +/// CROWN, not about the offer — and the declined offers are returned so a caller that wants to +/// assert on one can (`.first()`), and so the number of declines is reportable (`.len()`). +/// +/// MEASURED CONSEQUENCE: `DeclineShortcut` is a deliberate action and invalidates the ring +/// (`apply_action`'s deliberate-action ring invalidation), so the recurrence witness must be +/// re-accumulated after each decline and the caps tuned against an un-cleared ring no longer +/// hold. See `PRIMED_LOOP_BEATS`. +fn drive_collect_primed( + runner: &mut GameRunner, + cap: usize, +) -> (Vec, WaitingFor, bool, Vec) { let mut all: Vec = Vec::new(); let mut primed = false; + let mut declined: Vec = Vec::new(); for _ in 0..cap { + // Two separate `matches!` guards, not one `match`: the first borrow ends before + // `act` needs `&mut`. + if matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }) { + declined.push(runner.state().waiting_for.clone()); + let result = runner + .act(GameAction::DeclineShortcut) + .expect("DeclineShortcut is legal at a LoopShortcut window"); + all.extend(result.events); + continue; + } if !matches!( runner.state().waiting_for, WaitingFor::Priority { .. } | WaitingFor::OrderTriggers { .. } @@ -321,10 +355,10 @@ fn drive_collect_primed(runner: &mut GameRunner, cap: usize) -> (Vec, primed = state .loop_detect_ring .iter() - .any(|prior| loop_states_equal_modulo_resources(prior, state)); + .any(|prior| loop_states_equal_modulo_resources(&prior.normalized, state)); } } - (all, runner.state().waiting_for.clone(), primed) + (all, runner.state().waiting_for.clone(), primed, declined) } // ────────────────────────────── T-OFF ────────────────────────────── @@ -913,6 +947,7 @@ fn loop_shortcut_acting_player_reads_proposer() { win_kind: WinKind::LethalDamage, mandatory: false, residual_board_delta: BoardDelta::default(), + per_cycle: None, }; let wf_a = WaitingFor::LoopShortcut { proposer: P1, @@ -937,6 +972,7 @@ fn loop_shortcut_acting_player_reads_proposer() { unbounded: vec![], win_kind: WinKind::LethalDamage, template: None, + per_cycle: None, }; let wf_r = WaitingFor::RespondToShortcut { player: P2, @@ -1192,6 +1228,13 @@ fn interactive_queued_opponent_concede_no_deadlock() { /// suppression does not touch, so the ring still fills and this guard still reports primed. /// A negative "did not crown" test cannot distinguish a refused classification from a disabled /// one; the positive-side tests named above are what cover it. +/// +/// PR-7 Phase 5b — the cap was RE-MEASURED, not re-derived. `DeclineShortcut` is a deliberate +/// action and invalidates the ring, so the decline arm [`drive_collect_primed`] gained could in +/// principle have pushed the recurrence witness past this cap. Measured on this tree: it does +/// not — all three rows below still witness recurrence at 24 with the arm live, so the swept +/// value stands unchanged. The arm's liveness is not assumed either: deleting it drops all +/// three to `primed == false` / "0 bounded offers declined". const PRIMED_LOOP_BEATS: usize = 24; /// D2: a 3p loop that drains ONLY P1 (P2 a bystander, life delta 0) must NOT crown. @@ -1207,11 +1250,20 @@ const PRIMED_LOOP_BEATS: usize = 24; /// no-crown assertion below, which is the sole discriminator here: under that mutation the /// event-scan assertion measurably stays TRUE (no `GameOver{Some}` lands in the collected /// events) at every cap from 4 to 100. (Passes today, proving the gate holds.) +/// +/// PR-7 Phase 5b: this class now also raises a bounded CR 732.2a offer, so the row's former +/// "must NOT raise a LoopShortcut offer" clause is superseded and replaced by a positive +/// discriminator on `predicted_winner`. See the comment at that assertion. Two further +/// revert-probes, each flipping a DIFFERENT assertion so neither dominates the other: +/// * make `try_offer_bounded_cycle_shortcut` refuse unconditionally ⇒ `declined` is empty ⇒ +/// the `offered` `let-else` panics, while both no-CROWN assertions stay green. +/// * delete the `DeclineShortcut` arm from [`drive_collect_primed`] ⇒ the drive breaks at the +/// offer ⇒ `primed == false` ⇒ the trailing reach-guard FAILS. #[test] fn interactive_3p_subset_lethal_does_not_crown() { let (mut runner, kickoff) = setup_3p_subset_lethal(LoopDetectionMode::Interactive); let _ = runner.cast(kickoff).resolve(); - let (events, wf, primed) = drive_collect_primed(&mut runner, PRIMED_LOOP_BEATS); + let (events, wf, primed, declined) = drive_collect_primed(&mut runner, PRIMED_LOOP_BEATS); // Positive reach-guard: the drain loop genuinely ran on P1 while P2 stayed untouched — we // are in the subset-lethal regime the gate must refuse, not an unrelated upstream no-op. @@ -1237,10 +1289,89 @@ fn interactive_3p_subset_lethal_does_not_crown() { .any(|e| matches!(e, GameEvent::GameOver { winner: Some(_) })), "no GameOver{{Some}} event may be emitted for a subset-lethal loop" ); - // No offer either: the bridge does not OFFER a shortcut for a non-winner loop. - assert!( - !matches!(wf, WaitingFor::LoopShortcut { .. }), - "subset-lethal loop must NOT raise a LoopShortcut offer, got {wf:?}" + // PR-7 Phase 5b — the SUPERSEDED clause, replaced by its positive discriminator. + // + // This row used to assert `!matches!(wf, WaitingFor::LoopShortcut { .. })`. That + // expectation is superseded, not violated: a subset-lethal drain that crowns nobody is + // the exact class the bounded CR 732.2a offer exists to serve, so the class now OFFERS. + // The no-CROWN claims above are unchanged and are still the row's soundness content. + // + // Deleting a negative assertion silently would leave the row weaker than it was, so it is + // replaced by a POSITIVE assertion on the field that separates the two classes: a bounded + // offer must carry `predicted_winner: None`. A `Some(winner)` here would mean the offer + // came from Path A — i.e. something DID crown after all — which is exactly what the two + // assertions above forbid. + let offered = declined.first(); + let Some(WaitingFor::LoopShortcut { + predicted_winner, + certificate, + .. + }) = offered + else { + panic!( + "PR-7 5b: this class now OFFERS the bounded shortcut, and the drive above declined \ + every offer it saw; got {} declined offers, terminal {wf:?}", + declined.len() + ); + }; + assert_eq!( + *predicted_winner, None, + "the offer must carry predicted_winner: None — a Some(winner) here would mean Path A \ + crowned after all, contradicting the CR 104.2a assertions above" + ); + // BASIS: measured **A** (direct recurrence) — instrumenting the `basis_a` match in + // `try_offer_bounded_cycle_shortcut` prints `BASIS=A turn=2 phase=PreCombatMain ring=3` at + // this row's offer beat, which is consistent with `primed` below witnessing exactly basis + // A's first disjunct. `ring_delta_signature` — the only function the CR 703.1 + // turn-position conjunct modifies — is reached ONLY from the `None =>` arm, so this row is + // orthogonal to that conjunct in both directions. + // + // HONEST SCOPE OF THE ASSERTION BELOW: `frames_per_period` is a TRIPWIRE on the period's + // WIDTH, not a proof of basis. Basis B derives `k` from 1 upward, so a k==1 basis-B offer + // publishes the same value as a 1-frame basis-A one (the + // `dina_untargeted_drain_4p_offers_at_three_live_opponents` row is exactly that case). + // + // ⚠ VALUE CORRECTED 1 → 2. The NUMBER is the small half of the correction; the MECHANISM + // is the half worth carrying forward, because it names a class of defect rather than one + // fixture's constant. + // + // THIS ASSERTION WAS A SELF-RATIFYING ORACLE. Basis A published a HARDCODED + // `frames_per_period: 1` regardless of how far back its certifying prior actually sat. So + // the assertion compared a literal `1` against a constant `1` that no game state could + // influence: it could not fail for ANY fixture, ANY period width, or ANY future change to + // how the ring is sampled. It read the implementation's constant back to itself and + // reported that as agreement. Its stated premise — "this class certifies on a single-frame + // period" — was therefore never measured; it was inferred from the very line it was + // supposedly checking. + // + // That is the same family as this lane's other "guard that passes while proving nothing" + // findings: a check whose subject cannot vary is not a check. The tell is available + // WITHOUT running anything — trace the asserted expression back to its producer and ask + // whether any input can move it. If nothing can, the row's green is a tautology. + // + // The mint now DERIVES the span from the prior's ring index, so the expression varies with + // the fixture. For this one it is 2: the DRAIN_CLERIC / BLOOD_SIPPER pairing alternates a + // gain-life resolution and a lose-life resolution, so one whole repetition spans two + // retained ring frames and the pair one frame back does not recur. + // + // MEASURED through the production accept path on this same fixture (declare `Fixed(n)` + + // APNAP accepts), which is what makes 2 the RIGHT value rather than merely a different one: + // derived k = 2 ⇒ n=1 → δ{P0:+1,P1:-1}; n=2 → +2/-2; n=3 → +3/-3 (exactly n × δ) + // hardcoded 1 ⇒ n=1, 2, 3 → ZERO committed, every time + // That zero is `materialize_fixed_shortcut`'s conformance check doing its job: a cycle cut + // at one frame delivers half a period, which does not equal the published δ, so the drive + // drops it and hands back. A wrong period is therefore never a silent half-commit — but it + // does make the offer unusable, which is why the span has to be measured and not assumed. + assert_eq!( + certificate + .per_cycle + .as_ref() + .expect("a bounded offer publishes its per-period signature") + .frames_per_period, + 2, + "this class's repetition spans two retained ring frames (a gain-life resolution, then a \ + lose-life resolution); a drift in that width silently changes what one committed cycle \ + means" ); // Reach-guard on the regime itself (see `drive_collect_primed`): the drive really did reach @@ -1250,10 +1381,16 @@ fn interactive_3p_subset_lethal_does_not_crown() { // mode live in the same drive, so under the weakened-gate defect the crown must report as a // crown (the `wf` assertion above) — measured, a guard placed first steals that panic (M1 // crowns at beat ~1, before recurrence) and reports the wrong cause. + // + // RE-GROUNDED THROUGH the offer, not around it: `DeclineShortcut` invalidates the ring, so + // the witness must be re-accumulated after each decline, which is why the decline count is + // reported here. assert!( primed, - "the loop never reached a board-recurrent state within {PRIMED_LOOP_BEATS} beats — this \ - is not the primed-loop regime the assertions above assume, so they passed vacuously" + "the loop never reached a board-recurrent state within {PRIMED_LOOP_BEATS} beats \ + ({} bounded offers declined en route) — this is not the primed-loop regime the \ + assertions above assume, so they passed vacuously", + declined.len() ); } @@ -1321,6 +1458,7 @@ fn synthetic_lethal_cert() -> LoopCertificate { win_kind: WinKind::LethalDamage, mandatory: false, residual_board_delta: BoardDelta::default(), + per_cycle: None, } } @@ -1383,20 +1521,30 @@ fn vito_2p_optional_offer_declare_crowns() { /// `live_mandatory_loop_winner` returns None (CR 104.2a) and the shortcut falls back to manual /// play. REVERT-PROBE: making the crown unconditional (deleting the `live_mandatory_loop_winner` /// gate) wrongly crowns P0 here. +/// +/// PR-7 Phase 5b: the leading reach-guard is re-grounded THROUGH the bounded offers this class +/// now raises (see [`drive_collect_primed`]). REVERT-PROBE (MEASURED, not predicted): delete +/// that decline arm ⇒ `primed == false` ⇒ this row FAILS with "0 bounded offers declined". #[test] fn injected_3p_one_faller_no_crown() { let (mut runner, kickoff) = setup_3p_subset_lethal(LoopDetectionMode::Interactive); let _ = runner.cast(kickoff).resolve(); - let (_events, _wf, primed) = drive_collect_primed(&mut runner, PRIMED_LOOP_BEATS); + let (_events, _wf, primed, declined) = drive_collect_primed(&mut runner, PRIMED_LOOP_BEATS); // Reach-guard on the regime (see `drive_collect_primed`): the board reached a genuine // recurrence, so the E1 clone-drive below has a real cycle to measure rather than an // un-primed board. (It witnesses the live bridge's frame pair, not the E1 measure's own // boundary/work pair — those are different frames.) + // + // PR-7 Phase 5b: RE-GROUNDED THROUGH the bounded offers this class now raises — the driver + // declines each one and keeps driving, so the witness still ranges over a non-empty beat + // set. `DeclineShortcut` invalidates the ring, hence the decline count in the message. assert!( primed, - "the loop never reached a board-recurrent state within {PRIMED_LOOP_BEATS} beats — the \ - E1 measure below would have no primed cycle, so its no-crown assertion is vacuous" + "the loop never reached a board-recurrent state within {PRIMED_LOOP_BEATS} beats \ + ({} bounded offers declined en route) — the E1 measure below would have no primed \ + cycle, so its no-crown assertion is vacuous", + declined.len() ); // Reach-guard: the drain loop genuinely ran (P1 bled, alive) and P2 is untouched — this @@ -1408,7 +1556,11 @@ fn injected_3p_one_faller_no_crown() { ); assert_eq!(life(&runner, P2), 20, "P2 untouched (second non-faller)"); - // Inject the offer this subset-lethal loop never raises naturally, then confirm it. + // Inject the offer, then confirm it. PR-7 Phase 5b: this board is now ALSO reachable + // naturally (the drive above declined its natural bounded offers), but the injection stays + // — the injected `predicted_winner: Some(P0)` + `UntilLethal` certificate is what pins the + // E1 declare path this row is about, and the natural offer is a `None`/`Fixed` one that + // would route somewhere else entirely. runner.state_mut().waiting_for = WaitingFor::LoopShortcut { proposer: P0, predicted_winner: Some(P0), @@ -1431,6 +1583,11 @@ fn injected_3p_one_faller_no_crown() { "subset-lethal loop must NOT crown (CR 104.2a), got {:?}", runner.state().waiting_for ); + // MEASURED, not assumed (PA-2B.0b was a HYPOTHESIS that the materialized settle would now + // raise a natural bounded offer here and turn this green assertion red): on this tree the + // post-settle state IS `Priority`, so the assertion stands VERBATIM and needs no + // decline-and-re-read pass-through. Do not relax it to an `||` over two `WaitingFor` + // variants — that would make it pass on a state this row exists to exclude. assert!( matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), "the E1 measure hands back to manual play, got {:?}", @@ -1515,7 +1672,7 @@ fn declare_illegal_pin_falls_back_legal_ingests() { }; let schema = ShortcutDecisionSchema { iteration_count: IterationCount::UntilLethal, - // No narrowed CR 732.2a bound — the global cap, as every offer states today. + // No narrowed CR 732.2a bound — `Default` carries the global cap. max_iterations: ShortcutDecisionSchema::default().max_iterations, points: vec![DecisionPoint { slot: slot.clone(), @@ -1593,15 +1750,23 @@ fn declare_illegal_pin_falls_back_legal_ingests() { /// (staggered CR 704.3 lethal). The EQUAL-life sibling DOES crown (reach-guard proving the /// check is not always-reject). REVERT-PROBE: removing the F2 check wrongly crowns the /// unequal-life half. +/// +/// PR-7 Phase 5b: this class now raises a natural bounded CR 732.2a offer mid-drive, which +/// would end the drive before the recurrence witness accumulates. [`drive_collect_primed`] +/// declines it and keeps driving, so the reach-guard is grounded THROUGH the offer. +/// REVERT-PROBE (MEASURED, not predicted): delete that decline arm ⇒ the UNEQUAL half's +/// `primed` goes false and this row FAILS, while the equal half is untouched (it consumes 0 +/// beats) — so the probe flips exactly one half, which is the proof the two halves are +/// independently grounded. #[test] fn injected_3p_unequal_life_pin_all_no_crown() { // Drive one primed cycle of a confirmed 3p both-fall drain and report the terminal // waiting_for. - fn drive_confirmed(p1_life: i32, p2_life: i32) -> (WaitingFor, bool) { + fn drive_confirmed(p1_life: i32, p2_life: i32) -> (WaitingFor, bool, usize) { let (mut runner, kickoff) = setup_3p_both_fall(LoopDetectionMode::Interactive, p1_life, p2_life); let _ = runner.cast(kickoff).resolve(); - let (_events, _wf, primed) = drive_collect_primed(&mut runner, PRIMED_LOOP_BEATS); + let (_events, _wf, primed, declined) = drive_collect_primed(&mut runner, PRIMED_LOOP_BEATS); // Reach-guard: both opponents bled equally (loop primed, both are fallers) and stay // pairwise-offset by the initial gap (equal deltas preserve the difference). assert!( @@ -1626,31 +1791,40 @@ fn injected_3p_unequal_life_pin_all_no_crown() { }) .expect("P0 declares UntilLethal"); accept_all_opponents(&mut runner); - (runner.state().waiting_for.clone(), primed) + (runner.state().waiting_for.clone(), primed, declined.len()) } // UNEQUAL absolute life (gap 50) ⇒ NO crown (F2 staggered-death veto). - let (unequal, unequal_primed) = drive_confirmed(1000, 1050); + let (unequal, unequal_primed, unequal_declines) = drive_confirmed(1000, 1050); // Reach-guard on the regime (see `drive_collect_primed`) — asserted on THIS half only: the // equal-life half below is measured to consume 0 beats (already crowned as the kick-off // resolved), so it has no drive in which to recur. This is the half the F2 revert-probe // flips, so the whole discriminator lives on the guarded side. + // + // PR-7 Phase 5b: RE-GROUNDED THROUGH the bounded offers this class now raises — declined, + // not avoided. Only the unequal half is re-grounded, for the reason above. assert!( unequal_primed, - "the loop never reached a board-recurrent state within {PRIMED_LOOP_BEATS} beats — this \ - is not the ≥2-faller primed regime the assertions below assume" + "the loop never reached a board-recurrent state within {PRIMED_LOOP_BEATS} beats \ + ({unequal_declines} bounded offers declined en route) — this is not the ≥2-faller \ + primed regime the assertions below assume" ); assert!( !matches!(unequal, WaitingFor::GameOver { winner: Some(_) }), "unequal-life ≥2-faller drain must NOT crown (CR 704.3 simultaneity), got {unequal:?}" ); + // MEASURED, not assumed (PA-2B.0b was a HYPOTHESIS that the materialized settle would now + // raise a natural bounded offer here and turn this green assertion red): on this tree the + // post-settle state IS `Priority`, so the assertion stands VERBATIM and needs no + // decline-and-re-read pass-through. Do not relax it to an `||` over two `WaitingFor` + // variants — that would make it pass on a state this row exists to exclude. assert!( matches!(unequal, WaitingFor::Priority { .. }), "the F2 veto hands back to manual play, got {unequal:?}" ); // EQUAL absolute life ⇒ CROWN (reach-guard: the F2 check is not always-reject). - let (equal, _) = drive_confirmed(1000, 1000); + let (equal, _, _) = drive_confirmed(1000, 1000); assert_eq!( equal, WaitingFor::GameOver { winner: Some(P0) }, @@ -3958,7 +4132,7 @@ fn loop_shortcut_schema_redacts_hidden_targets_for_non_controller() { }; let schema = ShortcutDecisionSchema { iteration_count: IterationCount::UntilLethal, - // No narrowed CR 732.2a bound — the global cap, as every offer states today. + // No narrowed CR 732.2a bound — `Default` carries the global cap. max_iterations: ShortcutDecisionSchema::default().max_iterations, points: vec![DecisionPoint { slot, @@ -3980,6 +4154,7 @@ fn loop_shortcut_schema_redacts_hidden_targets_for_non_controller() { win_kind: WinKind::LethalDamage, mandatory: false, residual_board_delta: BoardDelta::default(), + per_cycle: None, }; runner.state_mut().waiting_for = WaitingFor::LoopShortcut { proposer: P0, @@ -4923,9 +5098,493 @@ fn gunzip_dump(gz: &[u8]) -> String { fn restore_dump(json: &str) -> GameState { let envelope: serde_json::Value = serde_json::from_str(json).expect("dump envelope parses as JSON"); - serde_json::from_value::(envelope["gameState"].clone()) - .expect("the real 4p gameState must restore through the persisted-state boundary") - .into_game_state() + // Decode AS `PersistedGameState` rather than decoding a bare `GameState` and wrapping + // it in `Raw`: only the former runs `reject_legacy_raw_prompt_authority` and + // `decode_persisted_resolution_state`, which is the rest of the production chokepoint + // — including the CR 732.2a load-seam bound invariant `w15_*` below pins. + // `.expect(..)`, not `?`: `into_game_state` returns `GameState`, not `Result`. + serde_json::from_value::( + envelope["gameState"].clone(), + ) + .expect("gameState deserializes through the production decoder") + .into_game_state() +} + +/// The migrated dellian dump's `gameState`, as a raw `serde_json::Value`. +fn dellian_game_state_value() -> serde_json::Value { + let json = gunzip_dump(include_bytes!( + "../fixtures/dellian_emblem_conqueror_4p.json.gz" + )); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + envelope["gameState"].clone() +} + +/// R0c — the migrated fixture decodes through BOTH decoders, and an un-migrated one +/// through NEITHER. +/// +/// This is the row that converts the next upstream save-compat break into a named +/// regression instead of four unrelated-looking red rows. Upstream #6718 (`0468df1f4`) +/// added `TargetSelectionSlot::effect_kind` with no `#[serde(default)]`; every dump +/// fixture captured before it became undecodable, and nothing said so in one place. +/// +/// The negative arm IS the positive arm's anti-vacuity control: without it, an +/// `assert!(ok)` pair would pass on any value at all, including one where the field was +/// never consulted. Both arms operate on the SAME value, differing only by the presence +/// of `effect_kind`, so the verdict is attributable to that field and nothing else. +#[test] +fn migrated_dump_decodes_through_both_decoders_and_unmigrated_through_neither() { + let migrated = dellian_game_state_value(); + + // Reach-guard: prove the mutation below has something to remove. A value with no + // `effect_kind` key would make the negative arm's `Err` unattributable. + let slots = migrated["waiting_for"]["data"]["target_slots"] + .as_array() + .expect("the dellian dump publishes a target_slots array"); + assert_eq!( + slots.len(), + 1, + "the dellian dump publishes exactly one slot" + ); + assert!( + slots[0].get("effect_kind").is_some(), + "the MIGRATED fixture must carry effect_kind — if this fails, the migration script \ + was not run, and the negative arm below would pass for the wrong reason" + ); + + // POSITIVE: both the direct `GameState` decode and the production `PersistedGameState` + // decode accept the migrated value. + assert!( + serde_json::from_value::(migrated.clone()).is_ok(), + "migrated fixture must decode as a bare GameState" + ); + assert!( + serde_json::from_value::(migrated.clone()) + .is_ok(), + "migrated fixture must decode through the PRODUCTION decoder" + ); + + // R8 — THE ROUTING IS STATE-NEUTRAL. The six loaders stopped decoding a bare + // `GameState` and wrapping it in `PersistedGameState::Raw`, and started decoding AS + // `PersistedGameState`: a different type, a different `Deserialize`, and a different + // conversion (`decode_persisted_resolution_state`, which injects + // `resolution_state_version` and decodes the resolution state as `ResolutionStateWire`). + // That is a real change to how six fixtures restore, and the two arms above cannot + // witness it — they assert only that a decode succeeds or fails. + // + // `GameState` has NO `PartialEq`, so this compares the SERIALIZED forms — key by key, + // recursively. Several fields are `HashSet`-backed and therefore have NO canonical array + // order (two sets built in one process do not even share a hasher seed), so a difference + // that is order-only under one of THOSE keys is accepted as a set difference and every + // other difference FAILS, naming its own key path. A blanket "sort every array" would + // have hidden a real reordering of the stack, a library or a seat order; a hand-picked + // single-field normalization would have flaked the first time a different set field + // happened to serialize in a different order — which is exactly what it did. + // + // The allowlist is DERIVED, not remembered: + // grep -rhoE 'pub [a-z_0-9]+: (std::collections::)?HashSet<' \ + // crates/engine/src/types/*.rs crates/engine/src/analysis/*.rs | sort -u + // An unlisted key that differs only by order still FAILS and names itself, so drift is + // loud rather than silent. + const SET_BACKED_FIELDS: &[&str] = &[ + "alt_cost_grant_permissions_used", + "applied", + "assassin_or_commander_dealt_combat_damage_this_turn", + "batched_zone_change_trigger_fired", + "bending_types_this_turn", + "city_blessing", + "commander_declined_zone_return", + "creatures_attacked_this_turn", + "creatures_blocked_this_turn", + "crew_activated_this_turn", + "dirty_objects", + "dirty_players", + "exerted_this_turn", + "exile_cast_permissions_used", + "exile_play_permissions_used", + "exile_play_single_use_consumed", + "graveyard_cast_permissions_used", + "graveyard_cast_permissions_used_per_type", + "hand_cast_free_permissions_used", + "modal_modes_chosen_this_game", + "modal_modes_chosen_this_turn", + "objects_that_dealt_damage", + "player_actions_this_way", + "players_attacked_this_step", + "players_attacked_this_turn", + "players_who_created_token_this_turn", + "players_who_discarded_card_this_turn", + "players_who_sacrificed_artifact_this_turn", + "players_who_searched_library_this_turn", + "public_revealed_cards", + "replacement_applied", + "revealed_cards", + "top_of_library_cast_permissions_used", + "triggers_fired_this_game", + "triggers_fired_this_turn", + "triggers_fired_this_turn_per_opponent", + ]; + + /// Collect every path at which `a` and `b` differ in a way that set-ordering cannot + /// explain. Returns an empty vec iff the two states are equal modulo set order. + fn differences( + a: &serde_json::Value, + b: &serde_json::Value, + path: &str, + out: &mut Vec, + ) { + use serde_json::Value; + match (a, b) { + (Value::Object(x), Value::Object(y)) => { + let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect(); + keys.sort(); + keys.dedup(); + for key in keys { + match (x.get(key), y.get(key)) { + (Some(l), Some(r)) => differences(l, r, &format!("{path}.{key}"), out), + _ => out.push(format!("{path}.{key} (present on one side only)")), + } + } + } + (Value::Array(x), Value::Array(y)) if x == y => {} + (Value::Array(x), Value::Array(y)) => { + let leaf = path.rsplit('.').next().unwrap_or(path); + let (mut xs, mut ys) = (x.clone(), y.clone()); + let key = |v: &Value| v.to_string(); + xs.sort_by_key(key); + ys.sort_by_key(key); + if xs == ys && SET_BACKED_FIELDS.contains(&leaf) { + // Order-only difference under a `HashSet`-backed field: not a state + // difference at all, because that field HAS no canonical order. + } else if xs == ys { + out.push(format!( + "{path} (REORDERED, and it is not a set-backed field)" + )); + } else { + out.push(format!("{path} (different elements)")); + } + } + _ if a == b => {} + _ => out.push(path.to_string()), + } + } + + let serialized = |state: &GameState| serde_json::to_value(state).expect("GameState serializes"); + let legacy_restored = { + let raw: GameState = serde_json::from_value(migrated.clone()) + .expect("the pre-routing loader form: a bare GameState decode"); + engine::types::game_state::PersistedGameState::Raw(Box::new(raw)).into_game_state() + }; + let routed_restored = + serde_json::from_value::(migrated.clone()) + .expect("the routed loader form: decode AS PersistedGameState") + .into_game_state(); + let routed_value = serialized(&routed_restored); + let mut diffs = Vec::new(); + differences( + &serialized(&legacy_restored), + &routed_value, + "state", + &mut diffs, + ); + assert!( + diffs.is_empty(), + "routing the six dump loaders through the production decoder must restore the SAME \ + state they restored before; differing paths: {diffs:?}" + ); + + // The perturbation IS that assertion's reach-guard: without it, a comparison that + // compared a value to itself — or explained every difference away as set order — would + // pass on any two states at all. Perturb ONE scalar; the comparison must SEE it, and + // must name the field it saw. + let mut perturbed = legacy_restored; + perturbed.turn_number += 1; + let mut perturbed_diffs = Vec::new(); + differences( + &serialized(&perturbed), + &routed_value, + "state", + &mut perturbed_diffs, + ); + assert_eq!( + perturbed_diffs, + vec!["state.turn_number".to_string()], + "the comparison must see a one-scalar difference AND name it; if it cannot, the \ + equality above proves nothing" + ); + + // NEGATIVE (the anti-vacuity control): strip the field back out and both must reject. + let mut unmigrated = migrated; + unmigrated["waiting_for"]["data"]["target_slots"] + .as_array_mut() + .expect("target_slots is an array") + .iter_mut() + .for_each(|slot| { + slot.as_object_mut() + .expect("each slot is an object") + .remove("effect_kind") + .expect("each slot carried effect_kind before removal"); + }); + assert!( + serde_json::from_value::(unmigrated.clone()).is_err(), + "an un-migrated save must NOT decode as a bare GameState" + ); + assert!( + serde_json::from_value::(unmigrated) + .is_err(), + "an un-migrated save must NOT decode through the production decoder — the strict \ + decoder is the point; a #[serde(default)] shim would silently accept it" + ); +} + +/// R0d — CR 732.2a: a persisted `LoopShortcut` offer whose WIRE bound is `0` must fail the +/// load, and one whose bound is `5` must not. +/// +/// The defect this pins (W15) is real and was measured before the fix: a wire +/// `max_iterations: 0` deserialized clean, satisfied `is_bounded()`, and reached +/// `ai_support/candidates.rs`, which echoed it as a declared `IterationCount::Fixed(0)` — +/// so the engine opened the CR 732.2b response window for an offer that admits no legally +/// takeable sequence. The offer was corrupt one beat BEFORE any count was declared. +/// +/// ⚠ THE FIXTURE CHOICE IS LOAD-BEARING — this row uses TENACITY, not the dellian dump the +/// dual-decode row uses. The dellian value is `TriggerTargetSelection` and carries no +/// `schema` object at all, so `…schema.max_iterations` cannot even be written onto it: both +/// arms would decode identically, for a reason having nothing to do with the invariant. +/// The tenacity dump is the only in-tree `LoopShortcut` capture. +/// +/// The key is ABSENT in the fixture, so the mutation CREATES it — asserted below, because a +/// mutation that silently failed to apply would make the `0` arm's verdict meaningless. +/// +/// ⚠ NON-VACUITY DEPENDS ON THE ROUTED LOADER. This row decodes AS `PersistedGameState`; +/// the pre-5c loader form (`PersistedGameState::Raw(Box::new(bare_decode))`) bypasses +/// `PersistedGameState`'s own `Deserialize` and therefore never runs +/// `decode_persisted_resolution_state` at all, so the same assertions written against that +/// form could not fail. REVERT-PROBE: delete `reject_zero_bound_shortcut_offer`'s body (or +/// its call) ⇒ the `0` arm decodes `Ok` ⇒ FAILS. The wire-`5` arm is the anti-vacuity half: +/// it proves the mutation instrument reaches the field and that the invariant refuses `0` +/// specifically rather than refusing every mutated save. +/// The SIBLING wire zero on the same offer: `PeriodicDelta::frames_per_period`. +/// +/// `max_iterations` says how many repetitions a proposal commits; `frames_per_period` says what +/// ONE repetition is. `drive_one_shortcut_cycle` closes a cycle on +/// `frames_per_period.is_some_and(|k| frames_this_cycle >= k)`, and `frames_this_cycle` is a +/// `u32` — so `k == 0` makes that disjunct a TAUTOLOGY, ending every "cycle" at the first +/// active-player priority beat instead of at the certified CR 732.2a period. +/// +/// THE PERIOD IS BUILT IN RUST AND SERIALIZED, not hand-written as JSON. `ResourceVector`'s +/// per-player maps are keyed by `PlayerId` and need the wire adapter to cross the boundary, so a +/// hand-authored object risks testing a shape production never emits — the failure mode this +/// suite has hit before. +/// +/// REVERT-PROBE: delete the `frames_per_period == 0` block in `reject_zero_bound_shortcut_offer` +/// ⇒ the `0` arm decodes `Ok` ⇒ this row FAILS while +/// `a_wire_zero_shortcut_bound_fails_the_load_and_a_wire_five_does_not` stays green, because the +/// fixture omits `max_iterations` entirely and defaults it to `MAX_SHORTCUT_CYCLES`. The `2` arm +/// is the anti-vacuity half: it proves the splice reaches the field and that the guard refuses +/// `0` specifically rather than refusing every save carrying a period. +#[test] +fn a_wire_zero_frames_per_period_fails_the_load_and_a_wire_two_does_not() { + let json = gunzip_dump(include_bytes!( + "../fixtures/tenacity_exquisite_blood_4p.json.gz" + )); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + let base = envelope["gameState"].clone(); + + assert_eq!( + base["waiting_for"]["type"].as_str(), + Some("LoopShortcut"), + "the invariant is scoped to the one variant that carries a LoopCertificate" + ); + assert!( + base["waiting_for"]["data"]["certificate"]["per_cycle"].is_null(), + "the fixture carries no certified period, so the splice below CREATES it" + ); + + let with_frames = |frames: u32| { + let mut v = base.clone(); + let period = engine::analysis::resource::PeriodicDelta { + frames_per_period: frames, + delta: Default::default(), + victim_slot: vec![], + }; + v["waiting_for"]["data"]["certificate"]["per_cycle"] = + serde_json::to_value(&period).expect("a PeriodicDelta serializes"); + assert_eq!( + v["waiting_for"]["data"]["certificate"]["per_cycle"]["frames_per_period"].as_u64(), + Some(u64::from(frames)), + "the splice must reach certificate.per_cycle.frames_per_period" + ); + v + }; + + let message = + serde_json::from_value::(with_frames(0)) + .expect_err("a wire frames_per_period of 0 must fail the load") + .to_string(); + assert!( + message.contains("frames_per_period 0"), + "the rejection must NAME the invariant it enforces, and must not be the sibling \ + max_iterations guard firing instead, got: {message}" + ); + + assert!( + serde_json::from_value::(with_frames(2)) + .is_ok(), + "a wire frames_per_period of 2 is a legal certified span and must still load" + ); + + // ── THE SECOND WIRE HOST, AND THE ONE THE DRIVE ACTUALLY READS ────────────────────── + // `frames_per_period` is a `PeriodicDelta` field, not a `LoopCertificate` field, and + // `ShortcutProposal` carries its own `per_cycle`. `materialize_fixed_shortcut` feeds the + // drive from `proposal.per_cycle.as_ref().map(|pd| pd.frames_per_period)` — THIS host — + // never from the offer's certificate. A restored `RespondToShortcut` is reached without + // re-entering `LoopShortcut`, so guarding only the arms above would leave the consumed path + // open while looking complete. + // + // REVERT-PROBE: delete the `RespondToShortcut` block in `reject_zero_bound_shortcut_offer` + // ⇒ every arm above still passes and only this one flips to `Ok`. + let respond_with_frames = |frames: u32| { + let mut v = base.clone(); + let waiting = engine::types::game_state::WaitingFor::RespondToShortcut { + player: engine::types::player::PlayerId(1), + remaining_players: vec![], + proposal: engine::analysis::loop_check::ShortcutProposal { + proposer: engine::types::player::PlayerId(0), + predicted_winner: None, + count: engine::analysis::decision_template::IterationCount::Fixed(3), + unbounded: vec![], + win_kind: engine::analysis::loop_check::WinKind::Advantage, + template: None, + per_cycle: Some(engine::analysis::resource::PeriodicDelta { + frames_per_period: frames, + delta: Default::default(), + victim_slot: vec![], + }), + }, + }; + v["waiting_for"] = serde_json::to_value(&waiting).expect("a WaitingFor serializes"); + assert_eq!( + v["waiting_for"]["data"]["proposal"]["per_cycle"]["frames_per_period"].as_u64(), + Some(u64::from(frames)), + "the splice must reach proposal.per_cycle.frames_per_period" + ); + v + }; + + let message = serde_json::from_value::( + respond_with_frames(0), + ) + .expect_err("a wire frames_per_period of 0 on the PROPOSAL must fail the load") + .to_string(); + assert!( + message.contains("frames_per_period 0") && message.contains("RespondToShortcut"), + "the rejection must name BOTH the invariant and the host that carried it, so a reader \ + can tell which of the two per_cycle sites fired, got: {message}" + ); + + assert!( + serde_json::from_value::( + respond_with_frames(2) + ) + .is_ok(), + "a legal span on the proposal host must still load — anti-vacuity for the arm above" + ); +} + +#[test] +fn a_wire_zero_shortcut_bound_fails_the_load_and_a_wire_five_does_not() { + let json = gunzip_dump(include_bytes!( + "../fixtures/tenacity_exquisite_blood_4p.json.gz" + )); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + let base = envelope["gameState"].clone(); + + // Reach-guards on the fixture itself. + assert_eq!( + base["waiting_for"]["type"].as_str(), + Some("LoopShortcut"), + "R0d must run on a LoopShortcut capture — the invariant is scoped to that variant" + ); + assert!( + base["waiting_for"]["data"]["schema"].is_object(), + "the tenacity offer carries a schema object for the bound to live on" + ); + assert!( + base["waiting_for"]["data"]["schema"] + .get("max_iterations") + .is_none(), + "the fixture predates the field, so the mutation below CREATES the key" + ); + + let with_bound = |n: u64| { + let mut v = base.clone(); + v["waiting_for"]["data"]["schema"]["max_iterations"] = serde_json::json!(n); + // Anti-vacuity: prove the write landed before drawing any conclusion from it. + assert_eq!( + v["waiting_for"]["data"]["schema"]["max_iterations"].as_u64(), + Some(n), + "the mutation must reach schema.max_iterations" + ); + v + }; + + // CR 732.2a: a proposal must describe "a sequence of game choices ... that may be + // legally taken based on the current game state". A bound of 0 admits none. + let zero = + serde_json::from_value::(with_bound(0)); + let message = zero + .expect_err("a wire max_iterations of 0 must fail the load, not revive a corrupt offer") + .to_string(); + assert!( + message.contains("max_iterations 0"), + "the rejection must NAME the invariant it enforces, got: {message}" + ); + + // The control: same fixture, same instrument, same mutated key, a legal bound. + assert!( + serde_json::from_value::(with_bound(5)) + .is_ok(), + "a wire max_iterations of 5 is a legal bound and must still load" + ); + + // ── THE SECOND INGRESS ────────────────────────────────────────────────────────── + // CR 732.2a again, through the OTHER decode entry point. Upstream #6933 split the + // decode surface in two: `GameStateDecode::decode_persisted_resolution_state` (which + // the arms above reach) deserializes `ResolutionStateWire` itself and NEVER calls + // `GameStateDecode::decode`. A bare `GameState` decode routes through `decode` with + // `GameStateDecodeMode::DirectCurrentRaw` instead (see `impl Deserialize for + // GameState`), so hosting the bound guard on only one of them leaves this path able + // to revive exactly the corrupt offer the arms above refuse. + // + // REVERT-PROBE: delete the `reject_zero_bound_shortcut_offer` call in + // `GameStateDecode::decode` ONLY — leaving the one in + // `decode_persisted_resolution_state` — and every arm above still passes while this + // one flips to `Ok`. That single-site revert is why this row exists and why the + // arms above cannot stand in for it. + // + // REACH-GUARD FIRST: `DirectCurrentRaw` deliberately SKIPS the legacy migrations, so + // if this fixture could not decode bare at all, the `Err` below would prove nothing + // about the bound. + assert!( + serde_json::from_value::(base.clone()).is_ok(), + "reach-guard: the unmutated fixture must decode through the bare-GameState \ + ingress, or the zero-bound Err below is not attributable to the bound" + ); + + let bare_zero = serde_json::from_value::(with_bound(0)); + let bare_message = bare_zero + .expect_err("the bare-GameState ingress must refuse a wire max_iterations of 0 too") + .to_string(); + assert!( + bare_message.contains("max_iterations 0"), + "the bare-ingress rejection must NAME the same invariant, got: {bare_message}" + ); + assert!( + serde_json::from_value::(with_bound(5)).is_ok(), + "a legal bound must still load through the bare-GameState ingress" + ); } /// Opponents the ENGINE considers living. `Player::is_eliminated` is the authority the @@ -5156,6 +5815,182 @@ fn two_site_retention_survives_a_prompt_and_its_answer() { ); } +/// PR-7 Phase 5a — CR 732.2a per-iteration pin enumeration, on a REAL 4p board. +/// +/// `bounded_cycle_pin_slots` is the single authority for the choice slots a bounded cycle +/// offer must publish. Dump B is the acceptance population: obj **541** is a CR 114.2 +/// emblem (command zone, "both owned and controlled by that player") whose triggered +/// ability drains `target opponent`, i.e. the +/// `Typed{type_filters: [], controller: Opponent, properties: []}` player shape. +/// +/// **TWO ARMS, and the pairing is the whole point.** The dump ships with the prompt UP +/// (`TriggerTargetSelection` carrying already-materialized `legal_targets`), so arm ⓐ alone +/// would pass against a prompt-READING implementation — which returns ZERO slots at the +/// real offer beat, where `waiting_for` is `Priority`. Arm ⓑ is the identical state with +/// exactly one field reassigned. +/// +/// REVERT-PROBES (both must flip): +/// * ⓘ narrow the AST predicate so `Typed{[], Opponent, []}` is rejected ⇒ BOTH arms return +/// zero ⇒ FAILS. +/// * ⓙ re-implement the enumerator to read `state.waiting_for`'s +/// `target_slots[..].legal_targets` ⇒ arm ⓐ still passes, arm ⓑ returns zero ⇒ FAILS. +/// * ⓐ' delete the `entry.controller != proposer` filter ⇒ the bystander-proposer +/// assertion FAILS. (On THIS board every one of the 152 stack entries is P0-controlled — +/// measured — so the honest form of that probe is a bystander proposer, not a rising +/// count for P0.) +/// +/// MUST-NOT-FLIP: `bounded_cycle_pin_slots(..).is_empty()` on the shipped +/// `b3_materialize_stop_short` offer board — asserted, not assumed. That zero is the +/// byte-identity pin for every shipped `Fixed(N)` drive, and pairing it with dump B's +/// non-zero in the SAME row proves the instrument returns both values. +#[test] +fn bounded_cycle_pin_slots_enumerates_the_emblem_slot() { + use engine::game::engine::bounded_cycle_pin_slots; + use engine::types::zones::Zone; + + const EMBLEM: ObjectId = ObjectId(541); + const P3: PlayerId = PlayerId(3); + + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dellian_emblem_conqueror_4p.json.gz" + ))); + + // ── reach guards, all derived from the loaded board, none from the predicate ── + let emblem = state + .objects + .get(&EMBLEM) + .expect("reach-guard: dump B carries the emblem object"); + assert_eq!( + emblem.zone, + Zone::Command, + "reach-guard: CR 114.2 puts the emblem in the command zone — the whole reason this \ + row exists (a battlefield-only slot builder would be untested by it)" + ); + let emblem_incarnation = emblem.incarnation; + let emblem_entries = state.stack.iter().filter(|e| e.source_id == EMBLEM).count(); + assert_eq!( + emblem_entries, 1, + "reach-guard, measured off the loaded 152-deep stack: the emblem has exactly one \ + live entry here, so this row's COUNT carries no claim about the per-SOURCE dedupe \ + — that is \ + `bounded_cycle_pin_slots_publishes_one_point_per_source_not_per_entry`'s job" + ); + assert_eq!( + engine_live_opponents(&state, P0), + vec![P1, P2, P3], + "reach-guard: three living opponents, so the per-iteration choice is REAL" + ); + + let expected_slot = DecisionSlot { + source: YieldTarget::ThisObject { + source_id: EMBLEM, + incarnation: Some(emblem_incarnation), + trigger_description: None, + }, + index: 0, + }; + let expected_legal = vec![ + TargetRef::Player(P1), + TargetRef::Player(P2), + TargetRef::Player(P3), + ]; + + // ── arm ⓐ: the shipped board, prompt UP ── + assert!( + matches!(state.waiting_for, WaitingFor::TriggerTargetSelection { .. }), + "arm ⓐ precondition: the dump ships AT the prompt; got {:?}", + state.waiting_for + ); + let with_prompt = bounded_cycle_pin_slots(&state, P0); + // The CR 115.2 TARGETS half — this row's subject. Filtered rather than counted whole + // because the mint also admits shape (B) (may-only, no announcement choice), whose + // points are asserted separately below; before shape (B) existed the two coincided. + let targets: Vec<_> = with_prompt + .iter() + .filter(|p| matches!(p.kind, DecisionPointKind::Targets { .. })) + .collect(); + assert_eq!( + targets.len(), + emblem_entries, + "one TARGETS point per qualifying SOURCE; on this board that count coincides with \ + the emblem's single entry, which is asserted above rather than assumed" + ); + for point in &targets { + assert_eq!( + point.slot, expected_slot, + "the slot names the CR 114.2 command-zone emblem at its CR 400.7 incarnation" + ); + assert_eq!( + point.kind, + DecisionPointKind::Targets { + legal_targets: expected_legal.clone(), + min_targets: 1, + max_targets: 1, + ordered: false, + }, + "the legal set is `find_legal_targets`' native output (CR 115.2), not a \ + declaration echo" + ); + } + // ── the shape-(B) half, measured on the SAME board rather than asserted elsewhere ── + // CR 603.5: three P0-controlled optional no-target triggers (sources 126, 208, 274) + // publish a `MayChoice` gate each. 126 and 208 carry 34 stack entries apiece here, so + // this count is ALSO the per-source dedupe on a real board: without it the shipped + // dump would publish 69 may points, not 3. + let may_sources: Vec<_> = with_prompt + .iter() + .filter(|p| p.kind == DecisionPointKind::MayChoice) + .map(|p| match &p.slot.source { + YieldTarget::ThisObject { source_id, .. } => *source_id, + other => panic!("a mint slot names an object source; got {other:?}"), + }) + .collect(); + assert_eq!( + may_sources, + vec![ObjectId(126), ObjectId(208), ObjectId(274)], + "CR 603.5: the may-only (shape (B)) sources this board publishes, deduped per \ + SOURCE across their 34/34/1 stack entries" + ); + for src in [ObjectId(126), ObjectId(208)] { + assert!( + state.stack.iter().filter(|e| e.source_id == src).count() > 1, + "reach-guard: {src:?} must carry MORE than one entry, or the dedupe assertion \ + above is vacuous" + ); + } + + // ── arm ⓑ: the SAME state at the real offer beat — one field reassigned ── + state.waiting_for = WaitingFor::Priority { player: P0 }; + assert_eq!( + bounded_cycle_pin_slots(&state, P0), + with_prompt, + "arm ⓑ: byte-for-byte the same slot set with NO prompt to read. Both production \ + call sites run at `WaitingFor::Priority`, so an implementation that reads \ + `target_slots[..].legal_targets` publishes nothing when it matters" + ); + + // ── ⓐ': a bystander proposer specifies none of these choices (CR 732.2a) ── + assert!( + state.stack.iter().all(|e| e.controller == P0), + "measured: every dump-B stack entry is P0-controlled, so the controller filter is \ + probed from the PROPOSER side" + ); + for bystander in [P1, P2, P3] { + assert!( + bounded_cycle_pin_slots(&state, bystander).is_empty(), + "a bystander ({bystander:?}) controls none of these entries" + ); + } + + // ── must-NOT-flip: the shipped Fixed(N) drive board publishes NOTHING ── + let (shipped, _l0, _cleric) = reach_2p_optional_drain_offer(); + assert!( + bounded_cycle_pin_slots(shipped.state(), P0).is_empty(), + "the untargeted `each opponent loses 1 life` drain reifies no per-iteration player \ + choice — every shipped Fixed(N) drive must stay byte-identical" + ); +} + /// Phase 1b crown-safety row. Retention only ADDS older frames to the ring, and /// `find_live_loop_winner` scans every suffix, so a window that crowns today must still /// crown after the exemption. Dump C is the population where that is measurable: it ships @@ -5469,8 +6304,10 @@ fn drawgo_ring_spans_turns_but_never_offers() { let before = state.loop_detect_ring.len(); let pair = (witness.is_none() && turn_based && before >= 4) .then(|| { - let front = state.loop_detect_ring.front()?.clone(); - let back = state.loop_detect_ring.back()?.clone(); + // The witness reports on the basis-B turn conjunct, which reads the + // CR 104.4b comparand half. + let front = state.loop_detect_ring.front()?.normalized.clone(); + let back = state.loop_detect_ring.back()?.normalized.clone(); (front.turn_number < state.turn_number).then_some((front, back)) }) .flatten(); @@ -5485,8 +6322,8 @@ fn drawgo_ring_spans_turns_but_never_offers() { front.turn_number, before, state.loop_detect_ring.len(), - (*front).clone(), - (*back).clone(), + front.clone(), + back.clone(), )); } } @@ -6822,3 +7659,3430 @@ fn ai_collapse_candidate_is_clamped_to_the_accepted_bound() { apply(&mut state, P0, candidates[0].clone()) .expect("the AI's generated candidate must be accepted by the reducer"); } + +// =========================================================================== +// PR-7 Phase 5b — CR 732.2a BOUNDED cycle fast-forward, on a REAL 4p dump. +// +// The class Path A and Path B both refuse: a drain lethal to SOME opponents leaves a +// second non-faller, so CR 104.2a determinacy (`loop_check`'s crown gate) will not crown, +// and a life-loss axis is not a CR 732.4 no-loss draw. Every row below LOADS +// `dina_conqueror_4p.json.gz` through the production restore chokepoint and DRIVES real +// beats through `apply()` — the offer is an accumulation across dozens of them, which no +// synthetic `GameScenario` reproduces. +// =========================================================================== + +/// Drive the loaded dump until the ENGINE ITSELF writes a bounded offer, and return the +/// state at that beat. Reads `state.waiting_for` — i.e. the production Path D write inside +/// `interactive_loop_bridge` — NEVER an out-of-band call to the offer predicate, which +/// would prove only that the predicate agrees with itself. +fn drive_to_bounded_offer(state: &mut GameState, cap: usize) -> Option { + let pin = engine_live_opponents(state, P0).first().copied(); + for beat in 0..cap { + if matches!( + state.waiting_for, + WaitingFor::LoopShortcut { + predicted_winner: None, + .. + } + ) { + return Some(beat); + } + if dump_drive_one_beat(state, pin).is_err() { + return None; + } + } + None +} + +fn bounded_offer_parts( + state: &GameState, +) -> ( + PlayerId, + &engine::analysis::loop_check::LoopCertificate, + &engine::analysis::decision_template::ShortcutDecisionSchema, +) { + match &state.waiting_for { + WaitingFor::LoopShortcut { + proposer, + predicted_winner: None, + certificate, + schema, + } => (*proposer, certificate, schema), + other => panic!("expected a bounded LoopShortcut offer, got {other:?}"), + } +} + +/// PR-7 Phase 5b acceptance — the bounded offer FIRES on the real 4-player Dina/Conqueror +/// drain, at three living opponents, with a bound computed from the offer-beat board. +/// +/// ⚠ CERTIFICATION BASIS — **B**, with a derived `frames_per_period == 1`. An earlier revision +/// of this doc said basis **A** ("direct recurrence"); that was WRONG and the correction is +/// load-bearing, so it is recorded rather than swapped. MEASURED two independent ways: +/// (i) instrumenting the `basis_a` match in `try_offer_bounded_cycle_shortcut` prints +/// `BASIS=B k=1 turn=5 phase=CombatDamage ring=3` at this row's offer beat; (ii) making +/// `ring_delta_signature` return `None` unconditionally removes this row's offer entirely +/// (the drive runs its full 400-beat cap and the `expect` below fires) — which could not +/// happen if basis A were certifying, because `ring_delta_signature` is reached only from the +/// `None =>` arm. +/// +/// ⚠ WHY BASIS A REFUSED — the MECHANISM, measured at this row's own offer beat by +/// instrumenting the `basis_a` walk (ring length 3, walked newest-first). Both disjuncts fail, +/// for two DIFFERENT reasons, and neither is the one a reader would guess: +/// * the **equal** disjunct is refused by stack growth. `ring[1] -> current` is +/// `stack[8 -> 10]`: **two more `ObjectId(401)` "Bloodthirsty Conqueror" `GainLife` +/// triggered-ability entries per period** (7 -> 9, alongside one steady `ObjectId(71)` +/// "Dina, Soul Steeper" `LoseLife` entry) — a super-critical mu > 1 cascade, so the board +/// provably never recurs. The single pair that IS `eq == true` (`ring[2]`, `stack[10 -> 10]`) +/// carries a ZERO delta, so `net_progress_for(proposer)` is false and it is discarded. +/// * the **cover** disjunct clears gates (1)-(4) on that same pair and is then vetoed at +/// **gate (5)** — the off-stack fire-time condition guard — by `ObjectId(90)` +/// **"Mortality Spear"** sitting in the **Library**, carrying +/// `ModifyCost { Reduce, {2} }` / `affected: SelfRef` gated on +/// `LifeGainedThisTurn { Controller } >= 1`: a PROJECTED axis read at fire time. +/// The `scope.cast_card_ids` relief that exists for exactly this def shape cannot apply, +/// because step (1b) of the bounded class REQUIRES an empty `last_loop_action_sequence`, +/// so `window_cast_card_ids` returns `None` and gate (5) scans everything. Two +/// individually-correct constraints composing into a refusal neither intended. +/// (On the older `ring[0]` pair cover instead fails at **gate (1)**, on `loop_states_equal` +/// of the stack-cleared projected board — `object_resource_axes_match` was `true` at every +/// gate-(1) refusal measured in this run, so it is NOT the refuser here.) +/// +/// THE PUBLISHED PAYLOAD CANNOT DISTINGUISH THE TWO HERE, and that is why the row asserts a +/// structural `frames_per_period >= 1` and not a basis. ⚠ RE-DERIVED IN FIX ROUND 2, because +/// fix round 1 moved the ground under the older wording: basis A no longer publishes a hardcoded +/// `1`, it MEASURES the span from the certifying prior's ring index, and basis B *derives* `k` +/// from `1` upward. Both therefore range over the same values and **NO published value +/// discriminates in either direction** — not `== 1`, and no longer `!= 1` either (a basis-A span +/// of 2 is exactly what `interactive_3p_subset_lethal_does_not_crown` publishes). Any row still +/// reading a basis off this field is stating a necessary-not-sufficient condition at best. No +/// `pub` predicate closes the gap either: basis A's certifying condition is a DISJUNCTION whose +/// second half, `loop_states_cover_modulo_growth_pinned`, is `pub(crate)` and unnameable from +/// an integration test — so the sound attribution stays the discriminating probe (force +/// `ring_delta_signature` to return `None`; basis-B rows lose their offer, basis-A rows keep it). +/// +/// CONSEQUENCE, and it is the good one: this row is a basis-B positive control on a REAL 4p +/// dump. It is also therefore SUBJECT TO the CR 703.1 turn-position conjunct rather than +/// bypassing it — the conjunct is evaluated here and PASSES, because the certifying window is +/// `turns[5,5,5] phases[CombatDamage x3] extra[0x3]`. It is a must-NOT-flip in both +/// directions, MEASURED: deleting the conjunct leaves this row green, and keeping it leaves +/// this row green. It is therefore NOT a discriminating control for that conjunct — the rows +/// that carry that discrimination are `analysis::resource`'s +/// `drawgo_turn_structure_yields_no_basis_b_signature` and +/// `ring_delta_signature_certifies_only_a_period_seen_twice` arm ⓕ (refusing side), and +/// `bounded_offer_on_a_within_turn_draw_drain_is_basis_b` (positive side). +/// +/// EVERY NUMBER IS COMPUTED IN-TEST from the offer-beat state. The chain's reported "32" +/// is not a fixture fact: it drifts with how many beats the drive takes to accumulate, and +/// this row recomputes `min over living opponents of (life - 1) / per-cycle loss` at the +/// beat the offer actually appeared. +/// +/// NON-VACUITY: BASE is a measured NO-OFFER trajectory. Before Path D existed the same +/// drive ran 326 beats on this dump and reached `WaitingFor::LoopShortcut` zero times, so +/// the offer cannot appear here vacuously. The two field-value discriminators are asserted +/// rather than a code location: `predicted_winner == None` (this seam never calls +/// `live_mandatory_loop_winner`, so it cannot have inherited Path A's crown) and +/// `last_loop_action_sequence` EMPTY (the object-growth producer's class is the complement). +/// +/// REVERT-PROBES (each must FLIP to FAIL): +/// * delete the Path D block in `interactive_loop_bridge` ⇒ no offer ⇒ the +/// `drive_to_bounded_offer` expect FAILS. +/// * make step (7)'s range check `1..=MAX_SHORTCUT_CYCLES` ⇒ NOTHING flips, anywhere. The +/// claim this bullet used to make — that the `schema.is_bounded()` assertion below "is the +/// one that flips" — was FALSE, and is corrected rather than quietly deleted. RE-MEASURED +/// under that exact mutation, with the runner and the filter both named because the earlier +/// revision quoted a count whose shape did not match the filter beside it (fix round 2, +/// LOW-2): `cargo test -p phase-engine --test integration -- loop_shortcut::` (module filter on the +/// `integration` binary) ⇒ **85 passed / 0 failed, 4090 filtered out**. `schema.is_bounded()` +/// included, because on every fixture the bound really IS narrowed and that assertion holds +/// independently of the range check. A SUBSTRING filter is a different question with a +/// different answer — `cargo test -p phase-engine loop_shortcut` sweeps every target and additionally +/// matches `loop_shortcut_activation` / `loop_shortcut_mana_engine`; do not quote one shape's +/// number beside the other's filter. +/// No single-conjunct revert of step (7) flips a row on THIS fixture, and that is a property +/// of the mutation rather than a gap: it widens only the range's UPPER end, and a bound of +/// exactly `MAX_SHORTCUT_CYCLES` means no axis narrowed — which `classify_win_kind` already +/// reports as `Advantage`, so step (5) refuses two conjuncts earlier. The REACHABLE end is +/// the lower one, and its named row is +/// `game::engine::bounded_offer_conjunct_tests::a_bound_of_zero_mints_no_bounded_offer` +/// (revert-probe: `0..MAX_SHORTCUT_CYCLES` ⇒ that row FAILS; measured). +/// * remove `elimination_bounds`' `p.life as i64 - 1` headroom term ⇒ the recomputed bound +/// and the published one diverge ⇒ FAILS. +#[test] +fn dina_untargeted_drain_4p_offers_at_three_live_opponents() { + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_conqueror_4p.json.gz" + ))); + + // ── reach-guards on the loaded board; every assertion below is meaningless without them + assert!( + state.loop_detection.samples(), + "reach-guard: a non-sampling mode never populates the ring, so no offer could ever \ + be raised and this row would be vacuous; got {:?}", + state.loop_detection + ); + assert_eq!( + state.loop_detect_ring.len(), + 0, + "reach-guard: the dump ships with an EMPTY ring — every frame the offer certifies \ + against was accumulated by THIS drive, not restored" + ); + assert_eq!( + engine_live_opponents(&state, P0).len(), + 3, + "reach-guard: the whole point of this class is that Path A cannot crown, which needs \ + >= 2 non-fallers, i.e. three living opponents here" + ); + assert!( + !matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }), + "reach-guard: the dump must NOT ship at a saved offer — the offer is this row's \ + deliverable, not its input" + ); + + let beat = drive_to_bounded_offer(&mut state, 400).expect( + "CR 732.2a: the bounded offer must FIRE on this real 4p drain. BASE (no Path D) drove \ + 326 beats on this same dump and reached zero LoopShortcut beats, so a failure here is \ + the offer never being raised, not a fixture accident.", + ); + + let (proposer, certificate, schema) = bounded_offer_parts(&state); + + // ── the two binding field-value discriminators ── + assert_eq!( + proposer, state.active_player, + "CR 732.2a: the proposer is the priority holder, and step (2) requires that to be the \ + active player the ring sampler gates on" + ); + assert!( + state.last_loop_action_sequence.is_empty(), + "the bounded class's entry must NOT require a driving sequence — a non-empty one \ + routes an accepted proposal to the object-growth materializer, which commits zero \ + bounded cycles (beat {beat})" + ); + assert!( + schema.points.is_empty(), + "the UNTARGETED class publishes no per-iteration choice, so the schema exposes no \ + decision points; got {:?}", + schema.points + ); + + // ── the per-period signature, bound FROM the value ── + let per_cycle = certificate + .per_cycle + .as_ref() + .expect("a bounded offer publishes the per-period signature its bound was divided by"); + assert!( + per_cycle.frames_per_period >= 1, + "a period spans at least one retained frame; got {}", + per_cycle.frames_per_period + ); + assert!( + per_cycle.victim_slot.is_empty(), + "no slot is published, so nothing is charged to a declared victim — the victims are \ + already visible in `delta.life`; got {:?}", + per_cycle.victim_slot + ); + assert!( + per_cycle.delta != engine::analysis::resource::ResourceVector::default(), + "a zero-delta cycle states no CR 704 threshold and must never be offered" + ); + + // ── the bound, RECOMPUTED from the offer-beat board ── + let living_opponents: Vec = engine_live_opponents(&state, proposer); + assert_eq!( + living_opponents.len(), + 3, + "three living opponents must still be the population AT THE OFFER BEAT ({beat}), not \ + only at load — otherwise the bound below is computed over the wrong seats" + ); + let mut losses: Vec<(PlayerId, i64, i64)> = vec![]; + for p in state.players.iter().filter(|p| !p.is_eliminated) { + let loss = -per_cycle.delta.life.get(&p.id).copied().unwrap_or(0); + losses.push((p.id, p.life as i64, loss)); + } + let opponent_losses: Vec = losses + .iter() + .filter(|(id, _, _)| *id != proposer) + .map(|(_, _, loss)| *loss) + .collect(); + assert!( + opponent_losses.iter().all(|&l| l > 0), + "REACH-GUARD against a degenerate fixture: every living opponent must actually be \ + LOSING life per cycle, else the CR 704.5a headroom term never narrows and the bound \ + below would be the safety cap for the wrong reason; measured {losses:?}" + ); + let expected_bound = losses + .iter() + .filter(|(_, _, loss)| *loss > 0) + .map(|(_, life, loss)| (life - 1) / loss) + .min() + .expect("at least one seat is losing life, asserted above"); + assert_eq!( + i64::from(schema.max_iterations), + expected_bound, + "CR 704.5a: the published bound must equal `min over living seats of (life - 1) / \ + per-cycle loss`, recomputed here from the offer-beat board {losses:?} at beat {beat}" + ); + assert_eq!( + schema.iteration_count, + engine::analysis::decision_template::IterationCount::Fixed(schema.max_iterations), + "CR 732.1b: the SUGGESTION seeded into the picker is the bound itself" + ); + assert!( + schema.is_bounded(), + "the whole claim of this producer is that it NARROWED the repetition bound; \ + max_iterations = {}", + schema.max_iterations + ); + + // ── siblings: nothing terminal happened, and no revocable-infinity was marked ── + assert_eq!( + certificate.win_kind, + engine::analysis::loop_check::WinKind::LethalDamage, + "CR 704.5a: a life drain is not `Advantage`, which is the conjunct that keeps this \ + seam disjoint from the Path C revocable-infinity mark" + ); + assert!( + state.unbounded_resources.is_empty(), + "an OFFER is not a grant: CR 104.4b's revocable-infinity mark belongs to Path C and \ + must not be written by raising a bounded offer; got {:?}", + state.unbounded_resources + ); +} + +/// The dina 4p drain DRIVEN through `apply()` to the beat the engine itself raises the +/// bounded offer on, with that beat's index. Shared by every row that has to observe the +/// mint at the ONE beat this corpus offers on. +fn dina_driven_to_bounded_offer() -> (GameState, usize) { + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_conqueror_4p.json.gz" + ))); + let beat = drive_to_bounded_offer(&mut state, 400).expect( + "CR 732.2a: the bounded offer must FIRE on this real 4p drain. BASE (no Path D) drove \ + 326 beats on this same dump and reached zero LoopShortcut beats, so a failure here is \ + the offer never being raised, not a fixture accident.", + ); + (state, beat) +} + +/// Restore the `Priority` window the bridge consumed when it raised the offer, so the mint can +/// be re-run on the offer beat's own board. Everything else the mint reads — the ring, the +/// stack, the resources, `last_loop_action_sequence` — is untouched, and each caller proves +/// the reconstruction faithful by requiring the SAME outcome the production path produced. +fn replay_at_priority(state: &GameState, proposer: PlayerId) -> GameState { + let mut replay = state.clone(); + replay.waiting_for = WaitingFor::Priority { player: proposer }; + replay +} + +/// R16 (v) — THE SEQUENCING PIN: NOTHING SPENDS BEFORE THE RING GATE, BECAUSE NOTHING ASKS. +/// +/// CR 732.2a. At a ring-WARM-UP beat (`loop_detect_ring.len() < 2`) there is no window, hence +/// no reachable certificate, so the mint must refuse at the ring-usability gate BEFORE the +/// verdict door is asked anything. This is the property that keeps the frozen exemption's +/// cost argument honest: at these beats the frozen set is empty and dellian's non-exempt +/// population is 152–153 entries, i.e. exactly the unexempted full sweep the ring gate exists +/// to keep off the critical path. +/// +/// The property is STRUCTURAL, not an ordering the executor had to remember: the verdict +/// container is constructed BELOW the gate, so at this beat there is nothing to ask. +/// +/// ⚠ WHAT THIS ROW DOES **NOT** CATCH, stated because the plan's proposed revert-probe was +/// analysed NOT to flip through this instrument. `MintMeter` is populated from the container +/// AFTER `certified_bounded_cycle_offer` returns, and the ring gate is an EARLY RETURN above +/// that snapshot — so an eager pass hoisted above the gate would spend a budget this meter +/// never reads, and the all-zero reading below would survive it. The row therefore pins the +/// OBSERVABLE part (the gate refuses with `NoCertification`, on a beat carrying a stack an +/// eager pass would really pay for) while the structural part rests on the construction order +/// in `bounded_cycle_offer`. Disclosed as a stop-and-return item rather than papered over with +/// a probe that cannot fire. +#[test] +fn r16v_a_ring_warmup_beat_spends_nothing() { + use engine::game::engine::{ + try_offer_bounded_cycle_shortcut_metered, BoundedOfferRefusal, ProbeCap, + }; + + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dellian_emblem_conqueror_4p.json.gz" + ))); + // Find a beat that is AT PRIORITY (so steps 1/1b/2 pass and the ring gate is the first + // thing that can refuse) and still ring-starved. The dump ships with an empty ring, so + // this is reachable by construction; the search makes the row robust to drive drift. + let mut found = None; + for beat in 0..40usize { + if state.loop_detect_ring.len() < 2 { + if let WaitingFor::Priority { player } = state.waiting_for { + if player == state.active_player && state.last_loop_action_sequence.is_empty() { + found = Some((beat, state.clone())); + break; + } + } + } + if dump_drive_one_beat(&mut state, None).is_err() { + break; + } + } + let (beat, board) = found.expect( + "REACH-GUARD: no ring-starved beat at priority was reached, so this row would be \ + asserting about a gate it never arrived at", + ); + assert!( + board.stack.len() > 2, + "REACH-GUARD: the beat must carry a stack an eager pass would actually PAY for, else \ + `spent == 0` is true for want of anything to classify; got {} entries at beat {beat}", + board.stack.len() + ); + + let (outcome, meter) = + try_offer_bounded_cycle_shortcut_metered(&board, false, ProbeCap::Shipped); + assert!( + matches!(outcome, Err(BoundedOfferRefusal::NoCertification)), + "a ring of {} frames reaches no window, so the refusal is the ring-usability gate's; \ + got {outcome:?}", + board.loop_detect_ring.len() + ); + assert_eq!( + ( + meter.spent, + meter.denied, + meter.conjunct6_asks, + meter.conjunct4_scans + ), + (0, false, 0, 0), + "R16(v): the gate refuses BEFORE the verdict container can be asked anything — a \ + non-zero counter here is an eager classification pass reintroduced above the ring \ + gate. beat {beat}, meter {meter:?}" + ); + assert!( + meter.certification.is_none(), + "no certificate is reachable at a ring-starved beat; meter {meter:?}" + ); +} + +/// R15 — A BUDGET-EXCEEDED MINT IS A REFUSAL, NEVER A STALL AND NEVER A CERTIFICATE. +/// +/// CR 732.2a. This is the row that makes *"cost is a coverage knob, never a soundness knob"* a +/// measurement instead of a sentence: with the per-mint cap forced to zero, the classifier can +/// afford nothing, `probe_resolution` returns `Prompted`, and the offer is REFUSED — the mint +/// does not fall through to a clone-and-resolve, and it does not hang. +/// +/// The two arms are the SAME BOARD one argument apart — the real dina offer beat, replayed +/// through the only cap channel the seam admits. The `Shipped` arm is the matched positive +/// reach-guard: without it a starved refusal proves nothing, because a board that never offers +/// refuses at zero budget too. +/// +/// REVERT-PROBE: delete `probe_resolution`'s `try_charge_one` arm (`resolution_prompt.rs`) ⇒ +/// the exhausted budget falls through to the clone-and-resolve ⇒ the starved arm OFFERS ⇒ +/// FLIPS. +#[test] +fn r15_a_zero_probe_budget_refuses_the_bounded_offer() { + use engine::game::engine::{try_offer_bounded_cycle_shortcut_metered, ProbeCap}; + + let (state, beat) = dina_driven_to_bounded_offer(); + let (proposer, _, _) = bounded_offer_parts(&state); + let replay = replay_at_priority(&state, proposer); + + // MATCHED POSITIVE, first so a starved refusal below can never pass vacuously. + let (healthy, healthy_meter) = + try_offer_bounded_cycle_shortcut_metered(&replay, false, ProbeCap::Shipped); + assert!( + healthy.is_ok(), + "REACH-GUARD: at the shipped cap this beat must OFFER, else the starved arm is not \ + keyed to the budget. beat {beat}, meter {healthy_meter:?}" + ); + assert!( + !healthy_meter.denied, + "REACH-GUARD: the positive arm must not itself be exhausted; meter {healthy_meter:?}" + ); + + // THE ROW: the same board, the same beat, the cap forced to zero. + let (starved, meter) = + try_offer_bounded_cycle_shortcut_metered(&replay, false, ProbeCap::Lowered(0)); + assert!( + starved.is_err(), + "CR 732.2a: an unaffordable probe degrades to honest-red — no certificate and no \ + offer. Got {starved:?}, meter {meter:?}" + ); + assert!( + meter.denied && meter.spent == 0, + "the refusal must be the BUDGET's: a zero cap denies the very first charge, so \ + nothing is spent and the denial flag is what carries the cause. meter {meter:?}" + ); + // WHERE the denial lands, MEASURED rather than assumed — and it is not the intuitive + // answer. Basis B consults NO board predicate, so it certifies for FREE even at a zero cap + // (`certification == Some(ResourceSignatureOnly)` at `spent == 0`); the first gate that + // must actually PAY is conjunct (6), which asks the door, is denied, and therefore reads + // `Prompted`. Exhaustion surfaces as an UNSPECIFIED WINDOW, not as a missing certificate. + assert!( + meter.conjunct6_asks > 0, + "REACH-GUARD: the starved mint must have REACHED the paying gate, else `denied` is \ + about a charge nobody ever attempted. meter {meter:?}" + ); + assert!( + matches!( + starved, + Err(engine::game::engine::BoundedOfferRefusal::UnspecifiedChoiceWindow) + ), + "an exhausted classifier reads `Prompted`, so the step-6 predicate goes false and the \ + mint REFUSES — never a stall, never a certified offer. Got {starved:?}, \ + meter {meter:?}" + ); +} + +/// R33 arm (d) — THE CORPUS'S ONE OFFERING BEAT CERTIFIES THROUGH BASIS B, AND THE FROZEN +/// EXEMPTION IS THEREFORE WITHDRAWN THERE. +/// +/// CR 732.2a. The row R33 (a)/(b)/(a′) prove at the constructor and the selection site; this +/// arm proves it on the REAL board the engine actually offers on, because the plan's cost +/// argument (the frozen subtraction, and the speed-up that rests on it) was measured at +/// *dellian-shaped* beats and this is the beat the corpus *offers* at. Escalated to the lead +/// and ruled on before this test was written; the figures are in the commit message. +/// +/// MEASUREMENT, not re-assertion of a design: the certifying disjunct has no other surface. +/// Both bases publish `frames_per_period`, so `LoopCertificate` discriminates in NEITHER +/// direction — hence [`MintMeter::certification`]. +/// +/// FIDELITY OF THE OBSERVATION, which is this row's real risk. The production mint runs +/// INSIDE the offering beat's `apply()`, from a `Priority` window the bridge has already +/// consumed by the time the drive returns, so the beat's own meter is unreachable from a +/// test. The mint is re-run here on the offer beat's state with that window restored, and +/// the reconstruction is PROVEN faithful rather than assumed: it must (1) offer at all and +/// (2) publish a `per_cycle` EQUAL to the one the production path wrote into `waiting_for`. +/// A reconstruction that drifted would fail (1) or (2) before the basis assertion is reached. +#[test] +fn dina_offering_beat_certifies_through_basis_b_and_exempts_nothing() { + use engine::analysis::resource::PeriodCertification; + use engine::game::engine::{try_offer_bounded_cycle_shortcut_metered, ProbeCap}; + + let (state, beat) = dina_driven_to_bounded_offer(); + let (proposer, certificate, _) = bounded_offer_parts(&state); + let published = certificate + .per_cycle + .clone() + .expect("the bounded offer publishes a per-cycle signature"); + + let replay = replay_at_priority(&state, proposer); + let (outcome, meter) = + try_offer_bounded_cycle_shortcut_metered(&replay, false, ProbeCap::Shipped); + + // (1) reconstruction fidelity, part one + assert!( + outcome.is_ok(), + "REACH-GUARD: the replayed mint must reach the same OFFER the production path raised \ + at beat {beat}; a refusal here means this row measures a different board than the \ + engine did, and the basis assertion below would be about nothing. Got {outcome:?}, \ + meter {meter:?}" + ); + // (1) reconstruction fidelity, part two — the same certificate, not merely some offer + let WaitingFor::LoopShortcut { + certificate: replayed, + .. + } = outcome.expect("asserted Ok above") + else { + panic!("the bounded offer is a LoopShortcut window"); + }; + assert_eq!( + replayed.per_cycle.as_ref(), + Some(&published), + "REACH-GUARD: the replayed mint must publish the SAME per-cycle signature as the \ + production offer at beat {beat}" + ); + + // (2) THE MEASURED AXIS. Basis A certified NOTHING corpus-wide (0 of 129 certifications + // across the three 4p dumps); this beat takes `ring_delta_signature`, which by its own + // doc consults no board predicate. + assert_eq!( + meter.certification, + Some(PeriodCertification::ResourceSignatureOnly), + "R33: the corpus's one offering beat (beat {beat}) certifies through BASIS B. If this \ + ever reads `BoardCovered`, the frozen exemption became available at an offering beat \ + and the plan's exempted-cost row must be re-derived before that is relied on" + ); + + // (3) THE CONSEQUENCE, which is the half that makes this arm about the exemption rather + // than about a label: under a non-`BoardCovered` certificate `frozen_ids` is empty, so + // conjunct (6) skips NOTHING and scans every non-exempt entry it is handed. + assert_eq!( + meter.conjunct6_frozen_skips, 0, + "R33: `ResourceSignatureOnly` supplies neither P2 nor P4, so the subtraction is \ + withdrawn and conjunct (6) exempts nothing at this beat" + ); + assert!( + meter.conjunct6_asks > 0, + "REACH-GUARD against a vacuous skip count: conjunct (6) must actually have RUN at \ + this beat, else `frozen_skips == 0` is trivially true; meter {meter:?}" + ); + + // (4) THE BUDGET, re-derived from this very beat (R16(ii-a)). The offer fires WITH the + // cap binding, not because the cap stopped mattering. + assert!( + !meter.denied, + "R16(ii-a): the shipped cap must not starve the corpus's acceptance offer; measured \ + demand at this beat is 13 charges. meter {meter:?}" + ); +} + +/// R16 (i) + (ii-a) + (iv) — THE BUDGET DOES NOT STARVE THE CORPUS'S ACCEPTANCE BEAT, ITS +/// DEMAND THERE IS EXACTLY MEASURED, AND THE MINT DOES NOT STALL. +/// +/// CR 732.2a. Without this row the per-mint probe cap is a number nobody checked against the +/// fixture it has to serve — which is exactly how the shipped `12` came to starve this beat by +/// one charge. +/// +/// (i) the offer still fires at the shipped cap. (ii-a) the cap does NOT bind there +/// (`denied == false`) — note that `spent <= cap` is true by construction of the budget, so +/// the non-vacuous form of (ii-a) is the denial flag, not the inequality. +/// +/// THE EXACT-DEMAND PIN is what makes (ii-a) discriminating rather than a restatement. The +/// demand `D` is SEARCHED through the seam's own closed cap domain, from zero upward, so it +/// is measured on this run instead of copied from a log: every cap below `D` must REFUSE and +/// `D` itself must OFFER. A budget re-derivation that drifts the true demand fails here with +/// the new number in the message. +/// +/// (iv) THE WALL-CLOCK. The whole mint — memo construction, the D2 window work +/// (`certified_period_touch` + `bounded_cycle_pin_slots_for_window` over up to +/// `LOOP_DETECT_RING_CAP` windows) and the budgeted classification — is timed end to end. +/// ⚠ THE CEILING IS DEBUG-SCALED AND SAYS SO: the plan's ~1 s figure is a player-facing +/// RELEASE budget, and this binary is `-C opt-level=0`. The measured figure is carried in the +/// message so the release claim is derived from a number rather than asserted. +/// +/// REVERT-PROBE: lower `PROBE_BUDGET` below the measured `D` ⇒ (i) fails +/// (`dina_driven_to_bounded_offer` cannot reach an offer) ⇒ FLIPS. Round 2 measured exactly +/// that: at `12` this row and six shipped siblings go red together. +#[test] +fn r16_the_offering_beats_probe_demand_is_exactly_measured() { + use engine::game::engine::{ + try_offer_bounded_cycle_shortcut_metered, BoundedOfferRefusal, ProbeCap, + }; + + let (state, beat) = dina_driven_to_bounded_offer(); + let (proposer, _, _) = bounded_offer_parts(&state); + let replay = replay_at_priority(&state, proposer); + + // ── (i) + (iv): the shipped cap, timed ─────────────────────────────────────────────── + let started = std::time::Instant::now(); + let (shipped, meter) = + try_offer_bounded_cycle_shortcut_metered(&replay, false, ProbeCap::Shipped); + let elapsed = started.elapsed(); + assert!( + shipped.is_ok(), + "R16(i) beat {beat}: the corpus's acceptance offer must FIRE at the shipped cap. \ + Got {shipped:?}, meter {meter:?}" + ); + assert!( + !meter.denied, + "R16(ii-a) beat {beat}: the cap must not BIND at the beat that offers — `spent <= cap` \ + is true of every mint by construction, so the denial flag is the only non-vacuous \ + form of this claim. meter {meter:?}" + ); + assert!( + meter.spent > 0, + "REACH-GUARD: the mint must have CLASSIFIED something, else `!denied` is true for \ + want of anything to charge. meter {meter:?}" + ); + assert!( + elapsed < std::time::Duration::from_secs(10), + "R16(iv) beat {beat}: the whole mint took {elapsed:?}, which is past even the \ + debug-scaled stall ceiling. Measured at the time of writing: ~12 ms for this beat's \ + `ring=3 stack=10` mint in an unoptimized build. meter {meter:?}" + ); + + // ── THE EXACT-DEMAND PIN ───────────────────────────────────────────────────────────── + let demand = meter.spent; + let (at_demand, demand_meter) = + try_offer_bounded_cycle_shortcut_metered(&replay, false, ProbeCap::Lowered(demand)); + assert!( + at_demand.is_ok() && !demand_meter.denied, + "R16: a cap of exactly the measured demand ({demand}) must still OFFER — that is what \ + makes {demand} the DEMAND rather than an upper bound. Got {at_demand:?}, \ + meter {demand_meter:?}" + ); + for lowered in 0..demand { + let (starved, starved_meter) = + try_offer_bounded_cycle_shortcut_metered(&replay, false, ProbeCap::Lowered(lowered)); + assert!( + matches!(starved, Err(BoundedOfferRefusal::UnspecifiedChoiceWindow)), + "R16: every cap below the measured demand must refuse fail-CLOSED at the choice \ + gate. cap {lowered} of {demand} gave {starved:?}, meter {starved_meter:?}" + ); + assert!( + starved_meter.denied, + "R16: …and the refusal must be attributable to the BUDGET, not to an unrelated \ + conjunct. cap {lowered}, meter {starved_meter:?}" + ); + } + assert!( + demand > 1, + "REACH-GUARD: a demand of 0 or 1 would make the starvation sweep above empty or \ + trivial; measured {demand} at beat {beat}" + ); +} + +/// Drive a `GameScenario`-built board until the ENGINE writes a bounded offer, declining +/// nothing and injecting nothing. Returns the beat, or `None` if the cap ran out. Reads +/// `state.waiting_for` — the production Path D write — never an out-of-band predicate call. +fn drive_scenario_to_bounded_offer(runner: &mut GameRunner, cap: usize) -> Option { + for beat in 0..cap { + if matches!( + runner.state().waiting_for, + WaitingFor::LoopShortcut { + predicted_winner: None, + .. + } + ) { + return Some(beat); + } + if dump_drive_one_beat(runner.state_mut(), None).is_err() { + return None; + } + } + None +} + +/// PR-7 Phase 5b — THE BASIS-B POSITIVE CONTROL, at the offer level. +/// +/// This is the row whose WHOLE PURPOSE is detecting a back-door deletion of certification +/// basis B. It is **not** the only row that detects one — an earlier revision of this doc said +/// it was ("every other bounded-offer row in this file certifies on basis A") and that was +/// MEASURABLY FALSE, and it licenses exactly the overread that the ⚠ SCOPE note on +/// [`basis_a_bounded_fixed_count_commits_exactly_n_periods`] exists to prevent — which a reader +/// reaches much later in this file than this sentence. +/// +/// RE-MEASURED in fix round 4 at `025015135`, using this file's own prescribed attribution +/// probe (force `ring_delta_signature` to return `None` unconditionally — basis-B rows lose +/// their offer, basis-A rows keep it), runner +/// `cargo test -p phase-engine --test integration -- loop_shortcut::` (module filter on the +/// `integration` binary): **74 passed / 11 failed / 4090 filtered out**, against a clean +/// **85 passed / 0 failed / 4090 filtered out**. ELEVEN rows flip. This one, plus these ten: +/// `dina_untargeted_drain_4p_offers_at_three_live_opponents`, +/// `bloodloop_mandatory_draw_cascade_offers_at_2p_3p_and_4p`, +/// `ai_bounded_declare_candidate_is_generated_legal_and_drives`, +/// `bounded_fixed_count_commits_exactly_n_periods`, +/// `bounded_fixed_drive_stops_at_the_first_lethal_cycle`, +/// `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle`, +/// `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`. +/// +/// 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`, +/// `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 +/// `bloodloop_state` boards, so it is the one MIXED row), and +/// `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle`. The remaining five are +/// `GameScenario` builds only — this row inline, the other four via `bloodloop_state`. Counted +/// by resolving every fixture-loading call site in this file to its enclosing test fn, NOT by +/// grep hit count: these very sentences add doc-comment hits for the names they list. +/// Consistent with the plural "the file's real 4p dumps" at the ⚠ SCOPE note on +/// [`basis_a_bounded_fixed_count_commits_exactly_n_periods`]. +/// +/// What is distinctive here is INTENT and DIAGNOSIS, not exclusivity: NINE of those ten fail +/// for a reason their own docs do not name, whereas this row says so in the failure text +/// ("A failure here means basis B minted nothing"). The tenth, +/// `dina_untargeted_drain_4p_offers_at_three_live_opponents`, is the EXCEPTION and was +/// mis-covered by an earlier "each of those ten" — its ⚠ CERTIFICATION BASIS note documents +/// this very probe as measurement (ii), down to the drive running its full 400-beat cap and +/// its `expect` firing. Checked against all ten doc blocks in fix round 5: no other one +/// mentions `ring_delta_signature` or basis B at all. Nor is the list a basis census: it is the +/// set of rows that cannot reach their assertions once basis B stops minting, which includes +/// rows whose subject is the DRIVE rather than the basis. +/// +/// FIXTURE — a fully mandatory two-card draw↔drain cascade. What it shares with the class +/// basis B exists for is the one property that matters: **it draws a card every cycle**, so a +/// card moves library→hand each period, the board never recurs, and basis A's +/// `loop_states_equal_modulo_resources` (library and hand are board, not projected resources) +/// and its cover disjunct must BOTH refuse. The `None =>` arm is then the only way an offer +/// can be minted here. +/// +/// ORACLE-TEXT PROVENANCE, verified against the Scryfall API, and deliberately honest: +/// * *"Whenever you draw a card, each opponent loses 1 life."* is **real** — Psychosis +/// Crawler's second ability, verbatim. +/// * *"Whenever an opponent loses life, draw a card."* matches **no printing**. The two +/// nearest real cards are both GATED — Kefka, Ruler of Ruin (*"…during your turn"*) and +/// Valgavoth, Harrower of Souls (*"…for the first time during each of their turns"*) — and +/// that gating is precisely what would stop the cascade. This fixture is therefore +/// SYNTHETIC and deliberately stronger than any printed card. It must not be described as a +/// real-card loop. Its matched negative control +/// (`drawgo_ring_spans_turns_but_never_offers`) is a `GameScenario` too, which is the +/// precedent for a synthetic matched control pair for one predicate. +/// +/// MEASURED at the seam (not from an out-of-band predicate call): the engine writes the offer +/// at beat 31, turn 4, `Draw`, with a derived `frames_per_period == 2`, δ = +/// `life{P1:-1} lib{P0:-1}` and a bound of 16; the whole ring sits at `turns[4×6]` +/// `phases[Draw×6]` `extra[0×6]`, so every consecutive pair is turn-position invariant and the +/// CR 703.1 conjunct passes. Note δ carries `lib{P0:-1}` ONLY — P1's library is untouched, so +/// no second draw step is inside the period. Contrast drawgo (`lib{P0:-1,P1:-1}`), whose +/// "period" is one 2-player turn cycle. +/// +/// REVERT-PROBE ⓐ (MEASURED, not predicted): make `ring_delta_signature` return `None` +/// unconditionally ⇒ the `None =>` arm converts that into `Err(NoCertification)` ⇒ no offer is +/// written ⇒ the `expect` below FAILS. +/// +/// ⚠ SCOPE, stated so it is not overread: this SYNTHETIC control fires at TWO players. The +/// same cascade built at 3 and 4 players certifies but mints zero offers (they refuse +/// downstream, at step (6) `stack_choices_are_all_specified`, because `Effect::Draw` is +/// outside that gate's allow-list). `multiplayer_pure_life_drain_offers_at_three_and_four_players` +/// is NOT a substitute — it is measured basis A. The ≥3-player basis-B coverage this file DOES +/// carry is `dina_untargeted_drain_4p_offers_at_three_live_opponents`, which is measured basis +/// B (k == 1) on a real 4p dump; see that row's doc for the measurement and for why its +/// published payload cannot assert the basis on its own. +#[test] +fn bounded_offer_on_a_within_turn_draw_drain_is_basis_b() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + scenario.with_life(P1, 20); + scenario.add_creature_from_oracle( + P0, + "Test Bleeder", + 2, + 2, + "Whenever you draw a card, each opponent loses 1 life.", + ); + scenario.add_creature_from_oracle( + P0, + "Test Chronicler", + 2, + 2, + "Whenever an opponent loses life, draw a card.", + ); + // CR 504.1: the libraries must outlast the drive — a deck-out would end the game and + // silently truncate every assertion below. + let names: Vec = (0..60).map(|i| format!("Filler {i}")).collect(); + let refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); + scenario.with_library_top(P0, &refs); + scenario.with_library_top(P1, &refs); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + + assert!( + runner.state().loop_detection.samples(), + "reach-guard: a non-sampling mode never populates the ring, so no offer could ever be \ + raised and this row would be vacuous" + ); + assert_eq!( + runner.state().loop_detect_ring.len(), + 0, + "reach-guard: every frame the offer certifies against is accumulated by THIS drive" + ); + + let beat = drive_scenario_to_bounded_offer(&mut runner, 200).expect( + "CR 732.2a: the within-turn mandatory draw↔drain cascade must raise a bounded offer. \ + A failure here means basis B minted nothing — which is exactly what revert-probe ⓐ \ + (make `ring_delta_signature` return None) produces.", + ); + + let state = runner.state(); + let (proposer, certificate, schema) = bounded_offer_parts(state); + + // (i) the binding field-value discriminator. + assert_eq!( + proposer, state.active_player, + "CR 732.2a: the proposer is the priority holder, and step (2) requires that to be the \ + active player the ring sampler gates on" + ); + + // (ii) the period, bound FROM the returned value — no literal `2` appears in this row. + let per_cycle = certificate + .per_cycle + .as_ref() + .expect("a bounded offer publishes the per-period signature its bound was divided by"); + let k = per_cycle.frames_per_period; + assert!( + k >= 1, + "a period spans at least one retained frame; got {k}" + ); + // Bound to a local rather than inlined: `2k + 1` is the CONTRACT expression (2k deltas ⇒ + // the period was observed twice), and clippy's `int_plus_one` would otherwise push it to a + // `> 2k` that no longer reads as the rule. + let frames_needed = 2 * k as usize + 1; + assert!( + state.loop_detect_ring.len() >= frames_needed, + "the structural invariant every certified period satisfies: a period seen TWICE needs \ + 2k+1 frames. k = {k}, ring = {} (beat {beat})", + state.loop_detect_ring.len() + ); + assert!( + per_cycle.delta != engine::analysis::resource::ResourceVector::default(), + "a zero-delta cycle states no CR 704 threshold and must never be offered" + ); + + // (iii) A PERIOD-WIDTH TRIPWIRE — no longer a basis attribution (fix round 2). The older + // wording read `!= 1` as sufficient for "not basis A" on the premise that basis A published + // a hardcoded `1`. Fix round 1 replaced that hardcode with a MEASURED span, so basis A now + // publishes 2 on at least one shipped fixture + // (`interactive_3p_subset_lethal_does_not_crown`) and the inference is dead in both + // directions. The basis attribution this row actually stands on is its named revert-probe ⓐ + // — force `ring_delta_signature` to return `None` and this row loses its offer entirely, + // which no basis-A row does. The assertion is KEPT as a width tripwire: this cascade draws a + // card every cycle, so its period genuinely spans more than one retained frame, and a drift + // to 1 means the fixture stopped being what the row describes. + assert_ne!( + k, 1, + "this cascade draws a card every cycle, so one repetition spans more than one retained \ + ring frame; a k of 1 means the fixture no longer has the shape this row asserts" + ); + + // (iv) the bound is narrowed, asked of the single authority. `MAX_SHORTCUT_CYCLES` is + // `pub(crate)` and unnameable from an integration test; `is_bounded()` is the shipped + // `pub` predicate for exactly this question. + assert!( + schema.max_iterations >= 1, + "a bound of 0 states no repetition and must not be offered" + ); + assert!( + schema.is_bounded(), + "the whole claim of this producer is that it NARROWED the repetition bound below the \ + engine-wide safety cap; max_iterations = {}", + schema.max_iterations + ); + + // (v) the untargeted class publishes no per-iteration choice. + assert!( + schema.points.is_empty(), + "the UNTARGETED class exposes no decision points; got {:?}", + schema.points + ); +} + +/// PR-7 Phase 5b — the MULTIPLAYER offer control: the untargeted every-opponent drain raises a +/// bounded offer at THREE and at FOUR players, not only at two. +/// +/// FIXTURE — the pure life↔life cascade, both halves verbatim real-card Oracle text verified +/// against the Scryfall API: Marauding Blight-Priest (*"Whenever you gain life, each opponent +/// loses 1 life."*) plus Exquisite Blood (*"Whenever an opponent loses life, you gain that much +/// life."*). Untargeted and every-opponent, which is the true full-multiplayer drain and the +/// same class as the real 4p `dina_conqueror_4p` dump. +/// +/// WHY THIS SHAPE: its stack holds only `GainLife` / `LoseLife`, so it clears step (6) +/// `stack_choices_are_all_specified` and is DECOUPLED from the separate `Effect::Draw` +/// allow-list hole that silences the 2-player basis-B control at ≥3 players. A control that +/// steered around that hole would guard nothing about it; this one does not touch it. +/// +/// UNEQUAL OPPONENT LIFE IS LOAD-BEARING, not decoration. With every opponent falling, the +/// living partition is `nonfallers == {P0}`, and at EQUAL life `live_mandatory_loop_winner`'s +/// CR 704.3 simultaneity floor passes and Path A crowns P0 while the kick-off is still +/// resolving — measured, at 2, 3 and 4 players. Staggering the totals fails +/// `fallers_lives_pairwise_equal`, which is what leaves the board in the "lethal to some, +/// crowns nobody" regime the bounded offer exists to serve. +/// +/// ⚠ BASIS: **A** — established by DISCRIMINATING PROBE, never from `frames_per_period`. +/// The probe: force `ring_delta_signature` to return `None` unconditionally (basis B's only +/// entry point, reached solely from the `basis_a` match's `None =>` arm). This row stays +/// GREEN at both player counts while `dina_untargeted_drain_4p_offers_at_three_live_opponents` +/// and `bounded_offer_on_a_within_turn_draw_drain_is_basis_b` both FAIL. Surviving that +/// mutation is what proves basis A certified here. **`frames_per_period == 1` proves NOTHING +/// about the basis** — basis B *derives* `k` from `1` upward, so a k==1 basis-B offer is +/// byte-identical in the payload, and since fix round 1 basis A MEASURES its span too (2 on +/// `interactive_3p_subset_lethal_does_not_crown`), so a `!= 1` reading is dead as well. The +/// `== 1` assertion below is a structural consistency check on THIS fixture's period width, +/// necessary but never sufficient; treating it as a basis attribution is the exact +/// non-discriminating inference that mislabelled the dina row. +/// +/// ⚠ MECHANISM — CORRECTED, and the correction is load-bearing. An earlier revision of this +/// doc said *"a pure life↔life loop moves only axes `loop_states_equal_modulo_resources` +/// projects out, so the board DOES recur and basis A always matches."* That is MEASURABLY +/// FALSE on this very fixture: the loop moves the STACK, which is not a projected axis. +/// Instrumenting the `basis_a` walk at the offer beat (ring length 3, walked newest-first): +/// * `ring[2]` — the only pair with `eq == true` (stack unchanged) — carries a ZERO δ, so +/// `net_progress_for(proposer)` is **false** and the pair is discarded. +/// * `ring[1]` — `eq == FALSE`, and this is the pair that certifies, through the +/// **`loop_states_cover_modulo_growth_pinned` disjunct**, never through the equal one. +/// The stack grows one period's worth of `Test Exquisite Blood` triggered abilities: +/// **3p `stack[2 -> 3]` (+1), 4p `stack[3 -> 5]` (+2)**. Cover exists for exactly this — +/// growth confined to places `prior` already occupied, by mandatory no-ordering-input +/// triggers. +/// +/// So the real reason basis A wins here is not resource-purity: it is that nothing on this +/// board trips a cover gate. Contrast dina, whose identical-in-kind stack growth also clears +/// cover's gates (1)-(4) and is then vetoed at **gate (5)** by an off-stack `ModifyCost` +/// static whose fire-time condition reads a projected axis. The shared write-up lives at the +/// `basis_a` dispatch site in `game::engine::try_offer_bounded_cycle_shortcut`. +/// +/// ⚠ CONSEQUENCE for "no basis-B control at ≥3 players from this shape": still true AS BUILT, +/// but for the corrected reason — cover SUCCEEDS here, so the `None =>` arm is never reached. +/// That is a property of this board, not a resource-purity invariant: adding a gate-(5) +/// refuser to the same two cards would flip it to basis B. The ≥3p basis-B coverage this file +/// carries is `dina_untargeted_drain_4p_offers_at_three_live_opponents` (basis B, k == 1, on a +/// real 4p dump). +/// +/// REVERT-PROBE (must FLIP): delete the Path D block in `interactive_loop_bridge` ⇒ no offer +/// at either player count ⇒ the `expect` FAILS. +#[test] +fn multiplayer_pure_life_drain_offers_at_three_and_four_players() { + /// Marauding Blight-Priest, verbatim (Scryfall). + const BLIGHT_PRIEST: &str = "Whenever you gain life, each opponent loses 1 life."; + /// Exquisite Blood, verbatim (Scryfall). + const EXQUISITE_BLOOD: &str = "Whenever an opponent loses life, you gain that much life."; + + /// Build the cascade at `seats` players with staggered opponent life, cast the kick-off, + /// and return the runner plus the seats the engine considers living opponents of P0. + fn cascade(seats: u8) -> GameRunner { + let mut scenario = GameScenario::new_n_player(seats, 7); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + for (i, seat) in (1..seats).map(PlayerId).enumerate() { + // Staggered: pairwise-UNEQUAL absolute life, equal per-cycle delta. + scenario.with_life(seat, 1000 + 50 * i as i32); + } + scenario.add_creature_from_oracle(P0, "Test Blight Priest", 2, 2, BLIGHT_PRIEST); + scenario.add_creature_from_oracle(P0, "Test Exquisite Blood", 2, 2, EXQUISITE_BLOOD); + let kickoff = scenario + .add_spell_to_hand_from_oracle(P0, "Test Lifegain Kickoff", false, KICKOFF) + .id(); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + let _ = runner.cast(kickoff).resolve(); + runner + } + + for seats in [3u8, 4] { + let mut runner = cascade(seats); + let opponents = engine_live_opponents(runner.state(), P0); + assert_eq!( + opponents.len(), + usize::from(seats) - 1, + "reach-guard at {seats} players: every opponent must still be living at the offer \ + beat, else the bound below is computed over the wrong seats" + ); + assert!( + !matches!(runner.state().waiting_for, WaitingFor::GameOver { .. }), + "reach-guard at {seats} players: the staggered life totals must keep Path A from \ + crowning while the kick-off resolves — at EQUAL life it does, and then there is \ + no board left to offer on; got {:?}", + runner.state().waiting_for + ); + + let beat = drive_scenario_to_bounded_offer(&mut runner, 200).unwrap_or_else(|| { + panic!( + "CR 732.2a: the untargeted every-opponent drain must raise a bounded offer at \ + {seats} players. This is the multiplayer half of the claim — a 2-player-only \ + detector is not what this lane ships." + ) + }); + + let state = runner.state(); + let (proposer, certificate, schema) = bounded_offer_parts(state); + assert_eq!( + proposer, state.active_player, + "{seats}p: CR 732.2a step (2) — the proposer is the active priority holder" + ); + assert!( + schema.points.is_empty(), + "{seats}p: the UNTARGETED class exposes no decision points; got {:?}", + schema.points + ); + + let per_cycle = certificate + .per_cycle + .as_ref() + .expect("a bounded offer publishes its per-period signature"); + assert_eq!( + per_cycle.frames_per_period, 1, + "{seats}p: a PERIOD-WIDTH tripwire, not a basis attribution. This cascade's \ + certifying prior sits one retained frame back, so its MEASURED span is 1 — a drift \ + means the fixture changed shape and the row must be re-derived, not relaxed. It \ + establishes nothing about the basis in either direction: basis B derives k from 1 \ + upward, and since fix round 1 basis A measures its span too (2 on \ + `interactive_3p_subset_lethal_does_not_crown`). The label is carried by the \ + `ring_delta_signature -> None` probe named in this row's doc, not by this number" + ); + + // EVERY living opponent loses life every cycle — the multiplayer content of the claim. + // A 2-player-shaped detector that only ever charges one seat fails here. + let losses: Vec<(PlayerId, i64)> = opponents + .iter() + .map(|p| (*p, -per_cycle.delta.life.get(p).copied().unwrap_or(0))) + .collect(); + assert!( + losses.iter().all(|(_, loss)| *loss > 0), + "{seats}p: the published per-cycle δ must charge EVERY living opponent, which is \ + what makes this the untargeted multiplayer class; measured {losses:?} at beat \ + {beat}" + ); + + // The bound, RECOMPUTED from the offer-beat board. + let expected_bound = state + .players + .iter() + .filter(|p| !p.is_eliminated) + .filter_map(|p| { + let loss = -per_cycle.delta.life.get(&p.id).copied().unwrap_or(0); + (loss > 0).then(|| (p.life as i64 - 1) / loss) + }) + .min() + .expect("at least one seat is losing life, asserted above"); + assert_eq!( + i64::from(schema.max_iterations), + expected_bound, + "{seats}p: CR 704.5a — the published bound must equal `min over living seats of \ + (life - 1) / per-cycle loss`, recomputed here from the offer-beat board" + ); + assert!( + schema.is_bounded(), + "{seats}p: this producer's whole claim is that it NARROWED the bound; \ + max_iterations = {}", + schema.max_iterations + ); + } +} + +/// PR-7 Phase 5b (G1) — the bounded offer must FORBID a non-empty `last_loop_action_sequence`. +/// +/// PAIRED ARMS ON ONE CERTIFYING STATE, differing in exactly one field, asserting opposite +/// outcomes — so no constant implementation passes. +/// +/// 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. +/// +/// 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 +/// 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() { + use engine::game::engine::{try_offer_bounded_cycle_shortcut, BoundedOfferRefusal}; + 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 itself, so rewind that one field to the + // Priority beat the offer was raised AT — the bridge's own entry condition. + let (proposer, _, _) = bounded_offer_parts(&state); + state.waiting_for = WaitingFor::Priority { player: proposer }; + + // ⓐ the state certifies. + let armed = try_offer_bounded_cycle_shortcut(&state, false); + assert!( + armed.is_ok(), + "REACH-GUARD: arm ⓑ is vacuous unless the SAME state certifies with an empty \ + sequence; got {armed:?}" + ); + + // ⓑ one field reassigned. + state.last_loop_action_sequence = vec![LoopActionContext { + card_id: state + .objects + .values() + .next() + .map(|o| o.card_id) + .expect("the dump has objects"), + controller: proposer, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::NotUsed, + }, + convoke: None, + pins: vec![], + }]; + assert_eq!( + try_offer_bounded_cycle_shortcut(&state, false), + Err(BoundedOfferRefusal::DrivingSequenceNotEmpty), + "CR 732.2a: a bounded offer minted with a driving sequence would be routed to the \ + object-growth materializer and commit zero bounded cycles" + ); +} + +/// 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 +/// (`handle_declare_shortcut`'s `Fixed(n) if *n > offer.schema.max_iterations` arm). It was +/// unbuildable before this phase because no producer narrowed the bound below +/// `MAX_SHORTCUT_CYCLES`, so the comparison was inert; the bounded offer is the first +/// producer that can exercise it. Do not read this row as new mechanism. +/// +/// REVERT-PROBE: delete that arm ⇒ the over-bound count is accepted, APNAP opens, and the +/// proposal drives past a CR 704.5a threshold INSIDE the proposal ⇒ the zero-elimination +/// assertion FAILS. +/// MUST-NOT-FLIP: `over_cap_fixed_count_hands_back_with_no_drive` (the global-cap arm) and +/// every unbounded offer's acceptance of any `Fixed(n <= MAX)`. +/// +/// ⚠ WHAT SEPARATES THE ARMS, and why this row was NON-DISCRIMINATING until fix round 1. +/// `lives_after == lives_before` + zero eliminations is the handback observation — but at +/// `c6d834040` the ACCEPTED, within-bound path produced the identical observation on this same +/// dump, because `materialize_fixed_shortcut` aborted at cycle 0 and committed nothing. The row +/// therefore could not tell "handed back" from "accepted and driven", and would have stayed +/// green with the guard deleted. It discriminates now because the accepted path MOVES LIFE: +/// `bounded_fixed_count_commits_exactly_n_periods` measures `n × δ` committed on this exact +/// dump (`n=1` → `[50,34,30,35]`, `n=3` → `[52,32,28,33]` from `[49,35,31,36]`). That row is +/// this one's positive control; the `WaitingFor::Priority` + no-`RespondToShortcut` assertions +/// below are what separate a handback from a completed drive, since both end at priority. +#[test] +fn declared_count_above_the_offered_bound_is_handed_back() { + use engine::analysis::decision_template::IterationCount; + + 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); + let bound = schema.max_iterations; + assert!( + schema.is_bounded(), + "REACH-GUARD: this row is about the PER-OFFER bound, so the offer must have narrowed \ + one — at `MAX_SHORTCUT_CYCLES` the global-cap arm would answer instead and the row \ + would test the wrong guard; got {bound}" + ); + let lives_before: Vec = state.players.iter().map(|p| p.life).collect(); + let eliminated_before = state.players.iter().filter(|p| p.is_eliminated).count(); + + let result = apply( + &mut state, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::Fixed(bound + 1), + template: None, + }, + ) + .expect("the declare is a legal action; it is REFUSED by being handed back, not by Err"); + + assert!( + matches!(result.waiting_for, WaitingFor::Priority { .. }), + "CR 732.2a: an over-bound count contains a conditional action, so it is handed back to \ + ordinary priority — no APNAP window, no drive; got {:?}", + result.waiting_for + ); + assert!( + !matches!(state.waiting_for, WaitingFor::RespondToShortcut { .. }), + "the CR 732.2b response window must never open for a rejected declaration" + ); + assert_eq!( + state.players.iter().filter(|p| p.is_eliminated).count(), + eliminated_before, + "ZERO eliminations: the whole reason the bound exists is that a count above it crosses \ + a CR 704.5a threshold inside the proposal" + ); + assert_eq!( + state.players.iter().map(|p| p.life).collect::>(), + lives_before, + "zero committed cycles ⇒ no life moved" + ); +} + +/// PR-7 Phase 5b — `UntilLethal` against a BOUNDED offer is rejected. +/// +/// **TEST-ONLY ROW** for the same reason as the row above: the guard ships already. It is +/// also the D-1 rider — the ONLY test that exercises `handle_declare_shortcut`'s +/// `UntilLethal if offer.schema.is_bounded()` arm, so it is the behavioural proof that +/// swapping the inline `max_iterations < MAX_SHORTCUT_CYCLES` for the shared predicate is +/// semantics-preserving. +/// +/// REVERT-PROBES: delete that arm ⇒ an unbounded drive runs past the measured threshold. +/// Invert `ShortcutDecisionSchema::is_bounded()` to `>=` ⇒ THIS row flips too, together with +/// both `phase-ai` rows — one edit to one predicate measurable at every caller. If that +/// inversion leaves this row green, the engine kept a private copy of the comparison. +/// MUST-NOT-FLIP: the whole shipped suite's unbounded offers still accept `UntilLethal`. +/// +/// ⚠ SAME NON-DISCRIMINATION CORRECTION as the row above. `lives_after == lives_before` was +/// satisfied by BOTH arms at `c6d834040` (the accepted path committed nothing), so it did not +/// separate "rejected" from "accepted and driven". `bounded_fixed_count_commits_exactly_n_periods` +/// is now the positive control that makes the accepted path observably move life on this dump; +/// the discriminating assertions here are the `Priority` handback plus the absence of a +/// `RespondToShortcut` window, which no accepted declaration produces. +#[test] +fn until_lethal_against_a_bounded_offer_is_rejected() { + use engine::analysis::decision_template::IterationCount; + + 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.is_bounded(), + "REACH-GUARD: the guard under test is keyed on `is_bounded()`, so an unnarrowed offer \ + would take a different arm and the row would be vacuous" + ); + let lives_before: Vec = state.players.iter().map(|p| p.life).collect(); + + let result = apply( + &mut state, + proposer, + GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: None, + }, + ) + .expect("the declare is a legal action; it is REFUSED by being handed back"); + + assert!( + matches!(result.waiting_for, WaitingFor::Priority { .. }), + "CR 732.2a: `UntilLethal` names no count at all, so it cannot be legal against an \ + offer whose producer measured a CR 704 threshold inside the loop; got {:?}", + result.waiting_for + ); + assert!( + !matches!(state.waiting_for, WaitingFor::RespondToShortcut { .. }), + "no CR 732.2b window for a rejected declaration" + ); + assert_eq!( + state.players.iter().map(|p| p.life).collect::>(), + lives_before, + "zero committed cycles" + ); +} + +// ─────────────── PR-7 Phase 5c — the MANDATORY-DRAW cascade, ≥3 players ─────────────── + +/// Real card (Psychosis Crawler is the printed member of this class); the synthetic name +/// keeps the fixture off the card database. +const BLEEDER: &str = "Whenever you draw a card, each opponent loses 1 life."; +/// Synthetic mandatory payoff. Deliberately NOT "you may draw a card" — the "may" is a +/// genuine CR 603.5 resolution-time choice that step (6) must keep refusing, and this +/// fixture's whole job is to exercise the MANDATORY arm. +const CHRONICLER: &str = "Whenever an opponent loses life, draw a card."; + +/// `bloodloop` at N players: a WITHIN-TURN MANDATORY drain cascade whose every cycle also +/// DRAWS, so the board never recurs (a card moves library→hand each cycle) and the offer +/// must come from the growth-cover basis rather than exact recurrence. +/// +/// The draw payload is the POINT of the fixture, not an incidental detail. Before this +/// commit, `Effect::Draw` was fail-closed `MayPrompt`, so step (6) +/// `stack_choices_are_all_specified` refused every beat whose stack held one of these +/// triggers and the multiplayer cascade could never be offered. Do NOT "simplify" this to +/// a pure life loop to make it pass — a control that steers around the hole guards nothing +/// about the hole. +fn bloodloop_state(players: u8) -> GameState { + let mut scenario = GameScenario::new_n_player(players, 7); + scenario.at_phase(Phase::PreCombatMain); + for i in 0..players { + scenario.with_life(PlayerId(i), 20); + } + scenario.add_creature_from_oracle(P0, "Test Bleeder", 2, 2, BLEEDER); + scenario.add_creature_from_oracle(P0, "Test Chronicler", 2, 2, CHRONICLER); + let names: Vec = (0..60).map(|i| format!("Filler {i}")).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + for i in 0..players { + scenario.with_library_top(PlayerId(i), &refs); + } + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + runner.state().clone() +} + +/// PR-7 Phase 5c ACCEPTANCE — the bounded CR 732.2a offer fires on a MANDATORY-DRAW +/// cascade at 2, 3 and 4 players. +/// +/// # What flips when the widening is reverted +/// +/// Measured at HEAD `ea6000b5c` with the same fixture and the same driver: 3p and 4p mint +/// **`ENGINE_OFFERS = 0`**, every candidate beat refused `UnspecifiedChoiceWindow` (34 +/// bridge-moment refusals on each), while certification itself succeeded. Classify +/// `Effect::Draw` back to `MayPrompt` and `drive_to_bounded_offer` returns `None` for +/// `players >= 3` — `expect` panics and this test is RED. The 2p row discriminates too, on +/// the beat: the offer moves 29 → 31 under the revert, because step (6) starts passing one +/// full drain cycle earlier once the draw entries stop refusing. +/// +/// # Why the beats are pinned literals +/// +/// The drive is deterministic — fixed scenario seed, `dump_drive_one_beat`'s policy is +/// total (always pass at `Priority`, first legal answer otherwise), and no RNG is on the +/// path. Re-run identical across runs. An offer beat is observable behaviour that this +/// commit CHANGED; pinning it is what stops a later change from moving it silently. +#[test] +fn bloodloop_mandatory_draw_cascade_offers_at_2p_3p_and_4p() { + for (players, expected_beat, expected_turn) in [(2u8, 29usize, 4u32), (3, 58, 5), (4, 97, 6)] { + let mut state = bloodloop_state(players); + let beat = drive_to_bounded_offer(&mut state, 400).unwrap_or_else(|| { + panic!( + "{players}p mandatory-draw cascade must raise a bounded offer; a `None` here is \ + the pre-widening behaviour (step (6) refusing every draw-bearing stack)" + ) + }); + assert_eq!(beat, expected_beat, "{players}p offer beat"); + assert_eq!(state.turn_number, expected_turn, "{players}p offer turn"); + + let (proposer, certificate, schema) = bounded_offer_parts(&state); + assert_eq!( + proposer, P0, + "{players}p: the cascade's controller proposes" + ); + assert!( + schema.is_bounded(), + "{players}p: an unbounded schema would take a different declare arm" + ); + let per_cycle = certificate + .per_cycle + .as_ref() + .expect("a bounded offer states its per-period signature"); + assert_eq!( + per_cycle.frames_per_period, 2, + "{players}p: this cascade's derived period spans two retained ring frames. A WIDTH \ + tripwire only — since fix round 1 both bases measure the span, so no value \ + attributes a basis (see `bounded_offer_on_a_within_turn_draw_drain_is_basis_b`)" + ); + + // The multiplayer property the ≥3p rows exist for: ONE cycle charges EVERY + // opponent, not just the first. A 2p-only guard could not see this. + let opponents: Vec = state + .players + .iter() + .map(|p| p.id) + .filter(|p| *p != P0) + .collect(); + assert_eq!(opponents.len(), usize::from(players) - 1); + for opponent in &opponents { + assert_eq!( + per_cycle.delta.life.get(opponent).copied(), + Some(-1), + "{players}p: one cycle drains {opponent:?}" + ); + } + assert_eq!( + per_cycle.delta.life.get(&P0).copied().unwrap_or(0), + 0, + "{players}p: the cascade's controller loses no life" + ); + } +} + +// ═══════════════ FIX ROUND 1 — the declared count is CONSUMABLE ═══════════════ +// +// Before this round `materialize_fixed_shortcut`'s `'cycles: for i in 0..n` advanced only on +// `CycleOutcome::Recurred`, which needs board RECURRENCE. A basis-B certificate — what +// `ring_delta_signature` mints, and the whole class `try_offer_bounded_cycle_shortcut` widened +// offers to — certifies a periodic DELTA, not a recurring board, so neither recurrence +// predicate can ever fire and `n` was structurally inert. MEASURED at `c6d834040` with the same +// fixtures and the same production entry (`apply` → declare → APNAP accepts): +// +// bloodloop3 n=1 → [20,0,0] GameOver{P0} elim=2 │ n=3 → [20,0,0] GameOver{P0} elim=2 +// bloodloop4 n=1 → [20,0,0,0] elim=3 │ n=3 → [20,0,0,0] elim=3 +// dina 4p n=1 → [49,35,31,36] (unchanged) │ n=3 → [49,35,31,36] (unchanged) +// +// `Fixed(1)` and `Fixed(3)` byte-identical — either the table dies or nothing commits. The +// rows below are the antidote: the same trajectories, with `n` bound to the OBSERVED board +// delta and to the OFFER's own published signature, never to a literal. + +/// Declare `Fixed(n)` on the bounded offer `state` is parked at, accept with every living +/// opponent through `apply()`, and return the per-seat life delta the drive COMMITTED +/// alongside the signature and bound the offer published. +/// +/// Everything is read off the production state: the signature comes from the offer the ENGINE +/// wrote, the delta from `Player::life` before and after. Nothing is recomputed by the test. +fn accept_bounded_fixed( + state: &mut GameState, + n: u32, +) -> ( + Vec<(PlayerId, i64)>, + engine::analysis::resource::PeriodicDelta, + u32, +) { + let (proposer, certificate, schema) = bounded_offer_parts(state); + let per_cycle = certificate + .per_cycle + .clone() + .expect("a bounded offer publishes the per-period signature its bound was divided by"); + let bound = schema.max_iterations; + let before: Vec<(PlayerId, i64)> = state + .players + .iter() + .map(|p| (p.id, p.life as i64)) + .collect(); + r6a_declare_and_accept_all(state, proposer, n); + let committed: Vec<(PlayerId, i64)> = state + .players + .iter() + .zip(&before) + .map(|(p, (id, l0))| { + assert_eq!( + p.id, *id, + "the seat vector is positional and never reordered" + ); + (p.id, p.life as i64 - l0) + }) + .collect(); + (committed, per_cycle, bound) +} + +/// FIX ROUND 1 PRIMARY (HIGH-1/HIGH-2/HIGH-3) — an accepted `Fixed(n)` within the offered +/// bound commits **exactly `n` copies of the published per-period delta**, on the real 4p +/// Dina/Conqueror dump and on the synthetic mandatory-draw cascade at 3 and 4 players. +/// +/// # What the assertion is bound to +/// +/// `n * per_cycle.delta.life[seat]`, derived from the certificate the ENGINE published at the +/// offer beat. No literal life total appears in an equality. A fixture whose drain rate drifts +/// moves both sides together; a drive that commits the wrong NUMBER of periods moves only one. +/// +/// # Why this is not vacuous +/// +/// * Reach-guards below establish that the published δ is non-zero, that at least two seats +/// carry a non-zero term (so a 2-player-shaped bug cannot hide), and that the bound leaves +/// room for `n = 3` — without which the `n`-scaling is untestable. +/// * The `n = 1` vs `n = 3` boards are asserted DIFFERENT on the same fixture. That single +/// assertion is the direct antidote to the defect: at `c6d834040` they were byte-identical. +/// +/// # REVERT-PROBES (each RUN, each FLIPPED) +/// +/// * ⓐ delete `|| frames_per_period.is_some_and(|k| frames_this_cycle >= k)` from +/// `drive_one_shortcut_cycle` ⇒ HEAD behaviour returns: dina commits ZERO (`Abort` at cycle +/// 0, delta `[0,0,0,0]`) and bloodloop cross-lethals the whole table. Every `assert_eq!` on +/// the committed delta FAILS, and so does `no seat is eliminated`. +/// * ⓑ replace the delimiter's `k` with a hardcoded `1` ⇒ bloodloop (whose derived period is +/// `k == 2` ring frames) commits HALF-periods: `n` cycles deliver `n/2` copies of δ. The +/// 3p/4p `assert_eq!` FAILS while the `k == 1` dina row stays green — which is what proves +/// the row reads the VALUE of `frames_per_period` and not merely its presence. +#[test] +fn bounded_fixed_count_commits_exactly_n_periods() { + /// Rebuilt from scratch per `n` — a driven trajectory is not replayable from a used state. + fn build(name: &str) -> GameState { + match name { + "dina_conqueror_4p" => restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_conqueror_4p.json.gz" + ))), + "bloodloop3" => bloodloop_state(3), + "bloodloop4" => bloodloop_state(4), + other => panic!("unknown fixture {other}"), + } + } + + for name in ["dina_conqueror_4p", "bloodloop3", "bloodloop4"] { + let mut boards: Vec> = vec![]; + for n in [1u32, 3] { + let mut state = build(name); + drive_to_bounded_offer(&mut state, 400).unwrap_or_else(|| { + panic!("{name}: the bounded offer must fire; see the acceptance row") + }); + + let (committed, per_cycle, bound) = accept_bounded_fixed(&mut state, n); + + // ── reach-guards: without these the equality below can pass degenerately ── + assert!( + per_cycle.delta != engine::analysis::resource::ResourceVector::default(), + "{name}: a zero-delta period makes `n * δ` zero for every `n`, so the scaling \ + assertion would hold for a drive that committed nothing" + ); + assert!( + per_cycle.delta.life.values().filter(|v| **v != 0).count() >= 2, + "{name}: fewer than two seats with a non-zero life term is a 2-player shape; \ + the whole class exists because a MULTIPLAYER drain crowns nobody. got {:?}", + per_cycle.delta.life + ); + assert!( + bound >= 3, + "{name}: `n = 3` must be WITHIN the offered bound, else the declaration is \ + handed back and this row silently tests the rejection arm; bound = {bound}" + ); + assert!( + per_cycle.frames_per_period >= 1, + "{name}: a period spans at least one retained ring frame; got {}", + per_cycle.frames_per_period + ); + + // ── THE PROPERTY: committed delta == n × published per-period delta ── + for (seat, delta) in &committed { + assert_eq!( + *delta, + i64::from(n) * per_cycle.delta.life.get(seat).copied().unwrap_or(0), + "{name} n={n}: {seat:?}'s committed life delta must be exactly `n` copies \ + of the period the offer published ({:?}); committed {committed:?}", + per_cycle.delta.life + ); + } + + // ── the bound's own contract: CR 704.5a headroom is `life - 1`, so no seat may + // be eliminated by a within-bound count ── + assert_eq!( + state.players.iter().filter(|p| p.is_eliminated).count(), + 0, + "{name} n={n}: CR 704.5a — `min over living seats of (life - 1) / loss` \ + reserves one point of headroom, so a within-bound drive eliminates nobody" + ); + assert!( + state.players.iter().all(|p| p.life > 0), + "{name} n={n}: every seat is above the CR 704.5a threshold; lives {:?}", + state.players.iter().map(|p| p.life).collect::>() + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "{name} n={n}: a completed finite drive hands back to ordinary priority \ + (CR 800.4a living seat), not a terminal state; got {:?}", + state.waiting_for + ); + + boards.push(committed); + } + + // ── THE DISCRIMINATOR. At `c6d834040` these two were identical for every fixture. + assert_ne!( + boards[0], boards[1], + "{name}: `Fixed(1)` and `Fixed(3)` must produce MEASURABLY different boards — \ + identical outcomes are exactly the defect this round fixes" + ); + } +} + +/// 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. +/// +/// # Why the basis matters, and what was uncovered +/// +/// `frames_per_period` reaches the drive from two different producers. +/// Basis B derives it from `ring_delta_signature` (the ring window's own period `k`); basis A +/// derives it from the certifying prior's ring index. Fix round 1 changed ONLY the basis-A +/// producer — a hardcoded `1` became the measured span — and MEASURED, reverting that hardcode +/// flips exactly one of the **83** rows that existed in this file's `loop_shortcut::` module +/// BEFORE this row: [`interactive_3p_subset_lethal_does_not_crown`]. ⚠ On THIS tree the count is +/// **2 of 85** — the second being this row, by design (its probe ⓐ below IS that revert). Fix +/// round 3 (LOW-1) corrected the earlier wording "one row of the 85", which took its numerator +/// from the pre-commit tree and its denominator from the post-commit one — two epochs in one +/// sentence. Runner and filter for the flip claim: +/// `cargo test -p phase-engine --test integration -- loop_shortcut::` (module filter on the +/// `integration` binary) — and that runner is also the AUTHORITY for the denominator: it +/// reports **85 passed / 0 failed / 4090 filtered out** on this tree, re-run in fix round 4 at +/// `025015135`. The 83 is the same module at `bc20d4ff4`. +/// +/// ⚠ The denominator is anchored to the RUNNER and not, as fix round 3 wrote it, to a grep of +/// the test-attribute literal over this file (fix round 4, LOW-4). That grep is contaminable by +/// this very doc: round 3's own correction quoted the literal inside this comment, so at +/// `025015135` the grep returned **86** while the runner still reported 85, and a reader +/// applying the stated method would have concluded the doc was stale. This round dropped the +/// quoted literal, so the two agree again — but the runner counts ROWS and the grep counts +/// MENTIONS, and only one of those is what "of 85" means. That row asserts the PUBLISHED VALUE and +/// nothing else; it never declares a count, so nothing in the tree observed what a basis-A +/// offer's drive actually commits. The claim "under the hardcode that fixture's accepted drive +/// committed nothing at all" was true and untracked. This row tracks it. +/// +/// # The fixture, and why it is the right one +/// +/// `setup_3p_subset_lethal` is the ONE basis-A fixture whose published span is not 1: the +/// `DRAIN_CLERIC` / `BLOOD_SIPPER` pairing alternates a gain-life resolution and a lose-life +/// resolution, so one whole repetition spans TWO retained ring frames (`frames_per_period == 2`, +/// asserted below as a reach-guard). A fixture with `k == 1` could not tell a drive that reads +/// the VALUE from one that reads any positive constant. +/// +/// ⚠ SCOPE (fix round 3, LOW-4): this coverage is **synthetic-only**. All three basis-A rows in +/// this file are `GameScenario` builds; **no real dump certifies on basis A** — the file's real +/// 4p dumps are basis B, `dina_untargeted_drain_4p_offers_at_three_live_opponents` measured so +/// two ways in its own doc. This repo's standing lesson is real-dump-over-synthetic, so the row +/// says which it is rather than letting a reader take it for real-game evidence. Building a real +/// basis-A dump is its own round, not this one. +/// +/// # MEASURED, through the production accept path (`apply` → declare → APNAP accepts) +/// +/// derived `k = 2` ⇒ `n=1` commits `{P0:+1, P1:-1, P2:0}`, `n=2` `{+2,-2,0}`, `n=3` `{+3,-3,0}` +/// — exactly `n × δ`. P2 is the life-loss-immune bystander and is untouched at every `n`, which +/// is the multiplayer half: one cycle charges the seats the certificate names and only those. +/// The governing rule is **CR 101.2** — `LIFE_LOSS_IMMUNE` is "Your life total can't change.", +/// a "can't" effect, which takes precedence over the trigger's life-loss instruction. (Fix +/// round 3, LOW-2: this line cited CR 119.8, which governs life EXCHANGES, life REDISTRIBUTION, +/// and pay-life COSTS — none of which happens here. `setup_3p_bystander_winner` above already +/// names 101.2 as governing, with 119.8 only as a `cf.`, and 101.2 is the engine's own +/// convention for "can't" overrides.) +/// +/// # REVERT-PROBES — both RUN, and the second one does NOT flip +/// +/// * ⓐ **FLIPS.** Restore basis A's hardcoded `frames_per_period: 1` ⇒ every `n` commits +/// `{P0: 0, P1: 0, P2: 0}`, and the row reports it at the non-zero-commit guard. The +/// mechanism: `frames_per_period` is an OR-ed delimiter, so a `k` SMALLER than the true span +/// cuts the cycle early — here at one frame, half a period — and +/// `materialize_fixed_shortcut`'s conformance check then drops every one of them. +/// * ⓑ **DOES NOT FLIP**, measured, and it is recorded rather than quietly dropped. Deleting +/// `|| frames_per_period.is_some_and(|k| frames_this_cycle >= k)` from +/// `drive_one_shortcut_cycle` leaves this row GREEN, because this is a basis-**A** fixture: +/// its board genuinely RECURS, so `loop_states_equal_modulo_resources(boundary, &norm)` is a +/// working delimiter on its own and lands on the same two-frame cycle. (The same probe flips +/// five other rows in this module, including +/// `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle` — the basis-B fixtures, whose +/// boards never recur, are the ones that need the delimiter to exist at all.) +/// +/// So this row's discrimination rests ENTIRELY on ⓐ — which is the point: ⓐ is the only edit +/// that distinguishes a measured span from a hardcoded one, and before this row nothing in the +/// tree observed its drive-level consequence. +#[test] +fn basis_a_bounded_fixed_count_commits_exactly_n_periods() { + let mut boards: Vec> = vec![]; + for n in [1u32, 2, 3] { + // Rebuilt per `n`: a driven trajectory is not replayable from a used state. + let (mut runner, kickoff) = setup_3p_subset_lethal(LoopDetectionMode::Interactive); + let _ = runner.cast(kickoff).resolve(); + drive_scenario_to_bounded_offer(&mut runner, PRIMED_LOOP_BEATS).unwrap_or_else(|| { + panic!( + "the subset-lethal class raises a bounded offer (see \ + `interactive_3p_subset_lethal_does_not_crown`); got {:?}", + runner.state().waiting_for + ) + }); + let mut state = runner.state().clone(); + + let (committed, per_cycle, bound) = accept_bounded_fixed(&mut state, n); + + // ── reach-guards: without these the `n × δ` equality can pass degenerately ── + assert!( + per_cycle.delta.life.values().filter(|v| **v != 0).count() >= 2, + "a zero-or-single-term δ makes `n × δ` trivially satisfiable; got {:?}", + per_cycle.delta.life + ); + assert!( + bound >= 3, + "`n = 3` must be WITHIN the offered bound, else the declaration is handed back and \ + this row silently tests the rejection arm; bound = {bound}" + ); + // THE ANTIDOTE TO THE HARDCODE: the drive must commit something. Under + // `frames_per_period: 1` on this fixture the conformance check drops every half-period + // and this is `{0,0,0}` — a state in which the `n × δ` equality below still holds for + // the zero-δ seats and would not, on its own, notice. + assert!( + committed.iter().any(|(_, delta)| *delta != 0), + "n={n}: a basis-A drive that commits nothing is the hardcoded-span defect; \ + committed {committed:?}" + ); + + // ── THE PROPERTY: committed delta == n × published per-period delta ── + for (seat, delta) in &committed { + assert_eq!( + *delta, + i64::from(n) * per_cycle.delta.life.get(seat).copied().unwrap_or(0), + "n={n}: {seat:?}'s committed life delta must be exactly `n` copies of the \ + period the offer published ({:?}); committed {committed:?}", + per_cycle.delta.life + ); + } + + assert_eq!( + state.players.iter().filter(|p| p.is_eliminated).count(), + 0, + "n={n}: CR 704.5a — a within-bound drive eliminates nobody" + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "n={n}: a completed finite drive hands back to ordinary priority; got {:?}", + state.waiting_for + ); + + // ── WIDTH TRIPWIRE, deliberately LAST. It is the same published-number observation + // `interactive_3p_subset_lethal_does_not_crown` already carries, and MED-2's whole + // finding is that a published number is not a drive-level fact. Placed after the + // assertions above so that under the hardcoded-span revert the failure this row + // REPORTS is "the drive committed nothing", not "the offer published 1" — measured: + // with it placed first, the hardcode probe failed here and the drive assertions were + // never reached, which would have made this row a second copy of the existing one. + assert_eq!( + per_cycle.frames_per_period, 2, + "n={n}: this fixture's repetition spans two retained ring frames; a drift changes \ + what one committed cycle means and every equality above with it" + ); + boards.push(committed); + } + + // Three DISTINCT boards. Under the hardcoded span all three are `{0,0,0}`. + assert_ne!(boards[0], boards[1], "`Fixed(1)` vs `Fixed(2)`"); + assert_ne!(boards[1], boards[2], "`Fixed(2)` vs `Fixed(3)`"); +} + +/// FIX ROUND 1 MIRROR (A1.2) — the drive STOPS AT the first lethal cycle. It does not commit +/// `n` cycles blindly and reconcile the deaths afterwards. +/// +/// # Why the offer has to be doctored, and why that is the honest construction +/// +/// A within-bound count can NEVER cross a CR 704.5a threshold: `elimination_bounds` narrows to +/// `min over living seats of (life - 1) / per-cycle loss` with FLOOR division, so `n * loss <= +/// life - 1` for every seat and every legal `n`. MEASURED at the bound on both fixtures after +/// this round's fix — bloodloop3 `n = 16` lands `[20, 1, 1]`, dina `n = 30` lands +/// `[79, 5, 1, 6]`, zero eliminations in both. The bounded class therefore cannot reach its +/// own cross-lethal arm through an undoctored offer, and a mirror row built on one would be +/// unbuildable rather than merely weak. +/// +/// So this row is a HOSTILE fixture: it widens `schema.max_iterations` on the offer the engine +/// wrote — simulating a producer whose bound is WRONG — and then declares a count that arithmetic +/// says must kill. Everything downstream is production: `apply()`'s declare handler, the APNAP +/// window, `apply_confirmed_shortcut`, `materialize_fixed_shortcut`. The question it answers is +/// the one that matters when a certificate is unsound: does the drive stop at the boundary, or +/// does it drive through it? +/// +/// CR 704.3: state-based actions are checked whenever a player would get priority, and the +/// drive's every beat goes through `pass_priority_once_with_pipeline`, so CR 704.5a ("if a +/// player has 0 or less life, that player loses the game") is applied INSIDE the drive. +/// +/// # SCOPE — this row covers the TOTAL-WIPE arm ONLY (fix round 2, MED-1) +/// +/// bloodloop3 seats its two opponents at EQUAL life (17/17 at the offer beat, measured), so they +/// cross 0 on the SAME cycle, CR 104.2a crowns, and the drive takes `CycleOutcome::CrossLethal`. +/// The fixture is structurally incapable of a partial wipe: a symmetric fixture collapses every +/// partial case into a total case. The other arm — one seat crosses while ≥2 players survive, no +/// `GameOver`, `CycleOutcome::Abort`, the crossing cycle rolling back whole while prior +/// conforming cycles stay committed — behaves DIFFERENTLY +/// and has its own row, [`bounded_fixed_drive_rolls_back_a_partial_crossing_cycle`], which +/// carries the arm-asymmetry table. Both arms are out of contract for any legitimately-derived +/// bound; each is reachable only under a doctored one. +/// +/// # The MATCHED PAIR, on the same doctored offer +/// +/// * ⓐ `n = cycles_to_lethal - 1` — the drive runs to completion, every seat survives at +/// exactly one point of life, nobody is eliminated. +/// * ⓑ `n = 2 * cycles_to_lethal` — the drive stops at the FIRST crossing cycle. +/// +/// Without ⓐ, ⓑ alone is satisfied by a materializer that ignores `n` entirely and simply runs +/// the loop until something dies — which is exactly what `c6d834040` did. ⓐ is what forces the +/// stop point to be `n`-sensitive. +/// +/// ⚠ ⓐ's DOCTORING IS A NO-OP ON THIS FIXTURE, and that is stated rather than dressed up (fix +/// round 2, LOW-1). bloodloop3's honest bound is 16 and `cycles_to_lethal - 1 = 17 - 1 = 16`, so +/// `schema.max_iterations = survivor_n` writes back the value already present — asserted below, +/// so a fixture drift cannot silently turn it into a real widening. ⓐ is therefore an +/// AT-THE-BOUND instance of [`bounded_fixed_count_commits_exactly_n_periods`], not an +/// independent stop-short observation. The pair's stop-short content rests entirely on ⓑ's +/// clause (b). +/// +/// # What flips +/// +/// * delete the frame delimiter from `drive_one_shortcut_cycle` ⇒ arm ⓐ runs to lethal instead +/// of stopping at 16 periods ⇒ its zero-elimination assertion FAILS. (Arm ⓑ does NOT flip: +/// the unbounded HEAD drive coincidentally halts at the same lethal board. Stated so the +/// pair's discrimination is not overclaimed — ⓐ carries it.) +/// * a blind implementation that ran all `2 * cycles_to_lethal` periods and reconciled the +/// deaths afterwards would leave the opponents at `17 - 34 = -17`; ⓑ's (b) pins the stop +/// point to `ceil(life / loss)` periods, derived from the published δ, so an overshoot of +/// even one cycle FAILS. +#[test] +fn bounded_fixed_drive_stops_at_the_first_lethal_cycle() { + let mut state = bloodloop_state(3); + drive_to_bounded_offer(&mut state, 400).expect("the bounded offer must fire at 3 players"); + + let (proposer, certificate, schema) = bounded_offer_parts(&state); + let per_cycle = certificate + .per_cycle + .clone() + .expect("a bounded offer publishes its per-period signature"); + let bound = schema.max_iterations; + let lives_before: Vec<(PlayerId, i64)> = state + .players + .iter() + .map(|p| (p.id, p.life as i64)) + .collect(); + + // Per-seat loss the offer published; the stop point is derived from it, never from a literal. + let loss = |seat: &PlayerId| -per_cycle.delta.life.get(seat).copied().unwrap_or(0); + let victims: Vec = lives_before + .iter() + .map(|(id, _)| *id) + .filter(|id| loss(id) > 0) + .collect(); + assert_eq!( + victims.len(), + 2, + "REACH-GUARD: this row is about a MULTI-seat partial wipe, so both opponents must be \ + losing life per period; published δ {:?}", + per_cycle.delta.life + ); + + // The smallest count that drives some living seat to 0 or less: `ceil(life / loss)`. + let cycles_to_lethal = lives_before + .iter() + .filter(|(id, _)| loss(id) > 0) + .map(|(id, l0)| l0.div_euclid(loss(id)) + i64::from(l0.rem_euclid(loss(id)) != 0)) + .min() + .expect("at least one seat is losing life, asserted above"); + let n = u32::try_from(cycles_to_lethal).expect("fits") * 2; + assert!( + i64::from(n) > cycles_to_lethal, + "REACH-GUARD: `n` must be COMFORTABLY past the first lethal cycle, else 'stops at the \ + boundary' and 'ran to completion' are the same observation" + ); + assert!( + n > bound, + "REACH-GUARD: a lethal `n` is by construction above the honest bound ({bound}) — that \ + is the contract this row is deliberately violating to test the drive's own behaviour" + ); + + // ⓐ SURVIVING ARM — one period short of the first crossing. Same doctored offer, so the + // only difference between the arms is `n` itself. + { + let mut survive = state.clone(); + let survivor_n = u32::try_from(cycles_to_lethal - 1).expect("fits"); + let WaitingFor::LoopShortcut { schema, .. } = &mut survive.waiting_for else { + unreachable!("bounded_offer_parts already matched the offer") + }; + // The no-op recorded in this row's doc, pinned so it cannot drift unnoticed: on THIS + // fixture the honest bound already equals `cycles_to_lethal - 1`, so the line below + // rewrites the value in place. If a fixture change ever makes them differ, ⓐ becomes a + // genuine doctored widening and its doc must be re-derived rather than re-read. + assert_eq!( + schema.max_iterations, survivor_n, + "ⓐ's assignment is a NO-OP on this fixture (honest bound == cycles_to_lethal - 1); \ + a divergence means ⓐ is no longer an at-the-bound instance" + ); + schema.max_iterations = survivor_n; + r6a_declare_and_accept_all(&mut survive, proposer, survivor_n); + assert_eq!( + survive.players.iter().filter(|p| p.is_eliminated).count(), + 0, + "ⓐ CR 704.5a: one period short of the crossing, every seat is still above 0; \ + lives {:?}", + survive.players.iter().map(|p| p.life).collect::>() + ); + for (seat, l0) in &lives_before { + let life_now = survive.players.iter().find(|p| p.id == *seat).unwrap().life as i64; + assert_eq!( + l0 - life_now, + i64::from(survivor_n) * loss(seat), + "ⓐ {seat:?}: exactly `n` periods committed, no more — the drive must stop \ + because `n` ran out, not because something died" + ); + } + assert!( + matches!(survive.waiting_for, WaitingFor::Priority { .. }), + "ⓐ a completed finite drive hands back to priority; got {:?}", + survive.waiting_for + ); + } + + // ⓑ the doctoring, and ONLY this ── + let WaitingFor::LoopShortcut { schema, .. } = &mut state.waiting_for else { + unreachable!("bounded_offer_parts already matched the offer") + }; + schema.max_iterations = n; + + r6a_declare_and_accept_all(&mut state, proposer, n); + + // (a) CR 704.3 + CR 704.5a: the drive stopped at a terminal state applied INSIDE it. + assert_eq!( + state.waiting_for, + WaitingFor::GameOver { + winner: Some(proposer) + }, + "CR 704.5a: with every opponent at 0 or less life, CR 104.2a crowns the last player \ + standing, and the drive commits + stops there" + ); + let eliminated: Vec = state + .players + .iter() + .filter(|p| p.is_eliminated) + .map(|p| p.id) + .collect(); + + // (c) EXACTLY the seats the published period drains — never a full-`n` overshoot that takes + // the proposer down too, and never a subset that leaves a drained seat alive. + // + // ⚠ THIS CLAIM IS PER-ARM (fix round 2, MED-1). It holds on the `CycleOutcome:: + // CrossLethal` arm, which is the only arm this symmetric fixture can reach: the crossing + // cycle COMMITS and the eliminated set is exactly the victims. On the `Abort` arm — one + // seat crosses while ≥2 survive — the eliminated set is EMPTY, and empty because the + // crossing cycle was rolled back whole, not because nobody crossed. Same surface + // reading, two different facts; conflating them is what let this doc ship a claim + // measurement contradicts. The Abort arm's own row is + // `bounded_fixed_drive_rolls_back_a_partial_crossing_cycle`. + assert_eq!( + eliminated, + victims, + "CR 704.5a: on the total-wipe (GameOver) arm the eliminated set is exactly the seats the \ + published period drains; lives {:?}", + state.players.iter().map(|p| p.life).collect::>() + ); + + // (b) STRICTLY LESS than `n` periods committed, and pinned to the FIRST crossing cycle. + for (seat, l0) in &lives_before { + let committed = l0 - state.players.iter().find(|p| p.id == *seat).unwrap().life as i64; + let full_n = i64::from(n) * loss(seat); + if loss(seat) > 0 { + assert!( + committed < full_n, + "{seat:?}: a drive that ran all {n} periods would have committed {full_n}; it \ + must stop at the CR 704.5a boundary instead, got {committed}" + ); + assert_eq!( + committed, + cycles_to_lethal * loss(seat), + "{seat:?}: the drive stops at the FIRST cycle that crosses the threshold — \ + `ceil(life / loss)` periods, derived from the published δ, not one more" + ); + } + } +} + +/// FIX ROUND 2 (MED-1) — THE OTHER LETHAL ARM. A crossing that eliminates ONE seat while +/// **≥2 players survive** raises no `GameOver`, so the drive does not cross-lethal: it ABORTS. +/// The crossing cycle rolls back whole, the cycles before it stay committed, and every seat is +/// still alive at handback. +/// +/// # The arm asymmetry, stated so a future drive learns it from the doc and not by accident +/// +/// | arm | trigger | outcome | +/// |---|---|---| +/// | **total wipe** | every remaining opponent crosses 0 on the same cycle ⇒ `WaitingFor::GameOver` | `CycleOutcome::CrossLethal` — **the crossing cycle COMMITS**, the game ends | +/// | **partial crossing** | one seat crosses 0 while **≥2** players survive ⇒ no `GameOver` | `CycleOutcome::Abort` — **the crossing cycle rolls back WHOLE; prior conforming cycles STAY COMMITTED**; priority handback | +/// +/// Both arms are **out of contract for any legitimately-derived bound**. `elimination_bounds` +/// narrows to `min over living seats of (life - 1) / per-cycle loss` with FLOOR division, so +/// `n * loss <= life - 1` for every seat at every legal `n` and a within-bound drive can never +/// reach either arm. Each is therefore reachable only under a **doctored** bound — which is what +/// both this row and [`bounded_fixed_drive_stops_at_the_first_lethal_cycle`] construct. +/// +/// The `Abort` is the DESIGNED behaviour and this row asserts it rather than a wish. The property +/// it buys is **no half-applied period, ever**: the out-of-contract cycle is refused ATOMICALLY, +/// while conforming work already done is NOT discarded. That is strictly better than a +/// whole-drive rollback — materializing a partial elimination would leave the remaining +/// repetitions bounded by a δ the board stops moving (the surviving seats' per-cycle drain +/// changes the moment a drain target leaves the game), and discarding the conforming prefix +/// would throw away cycles the table's own agreed bound covers. See +/// `materialize_fixed_shortcut`'s `CycleOutcome::Abort` arm. +/// +/// MEASURED SHAPE of that split on this fixture: honest bound 30, doctored `n` at or past the +/// first crossing (31) ⇒ **30 periods committed**, cycle 30 refused, nobody eliminated. The +/// assertions below bind to exactly that: `first_crossing - 1` periods, not zero and not `n`. +/// +/// # Why this row had to exist separately — the fixture-symmetry trap +/// +/// [`bounded_fixed_drive_stops_at_the_first_lethal_cycle`] is the mirror for the same +/// stop-short property, but its bloodloop3 fixture seats **two opponents at equal life** (17/17, +/// measured), so they cross on the SAME cycle and it can only ever exhibit the total-wipe arm. +/// A symmetric fixture collapses every partial case into a total case; the partial arm — the one +/// real multiplayer boards take, since equal life totals are the exception — had no fixture at +/// all. This row's dina 4p dump is ASYMMETRIC by measurement (opponents at 35/31/36, all draining +/// 1 per period ⇒ first crossings 35/31/36), and the reach-guards below FAIL if that ever drifts +/// into symmetry, which is what stops this row from silently becoming a second copy of the mirror. +/// +/// # What is asserted, and what is deliberately NOT +/// +/// Every quantity is derived from the certificate the ENGINE published and the offer-beat board. +/// The row asserts the OBSERVABLE outcome: exactly `first_crossing - 1` periods committed, zero +/// eliminations, every seat above 0, handback to ordinary priority. +/// +/// It does NOT assert "the conformance check never fired", because a conformance drop at the same +/// cycle index and an `Abort` at that index leave IDENTICAL final states — both `break 'cycles` +/// onto the same rollback. That distinction was settled by a REVERT-PROBE instead: deleting the +/// conformance check from `materialize_fixed_shortcut` leaves this row GREEN and unchanged, so +/// the stop is the `Abort`, not the conformance drop. Asserting it from the state would have been +/// an unfalsifiable claim. +/// +/// # REVERT-PROBES +/// +/// * delete `|| frames_per_period.is_some_and(|k| frames_this_cycle >= k)` from +/// `drive_one_shortcut_cycle` ⇒ the dina drive commits ZERO (`Abort` at cycle 0) ⇒ the +/// committed-delta `assert_eq!` FAILS. +/// * MUST-NOT-FLIP: deleting the conformance check leaves this row green (measured) — it is the +/// `Abort` arm, not the conformance arm. +#[test] +fn bounded_fixed_drive_rolls_back_a_partial_crossing_cycle() { + 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 on the 4p dump"); + + let (proposer, certificate, schema) = bounded_offer_parts(&state); + let per_cycle = certificate + .per_cycle + .clone() + .expect("a bounded offer publishes its per-period signature"); + let honest_bound = schema.max_iterations; + let lives_before: Vec<(PlayerId, i64)> = state + .players + .iter() + .map(|p| (p.id, p.life as i64)) + .collect(); + let loss = |seat: &PlayerId| -per_cycle.delta.life.get(seat).copied().unwrap_or(0); + + // `ceil(life / loss)` — the first cycle index at which each drained seat crosses 0. + let crossings: Vec<(PlayerId, i64)> = lives_before + .iter() + .filter(|(id, _)| loss(id) > 0) + .map(|(id, l0)| { + ( + *id, + l0.div_euclid(loss(id)) + i64::from(l0.rem_euclid(loss(id)) != 0), + ) + }) + .collect(); + assert!( + crossings.len() >= 2, + "REACH-GUARD: a PARTIAL wipe needs at least two drained seats, else 'one crosses while \ + others survive' is unconstructible; published δ {:?}", + per_cycle.delta.life + ); + let first_crossing = crossings + .iter() + .map(|(_, c)| *c) + .min() + .expect("at least two drained seats, asserted above"); + + // ── THE ASYMMETRY REACH-GUARD. This is the guard the mirror row could not have had. + let first_victims: Vec = crossings + .iter() + .filter(|(_, c)| *c == first_crossing) + .map(|(id, _)| *id) + .collect(); + assert_eq!( + first_victims.len(), + 1, + "REACH-GUARD: this row is about the PARTIAL arm, so exactly ONE seat may cross first. \ + Equal crossings would make the whole table die together, take `CycleOutcome::CrossLethal` \ + and silently re-test the mirror row's total-wipe arm instead; crossings {crossings:?}" + ); + let survivors = state.players.iter().filter(|p| !p.is_eliminated).count() - first_victims.len(); + assert!( + survivors >= 2, + "REACH-GUARD: with fewer than two survivors CR 104.2a crowns and `WaitingFor::GameOver` \ + routes the drive to the CrossLethal arm; got {survivors} survivors at the first crossing" + ); + + // The honest bound is exactly one period short of that crossing — the CR 704.5a headroom + // term (`life - 1`) with floor division. Asserted, not assumed: it is what makes the + // doctoring below a REAL widening rather than a re-write of the value already present. + assert_eq!( + i64::from(honest_bound), + first_crossing - 1, + "`elimination_bounds` reserves one point of headroom, so the honest bound sits one \ + period below the first crossing; bound {honest_bound}, crossings {crossings:?}" + ); + + // Three doctored bounds: at the crossing, and comfortably past it. All three must stop at + // the same place — a drive that stopped `n`-relative rather than at the boundary would not. + for over in [0u32, 3, 9] { + let mut doctored = state.clone(); + let n = u32::try_from(first_crossing).expect("fits") + over; + let WaitingFor::LoopShortcut { schema, .. } = &mut doctored.waiting_for else { + unreachable!("bounded_offer_parts already matched the offer") + }; + schema.max_iterations = n; + + r6a_declare_and_accept_all(&mut doctored, proposer, n); + + // (a) NOBODY is eliminated — by ROLLBACK, not because nobody crossed. `n >= first + // crossing` means the arithmetic says a seat must die; the drive refuses the cycle. + assert_eq!( + doctored + .players + .iter() + .filter(|p| p.is_eliminated) + .map(|p| p.id) + .collect::>(), + Vec::::new(), + "n={n}: the crossing cycle is rolled back whole, so the eliminated set is EMPTY — \ + which is a different fact from 'nobody crossed'; lives {:?}", + doctored.players.iter().map(|p| p.life).collect::>() + ); + assert!( + doctored.players.iter().all(|p| p.life > 0), + "n={n}: every seat is above the CR 704.5a threshold; lives {:?}", + doctored.players.iter().map(|p| p.life).collect::>() + ); + + // (b) EXACTLY `first_crossing - 1` periods committed: every cycle before the crossing + // one, and none of it. Derived from the published δ, never from a literal. + for (seat, l0) in &lives_before { + let committed = l0 + - doctored + .players + .iter() + .find(|p| p.id == *seat) + .unwrap() + .life as i64; + assert_eq!( + committed, + (first_crossing - 1) * -per_cycle.delta.life.get(seat).copied().unwrap_or(0), + "n={n} {seat:?}: the drive commits every period up to the crossing cycle and \ + rolls that one back; lives {:?}", + doctored.players.iter().map(|p| p.life).collect::>() + ); + } + + // (c) NOT the CrossLethal arm. `GameOver` here would mean the partial crossing crowned + // someone, which is the confusion this row exists to keep separate. + assert_eq!( + doctored.waiting_for, + WaitingFor::Priority { player: proposer }, + "n={n}: CR 104.2a — a player wins only once ALL their opponents have left, and this \ + crossing eliminates at most one of three, so there is no winner to crown and the \ + aborted drive hands back ordinary priority rather than ending the game" + ); + } +} + +/// FIX ROUND 1 (HIGH-3) — the conformance check `PeriodicDelta`'s doc has always specified +/// ("so a bounded drive can check that each committed cycle actually conformed") and which +/// nothing implemented. A committed cycle whose measured resource delta differs from the +/// published signature is DROPPED WHOLE and the drive hands back to manual play. +/// +/// # Why it is load-bearing rather than belt-and-braces +/// +/// `elimination_bounds` divided the CR 704.5a headroom (`life - 1`) by `per_cycle.delta` to +/// produce the count the table agreed to. If a committed cycle moves a different amount, that +/// division no longer describes the drive, and the remaining repetitions can carry a seat past +/// the threshold INSIDE the proposal — the exact conditional action CR 732.2a forbids. +/// +/// # The hostile fixture +/// +/// The offer is real (the engine wrote it after 400 driven beats); ONE field is then doctored — +/// the published `per_cycle.delta` gains a life term for a seat the loop does not touch, which +/// no cycle can ever produce. Everything downstream is production: `apply()`'s declare handler, +/// the APNAP window, `apply_confirmed_shortcut`, `materialize_fixed_shortcut`. +/// +/// # Non-vacuity — the paired positive control is arm ⓐ +/// +/// ⓐ runs the SAME trajectory with the signature untouched and commits `n × δ`. Without it, ⓑ's +/// zero-delta observation would be indistinguishable from "the offer never fired" or "the drive +/// aborts on this fixture anyway" — which is precisely the shape the HEAD defect had. +/// +/// REVERT-PROBE: delete the `if actual != pd.delta { break 'cycles; }` block in +/// `materialize_fixed_shortcut` ⇒ ⓑ commits `n × (real δ)` like ⓐ ⇒ ⓑ's zero-delta assertion +/// FAILS while ⓐ stays green. +#[test] +fn a_cycle_that_does_not_match_the_published_period_is_dropped() { + let n: u32 = 2; + + // ⓐ POSITIVE CONTROL — untouched signature, same trajectory. + let mut control = bloodloop_state(3); + drive_to_bounded_offer(&mut control, 400).expect("the bounded offer must fire"); + let (committed_ok, per_cycle, _) = accept_bounded_fixed(&mut control, n); + assert!( + committed_ok.iter().any(|(_, d)| *d != 0), + "REACH-GUARD: the undoctored drive must COMMIT something, else ⓑ's zero proves nothing \ + about the conformance check; got {committed_ok:?}" + ); + + // ⓑ the same offer with the published period made unproducible. + let mut state = bloodloop_state(3); + drive_to_bounded_offer(&mut state, 400).expect("the bounded offer must fire"); + let (proposer, _, _) = bounded_offer_parts(&state); + let stowaway = state + .players + .iter() + .map(|p| p.id) + .find(|id| per_cycle.delta.life.get(id).copied().unwrap_or(0) == 0) + .expect("the cascade's own controller loses no life, so a zero-term seat exists"); + let WaitingFor::LoopShortcut { certificate, .. } = &mut state.waiting_for else { + unreachable!("bounded_offer_parts already matched the offer") + }; + certificate + .per_cycle + .as_mut() + .expect("a bounded offer publishes its signature") + .delta + .life + .insert(stowaway, -7); + + let lives_before: Vec = state.players.iter().map(|p| p.life).collect(); + r6a_declare_and_accept_all(&mut state, proposer, n); + + assert_eq!( + state.players.iter().map(|p| p.life).collect::>(), + lives_before, + "CR 732.2a: no cycle can produce the doctored period, so the FIRST one is dropped whole \ + and ZERO life moves — a partial commit would mean the drive kept a cycle whose \ + magnitude the agreed bound was not computed from" + ); + assert!( + matches!(state.waiting_for, WaitingFor::Priority { .. }), + "a non-conforming drive falls closed to manual play (CR 800.4a living seat), it does \ + not crown and does not wedge; got {:?}", + state.waiting_for + ); + assert_eq!( + state.players.iter().filter(|p| p.is_eliminated).count(), + 0, + "nothing was committed, so nothing crossed a CR 704.5a threshold" + ); +} + +/// FIX ROUND 1 (MED-4) — the AI's bounded-declare candidate is GENERATED, LEGAL, and DRIVES. +/// +/// The `if schema.points.is_empty() && schema.is_bounded()` block in +/// `ai_support::candidates` shipped with zero coverage: deleting it left the engine suite and +/// every `phase-ai` suite green. (Fix round 3, LOW-3: a bare "(4167)" stood here with neither a +/// runner nor a filter recorded beside it, so it named a shape nobody could reproduce; it is +/// deleted rather than re-dressed, exactly as the same count was at +/// `bounded_offer_conjunct_tests`' module doc. The reproducible claim is this row's own +/// REVERT-PROBE line below.) Its sibling one screen away +/// (`ai_collapse_candidate_is_clamped_to_the_accepted_bound`) sets the standard this row +/// mirrors — generate the candidate through the production generator, then `apply()` it. +/// +/// Without that candidate an AI proposer at a bounded offer has exactly two options: +/// `UntilLethal`, which `handle_declare_shortcut` refuses outright against a bounded offer, and +/// `DeclineShortcut`. The block is the difference between an AI that can take this shortcut and +/// one that structurally cannot. +/// +/// ⚠ RECORDED, NOT FIXED (a scope note, measured here): the block is gated on +/// `schema.points.is_empty()`. A TARGETED bounded offer publishes pins, so the AI gets no +/// accept candidate at all — `UntilLethal` is refused for bounded and the `Fixed` candidate +/// carries `template: None`, which fail-closes on published pins. A targeted bounded offer is +/// therefore AI-undeclarable today. That is a coverage gap in the candidate generator, not a +/// soundness bug (the AI declines, which is always legal), and it is left for its own round. +/// +/// REVERT-PROBE: delete the `schema.points.is_empty() && schema.is_bounded()` block ⇒ +/// assertion (2) FAILS (`Fixed(bound)` absent from the generated candidates). +#[test] +fn ai_bounded_declare_candidate_is_generated_legal_and_drives() { + use engine::analysis::decision_template::IterationCount; + + let mut state = bloodloop_state(3); + drive_to_bounded_offer(&mut state, 400).expect("the bounded offer must fire at 3 players"); + let (proposer, certificate, schema) = bounded_offer_parts(&state); + let per_cycle = certificate + .per_cycle + .clone() + .expect("a bounded offer publishes its per-period signature"); + let bound = schema.max_iterations; + + // (1) reach-guards: this row is about the BOUNDED, UNTARGETED shape the block gates on. + assert!( + schema.is_bounded(), + "REACH-GUARD: an unbounded offer takes a different generator arm; bound = {bound}" + ); + assert!( + schema.points.is_empty(), + "REACH-GUARD: the block is gated on an empty pin set; got {:?}", + schema.points + ); + + // (2) the production generator offers it. + let expected = GameAction::DeclareShortcut { + count: IterationCount::Fixed(bound), + template: None, + }; + let candidates = engine::ai_support::legal_actions(&state); + assert!( + candidates.contains(&expected), + "the AI must be able to declare the bounded offer's own count; got {candidates:?}" + ); + + // (3) ...and the reducer ACCEPTS it — which is what makes (2) load-bearing rather than a + // restatement of the generator. A refused declaration hands straight back to priority. + let lives_before: Vec = state.players.iter().map(|p| p.life as i64).collect(); + apply(&mut state, proposer, expected) + .expect("the AI's generated candidate must be accepted by the reducer"); + assert!( + matches!(state.waiting_for, WaitingFor::RespondToShortcut { .. }), + "CR 732.2b: an accepted declaration opens the APNAP response window; a handback to \ + Priority would mean the generator produced a count the engine refuses. got {:?}", + state.waiting_for + ); + + // (4) ...and the accepted count DRIVES. Bound to the published period, never a literal. + while let WaitingFor::RespondToShortcut { player, .. } = state.waiting_for.clone() { + apply( + &mut state, + player, + GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }, + ) + .expect("each living opponent accepts"); + } + for (seat, l0) in state + .players + .iter() + .map(|p| p.id) + .zip(&lives_before) + .collect::>() + { + let now = state.players.iter().find(|p| p.id == seat).unwrap().life as i64; + assert_eq!( + now - l0, + i64::from(bound) * per_cycle.delta.life.get(&seat).copied().unwrap_or(0), + "{seat:?}: the AI-declared count commits exactly `max_iterations` copies of the \ + published period" + ); + } + assert_eq!( + state.players.iter().filter(|p| p.is_eliminated).count(), + 0, + "CR 704.5a: the offered bound reserves `life - 1` of headroom, so the AI's own \ + maximal legal declaration still eliminates nobody" + ); +} + +// --------------------------------------------------------------------------- +// G1 — THE VALIDATED RANGE MUST COVER THE DRIVEN RANGE (rows R6 / R7 / R9). +// +// INVARIANT: at declare time the firewall must validate the image of the selection function +// over the range the ACCEPTED COUNT will actually drive (`0..n`), against the offer's +// PUBLISHED `legal_targets`. Before this fix it validated `0..shortcut_drive_period(..)` — a +// range derived from the SCHEDULE's own length, which answers a different question — so it +// both ACCEPTED a pin whose driven image leaves the published set at an index the count +// reaches (arm A), and REFUSED conforming declarations whose count is shorter than the +// schedule (arms D1 and E). +// +// Every arm drives the PRODUCTION entry `apply_action(GameAction::DeclareShortcut { .. })` +// and asserts on the published `waiting_for`: `RespondToShortcut` = ingested (CR 732.2b's +// response window opened), `Priority` = refused into the manual-play handback. +// --------------------------------------------------------------------------- + +/// Two objects on a 3p board, and the declare-time verdict for one (published set, count, +/// schedule) triple. The board is real and the offer is planted, exactly as +/// `declare_illegal_pin_falls_back_legal_ingests` plants it — what is under test is the +/// declare firewall, not the detector that would otherwise mint the offer. +/// +/// `max_iterations` is 1_000 (the un-narrowed global cap), so no arm below is refused by the +/// count cap instead of by the range: the cap and the bound are upstream conjuncts that would +/// otherwise dominate every verdict in the table. +fn g1_declare_verdict( + publish_b: bool, + count: IterationCount, + schedule_of: &dyn Fn(&YieldTarget, &YieldTarget) -> TargetSchedule, + pin_twice: bool, +) -> WaitingFor { + let mut scenario = GameScenario::new_n_player(3, 7); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + scenario.with_life(P1, 20); + scenario.with_life(P2, 20); + let obj_a = scenario.add_creature(P0, "Schedule Target A", 1, 1).id(); + let obj_b = scenario.add_creature(P0, "Schedule Target B", 1, 1).id(); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + + let source_of = |id: ObjectId| YieldTarget::ThisObject { + source_id: id, + incarnation: None, + trigger_description: None, + }; + let (a, b) = (source_of(obj_a), source_of(obj_b)); + let slot = DecisionSlot { + source: a.clone(), + index: 0, + }; + let mut legal_targets = vec![TargetRef::Object(obj_a)]; + if publish_b { + legal_targets.push(TargetRef::Object(obj_b)); + } + let schema = ShortcutDecisionSchema { + iteration_count: count.clone(), + // No narrowed CR 732.2a bound — `Default` carries the global cap. + max_iterations: ShortcutDecisionSchema::default().max_iterations, + points: vec![DecisionPoint { + slot: slot.clone(), + kind: DecisionPointKind::Targets { + legal_targets, + min_targets: 1, + max_targets: 1, + ordered: true, + }, + }], + convoke_tappable_count: 0, + }; + let mut targets = vec![TargetPin::Scheduled(schedule_of(&a, &b))]; + if pin_twice { + // E-neg: two pins against a `min_targets == max_targets == 1` point. The cardinality + // check sits OUTSIDE the per-index loop, so it must still refuse at count 0. + targets.push(TargetPin::Scheduled(TargetSchedule::Constant(a.clone()))); + } + let template = DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { slot, targets }], + replay: ReplayMode::Scheduled { + count: count.clone(), + }, + key: DecisionGroupKey::from_sources(std::slice::from_ref(&a), DecisionKind::LoopChoice), + }; + + runner.state_mut().waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: Some(P0), + certificate: synthetic_lethal_cert(), + schema, + }; + runner + .act(GameAction::DeclareShortcut { + count, + template: Some(template), + }) + .expect("declare dispatch succeeds (a refusal is a manual handback, not an error)"); + runner.state().waiting_for.clone() +} + +fn piecewise_a_then_b(a: &YieldTarget, b: &YieldTarget) -> TargetSchedule { + TargetSchedule::Piecewise(vec![(0, a.clone()), (5, b.clone())]) +} + +fn piecewise_b_then_a(a: &YieldTarget, b: &YieldTarget) -> TargetSchedule { + TargetSchedule::Piecewise(vec![(0, b.clone()), (5, a.clone())]) +} + +fn round_robin_a_b(a: &YieldTarget, b: &YieldTarget) -> TargetSchedule { + TargetSchedule::RoundRobin(vec![a.clone(), b.clone()]) +} + +/// R6 arms A / B / C — the validated range must COVER the driven range. +/// +/// * **A (the fix's positive, ⚠ behaviour change).** Publishes only A; the schedule switches +/// to the UNPUBLISHED B at index 5; the declared count is 8, so the drive reaches index 5. +/// Post-fix this is REFUSED. Pre-fix the validated range was the schedule length (2), so +/// indices 5..8 were never checked and the declaration was INGESTED — the soundness hole. +/// * **B (reach-guard).** The identical schedule at count 5 never reaches the switch, so it +/// is ingested. Without B, arm A would also pass under a firewall that rejected everything. +/// * **C (attribution control).** The identical count-8 declaration with B ALSO published is +/// ingested — so A's refusal is attributable to the PUBLISHED SET and not to the count, the +/// schedule, or the harness. +/// +/// REVERT-PROBE: pass `shortcut_drive_period(Some(t))` again in place of +/// `shortcut_validated_range(&count, Some(t))` ⇒ arm A is ingested ⇒ FAILS (and D1 below +/// FAILS with it), while B and C do not move. +#[test] +fn declared_count_beyond_the_published_schedule_window_is_refused() { + // A — the driven range reaches the unpublished arm. + assert!( + matches!( + g1_declare_verdict(false, IterationCount::Fixed(8), &piecewise_a_then_b, false), + WaitingFor::Priority { .. } + ), + "CR 732.2a: a count that drives into an UNPUBLISHED schedule arm is not a sequence \ + that may be legally taken — refuse to manual play" + ); + // B — reach-guard: the same schedule inside the published window is ingested. + assert!( + matches!( + g1_declare_verdict(false, IterationCount::Fixed(5), &piecewise_a_then_b, false), + WaitingFor::RespondToShortcut { .. } + ), + "reach-guard: a count that never reaches the switch is a conforming declaration" + ); + // C — attribution: publish the second arm and the count-8 declaration is fine. + assert!( + matches!( + g1_declare_verdict(true, IterationCount::Fixed(8), &piecewise_a_then_b, false), + WaitingFor::RespondToShortcut { .. } + ), + "attribution control: with BOTH arms published, count 8 is conforming — so A's \ + refusal is the published set and not the count" + ); +} + +/// R7 arms D1 / D2 / D3 — the range is EXACTLY the driven range, not a padded one. +/// +/// * **D1 (over-refusal fix, ⚠ behaviour change).** A `RoundRobin[A,B]` rotation with only A +/// published, declared at count 1: the drive touches index 0 only, which selects A. Post-fix +/// INGESTED. Pre-fix the schedule-derived period (2) forced index 1 — an index nothing +/// drives — to be validated, and the declaration was refused. This is the over-veto class. +/// * **D2 (must-not-flip).** The same rotation with BOTH arms published is ingested at count 1 +/// AND at count 8 — the protected arm, pinned so the fix cannot be mistaken for "accept +/// more". +/// * **D3 (mandatory discriminating negative).** The same rotation with only A published at +/// count 2 DOES reach index 1 ⇒ refused. D3 is what separates D1 from "the validation was +/// deleted": under a deleted firewall D3 would be ingested. +#[test] +fn declared_count_shorter_than_the_rotation_is_not_over_refused() { + // D1 — the fix's over-refusal half. + assert!( + matches!( + g1_declare_verdict(false, IterationCount::Fixed(1), &round_robin_a_b, false), + WaitingFor::RespondToShortcut { .. } + ), + "CR 732.2a: a count of 1 drives index 0 only, which selects the PUBLISHED arm — \ + refusing it is the over-veto this fix removes" + ); + // D2 — must-not-flip, both counts. + for count in [IterationCount::Fixed(1), IterationCount::Fixed(8)] { + assert!( + matches!( + g1_declare_verdict(true, count.clone(), &round_robin_a_b, false), + WaitingFor::RespondToShortcut { .. } + ), + "a fully-published rotation is conforming at {count:?} — this arm must not move" + ); + } + // D3 — the discriminating negative at the first index the count DOES reach. + assert!( + matches!( + g1_declare_verdict(false, IterationCount::Fixed(2), &round_robin_a_b, false), + WaitingFor::Priority { .. } + ), + "count 2 reaches index 1, which selects the UNPUBLISHED arm ⇒ refused. If this is \ + ingested, the firewall is gone rather than correctly ranged" + ); +} + +/// R9 arms E / E-neg — `Fixed(0)` validates over an EMPTY range, and the firewall is STILL +/// live there. +/// +/// CR 732.2b: a shortened proposal's new ending point is the first deviating choice, and +/// CR 732.2c makes taking the shortcut mandatory once accepted — so a zero-repetition +/// proposal must be representable AND validatable. The `.max(1)` floor validated index 0 of a +/// range nothing drives. +/// +/// * **E (⚠ behaviour change).** `Piecewise[(0,B),(5,A)]` with only A published, at count 0. +/// Index 0 selects the UNPUBLISHED B — but nothing drives index 0, so post-fix this is +/// INGESTED. The template shape is load-bearing: under `RoundRobin[A,B]` index 0 selects the +/// PUBLISHED A, so the arm would pass before and after and its revert-probe could not fail. +/// * **E-neg (anti-"validation deleted" control at the SAME count).** Two pins against a +/// one-target point at the same count 0: the cardinality check sits outside the index loop, +/// so it must still refuse. Without E-neg, E would also pass under a firewall that had been +/// deleted outright. +/// +/// REVERT-PROBE: restore the `.max(1)` floor ⇒ E FAILS and no other arm moves (every other +/// arm's range is already ≥ 1). +#[test] +fn a_zero_count_declaration_validates_over_an_empty_range_but_still_checks_cardinality() { + // E — nothing is driven, so nothing is out of the published set. + assert!( + matches!( + g1_declare_verdict(false, IterationCount::Fixed(0), &piecewise_b_then_a, false), + WaitingFor::RespondToShortcut { .. } + ), + "CR 732.2b/c: a zero-repetition proposal drives no index, so no index can leave the \ + published set — the floor was refusing a conforming declaration" + ); + // E-neg — the firewall is still live at the same count. + assert!( + matches!( + g1_declare_verdict(false, IterationCount::Fixed(0), &piecewise_b_then_a, true), + WaitingFor::Priority { .. } + ), + "the cardinality check is OUTSIDE the index loop: two pins against a one-target \ + point are refused even at count 0. If this is ingested, E passed because validation \ + was deleted rather than because the range is empty" + ); +} + +// ═════════════ PR-7 Phase 5c, ITEM 2 — the kill-declared-target stop-short row ═════════════ + +/// Player-scope hexproof (CR 702.11c). The refuser is RULED to be +/// hexproof rather than phasing: hexproof makes the pinned seat illegal at the DRIVE's +/// spec-aware CR 608.2b re-validation (the `GameAction::SelectTargets` the injector submits), +/// which is the backstop layer no other 5c row exercises. Phasing would instead fail at +/// `resolve_target`'s EXISTENCE half and double-cover R1's seam. +/// A SYNTHETIC harness prop, deliberately NOT named after any printing: it exists only to be +/// the thing that makes the pinned seat illegal mid-window, and inventing a real card name for +/// non-verbatim text is the fabrication hazard CLAUDE.md's "verify the card, not just the rule" +/// principle warns about. Plan §12 scopes the verbatim-Oracle rule to the card under test; the +/// card under test here is the SANGUINE_BOND drain, whose text IS verbatim. +const HEXPROOF_GRANT: &str = "You have hexproof."; + +const P3: PlayerId = PlayerId(3); + +/// The R5 board — 4 seats, P0 running the escalating TARGETED drain +/// (`SANGUINE_BOND` × `BLOODTHIRSTY_CONQUEROR`), P1/P2/P3 at 1000 life so the drive never +/// crosses lethal inside the declared window. +/// +/// FOUR SEATS, and that is the §6 reach-guard rather than padding: killing the pinned seat +/// must leave **at least two** other legal seats standing. A one-element surviving set cannot +/// witness "did not re-choose" — a retargeting engine would have exactly one place to go and +/// a stopped engine and a retargeting engine would be indistinguishable at the seat level. +/// +/// Returns `(runner, sanguine_bond, hexproof_source, kickoff)`. The hexproof source starts in +/// P1's HAND, where its static does not function, so both arms share a byte-identical board +/// up to the moment the kill arm puts it onto the battlefield. +fn r5_board() -> (GameRunner, ObjectId, ObjectId, ObjectId) { + let mut scenario = GameScenario::new_n_player(4, 7); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + for seat in 1..4u8 { + scenario.with_life(PlayerId(seat), 1000); + } + let bond = scenario + .add_creature_from_oracle(P0, "Sanguine Bond", 2, 2, SANGUINE_BOND) + .id(); + scenario.add_creature_from_oracle(P0, "Bloodthirsty Conqueror", 3, 4, BLOODTHIRSTY_CONQUEROR); + let hexproof_src = scenario + .add_creature_to_hand_from_oracle(P1, "Test Hexproof Source", 0, 4, HEXPROOF_GRANT) + .id(); + let kickoff = scenario + .add_spell_to_hand_from_oracle(P0, "Test Lifegain Kickoff", false, KICKOFF) + .id(); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + (runner, bond, hexproof_src, kickoff) +} + +/// A `Fixed(count)` template pinning the Sanguine Bond trigger's `target opponent` to one +/// seat for every iteration. The slot's source is the Bond itself, so `slot_source_prompted` +/// matches the mid-drive `TriggerTargetSelection` the injector must answer. +fn r5_pin_template(bond: ObjectId, seat: PlayerId, count: u32) -> DecisionTemplate { + let source = YieldTarget::ThisObject { + source_id: bond, + incarnation: None, + trigger_description: None, + }; + let slot = DecisionSlot { + source: source.clone(), + index: 0, + }; + DecisionTemplate { + owner: P0, + decisions: vec![PinnedDecision::Targets { + slot, + targets: vec![TargetPin::Player(seat)], + }], + replay: ReplayMode::Scheduled { + count: IterationCount::Fixed(count), + }, + key: DecisionGroupKey::from_sources(&[source], DecisionKind::LoopChoice), + } +} + +/// Reach the R5 board's own bounded `LoopShortcut` offer and return the runner parked on it +/// plus every seat's life at that instant. +/// +/// The offer is the ENGINE's, read off `state.waiting_for` — never an out-of-band call to the +/// offer predicate, which would only prove the predicate agrees with itself. +fn r5_reach_offer() -> (GameRunner, ObjectId, ObjectId, Vec) { + let (mut runner, bond, hexproof_src, kickoff) = r5_board(); + let _ = runner.cast(kickoff).target_player(P1).resolve(); + let WaitingFor::LoopShortcut { + proposer, schema, .. + } = runner.state().waiting_for.clone() + else { + panic!( + "the 4p targeted drain must OFFER a LoopShortcut, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!(proposer, P0, "P0 has priority and proposes the shortcut"); + // LAYER ATTRIBUTION, half one: this offer publishes NO decision points, so + // `handle_declare_shortcut`'s pin firewall (`if !offer.schema.points.is_empty()`) is + // provably not the refuser in the kill arm below. Whatever refuses there is downstream. + assert!( + schema.points.is_empty(), + "this offer publishes no points, so declare-time `validate_pins` never runs — the \ + kill arm's refusal must therefore come from the drive; got {} points", + schema.points.len() + ); + let lives = vec![ + life(&runner, P0), + life(&runner, P1), + life(&runner, P2), + life(&runner, P3), + ]; + (runner, bond, hexproof_src, lives) +} + +/// The per-cycle life the pinned seat loses, probed by an independent `Fixed(1)` +/// materialization of this same board (one recurrence = one full cycle). Mirrors +/// [`probe_drain_delta`]; nothing below is bound to a literal drain rate. +fn r5_probe_delta() -> i32 { + let (mut runner, bond, _hexproof_src, l0) = r5_reach_offer(); + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: Some(r5_pin_template(bond, P1, 1)), + }) + .expect("declare Fixed(1) with a Player pin"); + accept_all_opponents(&mut runner); + let delta = l0[1] - life(&runner, P1); + assert!( + delta > 0, + "Fixed(1) must materialize a nonzero drain cycle on the PINNED seat, got {delta}" + ); + delta +} + +/// ITEM 2 / R5 ⭐ — **a declared target made illegal mid-drive stops the drive short and is +/// NEVER re-chosen.** Governing ruling, ledgered verbatim: *stop-short/abort, never silently +/// re-choose or skip.* +/// +/// # The seam, and why hexproof is the ruled refuser +/// +/// A `Fixed(n)` drive re-resolves its template per cycle and answers each mid-cycle +/// `TriggerTargetSelection` through `inject_pinned_answer`, which submits the pinned value as +/// a real `GameAction::SelectTargets`. That submission is the **drive-time CR 608.2b +/// re-validation** — the backstop layer, one below the declare-time firewall. Hexproof +/// (CR 702.11c, "can't be the target of spells or abilities **your opponents control**": the +/// source is P0's permanent and the pinned seat is P0's opponent) makes the pinned seat +/// illegal exactly there and nowhere earlier. Phasing was rejected as the refuser precisely +/// because it fails one layer up, at `resolve_target`'s existence half, double-covering R1. +/// +/// # Constructed-board deviation, DISCLOSED (constructibility-first) +/// +/// This row is built on a constructed 4-seat board rather than on a dump fixture, which §6 +/// licenses explicitly. Two measurements forced it, and both are INLINED here rather than +/// cited, because the probe archive that holds them is untracked and never ships: (i) the +/// real `dellian_emblem_conqueror_4p` dump — the only tracked fixture whose loop targets a +/// player — runs **309 beats to `GameOver` and raises no `LoopShortcut` at all** under the +/// generic dump driver, so it cannot host a declared drive; (ii) the refuser has to be +/// *introduced* on a named seat's battlefield mid-window, which is a board construction +/// whichever fixture carries it. The construction itself is the tracked one — +/// `declare_illegal_pin_falls_back_legal_ingests` builds its declare-seam board the same way. +/// +/// # The four-assertion anti-retarget set (§6), each named at its assertion below +/// +/// 1. the drive **stops short** — zero of `N` cycles commit, and `N * delta` is what the same +/// board commits with the refuser absent; +/// 2. **no retarget** — neither surviving legal seat is drained; +/// 3. **no silent skip** — the aborting cycle is not skipped-and-continued: no seat moves at +/// all, and the drain's mirror gain on P0 does not move either; +/// 4. **state coherent post-abort** — ring cleared, priority handed to a living seat, nobody +/// eliminated, the board still carries the refuser. +/// +/// # Reach-guards (without these every assertion above is vacuous) +/// +/// * the CLEAN arm is the positive control: the identical board with the hexproof source left in +/// hand drives all `N` cycles onto the pinned seat, so a `0` in the kill arm is the refuser +/// firing and not a dead harness; +/// * `player_has_hexproof(P1)` flips `false → true` across the move, so the setup cannot +/// silently no-op; +/// * **the surviving legal set has `len() >= 2`** — §6's own reach-guard. A one-element set +/// cannot witness "did not re-choose"; +/// * the declare firewall **passed** (`RespondToShortcut` opened) and the offer publishes +/// **no points**, so the refusal is attributable to the drive and not to `validate_pins`. +/// +/// # REVERT-PROBES — every claim below is a MEASUREMENT, with its result INLINED +/// +/// Nothing here cites a log path: the probe archive is untracked and never ships, so the +/// measured values are reproduced in full instead. +/// +/// The headline result is that the anti-retarget OUTCOME is defended by **AT LEAST THREE +/// independent production guards**, which is why no single-guard probe flips this row. Three +/// are named and measured below. The enumeration is deliberately open — a fourth, the +/// pre-drive `decision_template::resolve` re-check at the top of `materialize_fixed_shortcut`'s +/// `'cycles` loop, exists and simply is not engaged by THIS refuser (measured: it fires in +/// neither arm, which is exactly §6's reason for ruling hexproof over phasing). +/// +/// * **GUARD 1 — the drive's per-slot CR 608.2b target-legality rejection** +/// (`ability_utils::validate_selected_slots_with_specs`, its "Illegal target selected" arm), +/// reached through `inject_pinned_answer`'s `GameAction::SelectTargets` submission. Measured +/// on the UNMUTATED tree, reached exactly ONCE, on the pinned seat, against a live-derived +/// legal set: `target=Player(P1) live_legal=[Player(P2), Player(P3)] would_reject=true` ⇒ +/// `pinned_submit_ok=false` ⇒ `RecastAbort` ⇒ `CycleOutcome::Abort` ⇒ `break 'cycles` at +/// `i=0`. This is the layer this row claims to exercise, and it is provably reached. +/// * **GUARD 2 — the CR 732.2a per-cycle conformance check** in `materialize_fixed_shortcut` +/// (`actual != per_cycle.delta` ⇒ `break 'cycles`). +/// * **GUARD 3 — `inject_pinned_answer`'s fail-closed catch-all** (CR 732.2a "no conditional +/// actions": any prompt kind with no Stage-2 pin producer ⇒ `RecastAbort` ⇒ +/// `CycleOutcome::Abort`). +/// +/// * **RP-1**, the plan-named *"first legal target"* fallback in `inject_pinned_answer` (when +/// the pinned submission is refused, answer the prompt with the prompt's own first legal +/// target) ⇒ **this row still PASSES** (`1 passed; 0 failed`, `EXIT=0`). Not a hole in the +/// assertions — instrumented, the mutation *does* reach and *does* retarget: the prompt's +/// live legal set is `[P2, P3]` (P1 already dropped by the layer system), +/// `pinned_submit_ok=false`, `fallback=[Player(P2)]`, `fallback_ok=true`. The retargeted +/// cycle is then caught by GUARD 2: measured `break=CONFORMANCE i=0 actual={P0:+1, P2:-1} +/// expected={P0:+1, P1:-1}` ⇒ the divergent cycle is dropped whole. +/// * **SINGLE-GUARD PROBE on GUARD 1** — disable ONLY the per-slot legality rejection, leaving +/// GUARD 2 and CR 732.2a conformance fully intact ⇒ **this row still PASSES** (`1 passed`, +/// `EXIT=0`). Measured: the illegal pinned submission is then ACCEPTED +/// (`pinned_submit_ok=true`), the cycle never recurs, and the drive walks on to a +/// `DeclareAttackers` prompt that GUARD 3 fails closed on ⇒ `CycleOutcome::Abort` ⇒ +/// `break 'cycles` at `i=0`, with **zero** conformance breaks. +/// * **RP-1b**, RP-1 *plus* GUARD 2 disabled ⇒ **FAILS at named assertion (2)**, the +/// anti-retarget assertion: `left: (997, 1000) right: (1000, 1000)` (all `N = 3` cycles +/// committed onto P2), `EXIT=101`. This is the row's discrimination proof. It does not need +/// to touch GUARD 3: a successfully retargeted cycle RECURS, so the unpinned-prompt arm is +/// never reached on that path. +/// * **RP-2**, `CycleOutcome::Abort => continue 'cycles` ⇒ **measured NOT discriminating** +/// (`1 passed`, `EXIT=0`), disclosed rather than papered over. This row's refuser is +/// PERMANENT, so every later cycle aborts too and `committed` never advances. A real property +/// of a permanent refuser, not a gap in the assertions — a transient one is unconstructible +/// on this harness (no in-drive hook removes a static). +/// +/// **Read the inertness correctly.** These assertions are insensitive to the removal of any ONE +/// guard, and that is a property of PRODUCTION'S REDUNDANCY, not a weakness of the row: the row +/// asserts the observable outcome (stop short, never retarget), and production defends that +/// outcome three ways over. The row IS a discriminator — it reaches GUARD 1 exactly once with +/// `would_reject=true` on the pinned seat against a live-derived legal set, and RP-1b flips it +/// at a named assertion. What it is not is a single-guard regression pin; no probe result here +/// should be read as claiming otherwise. +#[test] +fn a_declared_target_made_illegal_mid_drive_stops_short_and_never_retargets() { + use engine::types::zones::Zone; + + const N: u32 = 3; + let delta = r5_probe_delta(); + + // ───────────────────────── CLEAN arm — the positive control ───────────────────────── + // Identical board, hexproof source left in hand (its static does not function there), so the + // ONLY difference from the kill arm is whether the refuser is on the battlefield. + let (mut clean, clean_bond, _clean_hexproof_src, clean_l0) = r5_reach_offer(); + clean + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(N), + template: Some(r5_pin_template(clean_bond, P1, N)), + }) + .expect("declare Fixed(N) with a Player pin"); + accept_all_opponents(&mut clean); + assert_eq!( + life(&clean, P1), + clean_l0[1] - (N as i32) * delta, + "control: with no refuser the drive commits EXACTLY N cycles onto the PINNED seat" + ); + assert_eq!( + (life(&clean, P2), life(&clean, P3)), + (clean_l0[2], clean_l0[3]), + "control: the pin, not the seat order, is what selects the drained seat" + ); + + // ───────────────────────────────── KILL arm ───────────────────────────────────────── + let (mut runner, bond, hexproof_src, l0) = r5_reach_offer(); + assert!( + !engine::game::static_abilities::player_has_hexproof(runner.state(), P1), + "setup anti-vacuity: the pinned seat must START without hexproof, or the kill below \ + changes nothing" + ); + + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(N), + template: Some(r5_pin_template(bond, P1, N)), + }) + .expect("declare Fixed(N) with a Player pin"); + // LAYER ATTRIBUTION, half two: the declare-time firewall INGESTED this declaration. The + // refusal measured below therefore happened at the drive, which is the whole point of + // choosing hexproof over phasing as the refuser. + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::RespondToShortcut { .. } + ), + "the declare firewall must PASS — otherwise this row measures `validate_pins`, not \ + the drive's CR 608.2b backstop; got {:?}", + runner.state().waiting_for + ); + + // THE KILL: the refuser arrives on the pinned seat's battlefield through the production + // zone pipeline, after the declaration has been ingested and before the table's Accept. + { + let mut events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + hexproof_src, + Zone::Battlefield, + &mut events, + ); + // CR 613.1: the grant is a continuous effect — re-derive the board so the legality + // reads below are taken against the post-kill layers rather than a stale cache. + engine::game::layers::mark_layers_full(runner.state_mut()); + engine::game::layers::evaluate_layers(runner.state_mut()); + } + assert!( + engine::game::static_abilities::player_has_hexproof(runner.state(), P1), + "setup anti-vacuity: the kill must actually land — a silently inert hexproof source would \ + make every assertion below pass for the wrong reason" + ); + assert!( + !engine::game::targeting::player_is_legal_target(runner.state(), P1, bond, P0), + "CR 702.11c: the pinned seat must now be an ILLEGAL target of the Bond's ability" + ); + // §6's REACH-GUARD, asserted as a count so a shrinking board fails loudly: after the kill + // at least TWO other seats are still legal, so a retargeting engine has somewhere to go. + let surviving_legal: Vec = [P2, P3] + .into_iter() + .filter(|&seat| { + engine::game::targeting::player_is_legal_target(runner.state(), seat, bond, P0) + }) + .collect(); + assert!( + surviving_legal.len() >= 2, + "a 1-element surviving legal set cannot witness `did not re-choose`; got \ + {surviving_legal:?}" + ); + + accept_all_opponents(&mut runner); + + // (1) STOPS SHORT — zero of N cycles committed. Bound to the measured `delta` and to the + // control arm above, never to a literal: `N * delta > 0` is what a completing drive + // would have taken off the pinned seat. + assert!( + N >= 2 && delta > 0, + "the stop-short claim needs a window longer than one cycle and a nonzero drain rate" + ); + assert_eq!( + life(&runner, P1), + l0[1], + "the pinned seat must lose NOTHING: the drive stopped at the first cycle whose \ + re-validation refused, and that cycle rolled back whole" + ); + // (2) NO RETARGET — both seats that were measured LEGAL above are untouched. + assert_eq!( + (life(&runner, P2), life(&runner, P3)), + (l0[2], l0[3]), + "stop-short, never silently re-choose: neither surviving legal seat may be drained. \ + A `first legal target` fallback in the injector drains P2 here" + ); + // (3) NO SILENT SKIP — the drain's mirror gain on the controller did not move either, so + // the drive did not skip the refused cycle and press on with the remaining ones. + assert_eq!( + life(&runner, P0), + l0[0], + "no silent skip: a drive that skipped the refused cycle and continued would still \ + have run the remaining cycles and moved the controller's mirror gain" + ); + // (4) STATE COHERENT POST-ABORT. + assert_eq!( + runner.state().waiting_for, + WaitingFor::Priority { player: P0 }, + "the abort hands priority back to a living seat (manual fallback), not a wrong-crown \ + and not a stuck response window" + ); + assert!( + runner.state().loop_detect_ring.is_empty(), + "the ring is cleared on handback so the same apply() does not instantly re-offer" + ); + assert!( + [P0, P1, P2, P3] + .into_iter() + .all(|seat| !is_eliminated(&runner, seat)), + "the table stays live — this row is about a refused target, not about anyone dying" + ); + assert!( + runner.state().battlefield.contains(&hexproof_src), + "the roll-back is scoped to the DRIVE: the board change that made the pin illegal is \ + not undone by the abort" + ); +} + +/// **Row R27 conjunct (a1) — THE SPLIT IS REAL AND THE TWO HALVES DIFFER.** +/// +/// CR 104.4b + CR 732.2a: `LoopDetectSample` separates the equality *comparand* from the +/// shortcut *evaluable*. Every later conjunct of R27 (a2/a3/b/c, U3) asserts that a +/// period-touch consumer reads the un-normalized half; **all of them are false PASSes if +/// the two halves happen to hold the same thing.** This row is the BASE/POST discipline +/// applied to the split itself: it pins that a real production sample's halves DIFFER on +/// the axis that made the split necessary. +/// +/// The axis is the object allocator. `normalize_for_loop` zeroes `next_object_id` +/// (`types/game_state.rs`, `clone.next_object_id = 0;`) while the live sample keeps it, +/// and `zones::create_object` allocates `ObjectId(state.next_object_id)` then +/// `state.objects.insert(id, obj)` — so evaluating a token creation against a normalized +/// frame allocates `ObjectId(0)` and REPLACES whatever object id 0 is, corrupting the map +/// the resolution runs on. That is the concrete defect the split exists to prevent. +/// +/// **Revert-probe:** make `GameState::loop_detect_live_sample` return +/// `self.normalize_for_loop()` (i.e. collapse the split) ⇒ `live.next_object_id` becomes +/// `0` ⇒ the `assert_ne!` and the `live == pre_sample` assertion both FAIL. The row is +/// deliberately NOT sensitive to which half any consumer reads — that is (a2)/(a3)'s job — +/// so it stays the honest positive control while those arms are the subject. +/// +/// Fixture: the tracked `dellian_emblem_conqueror_4p` dump, driven through the production +/// `apply()` path so the ring is populated by `record_loop_detect_sample` itself and not by +/// a hand-built fixture. +#[test] +fn a_recorded_loop_detect_sample_keeps_a_live_half_normalization_would_have_erased() { + let json = gunzip_dump(include_bytes!( + "../fixtures/dellian_emblem_conqueror_4p.json.gz" + )); + let mut state = restore_dump(&json); + + // ── REACH-GUARDS. Without these the assertions below are vacuous. + assert!( + state.loop_detection.samples(), + "reach-guard: the dump must load with a SAMPLING loop-detection mode, else no sample \ + is ever recorded; got {:?}", + state.loop_detection + ); + assert_eq!( + state.loop_detect_ring.len(), + 0, + "reach-guard: the dump ships with an EMPTY ring — every frame below was accumulated by \ + THIS drive through the production producer, not restored from the dump" + ); + + let pin = engine_live_opponents(&state, P0).first().copied(); + + // Drive until the production sampler has recorded at least one sample, capturing the + // live `next_object_id` observed at the beat immediately BEFORE the ring grew. That + // pre-sample value is what the `live` half must have preserved. + let mut witness: Option<(u64, u64, u64, u64)> = None; + for _ in 0..400 { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + break; + } + let before = state.loop_detect_ring.len(); + let next_object_id_before = state.next_object_id; + if dump_drive_one_beat(&mut state, pin).is_err() { + break; + } + if state.loop_detect_ring.len() > before { + let sample = state + .loop_detect_ring + .back() + .expect("the ring just grew, so it has a back element"); + witness = Some(( + next_object_id_before, + sample.live.next_object_id, + sample.normalized.next_object_id, + state.next_object_id, + )); + break; + } + } + + let (before_beat, live, normalized, after_beat) = witness.expect( + "reach-guard: the drive must record at least one loop-detect sample, else this row \ + asserts about a ring that was never populated and passes vacuously", + ); + + // The whole point of the axis: it must be non-degenerate on this board, so that + // `live != normalized` is a real inequality and not `0 != 0` dressed up, and so that + // the lower bound below actually bites. + assert!( + before_beat > 0, + "reach-guard: the allocator axis must be non-degenerate — a board that had allocated \ + ZERO objects makes the split unobservable on this axis and every assertion below \ + trivially true; got next_object_id = {before_beat}" + ); + + // ── THE CLAIM, both directions. + assert_eq!( + normalized, 0, + "CR 104.4b: the comparand half is `normalize_for_loop()`d, which zeroes the volatile \ + monotonic allocator so two positions reached at different times can compare equal" + ); + // The sampler runs at the POST-pipeline frame, so objects allocated earlier in the same + // beat are already counted: the live half is bracketed by the beat's own endpoints + // rather than equal to either. Collapsing the split drives it to 0, which is below + // `before_beat` (> 0 by the reach-guard above) and so fails this bound. + assert!( + (before_beat..=after_beat).contains(&live), + "CR 732.2a: the evaluable half is the beat un-normalized — it must carry the live \ + allocator cursor as of the post-pipeline frame the sampler runs at, i.e. inside \ + [{before_beat}, {after_beat}], because a shortcut's 'predictable results' are \ + evaluated by really resolving against it and `zones::create_object` allocates \ + `ObjectId(state.next_object_id)`; got {live}" + ); + assert_ne!( + live, normalized, + "THE SPLIT IS REAL: if the two halves agreed on this axis, R27's later conjuncts \ + (a2)/(a3)/(b)/(c) — every one of which asserts that a period-touch consumer reads the \ + un-normalized half — would be satisfiable by a build in which the split does not exist" + ); +} + +// ─────────── 5d U2 / R28 — the declared template's `owner` is ENGINE-BOUND ─────────── + +/// The engine-issued offer's own point set, hand-assembled to match `r5_pin_template`'s slot. +/// +/// The R5 board's live offer publishes an EMPTY schema (`r5_reach_offer` asserts it), so a +/// NON-empty-schema declaration has to be staged. This is the tree's own idiom for staging a +/// `LoopShortcut` wait; `offer.proposer` — the firewall's engine-issued comparand — still comes +/// from `WaitingFor::LoopShortcut`, which is what the row is about. +fn r28_nonempty_schema_offer(runner: &mut GameRunner, bond: ObjectId) { + let WaitingFor::LoopShortcut { + proposer, + predicted_winner, + certificate, + schema, + } = runner.state().waiting_for.clone() + else { + panic!("staged from the live offer, never from thin air"); + }; + let slot = DecisionSlot { + source: YieldTarget::ThisObject { + source_id: bond, + incarnation: None, + trigger_description: None, + }, + index: 0, + }; + runner.state_mut().waiting_for = WaitingFor::LoopShortcut { + proposer, + predicted_winner, + certificate, + schema: ShortcutDecisionSchema { + points: vec![DecisionPoint { + slot, + kind: DecisionPointKind::Targets { + legal_targets: vec![ + TargetRef::Player(P1), + TargetRef::Player(P2), + TargetRef::Player(P3), + ], + min_targets: 1, + max_targets: 1, + ordered: false, + }, + }], + ..schema + }, + }; +} + +/// R28 arms (a)/(a′) — **CR 732.2a + CR 603.5: a declaration whose `template.owner` names +/// another seat is refused AT DECLARE.** +/// +/// `template.owner` arrives VERBATIM from the client (`GameAction::DeclareShortcut { template }` +/// is forwarded whole) and it is the comparand the drive's seat guard uses to decide whose +/// CR 603.5 choice a pin may answer. Without a declare-time binding to the engine-issued +/// `LoopShortcutOffer.proposer`, that guard compares an attacker-chosen value against itself: +/// a declaration carrying `owner: ` satisfies `*player != template.owner` exactly +/// when the prompt's recipient IS that other seat, and the proposer's pinned value is +/// dispatched as the other seat's `GameAction::DecideOptionalEffect`. +/// +/// **(a′) is the reach-guard**: the byte-identical declaration with `owner = P0` builds the +/// proposal and opens APNAP, proving the fixture reaches the firewall and that (a) is keyed to +/// the `owner` axis rather than to `predictability_gate` / `validate_pins` / the count cap. +/// +/// REVERT-PROBE: delete `if template.as_ref().is_some_and(|t| t.owner != offer.proposer) { .. }` +/// from `handle_declare_shortcut` ⇒ the wrong-owner declaration is accepted, a proposal is +/// built, APNAP opens ⇒ **(a) FLIPS TO FAIL** while (a′) stays green. +#[test] +fn r28_a_declared_template_owning_another_seat_is_refused_at_declare() { + for hostile in [false, true] { + let (mut runner, bond, _hexproof, _lives) = r5_reach_offer(); + r28_nonempty_schema_offer(&mut runner, bond); + let WaitingFor::LoopShortcut { schema, .. } = runner.state().waiting_for.clone() else { + panic!("staged offer"); + }; + assert_eq!( + schema.points.len(), + 1, + "reach-guard: this arm runs on a NON-empty schema, so `predictability_gate` and \ + `validate_pins` really run and (a′) proves they PASS" + ); + + let mut template = r5_pin_template(bond, P1, 1); + if hostile { + template.owner = P1; + } + assert_eq!( + template.owner, + if hostile { P1 } else { P0 }, + "the two arms differ in exactly one field" + ); + let before = runner.state().clone(); + let result = runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: Some(template), + }) + .expect("the declaration is dispatched either way — refusal is a HANDBACK"); + + if hostile { + // (a) refused into the manual handback. + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "(a) CR 800.4a: a wrong-`owner` declaration hands priority back, got {:?}", + runner.state().waiting_for + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::RespondToShortcut { .. } + ), + "(a) no `ShortcutProposal` may be built" + ); + assert!( + result.events.is_empty(), + "(a) `handle_declare_shortcut` pushes NO events at all, so this is an exact \ + assertion rather than a wildcard: {:?}", + result.events + ); + assert_eq!( + before.players.iter().map(|p| p.life).collect::>(), + runner + .state() + .players + .iter() + .map(|p| p.life) + .collect::>(), + "(a) nothing was driven" + ); + } else { + // (a′) the matched positive: the proposal IS built and APNAP opens. + let WaitingFor::RespondToShortcut { proposal, .. } = &runner.state().waiting_for else { + panic!( + "(a′) the honest declaration must open APNAP, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!( + proposal.template.as_ref().map(|t| t.owner), + Some(P0), + "(a′) the proposal carries the engine-bound owner" + ); + } + } +} + +/// R28 arm (a″) — **the firewall's PLACEMENT, which no other arm can see.** +/// +/// The firewall sits OUTSIDE `if !offer.schema.points.is_empty()`. On an EMPTY-schema offer +/// that block is skipped entirely, so a `Some(template)` declaration would otherwise reach the +/// proposal without passing any template validation at all — `predictability_gate` and +/// `validate_pins` both live inside it. Arms (a)/(a′) run on a non-empty schema and therefore +/// pass whether the firewall is inside the block or outside it. +/// +/// The R5 offer's schema is empty (asserted by `r5_reach_offer`), so this arm reaches exactly +/// that path. +/// +/// REVERT-PROBE: move the firewall INSIDE the `!offer.schema.points.is_empty()` block ⇒ the +/// wrong-owner declaration is accepted here ⇒ **(a″) FLIPS TO FAIL** while (a)/(a′) stay green. +#[test] +fn r28_a_the_owner_firewall_is_reached_on_an_empty_schema_offer_too() { + // matched positive first: the empty-schema path DOES accept an honest declaration. + let (mut runner, bond, _h, _l) = r5_reach_offer(); + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: Some(r5_pin_template(bond, P1, 1)), + }) + .expect("declare"); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::RespondToShortcut { .. } + ), + "(a″) reach-guard: an EMPTY-schema offer accepts an owner-correct declaration, so the \ + refusal below is the firewall and not the empty-schema path refusing everything" + ); + + let (mut runner, bond, _h, _l) = r5_reach_offer(); + let mut template = r5_pin_template(bond, P1, 1); + template.owner = P1; + let result = runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: Some(template), + }) + .expect("dispatched"); + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "(a″) the firewall runs BEFORE the `!points.is_empty()` guard, so an empty-schema \ + offer is covered too, got {:?}", + runner.state().waiting_for + ); + assert!(result.events.is_empty(), "(a″) no events on the handback"); +} + +/// R28 arms (c)/(c′)/(c″) — **the RESTORE ingress, the only one the declare firewall cannot +/// see, closed at the single consumption chokepoint.** +/// +/// `ShortcutProposal` is plainly serialized inside `GameState.waiting_for`, and the +/// untrusted-restore scrubber rewrites only the two PRE-CAST waits — so a persisted +/// `WaitingFor::RespondToShortcut` decodes with its template intact, having never run +/// `handle_declare_shortcut`. `apply_confirmed_shortcut` is the sole route into both drives, +/// which is why the re-validation conjunct joins ITS existing fail-closed guard. +/// +/// Driven over BOTH trust branches, because `Deserialize for PersistedGameState` dispatches on +/// the presence of a top-level `"state"` key and only the RAW arm runs the scrubber. Asserting +/// one branch would leave the other untested — and (c″) below shows they really are different +/// code paths rather than one path asserted twice. +/// +/// * **(c)** `t.owner = P1` on a `proposal.proposer = P0` proposal ⇒ Accept is refused into the +/// manual handback: priority to a living seat, ZERO cycles committed, no `GameOver`. +/// * **(c′)** MATCHED POSITIVE, byte-identical except that one field ⇒ the drive runs and +/// commits. This is the reach-guard proving the fixture reaches `apply_confirmed_shortcut` at +/// all, and that (c) is keyed to the `owner` axis rather than to `is_alive`, the count cap or +/// the conformance check. +/// * **(c″-Raw)** the scrubber RUNS on this branch and leaves the wait untouched — which is the +/// mechanism that makes (c) necessary. **(c″-Trusted)** is a DIFFERENT claim: the scrubber is +/// not on that path at all, so the arm asserts survival across the trusted envelope and +/// claims nothing about the scrubber. +/// +/// REVERT-PROBE: delete the `|| proposal.template.as_ref().is_some_and(|t| t.owner != +/// proposal.proposer)` conjunct from `apply_confirmed_shortcut`'s guard ⇒ the round-tripped +/// wrong-owner proposal drives and commits ⇒ **(c) FLIPS TO FAIL** on both branches, while +/// (c′) stays green (it never depended on the conjunct). +#[test] +fn r28_c_a_restored_proposal_with_a_foreign_template_owner_is_refused_at_consumption() { + for hostile in [false, true] { + for trusted in [false, true] { + let label = format!("hostile={hostile} trusted={trusted}"); + let (mut runner, bond, _h, lives) = r5_reach_offer(); + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::Fixed(1), + template: Some(r5_pin_template(bond, P1, 1)), + }) + .expect("declare opens APNAP"); + + // Tamper the persisted wait exactly as a hand-edited dump would, THEN round-trip. + // The declare firewall has already run and passed on the honest value, so nothing + // below can be attributed to it. + let WaitingFor::RespondToShortcut { proposal, .. } = + &mut runner.state_mut().waiting_for + else { + panic!("{label}: APNAP must be open"); + }; + let expected_owner = if hostile { P1 } else { P0 }; + proposal + .template + .as_mut() + .expect("the declared template rode into the proposal") + .owner = expected_owner; + // MEASURED CONSTRAINT ON THIS INGRESS, applied to BOTH arms so they stay + // byte-identical except `owner`: `ShortcutProposal.per_cycle` carries a + // `PlayerId`-keyed resource map, and `PlayerId` cannot deserialize from a JSON + // object KEY — so a persisted `RespondToShortcut` whose proposal carries a + // per-cycle signature fails to decode with `invalid type: string "0", expected + // u8`. That is a pre-existing serde asymmetry this change does not touch; its + // consequence here is that ingress I3 is reachable only for `per_cycle: None` + // proposals, which is exactly the shipped `Some(template)` population. The guard + // under test does not read `per_cycle`, so nulling it costs the row nothing. + proposal.per_cycle = None; + + // The TRUSTED arm must carry a real resolution-wire envelope, not a bare + // `GameState` under a `"state"` key. Upstream #6933 made + // `resolution_state_version` a required discriminator and gave only the + // PersistedRaw ingress permission to stamp v1 onto a legacy payload; the + // TrustedEnvelope ingress deliberately stamps nothing, because a trusted + // snapshot is WRITTEN as a versioned envelope and must retain its declared + // compatibility mode. `GameState`'s derived `Serialize` emits no such field, + // so hand-wrapping it produced a payload the trusted path is right to refuse. + // Building through `ResolutionStateWire` is what `TrustedGameStateEnvelope`'s + // own `Serialize` does, so this arm now round-trips the shape production + // writes instead of one only this test ever constructed. + let payload = if trusted { + let wire = engine::types::resolution::ResolutionStateWire::from_game_state( + runner.state().clone(), + ); + serde_json::json!({ "state": serde_json::to_value(wire).expect("wire serializes") }) + } else { + serde_json::to_value(runner.state()).expect("state serializes") + }; + let restored: GameState = + serde_json::from_value::(payload) + .unwrap_or_else(|error| { + panic!("{label}: decodes through the production boundary: {error}") + }) + .into_game_state(); + + // (c″) — the wait and its tampered owner SURVIVE the decode. On the Raw branch the + // scrubber ran and left it alone (its `semantic_owner` match names only the two + // pre-cast waits); on the Trusted branch the scrubber is not on the path at all. + let WaitingFor::RespondToShortcut { proposal, .. } = &restored.waiting_for else { + panic!( + "{label}: (c\u{2033}) the restore must NOT drop the wait — otherwise (c) \ + would pass by a different mechanism entirely; got {:?}", + restored.waiting_for + ); + }; + assert_eq!( + proposal.template.as_ref().map(|t| t.owner), + Some(expected_owner), + "{label}: (c\u{2033}) the tampered owner reaches `apply_confirmed_shortcut` \ + unchanged" + ); + assert_eq!( + proposal.proposer, P0, + "{label}: the proposer is engine state" + ); + + let mut restored_runner = GameRunner::from_state(restored); + accept_all_opponents(&mut restored_runner); + + let after: Vec = restored_runner + .state() + .players + .iter() + .map(|p| p.life) + .collect(); + if hostile { + assert!( + matches!( + restored_runner.state().waiting_for, + WaitingFor::Priority { .. } + ), + "{label}: (c) CR 800.4a manual handback, got {:?}", + restored_runner.state().waiting_for + ); + assert_eq!( + after, lives, + "{label}: (c) ZERO cycles committed — the board is byte-equal on life" + ); + } else { + assert_ne!( + after, lives, + "{label}: (c\u{2032}) the honest proposal DRIVES — without this the \ + hostile arm's `no delta` assertion is vacuous" + ); + } + } + } +} diff --git a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs index 75db5c1750..52c6b62253 100644 --- a/crates/engine/tests/integration/loop_shortcut_mana_engine.rs +++ b/crates/engine/tests/integration/loop_shortcut_mana_engine.rs @@ -679,6 +679,7 @@ fn loop_action_sequence_conditional_load_migration() { win_kind: WinKind::Advantage, mandatory: false, residual_board_delta: BoardDelta::default(), + per_cycle: None, }, schema: ShortcutDecisionSchema::default(), }; diff --git a/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs new file mode 100644 index 0000000000..8ea900fdf7 --- /dev/null +++ b/crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs @@ -0,0 +1,397 @@ +//! §6 R8 — THE OFFER-WRITER SURFACE TRIPWIRE, AS A TRACKED TEST RATHER THAN A +//! WRITTEN NUMBER. +//! +//! CR 732.2a: only the player with priority may suggest a shortcut, and the +//! engine's record of a live suggestion is a `WaitingFor::LoopShortcut` write. +//! The 5d period machinery adds certification paths, so the standing question +//! "did a new path learn to certify without declaring or driving?" needs an +//! instrument that re-measures on every `cargo test -p phase-engine` run. A +//! number written into a plan cannot fire; this can. +//! +//! WHAT IT PINS, and why it is an INVARIANCE claim rather than a re-measurement: +//! 22 production + 14 test sites across `crates/engine/src` and +//! `crates/phase-ai/src`. A failure reads *"5d (or a successor) changed the +//! offer-writer surface"*, not *"someone re-measured"*. +//! +//! THE ANCHOR IS BARE — `WaitingFor::LoopShortcut {`, with no `= ` / `Ok(` +//! qualifier. A prefix-anchored regex cannot be completed by adding prefixes: +//! `Some(WaitingFor::LoopShortcut {`, `vec![WaitingFor::LoopShortcut {`, a bare +//! literal in argument position and `return WaitingFor::LoopShortcut {` are all +//! constructions, and a match-arm PATTERN is a *consumer* whose appearance is as +//! worth surfacing as a writer's. Dropping the qualifier pins the whole surface +//! and has no form gap by construction. +//! +//! Pattern copied from `no_top_level_test_binaries.rs` — the in-tree precedent +//! for a `#[test]` that reads the source tree through +//! `Path::new(env!("CARGO_MANIFEST_DIR"))` and asserts a structural invariant. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// The bare anchor, ASSEMBLED AT RUNTIME. +/// +/// This file lives under `crates/engine/tests/`, which the census does not walk, +/// so a literal could not be self-counted today. Assembling it anyway keeps that +/// true across a future move: an instrument that can count its own needle +/// reports its own text as a finding. +fn anchor() -> String { + format!("{}::{} {{", "WaitingFor", "LoopShortcut") +} + +/// The ROUND-2 anchor this row replaces — a CONSTRUCTION-shaped detector. Kept +/// only so the foreign-form plant below can measure that it scores `(0, 0)` on +/// input the bare anchor scores `(4, 4)` on; that measurement is the statement +/// that the old tripwire was evadable. +fn construction_anchors() -> [String; 2] { + let bare = anchor(); + [format!("= {bare}"), format!("Ok({bare}")] +} + +/// One classified hit. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Hit { + file: String, + line: usize, + in_test: bool, +} + +/// CR-neutral source classification: which lines of `src` sit inside a +/// `#[cfg(test)]` scope? +/// +/// THE CORRECTED RULE (the shipped `.combofb-cfgscope.sh` gets this wrong with a +/// bare `/^mod /`, which reports every hit inside a `#[cfg(test)] pub mod tests +/// {` as PRODUCTION): +/// +/// * `#[cfg(test)]` immediately followed by an OPTIONAL VISIBILITY PREFIX and +/// then `mod ` (`mod` / `pub mod` / `pub(crate) mod` / `pub(super) mod`) opens +/// a module spanning to that `mod` line's own closing brace, at the `mod`'s +/// indentation. +/// * `#[cfg(test)]` followed by anything else scopes ONLY its own item. +/// +/// The naive "nearest preceding attribute" rule is measured wrong and yields +/// false TEST verdicts, so it is deliberately not used. +fn cfg_test_scoped_lines(src: &str) -> Vec { + let lines: Vec<&str> = src.lines().collect(); + let mut scoped = vec![false; lines.len()]; + let mut i = 0usize; + while i < lines.len() { + if lines[i].trim() != "#[cfg(test)]" || i + 1 >= lines.len() { + i += 1; + continue; + } + let next = lines[i + 1]; + let indent = next.len() - next.trim_start().len(); + let closing = format!("{}}}", " ".repeat(indent)); + let body = next.trim_start(); + let after_vis = body + .strip_prefix("pub(crate) ") + .or_else(|| body.strip_prefix("pub(super) ")) + .or_else(|| body.strip_prefix("pub ")) + .unwrap_or(body); + let opens_module = after_vis.starts_with("mod "); + // A `#[cfg(test)]` item that opens a brace spans to its own closing + // brace; one that does not (a `use`, a `const`) is a single line. + if opens_module || next.trim_end().ends_with('{') { + let mut j = i + 2; + while j < lines.len() && lines[j].trim_end() != closing { + j += 1; + } + for s in scoped.iter_mut().take((j + 1).min(lines.len())).skip(i) { + *s = true; + } + i = j + 1; + continue; + } + scoped[i + 1] = true; + i += 1; + } + scoped +} + +/// Classify every `needle` hit in `src`, skipping COMMENT lines. +/// +/// ⚠ THE COMMENT EXCLUSION IS A MEASURED DEVIATION FROM THE PLAN, DISCLOSED +/// HERE RATHER THAN ABSORBED. §6 R8's ROUND-7 pre-change-tree check asserts that +/// U1–U6 introduce no `WaitingFor::LoopShortcut {` token. Measured on this tree: +/// 5d U2's declare-time owner firewall added the DOC LINE +/// `// copied from `WaitingFor::LoopShortcut { proposer }`.` to `game/engine.rs`, +/// which a comment-blind bare anchor counts as a 23rd production site. A comment +/// is not a code surface — it writes no offer and consumes none — so counting it +/// would make the tripwire fire on prose and would force the pinned number to be +/// re-measured by the very commit that ships the row. Excluding comment lines +/// restores the plan's PRODUCTION count of 22 exactly, INCLUDING its per-file +/// production multiset. (It does not restore the plan's original test-half count +/// of 12: that half has since been adjudicated to 14, twice, and the assert below +/// is the authority for the pair. Prose that repeats a number is prose that can go +/// stale — this defers to the assert rather than restating it.) +fn classify(src: &str, needle: &str, file: &str) -> Vec { + let scoped = cfg_test_scoped_lines(src); + src.lines() + .enumerate() + .filter(|(_, line)| line.contains(needle) && !line.trim_start().starts_with("//")) + .map(|(n, _)| Hit { + file: file.to_string(), + line: n + 1, + in_test: scoped[n], + }) + .collect() +} + +fn rs_files(root: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}")) { + let path = entry.expect("read dir entry").path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|ext| ext == "rs") { + out.push(path); + } + } + } + out.sort(); + out +} + +/// The two crate roots R8 walks. `crates/engine/tests/**` is deliberately NOT +/// walked: the acceptance rows that name the variant live there, and they are +/// consumers of the surface rather than members of it. +fn census(needle: &str) -> Vec { + let engine_src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let ai_src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("phase-ai") + .join("src"); + let mut hits = Vec::new(); + for (root, prefix) in [(engine_src, "engine/src"), (ai_src, "phase-ai/src")] { + for path in rs_files(&root) { + let src = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + // Stable, checkout-independent label: `/src/...`. Built from the + // walk root rather than from the absolute path, because the phase-ai + // root is reached through `../` and would otherwise label as + // `engine/../phase-ai/src/...`. + let rel = path + .strip_prefix(&root) + .expect("walked path is under its root") + .to_string_lossy() + .replace('\\', "/"); + hits.extend(classify(&src, needle, &format!("{prefix}/{rel}"))); + } + } + hits +} + +/// R8 CONJUNCT 1 — the offer-writer surface, pinned BIDIRECTIONALLY (`== 22` / +/// `== 14`, so a REMOVED site fails too) and by per-file multiset. +/// +/// ⚠ THE `#[cfg(test)]` HALF HAS MOVED TWICE, 12 ⇒ 13 ⇒ 14, AND EACH +/// ADJUDICATION IS RECORDED RATHER THAN THE ASSERT RELAXED. +/// * 12 ⇒ 13: §6 R27 (b) +/// (`analysis::resource::tests::r27_b_a_stored_may_auto_choice_survives_the_ring`) +/// destructures the offer the mint RETURNED to count its published CR 603.5 +/// `MayChoice` points. +/// * 13 ⇒ 14 (5d U4): `game::engine::stage2_injector_tests::u4_park_on_offer` +/// parks a constructed board on a `LoopShortcut { proposer: P0 }` so §6 R28's +/// arm (b) can assert that the DECLARE firewall refuses a hostile +/// `template.owner` — i.e. that arm (b)'s drive-seam configuration is +/// production-unreachable. +/// +/// Both are WRITES in a `#[cfg(test)]` scope, which is the benign case this +/// row's own failure message names: a test fixture cannot make the period +/// machinery certify. The PRODUCTION half is unchanged at 22 and so is the +/// per-file multiset below, which is the half §10 ruling condition (2) is about. +/// +/// R8 CONJUNCT 2, same test — every production `validate_pins(` site is a +/// declare-time gate paired with `predictability_gate`. +/// +/// ON FAILURE, the named consequence (§10 ruling condition (2)): a new +/// production site in a certification-path file, or a declare site without its +/// `validate_pins` pairing, means the period machinery may have created a path +/// that CERTIFIES WITHOUT DECLARING OR DRIVING. That converts +/// answer-legality-at-certification from a doc note into owed work, and the +/// U-series stops until it is carried. Adjudication is a human step; this is not +/// a test to relax. A new *read* site is the benign case and the message says so. +#[test] +fn the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_validates_pins() { + let hits = census(&anchor()); + let production: Vec<&Hit> = hits.iter().filter(|h| !h.in_test).collect(); + let in_test: Vec<&Hit> = hits.iter().filter(|h| h.in_test).collect(); + + let mut per_file: BTreeMap<&str, usize> = BTreeMap::new(); + for h in &production { + *per_file.entry(h.file.as_str()).or_default() += 1; + } + let multiset: Vec<(String, usize)> = per_file + .iter() + .map(|(f, n)| ((*f).to_string(), *n)) + .collect(); + + assert_eq!( + (production.len(), in_test.len()), + (22, 16), + "CR 732.2a OFFER-WRITER SURFACE CHANGED (not re-measured — this number is an \ + INVARIANCE pin over the whole 5d U-series).\n\ + The three CERTIFICATION-PATH writers are `reconcile_terminal_result` (object-growth \ + arm), `interactive_loop_bridge` (drain bridge arm) and \ + `try_offer_bounded_cycle_shortcut` (bounded arm), all in `engine/src/game/engine.rs`. \ + `game/visibility.rs`'s `filter_state_for_viewer` writer is EXCLUDED by name and not \ + silently: it re-emits an ALREADY-minted offer into a per-viewer projection and cannot \ + run unless `state.waiting_for` is already a `LoopShortcut`.\n\ + A new PRODUCTION site in a certification-path file means the period machinery may \ + certify without declaring or driving — §10 ruling condition (2), i.e. \ + answer-legality-at-certification becomes OWED WORK and the U-series stops. A new READ \ + site is the benign case; adjudicate, do not relax the assert.\n\ + THE TEST HALF HAS BEEN ADJUDICATED THREE TIMES (12 ⇒ 13, §6 R27 (b)'s schema read in \ + `engine/src/analysis/resource.rs`; 13 ⇒ 14, 5d U4's `u4_park_on_offer` fixture in \ + `engine/src/game/engine.rs`, which parks a constructed board on an offer so §6 R28 \ + arm (b) can assert the DECLARE firewall refuses a hostile `template.owner`; 14 ⇒ 16, \ + BOTH in `phase-ai/src/policies/loop_shortcut.rs`'s `#[cfg(test)]` module — \ + `bounded_offer_with_period`, a builder minting an offer whose certificate carries a \ + real `per_cycle` so the proposer-elimination arm can be driven, and `certificate_of`, \ + a read accessor for the same rows. PRODUCTION STAYED AT 22 across that change, which \ + is the half this pin exists to protect: the new policy arm READS the certificate and \ + writes no offer); if it moves again, name the new site here too rather than only \ + moving the number.\n\ + measured per-file production multiset: {multiset:?}\n\ + production: {production:?}\n\ + test: {in_test:?}" + ); + assert_eq!( + multiset, + vec![ + ("engine/src/ai_support/candidates.rs".to_string(), 1), + ("engine/src/game/engine.rs".to_string(), 5), + ("engine/src/game/interaction.rs".to_string(), 5), + ("engine/src/game/scenario.rs".to_string(), 1), + ("engine/src/game/visibility.rs".to_string(), 2), + ("engine/src/types/game_state.rs".to_string(), 4), + ("phase-ai/src/decision_kind.rs".to_string(), 1), + ("phase-ai/src/policies/loop_shortcut.rs".to_string(), 1), + ("phase-ai/src/projection.rs".to_string(), 1), + ("phase-ai/src/search.rs".to_string(), 1), + ], + "the COUNT can be preserved by a move that relocates a writer into a \ + certification-path file, so the per-file multiset is pinned too" + ); + + // ── CONJUNCT 2: every production `validate_pins(` site is a declare-time gate ── + // UNQUALIFIED anchor, deliberately: the fully-qualified + // `crate::analysis::decision_template::validate_pins(` form matches only the + // `engine.rs` site and under-counts by one — this plan's own finding 5, + // applied symmetrically. + let pins = census("validate_pins("); + let pins_production: Vec<&Hit> = pins.iter().filter(|h| !h.in_test).collect(); + assert_eq!( + pins_production.len(), + 3, + "expected 1 definition (`analysis/decision_template.rs`) + 2 declare-time call sites \ + (`game/engine.rs::handle_declare_shortcut`, \ + `game/interaction.rs::materialize_loop_shortcut_response`); got {pins_production:?}" + ); + let definition = pins_production + .iter() + .filter(|h| h.file == "engine/src/analysis/decision_template.rs") + .count(); + assert_eq!(definition, 1, "exactly one definition: {pins_production:?}"); + + // Each CALL SITE is paired with `predictability_gate` — the coverage half of + // the same declare-time gate. Pairing is asserted WITHIN the enclosing + // statement, i.e. a `predictability_gate` hit within two lines of the call. + let gates = census("predictability_gate("); + for site in pins_production + .iter() + .filter(|h| h.file != "engine/src/analysis/decision_template.rs") + { + let paired = gates + .iter() + .any(|g| g.file == site.file && g.line.abs_diff(site.line) <= 2); + assert!( + paired, + "CR 732.2a: a declare site that validates pin VALUES without also running \ + `predictability_gate`'s COVERAGE check can accept a proposal that leaves a \ + published choice unpinned — the certifies-without-declaring shape §10 condition \ + (2) names. Unpaired site: {site:?}; gates: {gates:?}" + ); + } +} + +/// R8 ANTI-VACUITY ARM 2 — THE FOREIGN-FORM PLANT. +/// +/// Feeds the classifier a synthetic source carrying the anchor in FOUR forms the +/// round-2 construction anchor could not match — the bare literal in expression +/// position (the `types/game_state.rs` shape, the one genuinely-missed site), +/// `Some(..)`, a match-arm PATTERN, and `return ..` — plus one `cfg(test)` +/// mod-scoped copy of each. `(production, test) == (4, 4)`. +/// +/// THE PLANT IS DELIBERATELY NOT IN THE PLAN'S OWN ANCHOR FORM. A tripwire that +/// only detects its own shape is the defect this row files against the +/// superseded instrument, and planting in that shape would repeat it. +/// +/// KEYED, not trusted: the round-2 construction anchors are run over the SAME +/// input and must score `(0, 0)` — the measured statement that the old tripwire +/// was evadable — while the bare anchor scores `(4, 4)`. One instrument +/// resolving two different values on one input is what makes this a measurement +/// rather than a constant. +/// +/// REVERT-PROBE (arm 3, the ONLY remaining non-trivial conjunct under a bare +/// anchor): remove the cfg-scope filter — i.e. make `cfg_test_scoped_lines` +/// return all-`false` — and the four mod-scoped plants count as production, so +/// `(4, 4)` becomes `(8, 0)` and this test FLIPS TO FAIL. The classifier is also +/// measured keyed on the real tree: it returns BOTH 22 production AND 12 test +/// above, so it is not constant in either direction. +#[test] +fn the_cfg_scope_classifier_sees_four_foreign_forms_the_construction_anchor_misses() { + let bare = anchor(); + let forms = [ + // 1. bare literal in ARGUMENT position — `types/game_state.rs`'s shape, + // the one site the construction anchor genuinely missed. + format!( + " cases.push((\"answered by DeclareShortcut\", {bare} proposer: PlayerId(0) }}));" + ), + // 2. `Some(..)`. + format!(" let offer = Some({bare} proposer, schema }});"), + // 3. a match-arm PATTERN — a CONSUMER of the surface. + format!(" {bare} proposer, .. }} => *proposer,"), + // 4. `return ..`. + format!(" return {bare} proposer, schema, certificate, predicted_winner }};"), + ]; + let mut src = String::from("fn production_side() {\n"); + for f in &forms { + src.push_str(f); + src.push('\n'); + } + src.push_str("}\n\n#[cfg(test)]\npub(crate) mod tests {\n fn test_side() {\n"); + for f in &forms { + // Same four forms, one indent deeper, inside a `pub(crate) mod` — the + // visibility prefix the superseded shell classifier's `/^mod /` misses. + src.push_str(" "); + src.push_str(f); + src.push('\n'); + } + src.push_str(" }\n}\n"); + + let hits = classify(&src, &bare, "synthetic"); + let production = hits.iter().filter(|h| !h.in_test).count(); + let in_test = hits.iter().filter(|h| h.in_test).count(); + assert_eq!( + (production, in_test), + (4, 4), + "the bare anchor must see all four foreign forms on BOTH sides of the cfg scope, and \ + the cfg-scope classifier must put the `pub(crate) mod tests` copies in the TEST \ + column. Removing the cfg-scope filter makes this (8, 0). hits: {hits:?}\nsrc:\n{src}" + ); + + for old in construction_anchors() { + let old_hits = classify(&src, &old, "synthetic"); + assert_eq!( + old_hits.len(), + 0, + "keying control: the ROUND-2 construction anchor `{old}` scores 0 on input the \ + bare anchor scores 8 on — that is the measured statement that the superseded \ + tripwire was evadable, and it is what makes the (4, 4) above a measurement \ + rather than a constant. hits: {old_hits:?}" + ); + } +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 6d7e382846..38a7059789 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -188,6 +188,7 @@ mod export_runtime_canaries; mod exquisite_blood_routing; mod eyetwitch_learn_decline_lesson; mod fact_or_fiction_pile_separation; +mod fantastic_four_bounded_loop; mod fateful_handoff_target_mana_value_draw; mod favor_of_the_mighty_greatest_mana_value_protection; mod felisa_fang_of_silverquill; @@ -222,6 +223,7 @@ mod giada_angel_counters; mod giant_ox_crew_toughness; mod gideon_trials_emblem; mod gift_delivery_draw_sequence_migration; +mod gift_recipient_phased_out_opponent; mod giggling_skitterspike_issue_890; mod gimbal_gremlin_prodigy; mod glen_elendras_answer_counter_all_conjunction; @@ -1097,6 +1099,7 @@ mod loop_counter_growth; mod loop_shortcut; mod loop_shortcut_activation; mod loop_shortcut_mana_engine; +mod loop_shortcut_offer_writer_census; mod lose_control_this_turn_delayed_trigger; mod lost_mine_fungi_cavern_duration_runtime; mod lost_mine_storeroom_targeting_runtime; diff --git a/crates/engine/tests/integration/rules/battle.rs b/crates/engine/tests/integration/rules/battle.rs index 7504406bb7..e0466e5fc2 100644 --- a/crates/engine/tests/integration/rules/battle.rs +++ b/crates/engine/tests/integration/rules/battle.rs @@ -365,6 +365,150 @@ fn battle_with_no_legal_protector_goes_to_graveyard() { )); } +/// R4l — CR 310.11a (*"must choose its protector from among their opponents"*) + +/// CR 704.5w (*"no player **in the game** designated as its protector"*): the protector +/// pick is a CHOICE (CR 115.10a), so a phased-out seat is not among the choosable +/// opponents (the CR 702.26b MIRROR), and a departed one is not either (CR 800.4 + +/// CR 102.1). +/// +/// THE SHARED 5-SEAT BOARD: P0 controls the Siege, P1 is phased out, P2 eliminated, P3/P4 +/// valid. Nothing in this file exercises phasing at all — every existing row asserts the +/// behaviour 5c changes — so the shapes below are copied and the setups are not. +/// +/// ARM 1 of three (the other two are `..._crosses_to_a_silent_auto_apply` and +/// `..._crosses_to_the_graveyard`). Arm 1 is the published-prompt arm: two survivors keep +/// `legal_choices.len() >= 2`, which is the reach-guard — below that the SBA takes a branch +/// that publishes nothing and every `candidates` assertion would be unreachable. +/// +/// REVERT-PROBE: restore `players::opponents` at the `legal_choices` derivation ⇒ P1 +/// reappears ⇒ the total equality FAILS. +#[test] +fn battle_protector_choice_excludes_a_phased_out_opponent_and_still_offers_the_rest() { + let (mut runner, battle) = phased_protector_board(&[P1]); + + let mut events = Vec::new(); + sba::check_state_based_actions(runner.state_mut(), &mut events); + + match runner.state().waiting_for.clone() { + WaitingFor::BattleProtectorChoice { + player, + battle_id, + candidates, + } => { + assert_eq!(player, P0); + assert_eq!(battle_id, battle); + assert_eq!( + candidates, + vec![PlayerId(3), PlayerId(4)], + "phased-out P1 and eliminated P2 are out; both valid opponents are in" + ); + } + other => panic!("Expected BattleProtectorChoice, got {other:?}"), + } +} + +/// R4l arm 2 — THE `2 → 1` CROSSING, which is the hazard this site is actually about. +/// +/// Narrowing the choosable set moves a board across `legal_choices.len()`'s branch +/// boundary, and at `1` the engine writes the protector ITSELF and publishes nothing: no +/// `WaitingFor`, no events. That is invisible to every `candidates` assertion the R4-family +/// shape prescribes, so it needs its own arm. The auto-applied seat is not wrong — it is +/// the sole surviving legal opponent, which CR 310.10 / CR 310.11a make the only +/// appropriate player. What this arm guards is the SILENT DISAPPEARANCE of the prompt. +/// +/// BOTH halves are required: (a) alone would pass on a board where the SBA never ran at +/// all, and (b) alone would pass if the prompt had ALSO been published. +/// +/// The crossing is reached by PHASING, not by board size — `battle_protector_auto_applies_ +/// with_single_candidate_2p` reaches `1` because its board has one opponent, which cannot +/// witness a narrowing. It is also not reached by elimination: that is the A5 confound, +/// which additionally ends the game. +/// +/// REVERT-PROBE: restore `players::opponents` at site 14 ⇒ both phased-out seats return ⇒ +/// `legal_choices` is `[P1, P3, P4]` ⇒ `len() >= 2` ⇒ the prompt returns ⇒ (a) FAILS. +#[test] +fn battle_protector_narrowing_to_one_auto_applies_silently() { + let (mut runner, battle) = phased_protector_board(&[P1, PlayerId(4)]); + + let mut events = Vec::new(); + sba::check_state_based_actions(runner.state_mut(), &mut events); + + // (a) the prompt is NOT published… + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::BattleProtectorChoice { .. } + ), + "one surviving legal opponent ⇒ the singleton branch, which publishes nothing" + ); + // (b) …and the SBA did run: it wrote the sole surviving legal opponent as protector. + assert_eq!( + runner.state().objects[&battle].protector(), + Some(PlayerId(3)), + "the auto-applied seat is the ONLY surviving legal opponent (CR 310.11a)" + ); + assert!(runner.state().battlefield.contains(&battle)); +} + +/// R4l arm 3 — the `→ 0` crossing: with every opponent phased out there is no appropriate +/// player, and CR 310.10 / CR 704.5w put the battle into its owner's graveyard. +/// +/// Reached by PHASING rather than by elimination on purpose: eliminating every opponent +/// also ends the game (`waiting_for = GameOver`), which would confound the assertions with +/// a game-over transition. Phasing keeps the table live, so what this arm reads is the +/// battle rule and nothing else — asserted below. +#[test] +fn battle_protector_narrowing_to_zero_sends_the_battle_to_the_graveyard() { + let (mut runner, battle) = phased_protector_board(&[P1, PlayerId(3), PlayerId(4)]); + + let mut events = Vec::new(); + sba::check_state_based_actions(runner.state_mut(), &mut events); + + assert_eq!(runner.state().objects[&battle].zone, Zone::Graveyard); + assert!(!runner.state().battlefield.contains(&battle)); + assert!(!matches!( + runner.state().waiting_for, + WaitingFor::BattleProtectorChoice { .. } + )); + assert!( + !matches!(runner.state().waiting_for, WaitingFor::GameOver { .. }), + "the table must still be LIVE — reaching 0 by phasing rather than by elimination \ + is what keeps this arm about CR 310.10 instead of about the game ending" + ); +} + +/// The shared choice-legality board for R4l's three arms: five seats, P0 controls a Siege +/// seeded with the illegal `protector == controller` (CR 704.5x) so the SBA fires, P2 +/// eliminated, and each seat in `phase_out` transitioned through the PRODUCTION phasing +/// API. Every arm differs ONLY in that list, which is what makes them one crossing series +/// rather than three unrelated boards. +fn phased_protector_board(phase_out: &[PlayerId]) -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new_n_player(5, 7); + scenario.at_phase(Phase::PreCombatMain); + let battle = scenario.add_creature(P0, "Contested Siege", 0, 0).id(); + let mut runner = scenario.build(); + make_into_siege(&mut runner, battle, P0, 3); + + let mut events = Vec::new(); + for seat in phase_out { + // Setup anti-vacuity: the production API reports what it transitioned, so a + // silent no-op fails loudly here rather than quietly weakening the arm. + let transitioned = + engine::game::phasing::phase_out_player(runner.state_mut(), *seat, &mut events); + assert_eq!( + transitioned, + vec![*seat], + "phase_out_player must actually transition {seat:?}" + ); + } + engine::game::elimination::eliminate_player(runner.state_mut(), PlayerId(2), &mut events); + assert!( + runner.state().players[2].is_eliminated, + "P2 must read as eliminated" + ); + (runner, battle) +} + /// CR 310.10 + CR 704.5w: AI routing — when the 3-player SBA pauses with a /// protector choice, `legal_actions` emits one `ChooseBattleProtector` candidate /// per legal opponent, so the AI has a deterministic decision surface. diff --git a/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs b/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs index c9664afb98..931e7d12da 100644 --- a/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs +++ b/crates/engine/tests/integration/sprout_inalla_realistic_offer.rs @@ -60,8 +60,12 @@ fn load_realistic_dump() -> GameState { )); let envelope: serde_json::Value = serde_json::from_str(&json).expect("dump envelope parses as JSON"); + // Decode AS `PersistedGameState` rather than decoding a bare `GameState` and wrapping + // it in `Raw`: only the former runs `reject_legacy_raw_prompt_authority` and + // `decode_persisted_resolution_state`, which is the rest of the production chokepoint. + // `.expect(..)`, not `?`: `into_game_state` returns `GameState`, not `Result`. serde_json::from_value::(envelope["gameState"].clone()) - .expect("the realistic 4p gameState restores through the persisted ingress") + .expect("gameState deserializes through the production decoder") .into_game_state() } diff --git a/crates/phase-ai/src/policies/loop_shortcut.rs b/crates/phase-ai/src/policies/loop_shortcut.rs index 98ecf29759..6038175d21 100644 --- a/crates/phase-ai/src/policies/loop_shortcut.rs +++ b/crates/phase-ai/src/policies/loop_shortcut.rs @@ -83,6 +83,7 @@ //! `ctx.ai_player == proposer` would silently DROP the veto in that case. use engine::analysis::decision_template::IterationCount; +use engine::analysis::loop_check::LoopCertificate; use engine::types::actions::GameAction; use engine::types::game_state::{GameState, WaitingFor}; use engine::types::player::PlayerId; @@ -122,6 +123,7 @@ impl TacticalPolicy for LoopShortcutPolicy { fn verdict(&self, ctx: &PolicyContext<'_>) -> PolicyVerdict { let na = || PolicyVerdict::neutral(PolicyReason::new("loop_shortcut_na")); + // (see `cycles_to_proposer_elimination` below for the CR 704 self-cost predicate) // Cheapest possible gate FIRST (one enum-discriminant compare): every `ActivateAbility` // candidate in the game runs this. It is also this policy's contribution to NaN safety — @@ -134,7 +136,8 @@ impl TacticalPolicy for LoopShortcutPolicy { let WaitingFor::LoopShortcut { proposer, predicted_winner, - .. + schema, + certificate, } = &ctx.state.waiting_for else { return na(); @@ -176,27 +179,224 @@ impl TacticalPolicy for LoopShortcutPolicy { PolicyVerdict::reject(PolicyReason::new("loop_shortcut_untillethal_cannot_crown")) } - // CR 732.2a "a loop that repeats a specified number of times": neither rejected nor - // boosted. Two independent reasons. (1) The AI never emits `Fixed` — its ONLY - // `DeclareShortcut` construction site (`candidates.rs:3012`) hardcodes `UntilLethal`. - // (2) A count-blind reject would be wrong for the CLASS: `materialize_fixed_shortcut` - // drives and COMMITS `n` whole cycles without ever reading `predicted_winner`, so a - // small-`n` `Fixed` is genuine committed board progress whoever is latched. + // CR 732.2a: a ZERO-repetition declaration is representable, and the engine treats + // it as legal — `a_zero_count_declaration_validates_over_an_empty_range_but_still_ + // checks_cardinality` in `crates/engine/tests/integration/loop_shortcut.rs` pins + // exactly that — but it commits NO cycles while still spending the CR 732.2b + // response window. That is the same weak-domination shape the over-bound arm below + // and the `(None, UntilLethal)` arm above reject: the outcome set is {no-op, minus a + // response window}, so declining weakly dominates it. // - // NOTE (tripwire): a `Fixed(n)` large enough to cross lethal WOULD commit a `GameOver` - // crowning whoever the DRIVE's state-based actions crown (`drive_one_shortcut_cycle`, - // `engine.rs:1180-1186`, forwards the SBA's own `Option` — which is the - // latched winner when the prediction was right, and can even be `None`, a CR 104.4b - // draw). `materialize_fixed_shortcut`'s `CrossLethal` arm (`engine.rs:1393-1402`) - // forwards it WITHOUT filtering on `proposal.predicted_winner`, unlike BOTH - // `UntilLethal` crown gates (`engine.rs:971`, `engine.rs:1000`). Such a declare by a - // faller proposer is therefore a committed self-loss — exactly what the `UntilLethal` - // arm above rejects. REVISIT THIS ARM if the candidate generator ever emits `Fixed`. - (_, IterationCount::Fixed(_)) => na(), + // ⚠ ORDER IS LOAD-BEARING: this arm MUST precede the `!schema.is_bounded() => na()` + // arm below. `Fixed(0)` matches that guard too, so with the arms the other way round + // a zero-count declare on an UNBOUNDED offer scored NEUTRAL rather than rejected — + // and the domination argument above does not depend on boundedness at all. A + // declaration that commits nothing spends the CR 732.2b window whether or not the + // offer carries a bound, so the two schema shapes must reach the same verdict. + // `loop_shortcut_unbounded_declare_rejects_zero_count` is the regression for the + // unbounded half specifically, and it goes RED via `na()` if this arm is moved back + // below. + // + // Not reachable from today's generator either way (it emits only + // `Fixed(max_iterations)`, and the load seam refuses `max_iterations: 0`), so no + // scoring that can occur today is reordered. The arm states the scoring arm's OWN + // precondition rather than leaving it to an invariant maintained a crate away. + (_, IterationCount::Fixed(0)) => PolicyVerdict::reject(PolicyReason::new( + "loop_shortcut_bounded_declare_zero_count", + )), + + // CR 732.2a "a loop that repeats a specified number of times". The verdict splits + // on whether the OFFER states a real CR 704 bound, because the domination argument + // below is valid only when it does. + // + // UNBOUNDED offer ⇒ neither rejected nor boosted, deliberately. An offer whose + // producer could not compute a bound publishes `MAX_SHORTCUT_CYCLES`, so it states + // no CR 704 threshold for a domination argument to stand on, and a count-blind + // reject would be wrong for the CLASS: `materialize_fixed_shortcut` drives and + // COMMITS `n` whole cycles without ever reading `predicted_winner`, so a small-`n` + // `Fixed` is genuine committed board progress whoever is latched. + // + // NOTE (tripwire, still live for the unbounded branch): a `Fixed(n)` large enough + // to cross lethal WOULD commit a `GameOver` crowning whoever the DRIVE's + // state-based actions crown — `materialize_fixed_shortcut`'s `CrossLethal` arm + // forwards the SBA's own `Option` WITHOUT filtering on + // `proposal.predicted_winner`, unlike both `UntilLethal` crown gates. Such a + // declare by a faller proposer is a committed self-loss, exactly what the + // `UntilLethal` arm above rejects. On a BOUNDED offer that hazard is discharged by + // `elimination_bounds`' contract rather than by an AI-side computation: it narrows to + // `min over living seats of (life - 1) / per-cycle loss` with FLOOR division, so + // `n * loss <= life - 1` for every seat and every `n` within `max_iterations`. + // + // ⚠ THAT PREMISE WAS FALSE WHEN THIS ARM SHIPPED, and it is stated here only because + // it has since been made true and RE-MEASURED. At `c6d834040` + // `materialize_fixed_shortcut` had no cycle delimiter for the basis-B class, so the + // drive ran to the beat cap and `Fixed(1)` on a bounded 3p/4p drain eliminated the + // whole table — the arithmetic above described a bound the drive never honoured. Fix + // round 1 delimits a cycle by the published `PeriodicDelta::frames_per_period` and + // drops any cycle whose measured delta differs from the published one. + // + // RE-MEASURED after that fix, through the production accept path (declare + APNAP + // accepts) at `n` = 1, 3 and AT the offered bound: + // bloodloop 3p (bound 16): n=1 [20,16,16] n=3 [20,14,14] n=16 [20,1,1] elim 0 + // bloodloop 4p (bound 16): n=1/3/16 likewise, elim 0 + // dina 4p (bound 30): n=1 [50,34,30,35] … n=30 [79,5,1,6] elim 0 + // The engine-side regression row that keeps this honest is + // `bounded_fixed_count_commits_exactly_n_periods`, which asserts zero eliminations + // and `committed == n × published δ` on all three fixtures. + (_, IterationCount::Fixed(_)) if !schema.is_bounded() => na(), + + // CR 732.2a: over the offered bound. The AI-side mirror of the engine's own + // declare-time guard — such a declare names a count at which some living player + // crosses a CR 704.5a / CR 704.5c / CR 104.3c threshold inside the proposal, which + // is a conditional action, so the engine hands it back fail-closed with ZERO + // committed cycles while the CR 732.2b response window is spent. Weakly dominated + // by declining: the outcome set is {no-op minus a response window}. Same + // domination shape the `(None, UntilLethal)` arm above encodes. + (_, IterationCount::Fixed(n)) if *n > schema.max_iterations => PolicyVerdict::reject( + PolicyReason::new("loop_shortcut_bounded_declare_over_bound") + .with_fact("declared", i64::from(*n)) + .with_fact("max_iterations", i64::from(schema.max_iterations)), + ), + + // CR 732.2a: within the offered bound on a bounded offer ⇒ committed board + // progress that eliminates nobody. Game-deciding ⇒ critical band, via the + // auto-banding `PolicyVerdict::score` (NEVER `preference`, whose `debug_assert!` + // band domain panics on this field's default). Both declare kinds route through + // the one reused config field on purpose: the winning arm above and this one are + // never both scoreable at a single node — a winning offer carries + // `predicted_winner: Some(..)` and an unnarrowed bound (so no `Fixed` candidate is + // generated), and a bounded offer carries `predicted_winner: None` (so the winning + // arm cannot be reached). With no ordering to distort, a second tuned field would + // buy nothing and cost the full `UNTUNED_POLICY_PENALTY_FIELDS` protocol. + // CR 704.5a / CR 704.5c / CR 104.3c: the offered bound is derived from EVERY living + // seat, and `ResourceVector::elimination_bounds` deliberately lets the PROPOSER be + // the binding one — CR 732.2a's shortcut proposer "need not be the player proposing + // the shortcut" who benefits, so the producer is right not to gate on proposer + // benefit (engine `game/engine.rs`, `bounded_cycle_offer` doc). That makes it the + // DECIDING side's job, and nothing was doing it: a bounded offer always carries + // `predicted_winner: None`, so the "hands somebody else the win" arm above is + // structurally unreachable here, and the AI's only bounded candidate is + // `Fixed(max_iterations)` — the maximum, never a smaller n. A self-mill period whose + // binding seat is the proposer therefore scored CRITICAL for running the proposer's + // own library to exactly 0. + // + // This arm asks the question the producer declines to ask, on the proposer's behalf + // only, and REJECTS rather than dropping to `na()`: neutral would still leave the + // declare competing on other policies' scores, and the domination argument here is + // the same shape as the zero-count arm's — a declare that eliminates the declarer is + // weakly dominated by declining, which rolls back to exactly where a decline lands. + (_, IterationCount::Fixed(n)) + if cycles_to_proposer_elimination(ctx.state, certificate, *proposer) + .is_some_and(|fatal| i64::from(*n) >= fatal) => + { + PolicyVerdict::reject( + PolicyReason::new("loop_shortcut_declare_eliminates_proposer") + .with_fact("declared", i64::from(*n)) + .with_fact( + "eliminates_at", + cycles_to_proposer_elimination(ctx.state, certificate, *proposer) + .unwrap_or_default(), + ), + ) + } + + (_, IterationCount::Fixed(n)) => PolicyVerdict::score( + ctx.penalties().loop_shortcut_winning_declare_bonus, + PolicyReason::new("loop_shortcut_bounded_declare_progress") + .with_fact("declared", i64::from(*n)), + ), } } } +/// The fewest whole cycles of this offer's certified period that drive `proposer` to a CR 704 +/// elimination threshold, or `None` if no measured axis ever does. +/// +/// This is the inverse of `ResourceVector::elimination_bounds` (engine `analysis/resource.rs`), +/// mirrored axis for axis and asked of ONE seat instead of narrowed over all of them: +/// +/// - **life**, CR 704.5a — reaching **0 or less** is the threshold. +/// - **poison**, CR 704.5c — reaching **10 or more** is the threshold. +/// +/// **LIBRARY IS DELIBERATELY EXCLUDED, and that is the non-obvious part of this function.** +/// CR 121.4: "A player who attempts to draw a card from a library with no cards in it loses the +/// game the next time a player would receive priority." The loss attaches to the DRAW ATTEMPT, +/// not to the library reaching zero — a player with an empty library and no draw ahead of them +/// has not lost and may still win. Milling yourself to exactly zero is a legal, sometimes +/// winning line (self-mill payoffs; `loop_check` classifies such a period as `Advantage`, and +/// `elimination_bounds` intentionally permits the exactly-zero terminal value), so vetoing it +/// would refuse a real strategy class. A certified period records per-cycle resource deltas; it +/// cannot express "and then the proposer is forced to draw", so on today's evidence no +/// library-based veto is sound. **Extension point:** if a future certificate can prove a forced +/// post-zero draw, that is what would license adding the axis back — reconcile it against +/// `loop_shortcut_declare_that_mills_the_proposer_to_exactly_zero_still_scores`. +/// +/// **The principle is accumulation ≠ realization, and the engine already tests it.** The One Ring +/// under the Kilo/Freed/Relic proliferate engine certifies an infinite burden-GROWTH loop as +/// `WinKind::Advantage` — not a win — naming the unbounded burden counter axis +/// (`analysis/corpus_tests.rs`, `one_ring_burden_growth_certificate`, with the 0-burden dead-loop +/// control beside it and the driver doc at `analysis/corpus.rs`). The burden's lethality realizes +/// only DOWNSTREAM, at the upkeep trigger, which is where CR 704.5a finally applies: +/// `tests/integration/one_ring_burden_upkeep_lethal.rs` pairs +/// `one_ring_burden_upkeep_kills_owner_p0_wins` with the sub-lethal control +/// `one_ring_sublethal_burden_owner_survives_no_gameover`. The engine therefore refuses to treat +/// in-loop accumulation of a doom resource as realized elimination. An emptying library is the +/// same shape with mill in place of burden and the draw attempt in place of the upkeep trigger, +/// so excluding it makes this policy consistent with the engine's own tested doctrine rather than +/// stricter than it. +/// +/// **That is also why life STAYS, and the line is principled rather than an ad-hoc keep/drop.** +/// `per_cycle.delta.life` is IN-CYCLE realization: the drain happens inside the certified period +/// and is state-based-checkable at cycle boundaries, so the certificate does prove the death it +/// implies. The One Ring's life loss would only enter a certificate the same way if the upkeep +/// trigger were inside the loop span. Both surviving axes are immediate state-based losses on +/// state alone (CR 704), requiring no intervening action — which is exactly the property an empty +/// library lacks. +/// +/// Only movement TOWARD death counts: a life gain or a poison decrease yields no bound on that +/// axis, which is why each rate is tested `> 0` before it is allowed to divide. +/// +/// Returns the MINIMUM across axes, so the caller compares one number against the declared +/// count. `None` means "no axis kills the proposer at any n" — including the case where this +/// offer carries no certified period at all. That last branch is unreachable for the bounded +/// class (`certified_bounded_cycle_offer` mints `per_cycle: Some(periodic)`), and it is written +/// as a plain `?` rather than an `expect` because a policy must never panic on a state shape; +/// the reach-guards in `loop_shortcut_declare_that_kills_the_proposer_on_life_is_refused` and +/// `loop_shortcut_declare_that_mills_the_proposer_to_exactly_zero_still_scores` are what prove +/// the `Some` path is the one actually exercised. +fn cycles_to_proposer_elimination( + state: &GameState, + certificate: &LoopCertificate, + proposer: PlayerId, +) -> Option { + let period = certificate.per_cycle.as_ref()?; + let player = state.players.get(proposer.0 as usize)?; + let per_cycle = |axis: &std::collections::BTreeMap| { + axis.get(&proposer).copied().unwrap_or(0) + }; + + // `headroom / rate` rounded UP: the first whole cycle at which the threshold is met. + // Written long-hand rather than with `i64::div_ceil`, which is still unstable on this + // toolchain (`int_roundings`). Both operands are non-negative here — `headroom` is clamped + // and `rate` is guarded `> 0` — so the `+ rate - 1` form is exact, with no negative-operand + // truncation-toward-zero trap. + let cycles = |headroom: i64, rate: i64| -> Option { + (rate > 0).then(|| (headroom.max(0) + rate - 1) / rate) + }; + + [ + // No library term — see the CR 121.4 exclusion in this function's doc comment. + cycles(i64::from(player.life), -per_cycle(&period.delta.life)), + cycles( + 10 - i64::from(player.poison_counters), + per_cycle(&period.delta.poison), + ), + ] + .into_iter() + .flatten() + .min() +} + #[cfg(test)] mod tests { use super::*; @@ -208,21 +408,27 @@ mod tests { use engine::ai_support::{ActionMetadata, AiDecisionContext, CandidateAction, TacticalClass}; use engine::analysis::decision_template::ShortcutDecisionSchema; use engine::analysis::loop_check::{LoopCertificate, WinKind}; - use engine::analysis::resource::BoardDelta; + use engine::analysis::resource::{BoardDelta, PeriodicDelta, ResourceVector}; + use engine::types::identifiers::ObjectId; use rand::rngs::SmallRng; use rand::SeedableRng; const P0: PlayerId = PlayerId(0); const P1: PlayerId = PlayerId(1); - /// A synthetic optional-lethal certificate — the policy never reads it; only `proposer` and - /// `predicted_winner` drive the verdict. + /// A synthetic optional-lethal certificate with NO certified period. + /// + /// The policy reads exactly one field of this: `per_cycle`, via + /// `cycles_to_proposer_elimination`. `None` here means "no self-elimination bound is + /// derivable", so every row built on this helper exercises the pre-existing arms only — + /// which is why the self-cost rows below use [`bounded_offer_with_period`] instead. fn cert() -> LoopCertificate { LoopCertificate { unbounded: vec![], win_kind: WinKind::LethalDamage, mandatory: false, residual_board_delta: BoardDelta::default(), + per_cycle: None, } } @@ -389,6 +595,13 @@ mod tests { /// Rows 3 + 5 + 7 — THE CLASS GUARD: `materialize_fixed_shortcut` never reads /// `predicted_winner` and COMMITS every cycle it drives, so a `Fixed(n)` declare is real board /// progress for ANY latched winner. Proves the reject set is not one state too wide. + /// + /// This row stays green LEGITIMATELY, not by luck: `ShortcutDecisionSchema::default()` + /// carries `max_iterations == MAX_SHORTCUT_CYCLES`, so `is_bounded()` is FALSE and the + /// verdict takes the deliberately-neutral unbounded branch. That branch is asserted + /// DIRECTLY by `loop_shortcut_unbounded_offer_keeps_fixed_neutral` below, so a future + /// re-scoping that deletes the `!schema.is_bounded()` guard fails THERE with a message + /// naming the guard, rather than here with no explanation. #[test] fn declare_fixed_is_never_rejected() { for predicted_winner in [None, Some(P0), Some(P1)] { @@ -404,6 +617,406 @@ mod tests { } } + /// A BOUNDED offer — the only shape `try_offer_bounded_cycle_shortcut` mints. `points` + /// stays empty because the engine's `Fixed` candidate generator is gated on that too. + fn bounded_offer_state(max_iterations: u32) -> GameState { + let mut state = GameState::new_two_player(0); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: None, + certificate: cert(), + schema: ShortcutDecisionSchema { + max_iterations, + ..Default::default() + }, + }; + state + } + + /// A BOUNDED offer carrying a real certified period — the shape + /// `certified_bounded_cycle_offer` actually mints (`per_cycle: Some(periodic)`), as opposed + /// to [`cert`]'s `None`. + fn bounded_offer_with_period(max_iterations: u32, period: PeriodicDelta) -> GameState { + let mut state = GameState::new_two_player(0); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: None, + certificate: LoopCertificate { + per_cycle: Some(period), + ..cert() + }, + schema: ShortcutDecisionSchema { + max_iterations, + ..Default::default() + }, + }; + state + } + + /// One repetition's whole-game delta, given only the axes a test cares about. + fn periodic(library_delta: &[(PlayerId, i64)], life: &[(PlayerId, i64)]) -> PeriodicDelta { + PeriodicDelta { + frames_per_period: 1, + delta: ResourceVector { + library_delta: library_delta.iter().copied().collect(), + life: life.iter().copied().collect(), + ..Default::default() + }, + victim_slot: vec![], + } + } + + /// One repetition's whole-game delta on the POISON axis (CR 704.5c). Kept separate from + /// [`periodic`] so the common life/library rows stay two-argument, and so a poison row needs + /// no post-construction mutation of `waiting_for` — reaching into the offer to patch the + /// certificate would add an in-test hit to the CR 732.2a offer-writer census for no gain. + fn periodic_poison(poison: &[(PlayerId, i64)]) -> PeriodicDelta { + PeriodicDelta { + frames_per_period: 1, + delta: ResourceVector { + poison: poison.iter().copied().collect(), + ..Default::default() + }, + victim_slot: vec![], + } + } + + fn stock_library(state: &mut GameState, player: PlayerId, cards: u64) { + state.players[player.0 as usize].library = (0..cards).map(ObjectId).collect(); + } + + fn certificate_of(state: &GameState) -> &LoopCertificate { + match &state.waiting_for { + WaitingFor::LoopShortcut { certificate, .. } => certificate, + other => panic!("expected a LoopShortcut offer, got {other:?}"), + } + } + + fn schema_of(state: &GameState) -> &ShortcutDecisionSchema { + match &state.waiting_for { + WaitingFor::LoopShortcut { schema, .. } => schema, + other => panic!("expected a LoopShortcut offer, got {other:?}"), + } + } + + /// CR 121.4 — running your OWN library to exactly zero is NOT a loss, and a bounded loop that + /// does it must still score. "A player who attempts to draw a card from a library with no + /// cards in it loses the game the next time a player would receive priority" — the loss + /// attaches to the DRAW ATTEMPT, not to the library reaching zero. A self-mill line that ends + /// at exactly zero with no draw in the cycle is legal, is a real strategy class (self-mill + /// payoffs, `loop_check` classifies it as `Advantage`), and can be the winning line. + /// + /// WRITTEN BEFORE THE FIX AND OBSERVED FAILING, which is this row's revert-probe: against the + /// library-axis veto it read `loop_shortcut_declare_eliminates_proposer` and the `assert_eq!` + /// below reported that kind. Re-adding a library term to `cycles_to_proposer_elimination` + /// flips it back, so the row cannot silently lose its grip on the regression. + /// + /// The certificate cannot represent a forced post-zero draw, so no library-based veto is + /// sound on today's evidence; if a future certificate proves a forced draw, that is the + /// extension point and this row is what it must be reconciled against. + /// + /// Same shape as the tested One Ring case (`one_ring_burden_upkeep_lethal.rs`): burden + /// accumulating in-loop is not the loss — the upkeep trigger that realizes it is. Here the + /// draw attempt plays the upkeep's role, per CR 121.4. Accumulation ≠ realization. + #[test] + fn loop_shortcut_declare_that_mills_the_proposer_to_exactly_zero_still_scores() { + // 30 cards, 3 of the proposer's own milled per cycle ⇒ EXACTLY zero at the 10th cycle. + let mut state = bounded_offer_with_period(10, periodic(&[(P0, -3), (P1, -1)], &[])); + stock_library(&mut state, P0, 30); + + assert!( + schema_of(&state).is_bounded(), + "REACH-GUARD: the arm under test is bounded-only; max_iterations = {}", + schema_of(&state).max_iterations + ); + assert!( + certificate_of(&state).per_cycle.is_some(), + "REACH-GUARD: a certificate with no period makes every assertion below vacuous" + ); + assert_eq!(state.players[P0.0 as usize].library.len(), 30); + assert_eq!( + state.players[P0.0 as usize].life, 20, + "REACH-GUARD: the proposer must be alive on the axes that DO kill (CR 704.5a life, \ + CR 704.5c poison), or this row would pass for the wrong reason" + ); + + let v = verdict_for(&state, &declare(IterationCount::Fixed(10))); + assert_eq!( + kind_of(&v), + "loop_shortcut_bounded_declare_progress", + "10 cycles put the proposer's library at exactly 0 with no draw — legal under \ + CR 121.4, so the scoring arm owns it; got {v:?}" + ); + assert!(delta_of(&v) > STRONG_MAX); + } + + /// The CONTRAST that proves the guard reads the PROPOSER's seat and not merely "some seat is + /// being drained": identical schema, identical bound, identical declared count — the only + /// change is which player the life loss names. Re-based from the library axis onto life when + /// CR 121.4 removed library as an elimination axis; a library-drain contrast would now pass + /// for BOTH seats and discriminate nothing. + #[test] + fn loop_shortcut_declare_that_kills_only_an_opponent_still_scores() { + let state = bounded_offer_with_period(10, periodic(&[], &[(P1, -2)])); + assert_eq!( + state.players[P1.0 as usize].life, 20, + "REACH-GUARD: 10 cycles at -2 must actually reach 0 on the OPPONENT, or the contrast \ + is vacuous" + ); + + let v = verdict_for(&state, &declare(IterationCount::Fixed(10))); + assert_eq!( + kind_of(&v), + "loop_shortcut_bounded_declare_progress", + "draining an OPPONENT to zero life is the loop working as intended; got {v:?}" + ); + assert!(delta_of(&v) > STRONG_MAX); + } + + /// CR 704.5a — the AI must not declare a bounded loop that runs its OWN life to 0 or less. + /// `elimination_bounds` deliberately lets the proposer be the binding seat, and the engine's + /// only bounded candidate is `Fixed(max_iterations)`, so before this guard the heuristic path + /// declared a self-killing loop at the CRITICAL band. + /// + /// THIS IS THE THRESHOLD DISCRIMINATOR, moved here from the library axis when CR 121.4 struck + /// library from `cycles_to_proposer_elimination`. Life is a true elimination axis: 0 or less + /// life is an immediate state-based loss on state alone, with no intervening action required + /// — unlike an empty library, which kills only at the next DRAW ATTEMPT. + /// + /// THE `Fixed(9)` ROW IS WHAT MAKES THIS A THRESHOLD TEST rather than a test that a + /// proposer-negative axis exists at all. A "fix" that rejected any period charging the + /// proposer's life would satisfy the `Fixed(10)` assertion and still be wrong — it would + /// refuse profitable loops the proposer survives. One cycle short leaves 2 life, and that + /// must still score. + /// + /// REVERT-PROBE: delete the `cycles_to_proposer_elimination` guard arm ⇒ `Fixed(10)` falls + /// through to the scoring arm and reads `loop_shortcut_bounded_declare_progress`, so the + /// first assertion FAILS while the `Fixed(9)` row stays green — the two rows fail + /// independently, which is what makes the pair discriminating rather than redundant. + #[test] + fn loop_shortcut_declare_that_kills_the_proposer_on_life_is_refused() { + let state = bounded_offer_with_period(10, periodic(&[], &[(P0, -2)])); + assert_eq!(state.players[P0.0 as usize].life, 20); + assert!( + schema_of(&state).is_bounded(), + "REACH-GUARD: the arm under test is bounded-only; max_iterations = {}", + schema_of(&state).max_iterations + ); + assert!( + certificate_of(&state).per_cycle.is_some(), + "REACH-GUARD: a certificate with no period makes every assertion below vacuous" + ); + + assert_eq!( + kind_of(&verdict_for(&state, &declare(IterationCount::Fixed(10)))), + "loop_shortcut_declare_eliminates_proposer" + ); + assert_eq!( + kind_of(&verdict_for(&state, &declare(IterationCount::Fixed(9)))), + "loop_shortcut_bounded_declare_progress", + "9 cycles leave the proposer at 2 life — alive, so the scoring arm still owns it" + ); + } + + /// The predicate is a MINIMUM across the surviving axes, and poison is one of them + /// (CR 704.5c: ten or more poison counters is a loss). Life is left untouched here, so the + /// ONLY binding axis is poison — a min that silently dropped it would score this offer. + #[test] + fn loop_shortcut_declare_that_poisons_the_proposer_out_is_refused() { + let state = bounded_offer_with_period(10, periodic_poison(&[(P0, 1)])); + assert_eq!( + state.players[P0.0 as usize].poison_counters, 0, + "REACH-GUARD: 10 cycles at +1 poison must actually reach the CR 704.5c threshold" + ); + assert_eq!( + state.players[P0.0 as usize].life, 20, + "REACH-GUARD: life must be untouched so poison is the ONLY binding axis" + ); + + assert_eq!( + kind_of(&verdict_for(&state, &declare(IterationCount::Fixed(10)))), + "loop_shortcut_declare_eliminates_proposer", + "10 cycles put the proposer at 10 poison — a CR 704.5c loss on state alone" + ); + assert_eq!( + kind_of(&verdict_for(&state, &declare(IterationCount::Fixed(9)))), + "loop_shortcut_bounded_declare_progress", + "9 cycles leave the proposer at 9 poison — one short of lethal, so it still scores" + ); + } + + /// CR 732.2a — the BOUNDED branch, both halves, on ONE schema differing only in `n`. + /// + /// (i) `Fixed(4)` with `max_iterations == 10` ⇒ committed progress that eliminates nobody + /// (`elimination_bounds`' contract), so the critical band `PolicyVerdict::score` routes + /// `8.0` to. (ii) `Fixed(11)` ⇒ the engine hands it back fail-closed with ZERO committed + /// cycles and the CR 732.2b window spent, i.e. weakly dominated by declining. + /// + /// REVERT-PROBES, each flipping a DIFFERENT subset so neither dominates the other: + /// * ⓟ1 restore `(_, IterationCount::Fixed(_)) => na()` ⇒ BOTH arms collapse to + /// `delta == 0.0` / `"loop_shortcut_na"` ⇒ FAILS. + /// * ⓟ2 delete the `n > schema.max_iterations` conjunct ⇒ arm (ii) SCORES instead of + /// rejecting ⇒ FAILS while arm (i) still passes, which is what proves the reject half is + /// not carried by ⓟ1. + /// * ⓟ3 invert `ShortcutDecisionSchema::is_bounded()` to `>=` ⇒ the in-test schema reads + /// unbounded ⇒ both arms take `na()` ⇒ FAILS, and so does + /// `loop_shortcut_unbounded_offer_keeps_fixed_neutral`. ⓟ3 flipping BOTH rows plus the + /// engine's `until_lethal_against_a_bounded_offer_is_rejected` is the single-authority + /// proof: one edit to one predicate is measurable at every caller. + #[test] + fn loop_shortcut_bounded_declare_scores_and_rejects_over_bound() { + let state = bounded_offer_state(10); + assert!( + schema_of(&state).is_bounded(), + "REACH-GUARD: every assertion below is vacuous unless the in-test schema really is \ + bounded; max_iterations = {}", + schema_of(&state).max_iterations + ); + + // (i) within the bound. + let inside = verdict_for(&state, &declare(IterationCount::Fixed(4))); + assert!( + matches!(inside, PolicyVerdict::Score { .. }), + "a declare within the offered bound must SCORE, got {inside:?}" + ); + assert_eq!(kind_of(&inside), "loop_shortcut_bounded_declare_progress"); + assert!( + delta_of(&inside) > STRONG_MAX, + "a bounded declare is board-deciding ⇒ the critical band; got {} (STRONG_MAX = \ + {STRONG_MAX})", + delta_of(&inside) + ); + + // (ii) above the bound — the OPPOSITE verdict variant on the SAME schema. + let outside = verdict_for(&state, &declare(IterationCount::Fixed(11))); + assert!( + matches!(outside, PolicyVerdict::Reject { .. }), + "a declare above the offered bound contains a conditional action and is handed \ + back with zero committed cycles ⇒ weakly dominated, got {outside:?}" + ); + assert_eq!( + kind_of(&outside), + "loop_shortcut_bounded_declare_over_bound" + ); + } + + /// CR 732.2a — the ZERO-count arm asserted directly. A `Fixed(0)` declaration commits no + /// cycles while still spending the CR 732.2b response window, so declining weakly + /// dominates it and it must be rejected rather than scored. + /// + /// The arm previously carried no row at all: the suite covered `Fixed(4)` and `Fixed(11)` + /// bounded and `Fixed(4)` unbounded, so deleting the `Fixed(0)` arm left every test green + /// and it carried zero regression protection. + /// + /// REVERT-PROBE: delete the `(_, IterationCount::Fixed(0))` arm ⇒ `Fixed(0)` falls through + /// to the in-bound progress arm ⇒ this row reads `Score` / + /// `"loop_shortcut_bounded_declare_progress"` and FAILS on both assertions. + #[test] + fn loop_shortcut_bounded_declare_rejects_zero_count() { + let state = bounded_offer_state(10); + // REACH-GUARD: the zero arm sits BELOW the `!is_bounded() => na()` branch, so on an + // unbounded schema this row would measure the neutral branch instead and pass for the + // wrong reason. + assert!( + schema_of(&state).is_bounded(), + "reach-guard: the zero arm is only reachable on a BOUNDED schema" + ); + let v = verdict_for(&state, &declare(IterationCount::Fixed(0))); + assert!( + matches!(v, PolicyVerdict::Reject { .. }), + "a zero-cycle declare commits nothing while spending the response window ⇒ weakly \ + dominated by declining, got {v:?}" + ); + assert_eq!(kind_of(&v), "loop_shortcut_bounded_declare_zero_count"); + } + + /// CR 732.2a — the UNBOUNDED half of the same arm, which the row above structurally cannot + /// see because it asserts a bounded schema as its reach-guard. + /// + /// The defect this pins was real and measured: with `(_, Fixed(0))` sitting BELOW + /// `(_, Fixed(_)) if !schema.is_bounded() => na()`, a zero-count declare on an unbounded + /// offer matched the `na()` guard first and scored NEUTRAL. The domination argument does + /// not depend on boundedness — a declaration committing no cycles spends the CR 732.2b + /// response window either way — so the two schema shapes must reach the same verdict. + /// + /// Driven across every `predicted_winner` because the arm binds `_` on that axis; if the + /// reorder had accidentally been written as a winner-specific arm, only one of these three + /// would pass. + /// + /// THE `Fixed(4)` CONTRAST IS LOAD-BEARING, not decoration. A "fix" that deleted the + /// `!is_bounded() => na()` arm outright would satisfy the reject assertions above while + /// silently re-scoping every unbounded `Fixed(n)` into the bounded scoring path. Asserting + /// that a NON-zero count on the SAME state is still neutral is what distinguishes "the zero + /// arm now precedes the neutral arm" from "the neutral arm is gone". + /// + /// REVERT-PROBE: move the `(_, IterationCount::Fixed(0))` arm back below the + /// `!schema.is_bounded()` arm ⇒ `Fixed(0)` reaches `na()` ⇒ this row reads `"loop_shortcut_na"` + /// with delta `0.0` and FAILS, while every bounded row stays green. + /// + /// On the reason string: it still reads `..._bounded_declare_zero_count` although the arm + /// now covers both schema shapes. Left as-is deliberately — a `PolicyReason` kind is a + /// stable identifier that AI-gate baselines key on, so renaming it is a separate change + /// with its own blast radius, not a drive-by. + #[test] + fn loop_shortcut_unbounded_declare_rejects_zero_count() { + for predicted_winner in [None, Some(P0), Some(P1)] { + let state = offer_state(predicted_winner); + assert!( + !schema_of(&state).is_bounded(), + "REACH-GUARD: this row is about the UNBOUNDED schema — the bounded row above \ + already covers the other half; measured max_iterations {}", + schema_of(&state).max_iterations + ); + let zero = verdict_for(&state, &declare(IterationCount::Fixed(0))); + assert!( + matches!(zero, PolicyVerdict::Reject { .. }), + "a zero-cycle declare commits nothing while spending the CR 732.2b window \ + whether or not the offer is bounded (winner {predicted_winner:?}), got {zero:?}" + ); + assert_eq!(kind_of(&zero), "loop_shortcut_bounded_declare_zero_count"); + + let nonzero = verdict_for(&state, &declare(IterationCount::Fixed(4))); + assert_eq!( + kind_of(&nonzero), + "loop_shortcut_na", + "the neutral arm must survive the reorder — only ZERO is pulled ahead of it \ + (winner {predicted_winner:?})" + ); + assert_eq!(delta_of(&nonzero), 0.0); + } + } + + /// CR 732.2a — the `na()` branch asserted DIRECTLY rather than inferred from which arm + /// happened to be reached. This row and the one above assert OPPOSITE outcomes for the + /// SAME `Fixed(n)`, discriminated by ONE field of the schema: a constant-`na()` + /// implementation fails the row above, a constant-`Score` implementation fails this one. + /// + /// REVERT-PROBES: ⓟ3 (invert `is_bounded()` to `>=`) ⇒ the default schema reads bounded ⇒ + /// this row gets `Score` / `"…_progress"` ⇒ FAILS. ⓟ4 delete the + /// `!schema.is_bounded() => na()` branch ⇒ same failure, and `declare_fixed_is_never_rejected` + /// fails with it. + #[test] + fn loop_shortcut_unbounded_offer_keeps_fixed_neutral() { + for predicted_winner in [None, Some(P0), Some(P1)] { + let state = offer_state(predicted_winner); + assert!( + !schema_of(&state).is_bounded(), + "REACH-GUARD: this row asserts the UNBOUNDED branch, so the default schema must \ + read unbounded — `ShortcutDecisionSchema::default()` carries \ + `max_iterations == MAX_SHORTCUT_CYCLES`; measured {}", + schema_of(&state).max_iterations + ); + let v = verdict_for(&state, &declare(IterationCount::Fixed(4))); + assert_eq!( + delta_of(&v), + 0.0, + "an offer stating NO CR 704 bound gives the domination argument nothing to \ + stand on, so `Fixed(n)` is deliberately neutral (winner {predicted_winner:?})" + ); + assert_eq!(kind_of(&v), "loop_shortcut_na"); + } + } + /// E2E, HEURISTIC branch (VeryEasy: `search.enabled == false` ⇒ the tactical score is added /// RAW). Without the policy the class-bonus table makes Declare (0.5) beat Decline (0.4) in /// every state; with it the `Reject` drives Declare's softmax weight to `exp(-inf/T) == 0`, so diff --git a/crates/phase-ai/src/projection.rs b/crates/phase-ai/src/projection.rs index 743de5f16e..09196587b1 100644 --- a/crates/phase-ai/src/projection.rs +++ b/crates/phase-ai/src/projection.rs @@ -645,6 +645,7 @@ mod tests { win_kind: engine::analysis::loop_check::WinKind::LethalDamage, mandatory: false, residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: None, }, schema: engine::analysis::decision_template::ShortcutDecisionSchema::default(), }; diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index b67755584b..98f9270b6f 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -4852,6 +4852,7 @@ mod tests { win_kind: engine::analysis::loop_check::WinKind::LethalDamage, mandatory: false, residual_board_delta: engine::analysis::resource::BoardDelta::default(), + per_cycle: None, }, schema: engine::analysis::decision_template::ShortcutDecisionSchema::default(), }; diff --git a/scripts/lib/trigger-firing.jq b/scripts/lib/trigger-firing.jq new file mode 100644 index 0000000000..917b662635 --- /dev/null +++ b/scripts/lib/trigger-firing.jq @@ -0,0 +1,199 @@ +# CR 603.7 firing carriers for a persisted game dump (upstream #6842, 8121fd1c6). +# +# SINGLE DEFINITION of the derivation. Both the pristine regeneration path and +# the in-place stamping path load THIS file, so neither can certify its own copy +# of the recipe. +# +# #6842 made a `TriggerFiring` carrier MANDATORY on every persisted triggered +# record and fails CLOSED without one, so a dump captured before that commit +# cannot load at all. The read-only pristine root predates it too (captured +# 2026-07-22/25), so the value cannot be recovered by re-reading the dump — it +# must be DERIVED, per record. +# +# `TriggerFiring::UnknownLegacy` is NOT an escape hatch: `validate_firing` +# rejects it for a live carrier ("has no canonical trigger firing +# discriminator") because it is the field-absent marker (`skip_serializing_if`) +# and the redaction default, never a legal persisted value. +# +# THE DISCRIMINANT, CR 603.1 vs CR 603.7a: +# ORDINARY <= the fired trigger's definition is present on its SOURCE OBJECT's +# own `trigger_definitions` / `base_trigger_definitions`, matched +# by exact `description`. A printed or granted triggered ability of +# a permanent is an ordinary triggered ability. +# DELAYED <= the trigger has an install receipt in `delayed_triggers`. Every +# dump in this corpus records `delayed_triggers: []` and no install +# journal, so `Delayed(Some(..))` could not validate regardless — +# `validate_firing` demands a registered install root. +# Anything else ABORTS by name. There is deliberately no fallback stamp: a wrong +# carrier silently re-classifies a CR 603.7 firing identity, which is exactly the +# inference upstream refuses to make. +# +# `stack_trigger_firings` is keyed by the STACK ENTRY id — what +# `validate_trigger_firing_coherence` looks up — not by the source id. + +# The two definition lists have DIFFERENT serialized shapes, and reading one field name +# across both silently drops a whole list: +# `trigger_definitions` is `Definitions`, and `TriggerEntry` is +# `{occurrence, definition}` — the text lives at +# `.definition.description` +# (`crates/engine/src/types/ability.rs`). +# `base_trigger_definitions` is `Arc>` — text at `.description`. +# +# MEASURED on the committed corpus: of the `trigger_definitions` entries, 0 expose a +# DIRECT `.description` and 100% (145 / 165 / 132 on dellian / dina / witherbloom) nest it +# under `.definition`. The struck form read `.description` on both lists, so every LIVE +# entry collapsed to the `// ""` fallback and that list contributed nothing — all 172 +# carriers matched through `base_trigger_definitions` alone. "Every carrier resolved" was +# therefore true but not evidence that this read was right. +# +# The gap is REACHABLE, not theoretical: `dellian_emblem_conqueror_4p` carries a GRANTED +# trigger ("When ~ dies, you gain 1 life.") that is present in the live list and ABSENT +# from the base list. A firing whose description existed only there would have aborted the +# entire stamp with `UNDETERMINED firing carrier` on a fixture that is in fact classifiable. +# +# Read each entry by its OWN shape rather than assuming one: `.definition.description` +# first, then `.description`. Empty strings are dropped so a shape this does not +# understand cannot match a description that is itself empty — an unrecognised entry must +# reach the `UNDETERMINED` abort, never satisfy a lookup by accident. +def _defs($objs; $src): + (($objs[($src|tostring)] // {}) + | (.trigger_definitions // []) + (.base_trigger_definitions // [])) + | map((.definition.description // .description) // "") + | map(select(. != "")); + +def _firing($objs; $src; $d): + if ((_defs($objs; $src)) | index($d)) then "Ordinary" + else error("UNDETERMINED firing carrier: source=\($src) description=\($d)") + end; + +# How many carriers this dump actually needs. 0 means the dump records no +# triggered pending/stack/resolving entry at all, so stamping it is a NO-OP and +# any "the bytes changed" control arm over it would be reporting jq +# re-serialization, not a stamp. +def trigger_carrier_count: + (if ((.gameState.pending_trigger // null) != null) then 1 else 0 end) + + ([ (.gameState.stack // [])[] | select(.kind.type == "TriggeredAbility") ] | length) + + (if (((.gameState.resolving_stack_entry // .gameState.resolving_trigger).kind.type? // "") + == "TriggeredAbility") then 1 else 0 end); + +# Pass a dump that is not `gameState`-shaped straight through. Several fixtures +# in this corpus are stored in a different envelope (top level `turn_number`, +# not `gameState`); without this guard `.gameState |= ...` would CREATE a +# gameState key on them, i.e. corrupt them. +# CR 603.7 delayed-trigger ALLOCATORS — a second field class #6842 repairs at +# load time, and only on ONE of the two decode paths. +# +# `next_delayed_trigger_token` carries `#[serde(default)]`, so a pre-#6842 dump +# that omits it restores as 0 through a bare `GameState` decode. The production +# `PersistedGameState` path instead runs the load-time migration +# next = max(existing // 1, (max used token) + 1) +# and restores 1. The two paths therefore disagree on a legacy dump, and 0 is +# invalid on its face: `validate_trigger_firing_coherence` rejects +# `next_delayed_trigger_token <= max_token`, and `max_token` is 0 when there are +# no install roots. Stamping the repaired value on disk makes the fixture look +# like a modern capture, which keeps the two decoders in agreement WITHOUT +# relaxing the assertion, and survives the eventual deletion of the shim. +# +# The GENERAL derivation of the used-token set is ENGINE logic — it walks +# `resolved_rules_journal` install commands and `delayed_triggers` provenance, +# with reuse and nonzero checks. Re-deriving that here would repeat exactly the +# mistake `migrate-dump-fixture.sh` refuses to make for `EffectKind`. So this +# stamps ONLY the case where the formula collapses to a constant — no install +# roots at all, so both used sets are empty and the result is +# `max(existing // 1, 1)` — and ABORTS BY NAME otherwise, leaving the general +# case to the engine. +def stamp_delayed_allocators: + if (.gameState // null) == null then . else + # `.command` is only indexable when it is an OBJECT. serde writes an externally + # tagged UNIT variant as a bare JSON string, and `select(.command.X)` on a string + # aborts jq with `Cannot index string with "DelayedTriggerInstall"`. That is not a + # named abort: this file's contract is that an undetermined case aborts BY NAME, and + # a raw type error leaves the operator unable to tell a shape it does not understand + # from a real install root. Filter to objects first so the probe stays total. + ([ (.gameState.resolved_rules_journal.entries // [])[] + | select((.command | objects | has("DelayedTriggerInstall")) // false) ] | length) as $installs + | ((.gameState.delayed_triggers // []) | length) as $delayed + | if ($installs > 0 or $delayed > 0) + then error("UNDETERMINED delayed-trigger allocators: \($installs) install command(s), \($delayed) delayed trigger(s) — deriving the used-token set is engine logic, not jq's") + else .gameState.next_delayed_trigger_token + = ([(.gameState.next_delayed_trigger_token // 1), 1] | max) + | .gameState.next_delayed_trigger_instance + = ([(.gameState.next_delayed_trigger_instance // 1), 1] | max) + end + end; + +# DERIVES ONLY WHERE NO CARRIER EXISTS. Never rewrites one that is already there. +# +# The struck form assigned all three keys unconditionally, without reading them. That +# made `stamp-fixture-firing.sh`'s header claim — in-place stamping "is additive and +# cannot revert anything" — false for exactly these keys, and arm 1 cannot catch it +# because it deletes all five stamped keys from both sides before comparing. +# +# The damage is silent and is the one inference this file exists to refuse. A fixture +# already carrying a canonical `{"Delayed": {...}}` (a modern capture, or an +# engine-side migration) has two ways to lose it: +# * the delayed trigger's description is absent from the source object's definitions, +# so `_firing` ABORTS the whole stamp on a fixture that was already correct; or +# * the description IS present there, so the carrier is silently rewritten to +# "Ordinary" — a CR 603.7a delayed firing re-classified as a CR 603.1 ordinary one, +# with no diagnostic. That is precisely the silent re-classification this file's +# header forbids. +# +# Deriving only into an ABSENT slot makes the "additive" claim true, and makes the whole +# stamp idempotent: re-running it over already-stamped fixtures is now a no-op rather +# than a re-derivation that has to agree with itself. +def stamp_trigger_firing: + if (.gameState // null) == null then . else + .gameState.objects as $objs + | (.gameState.resolving_stack_entry // .gameState.resolving_trigger) as $rt + # Existing carriers are preserved, but ONLY for stack entries that are still there. + # A key naming an entry that has left the stack is stale, and carrying it forward + # would inflate `stack_trigger_firings` past the number of triggered records — which + # is also the one shape that could defeat `stamp-fixture-firing.sh`'s arm 2, since + # that arm compares carrier TOTALS and a surplus here could cancel a deficit + # elsewhere. Scoping preservation to live entries keeps "preserve what is canonical" + # from becoming "accumulate whatever was there". + | ([ (.gameState.stack // [])[] + | select(.kind.type == "TriggeredAbility") | (.id|tostring) ]) as $live_ids + | ((.gameState.stack_trigger_firings // {}) + | with_entries(select(.key as $k | $live_ids | index($k)))) as $sf_existing + | .gameState |= ( + (if ((.pending_trigger // null) != null + and (.pending_trigger_firing // null) == null) + then .pending_trigger_firing = + _firing($objs; .pending_trigger.source_id; .pending_trigger.description) + else . end) + # Assign whenever the REBUILT map differs from what is on the fixture — not merely + # when new carriers were derived. Gating on `($sf | length) > 0` dropped the prune + # above on the floor in exactly the case the prune exists for: a fixture whose only + # change is that an entry LEFT the stack derives no new carrier, so `$sf` is empty + # and the stale key survived into the written fixture. Comparing against the current + # map keeps this idempotent (a re-run writes nothing) and keeps it from touching + # trigger-free fixtures (an absent slot and a rebuilt `{}` compare equal under + # `// {}`), while still writing `{}` when every carrier the fixture had went stale. + | (([ (.stack // [])[] + | select(.kind.type == "TriggeredAbility") + # BIND THE KEY FIRST. `$sf_existing | has((.id|tostring))` looks like it asks + # "is this entry already carried?", but jq evaluates a function argument against + # the INPUT of the pipe it sits in — here `$sf_existing`, not the stack entry. + # `.id` is absent on that object, so the argument was the literal string "null", + # `has` was ALWAYS false, and every live entry was re-derived and then allowed to + # override the canonical marker through `$sf_existing + $sf`. That silently + # rewrote `Delayed` to `Ordinary` — the CR 603.7a to CR 603.1 re-classification + # this file's header exists to refuse. + | (.id|tostring) as $k + | select(($sf_existing | has($k)) | not) + | {key: $k, + value: _firing($objs; .kind.data.source_id; .kind.data.description)} ] + | from_entries) as $sf + | ($sf_existing + $sf) as $sf_rebuilt + | if $sf_rebuilt != (.stack_trigger_firings // {}) + then .stack_trigger_firings = $sf_rebuilt + else . end) + | (if ($rt != null and ($rt.kind.type? // "") == "TriggeredAbility" + and (.resolving_trigger_firing // null) == null) + then .resolving_trigger_firing = + _firing($objs; $rt.kind.data.source_id; $rt.kind.data.description) + else . end) + ) + end; diff --git a/scripts/migrate-dump-fixture.sh b/scripts/migrate-dump-fixture.sh new file mode 100755 index 0000000000..1d2dd83700 --- /dev/null +++ b/scripts/migrate-dump-fixture.sh @@ -0,0 +1,455 @@ +#!/usr/bin/env bash +# Regenerates a saved-game test fixture under crates/engine/tests/fixtures/ from +# its READ-ONLY pristine dump, stamping the `effect_kind` field that upstream +# #6718 (0468df1f4) added to `TargetSelectionSlot` without `#[serde(default)]`. +# +# WHY A REGENERATION AND NOT A SERDE SHIM. The maintainer publicly declined both +# `#[serde(default)]` and an upstream save migration for this field +# (https://github.com/phase-rs/phase/pull/6718#issuecomment-5111207689 — "alpha +# means it may not load ... if you have a use-case then do the save changes +# locally"). Migrating the fixture locally is the maintainer's own named path, +# and it keeps the production decoder STRICT: an un-migrated save must still be +# rejected, which a serde default would silently prevent. +# +# WHY `--effect-kind` IS AN EXPLICIT ARGUMENT and never a jq name->variant table: +# such a table would re-derive `impl From<&Effect> for EffectKind` in jq, and +# that mapping is not the identity (`Effect::SetTapState` fans out to several +# kinds). The ENGINE stays the authority for the migrated value; the reading +# test beside `load_dellian_dump` in `game/engine.rs` asserts the stamped slots +# equal what `ability_utils::build_target_slots` builds for that board, so a +# wrong `--effect-kind` argument fails a tracked row rather than shipping. +# +# The pristine directory is READ-ONLY: this script only ever reads from it. +# +# Usage: +# scripts/migrate-dump-fixture.sh \ +# --pristine /path/to/dump.zip \ +# --expect-sha256 \ +# --effect-kind LoseLife \ +# --out crates/engine/tests/fixtures/name.json.gz +# +# # Control mode: re-run the FULL recipe and check it against the committed +# # fixture, then check the patch had teeth. Runnable by anyone, at any time, +# # with no engine build. +# scripts/migrate-dump-fixture.sh --pristine ... --expect-sha256 ... \ +# --effect-kind LoseLife \ +# --out crates/engine/tests/fixtures/name.json.gz --control +# +# BOTH control arms matter, and a one-arm check passes vacuously: +# arm 1 => BYTE_IDENTICAL=true the PATCHED regeneration reproduces the +# committed fixture byte for byte, so the committed bytes are exactly +# what this recipe produces from the read-only pristine dump. +# arm 2 => PATCHED_DIFFERS=true the same recipe run WITHOUT the patch differs +# from arm 1's output, so the jq filter actually REACHES target_slots. +# Without this arm, a filter that silently matched nothing would also +# report BYTE_IDENTICAL=true. +# +# ⚠ ARM 1 IS BASELINED ON THE MIGRATED FIXTURE, and it has to be. The committed +# fixture IS the patched artifact; comparing an UNPATCHED regeneration against it +# fails by construction post-migration (measured: BYTE_IDENTICAL=false, exit 1), +# which reads as "the fixture is corrupt" when it means "migrated, as designed". +# So the unpatched regeneration is arm 2's operand, never arm 1's expectation. +# +# TOOLCHAIN. Byte-identity is toolchain-coupled: gzip's deflate output and jq's +# key ordering are implementation details, not standards. The recipe below was +# established under the pinned versions; on any other version the control falls +# back to a canonical `jq -S` content comparison, which is toolchain-independent +# and still discriminating (it just cannot certify byte equality). + +set -euo pipefail + +PINNED_JQ="jq-1.7.1" +PINNED_GZIP="gzip 1.14" + +PRISTINE="" +EXPECT_SHA="" +EFFECT_KIND="" +OUT="" +CONTROL_MODE=0 + +usage() { + sed -n '2,57p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit "${1:-1}" +} + +while [ $# -gt 0 ]; do + case "$1" in + --pristine) PRISTINE="${2:?--pristine needs a path}"; shift 2 ;; + --expect-sha256) EXPECT_SHA="${2:?--expect-sha256 needs a hash}"; shift 2 ;; + --effect-kind) EFFECT_KIND="${2:?--effect-kind needs an EffectKind variant name}"; shift 2 ;; + --out) OUT="${2:?--out needs a path}"; shift 2 ;; + --control) CONTROL_MODE=1; shift ;; + -h|--help) usage 0 ;; + *) echo "unknown argument: $1" >&2; usage 1 ;; + esac +done + +[ -n "$PRISTINE" ] || { echo "missing --pristine" >&2; exit 1; } +[ -n "$EXPECT_SHA" ] || { echo "missing --expect-sha256" >&2; exit 1; } +[ -n "$OUT" ] || { echo "missing --out" >&2; exit 1; } +# Control mode needs --effect-kind too: arm 1 re-runs the FULL recipe, patch +# included, because the committed fixture is the patched artifact. +[ -n "$EFFECT_KIND" ] || { echo "missing --effect-kind" >&2; exit 1; } + +for tool in unzip jq gzip sha256sum; do + command -v "$tool" >/dev/null 2>&1 || { echo "required tool not found: $tool" >&2; exit 1; } +done + +# 1. Verify the pristine input. Abort rather than migrate an unexpected dump — +# fixture<->dump correspondence is by CONTENT, never by filename (the name +# trap is real: witherbloom-sprout-lumaret-works-slow.zip maps to +# witherbloom_sprout_lumaret_SIMPLE_4p.json.gz). +ACTUAL_SHA="$(sha256sum "$PRISTINE" | cut -d' ' -f1)" +if [ "$ACTUAL_SHA" != "$EXPECT_SHA" ]; then + echo "pristine sha256 mismatch for $PRISTINE" >&2 + echo " expected: $EXPECT_SHA" >&2 + echo " actual: $ACTUAL_SHA" >&2 + exit 1 +fi + +JQ_VERSION="$(jq --version)" +GZIP_VERSION="$(gzip --version | head -1)" + +# 2. Patch + 3. compress. ONE filter, applied to every slot in the prompt. +# ONE definition of the recipe, used by BOTH the migration and the control — +# a control that re-spelled the recipe would certify its own copy. +# +# STAGE 2b — CR 603.7 firing carriers (upstream #6842, 8121fd1c6). +# The derivation lives in ONE place, scripts/lib/trigger-firing.jq, loaded by +# both this script's pristine path and its --in-place path. See that file for +# the CR 603.1 vs CR 603.7a discriminant and why UnknownLegacy is not legal. +FIRING_LIB="$(dirname "${BASH_SOURCE[0]}")/lib/trigger-firing.jq" +[ -f "$FIRING_LIB" ] || { echo "missing $FIRING_LIB" >&2; exit 1; } + +# The final projection PRESERVES a non-`gameState` envelope instead of replacing it. +# +# `{gameState:.gameState}` is a REWRITE, not a projection, for any dump that is not +# `gameState`-shaped: `.gameState` is null on those, so the whole document became +# `{"gameState":null}` — the committed fixture destroyed and replaced by a one-key +# husk. That contradicted the pass-through `trigger-firing.jq` already implements for +# its own stages, and it is silent: the output is valid JSON, so nothing downstream +# objects. Several fixtures in this corpus really do use the other envelope (top level +# `turn_number`), which is why that guard exists in the first place. +# +# Keyed on PRESENCE, not on truthiness — a dump carrying an explicitly null +# `gameState` is malformed and must not be quietly normalised into the husk shape. +PROJECT='if (type == "object" and has("gameState")) then {gameState:.gameState} else . end' + +# Applies the recipe to stdin, writing to stdout. ONE definition of the transform, so +# the migration, the control arms, and the self-tests cannot drift apart. +transform() { # transform + local mode="$1" filter="$PROJECT" + if [ "$mode" = patched ]; then + filter="(if (.gameState.waiting_for.data.target_slots? // null) != null + then .gameState.waiting_for.data.target_slots |= map(. + {effect_kind: \$k}) + else . end) | stamp_trigger_firing | stamp_delayed_allocators | $PROJECT" + fi + jq -c --arg k "$EFFECT_KIND" -f <(printf '%s\n%s\n' "$(cat "$FIRING_LIB")" "$filter") +} + +# The `effect_kind` stage applies only to a dump whose prompt actually carries +# `target_slots`. Several dumps in this corpus are paused at a beat with no target +# prompt at all; for them this stage is vacuously absent, and `--effect-kind` is +# inert. Guarding it (rather than letting `map` abort on null) is what lets ONE +# recipe cover the whole corpus — an unguarded `|=` here made the script usable only +# on the two dumps that happen to have a prompt, which is why the other four were +# never regenerable through it. +# +# WRITES ATOMICALLY: stage to a temp file, `mv` only after the WHOLE pipeline +# succeeded. +# +# The struck form redirected the pipeline straight into `$dest`. The shell creates +# and TRUNCATES a redirection target before the first command in the pipeline runs, +# and on the production path `$dest` is the committed fixture (`$OUT`). The recipe +# aborts BY DESIGN — `_firing` raises `UNDETERMINED firing carrier` and +# `stamp_delayed_allocators` raises `UNDETERMINED delayed-trigger allocators` — so +# `set -e` / `pipefail` stopped the script only AFTER the fixture had already been +# truncated and a partial gzip stream written over it. The failure mode of a +# fail-closed recipe was destruction of the very artifact it refused to rewrite. +# `stamp-fixture-firing.sh` already had the right shape; this matches it. +# SINGLE DEFINITION of the staging rule, because the previous code had three copies of +# it and they drifted apart in the one way that mattered. +# +# The stage file MUST live in `dirname "$dest"`. `mv` is atomic only WITHIN a filesystem; +# across a boundary it degrades to copy-then-unlink, and an interruption mid-copy leaves +# `$dest` truncated — the exact destruction this staging exists to prevent. `mktemp -t` +# resolves to `$TMPDIR` (`/tmp` here, a separate mount from the checkout: measured +# `df --output=target` gives `/tmp` vs `/home`), so the previous form ADVERTISED atomicity +# it could not deliver, and elsewhere depended silently on the operator's mount layout. +# +# The self-tests below call this too. They used to re-spell the recipe with their own +# `mktemp -t`, which is why a self-test whose whole subject is atomicity could still pass +# against a non-atomic production path: it was exercising its own copy, and its `$tmp` and +# its destination happened to share a filesystem. A control that re-implements the thing +# it controls is not a control. +stage_path() { # stage_path — a stage file on $destination's OWN filesystem + mktemp "$(dirname "$1")/.migrate-dump-stage-XXXXXX.json.gz" +} + +# Stage files live BESIDE their destination (stage_path, for mv atomicity), and on the +# production path that directory is the tracked `crates/engine/tests/fixtures/`. A signal +# during the unzip|transform|gzip pipeline, or a failed `mv`, would otherwise strand a +# `.migrate-dump-stage-XXXXXX.json.gz` inside version control. The trap covers what the +# explicit `rm -f` cannot: death before the next statement runs. +STAGE_FILES="" +cleanup_stage_files() { + [ -n "$STAGE_FILES" ] || return 0 + # shellcheck disable=SC2086 # deliberate word-splitting over the staged-path list + rm -f $STAGE_FILES + STAGE_FILES="" +} +trap cleanup_stage_files EXIT INT TERM + +regenerate() { # regenerate + local mode="$1" dest="$2" staged + mkdir -p "$(dirname "$dest")" + staged="$(stage_path "$dest")" + STAGE_FILES="$STAGE_FILES $staged" + # CALL-SITE guard. The a0 self-test proves `stage_path` RETURNS a beside-destination + # path; it cannot see whether this line still calls it. Reverting only this binding to + # `mktemp -t` leaves the helper and the self-test intact and green while restoring the + # non-atomic write — the same blind spot the sibling stamper carries, fixed at both + # sites so the class is closed rather than one instance of it. + if [ "$(dirname "$staged")" != "$(dirname "$dest")" ]; then + echo "stage not beside destination: $staged vs $dest — mv would not be atomic" >&2 + rm -f "$staged" + return 1 + fi + if ! unzip -p "$PRISTINE" | transform "$mode" | gzip -9 -n > "$staged"; then + rm -f "$staged" + echo "REGENERATION FAILED (fail-closed, $dest left untouched)" >&2 + return 1 + fi + # A failed `mv` leaves `$staged` in place; the trap reaps it on exit. + mv "$staged" "$dest" || return 1 +} + +# PRE-FLIGHT SELF-TESTS for the two properties that no corpus fixture can witness, +# because both are about what happens to inputs this corpus does not contain. +# +# They run before anything is written, on synthetic inputs, through the SAME +# `transform` the migration uses — a self-test that re-spelled the recipe would be +# certifying its own copy. +# +# (a) FAILURE — when the recipe aborts, the destination must be left EXACTLY as it +# was. Asserted byte-wise against a sentinel, because "the file still +# exists" is not the claim; "the file is unchanged" is. +# (b) PASS-THROUGH — a non-`gameState` envelope must survive the projection +# unchanged, not become `{"gameState":null}`. +# +# Each has a paired POSITIVE control, or it would pass against a transform that did +# nothing at all. +selftests() { + local tmp sentinel out rc probe_stage + tmp="$(mktemp -d -t migrate-dump-selftest-XXXXXX)" + + # (a0) THE MECHANISM ITSELF: the stage file must be minted in the destination's OWN + # directory. Arms (a)/(a') below exercise the failure and success PATHS, and they pass + # under a non-atomic staging just as happily — their destination lives under + # `mktemp -d -t`, so a `stage_path` reverted to `mktemp -t` puts stage and destination + # on the same filesystem (measured: both device 50 here) and the `mv` is atomic BY + # ACCIDENT of where the test put its own files. That is a second, independent reason + # the original control could not fail on its subject, beyond the three-copies one: + # even one shared recipe would have been checked on a layout that cannot expose it. + # + # Compares DIRECTORIES, not devices. Device equality is the property that makes `mv` + # atomic, but it does NOT discriminate here: under a `-t` revert the stage lands in + # `/tmp` and this test's destination lives in a SUBDIRECTORY of `/tmp`, so the two + # still share a device and the check would pass. Same-directory is strictly stronger + # and is what actually flips. + probe_stage="$(stage_path "$tmp/dest")" + if [ "$(dirname "$probe_stage")" != "$(dirname "$tmp/dest")" ]; then + echo "SELFTEST STAGE_BESIDE_DEST=false — stage $(dirname "$probe_stage") vs dest $(dirname "$tmp/dest")" >&2 + echo " a cross-directory stage makes \`mv\` non-atomic whenever the two differ in filesystem" >&2 + rm -f "$probe_stage"; rm -rf "$tmp"; return 1 + fi + rm -f "$probe_stage" + + # (a) FAILURE leaves the destination untouched. + # `stamp_delayed_allocators` aborts by name on a dump with install roots it cannot + # collapse, which is the real abort shape, reached through the real recipe. + printf '%s' 'COMMITTED-FIXTURE-SENTINEL' > "$tmp/dest" + sentinel="$(sha256sum "$tmp/dest" | cut -d' ' -f1)" + printf '%s\n' '{"gameState":{"delayed_triggers":[],"next_delayed_trigger_token":0, + "resolved_rules_journal":{"entries":[{"command":{"DelayedTriggerInstall":{}}}]}}}' \ + > "$tmp/in.json" + set +e + # Same staged-write discipline as `regenerate`; the point is that `$tmp/dest` is + # never the redirection target, so an abort cannot reach it. + ( staged="$(stage_path "$tmp/dest")" + if ! transform patched < "$tmp/in.json" | gzip -9 -n > "$staged"; then + rm -f "$staged"; exit 1 + fi + mv "$staged" "$tmp/dest" ) >/dev/null 2>&1 + rc=$? + set -e + if [ "$rc" -eq 0 ]; then + echo "SELFTEST ATOMIC_ON_FAILURE=inconclusive — the abort input did not abort; the row cannot certify atomicity" >&2 + rm -rf "$tmp"; return 1 + fi + if [ "$(sha256sum "$tmp/dest" | cut -d' ' -f1)" != "$sentinel" ]; then + echo "SELFTEST ATOMIC_ON_FAILURE=false — a failed regeneration modified its destination" >&2 + rm -rf "$tmp"; return 1 + fi + + # (a′) POSITIVE control — a SUCCEEDING run must actually replace the destination, + # or (a) would pass simply because nothing ever writes. + printf '%s\n' '{"gameState":{"turn_number":7}}' > "$tmp/ok.json" + ( staged="$(stage_path "$tmp/dest")" + transform patched < "$tmp/ok.json" | gzip -9 -n > "$staged" + mv "$staged" "$tmp/dest" ) >/dev/null 2>&1 + if [ "$(sha256sum "$tmp/dest" | cut -d' ' -f1)" = "$sentinel" ]; then + echo "SELFTEST ATOMIC_ON_FAILURE=vacuous — a SUCCESSFUL run also left the destination unchanged" >&2 + rm -rf "$tmp"; return 1 + fi + + # (b) PASS-THROUGH — the other envelope in this corpus (top level `turn_number`). + printf '%s\n' '{"turn_number":7,"players":[]}' > "$tmp/env.json" + out="$(transform patched < "$tmp/env.json")" + if [ "$(printf '%s' "$out" | jq -S -c .)" != "$(jq -S -c . "$tmp/env.json")" ]; then + echo "SELFTEST ENVELOPE_PRESERVED=false — a non-gameState dump was rewritten: $out" >&2 + rm -rf "$tmp"; return 1 + fi + + # (b′) POSITIVE control — a `gameState` dump IS still projected, so (b) is not + # passing because the transform became a no-op for everything. + if [ "$(transform patched < "$tmp/ok.json" | jq -c 'has("gameState")')" != "true" ]; then + echo "SELFTEST ENVELOPE_PRESERVED=vacuous — the gameState projection stopped working" >&2 + rm -rf "$tmp"; return 1 + fi + + echo "SELFTEST ATOMIC_ON_FAILURE=true ENVELOPE_PRESERVED=true (both with positive controls)" + rm -rf "$tmp" +} + +selftests || exit 1 + +if [ "$CONTROL_MODE" -eq 1 ]; then + # 5. Control mode, TWO arms, both mandatory. Runnable by anyone, at any time, + # with no engine build. Arm 1 re-runs the full recipe and holds it against + # the committed fixture; arm 2 re-runs it WITHOUT the patch and requires the + # result to differ, which is what proves the patch filter has teeth. + [ -f "$OUT" ] || { echo "control mode needs an existing committed fixture at $OUT" >&2; exit 1; } + PATCHED="$(mktemp -t migrate-dump-patched-XXXXXX.json.gz)" + UNPATCHED="$(mktemp -t migrate-dump-unpatched-XXXXXX.json.gz)" + # COMPOSE, do not replace. `trap` is last-write-wins PER SIGNAL, so a bare + # `trap '...' EXIT` here would silently disarm the `cleanup_stage_files` EXIT handler + # installed above and leak a stage file on this path — the one path that regenerates + # twice. (INT/TERM keep their handler either way, which is what made the omission easy + # to miss: only the EXIT arm was disarmed.) + trap 'cleanup_stage_files; rm -f "$PATCHED" "$UNPATCHED"' EXIT + regenerate patched "$PATCHED" + regenerate unpatched "$UNPATCHED" + + echo "CONTROL pristine=$(basename "$PRISTINE") sha256=$ACTUAL_SHA" + echo "CONTROL effect_kind=$EFFECT_KIND out=$OUT" + echo "CONTROL jq=$JQ_VERSION gzip=$GZIP_VERSION" + + # ARM 1 — the patched regeneration reproduces the committed fixture. + case "$JQ_VERSION:$GZIP_VERSION" in + "$PINNED_JQ:$PINNED_GZIP"*) + if cmp -s "$PATCHED" "$OUT"; then + echo "CONTROL BYTE_IDENTICAL=true" + else + echo "CONTROL BYTE_IDENTICAL=false" >&2 + exit 1 + fi + ;; + *) + # Toolchain drift: byte equality is not certifiable, but content equality + # is, and it still catches a recipe that reads the wrong dump. + echo "CONTROL toolchain differs from pinned ($PINNED_JQ / $PINNED_GZIP) — falling back to canonical content comparison" + if [ "$(gzip -dc "$PATCHED" | jq -S -c .)" = "$(gzip -dc "$OUT" | jq -S -c .)" ]; then + echo "CONTROL CANONICALLY_EQUAL=true BYTE_IDENTICAL=unknown" + else + echo "CONTROL CANONICALLY_EQUAL=false" >&2 + exit 1 + fi + ;; + esac + + # ARM 2 — the `effect_kind` patch reached `target_slots`. + # + # COMPARES THE `target_slots` PROJECTION, not the whole document. + # + # The struck form compared the two documents wholesale and required a difference. + # That inference died when stage 2b landed: the patched filter also runs + # `stamp_trigger_firing` and `stamp_delayed_allocators`, and the allocator stage + # rewrites `next_delayed_trigger_token` / `..._instance` on EVERY `gameState`-shaped + # dump in this corpus (measured: all six move from absent/0 to 1). The unpatched + # filter runs neither stage. So the documents differed unconditionally, including on + # the dumps that carry no target prompt at all — arm 2 reported `PATCHED_DIFFERS=true` + # while the `effect_kind` filter had matched NOTHING. That is precisely the vacuous + # pass this arm exists to prevent, so it was reporting the opposite of its claim. + # + # The no-prompt case is now NAMED rather than counted as a pass: it is a real and + # expected shape here, but arm 2 cannot certify the `effect_kind` stage from it, and + # saying so is the honest reading. + SLOTS_P="$(gzip -dc "$PATCHED" | jq -S -c '[.gameState.waiting_for.data.target_slots[]?]')" + SLOTS_U="$(gzip -dc "$UNPATCHED" | jq -S -c '[.gameState.waiting_for.data.target_slots[]?]')" + if [ "$SLOTS_P" = "[]" ] && [ "$SLOTS_U" = "[]" ]; then + echo "CONTROL PATCHED_DIFFERS=n/a — this dump carries no target_slots, so the effect_kind stage is vacuously absent and arm 2 cannot certify it (arm 1 and the stage-2b arms still apply)" + elif [ "$SLOTS_P" = "$SLOTS_U" ]; then + echo "CONTROL PATCHED_DIFFERS=false — the effect_kind filter matched nothing; arm 1 above would pass vacuously" >&2 + exit 1 + else + echo "CONTROL PATCHED_DIFFERS=true stamped=$(gzip -dc "$PATCHED" | jq -c '[.gameState.waiting_for.data.target_slots[]?.effect_kind]') unpatched=$(gzip -dc "$UNPATCHED" | jq -c '[.gameState.waiting_for.data.target_slots[]?.effect_kind]')" + fi + + # ARM 3 — stage 2b landed: the firing carriers and the allocators are present and + # canonical in the patched regeneration. This is what actually distinguishes patched + # from unpatched on a no-prompt dump, and arm 2 above deliberately no longer claims it. + # + # MUST COMPARE AGAINST THE UNPATCHED REGENERATION. Asserting only that the patched + # side is canonical is vacuous on any pristine dump that ALREADY carries allocators + # >= 1 and its carriers: the arm would report `true` while stage 2b changed nothing. + # That is the same vacuity arm 2 was corrected for — a control that cannot tell "the + # stage landed" from "it was already there" certifies nothing. When the two sides + # agree, this arm SKIPS LOUDLY as `n/a` rather than claiming a landing it cannot see. + # The signature must be built from the fields stage 2b WRITES, not from the ones it + # reads. `trigger_carrier_count` counts the dump's NEED (pending_trigger, triggered + # stack entries, resolving_stack_entry) — inputs neither transform touches, so it is + # identical on both sides by construction and contributes nothing to the comparison. + # Keying on the STAMPED carriers is what makes the carrier half of this arm able to + # move at all. + # + # RESIDUAL, stated so the arm is not read as more than it is: the comparison is one + # equality over the COMBINED signature, so any differing term alone yields `true`. On + # the common corpus shape — allocators repaired 0 -> 1 — a carrier-stamp regression is + # still masked by the allocator half (measured: stamp disabled, allocators moving, + # arm reports LANDED=true). What the printed signature gives you is self-disclosure: + # `s:0` on both sides says the carrier half did not move, whatever the verdict. + alloc_sig() { # alloc_sig — the stage-2b observable: stamped carriers + allocators + gzip -dc "$1" | jq -c '{p: .gameState.pending_trigger_firing, + s: (.gameState.stack_trigger_firings // {} | length), + r: .gameState.resolving_trigger_firing, + t: (.gameState.next_delayed_trigger_token // 0), + i: (.gameState.next_delayed_trigger_instance // 0)}' + } + STAGE2B_P="$(alloc_sig "$PATCHED")" + STAGE2B_U="$(alloc_sig "$UNPATCHED")" + if [ "$(gzip -dc "$PATCHED" | jq -c 'if (.gameState // null) == null then "n/a" + elif (((.gameState.next_delayed_trigger_token // 0) >= 1) + and ((.gameState.next_delayed_trigger_instance // 0) >= 1)) + then "true" else "false" end')" = '"false"' ]; then + echo "CONTROL STAGE_2B_LANDED=false — the allocator repair did not reach the regeneration" >&2 + exit 1 + fi + if [ "$STAGE2B_P" = "$STAGE2B_U" ]; then + echo "CONTROL STAGE_2B_LANDED=n/a — the unpatched regeneration already carries $STAGE2B_U, so this arm cannot certify that stage 2b did anything (it is NOT evidence the stage ran)" + else + echo "CONTROL STAGE_2B_LANDED=true patched=$STAGE2B_P unpatched=$STAGE2B_U" + fi + exit 0 +fi + +regenerate patched "$OUT" + +OUT_SHA="$(sha256sum "$OUT" | cut -d' ' -f1)" +SLOTS="$(gzip -dc "$OUT" | jq -c '[.gameState.waiting_for.data.target_slots[]?.effect_kind]')" + +# 4. Record the provenance on stdout so a commit message can quote it. +echo "MIGRATED pristine=$(basename "$PRISTINE") sha256=$ACTUAL_SHA" +echo "MIGRATED effect_kind=$EFFECT_KIND stamped_slots=$SLOTS" +echo "MIGRATED out=$OUT sha256=$OUT_SHA" +echo "MIGRATED jq=$JQ_VERSION gzip=$GZIP_VERSION" diff --git a/scripts/stamp-fixture-firing.sh b/scripts/stamp-fixture-firing.sh new file mode 100755 index 0000000000..8e4345fba8 --- /dev/null +++ b/scripts/stamp-fixture-firing.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash +# Stamps the CR 603.7 `TriggerFiring` carriers that upstream #6842 (8121fd1c6) +# made mandatory onto an ALREADY-COMMITTED fixture, in place. +# +# WHY IN PLACE AND NOT A PRISTINE REGENERATION. `migrate-dump-fixture.sh` +# regenerates from the read-only pristine root, which is the stronger provenance +# and is preferred where it applies. It does NOT apply to every fixture in this +# corpus: measured, `dina_conqueror_4p` and `witherbloom_sprout_lumaret_simple_4p` +# differ from their pristine regeneration in exactly one object each (Priest of +# Forgotten Gods' `abilities` / `base_abilities` AST), because the committed +# fixture carries a LATER parser state than the 2026-07-22/25 capture. Rerunning +# those from pristine would silently REVERT that. Stamping in place is additive +# and cannot revert anything, and its arm-1 control is strictly stronger: the +# stamped artifact minus the five stamped keys (three firing carriers plus the +# two delayed-trigger allocators — the exact `del()` list below) must be +# BYTE-IDENTICAL to what was committed, which proves zero collateral change. +# +# The derivation is NOT re-spelled here — it is loaded from +# scripts/lib/trigger-firing.jq, the same single definition +# `migrate-dump-fixture.sh` uses. See that file for the CR 603.1 vs CR 603.7a +# discriminant and for why `UnknownLegacy` is not a legal persisted value. +# +# Usage: +# scripts/stamp-fixture-firing.sh crates/engine/tests/fixtures/name.json.gz [...] +# scripts/stamp-fixture-firing.sh --control crates/engine/tests/fixtures/name.json.gz +# +# It stamps TWO field classes, both made mandatory-or-repaired by #6842: +# 1. the CR 603.7 firing carriers themselves; and +# 2. the CR 603.7 delayed-trigger ALLOCATORS +# (`next_delayed_trigger_token` / `..._instance`), which #6842 repairs at +# load time on the PRODUCTION decode path only. Left unstamped, a legacy +# dump restores 0 through a bare `GameState` decode and 1 through the +# production decoder, so the two paths disagree — and 0 is the value the +# engine's own coherence validator rejects. Stamping the repaired value on +# disk keeps the decoders in agreement WITHOUT weakening any assertion. +# +# ALL FIVE control arms, and a partial check passes vacuously: +# arm 1 => NO_COLLATERAL=true stamped minus the 5 stamped keys is +# byte-identical to the committed fixture, so nothing else moved. +# arm 2 => CARRIERS_ADDED=true every firing carrier the dump needs is +# present (got == need). Keyed on CARRIER COUNT, not on byte +# difference: gzip/jq re-serialization alone changes bytes without +# stamping anything, which is the stale-artifact false pass. +# arm 3 => ALLOCATORS_CANONICAL=true both allocators exist and are >= 1. +# arm 4 => DEFINITION_SHAPES=true a PRE-FLIGHT control, run once before any +# fixture is touched: the shipped `_defs` must read BOTH serialized +# definition shapes, and must still ABORT when a description is in +# neither. See `definition_shape_control` below. +# arm 5 => CARRIER_PRESERVED=true a PRE-FLIGHT control: an existing canonical +# carrier must SURVIVE the stamp (the "additive" claim above, which arm 1 +# structurally cannot check because it deletes those keys before +# comparing), while an absent one is still derived. See +# `carrier_preservation_control` below. + +set -euo pipefail + +CONTROL=0 +[ "${1:-}" = "--control" ] && { CONTROL=1; shift; } +[ $# -gt 0 ] || { echo "usage: $0 [--control] ..." >&2; exit 1; } + +LIB="$(dirname "${BASH_SOURCE[0]}")/lib/trigger-firing.jq" +[ -f "$LIB" ] || { echo "missing $LIB" >&2; exit 1; } + +for tool in jq gzip sha256sum; do + command -v "$tool" >/dev/null 2>&1 || { echo "required tool not found: $tool" >&2; exit 1; } +done + +# Every key this script is allowed to add. Arm 1 deletes exactly these from both +# sides, so anything else that moved shows up as a collateral change. +CARRIERS='del(.gameState.pending_trigger_firing, .gameState.stack_trigger_firings, .gameState.resolving_trigger_firing, + .gameState.next_delayed_trigger_token, .gameState.next_delayed_trigger_instance)' + +# arm 4 — PRE-FLIGHT: does the shipped `_defs` actually read both serialized +# definition shapes? +# +# The two lists do NOT serialize alike. `trigger_definitions` is +# `Definitions` and nests its text at `.definition.description`; +# `base_trigger_definitions` is `Vec` and exposes +# `.description` directly. A filter that reads one field name across both still +# resolves every carrier in THIS corpus — measured, 172 of 172 — because the base +# list happens to repeat the same descriptions. So "every carrier resolved" is NOT +# evidence that both shapes are read, and no fixture-level arm can supply that +# evidence. This one can: it asks the question directly, per shape. +# +# Three cases, and the NEGATIVE is what makes the positives non-vacuous — without +# it a `_defs` that returned every string it could find would score green. +definition_shape_control() { + local nested direct absent + # (a) description ONLY in the live list, nested under `.definition`. + nested="$(jq -n -c -f <(printf '%s\n%s\n' "$(cat "$LIB")" \ + '_firing({"7":{"trigger_definitions":[{"definition":{"description":"D"}}]}}; 7; "D")') 2>/dev/null || echo FAILED)" + # (b) description ONLY in the base list, exposed directly. + direct="$(jq -n -c -f <(printf '%s\n%s\n' "$(cat "$LIB")" \ + '_firing({"7":{"base_trigger_definitions":[{"description":"D"}]}}; 7; "D")') 2>/dev/null || echo FAILED)" + # (c) NEGATIVE CONTROL — present in neither shape MUST still abort by name. + if jq -n -c -f <(printf '%s\n%s\n' "$(cat "$LIB")" \ + '_firing({"7":{"trigger_definitions":[{"definition":{"description":"OTHER"}}]}}; 7; "D")') >/dev/null 2>&1 + then absent=RESOLVED; else absent=ABORTED; fi + + if [ "$nested" = '"Ordinary"' ] && [ "$direct" = '"Ordinary"' ] && [ "$absent" = ABORTED ]; then + echo "CONTROL DEFINITION_SHAPES=true nested=$nested direct=$direct unknown=$absent" + return 0 + fi + echo "CONTROL DEFINITION_SHAPES=false nested=$nested direct=$direct unknown=$absent" >&2 + echo " the derivation does not read both serialized definition shapes (or no longer" >&2 + echo " aborts on an unknown description) — refusing to stamp anything" >&2 + return 1 +} + +# arm 5 — PRE-FLIGHT: stamping is genuinely ADDITIVE. +# +# The header above claims in-place stamping "cannot revert anything". Arm 1 cannot +# check that claim for the five stamped keys, because it DELETES exactly those keys +# from both sides before comparing — the one blind spot in an otherwise strong control. +# So a carrier that was already canonical (a modern capture, or an engine-side +# migration) could have been silently rewritten to "Ordinary", which is the CR 603.7a +# to CR 603.1 re-classification `lib/trigger-firing.jq` exists to refuse. +# +# The NEGATIVE half is what makes it non-vacuous: an ABSENT carrier must still be +# derived, or "preserved everything" would also describe a stamp that did nothing. +# +# VOCABULARY: these arms pin the LIVE `TriggerFiring` wire shapes +# (`types/identifiers.rs`): `"Ordinary"`, `"LegacyDelayed"`, +# `{"ReceiptEligible":{token,instance,source_id}}`, `"UnknownLegacy"`. They previously +# pinned `{"Delayed":null}`, a shape upstream #6933 removed. jq is untyped, so those arms +# stayed GREEN against a vocabulary the engine can no longer produce — a control passing +# on input the subject cannot emit is not evidence about the subject. +carrier_preservation_control() { + local kept receipt derived + local defs='"objects":{"7":{"base_trigger_definitions":[{"description":"D"}]}}' + local pend='"pending_trigger":{"source_id":7,"description":"D"}' + local firing='stamp_trigger_firing | .gameState.pending_trigger_firing' + # (a) An existing delayed carrier survives, and is NOT rewritten to "Ordinary" — + # note its description IS present in the object's definitions, so the struck + # form would have overwritten it rather than aborting. + kept="$(printf '%s' "{\"gameState\":{$defs,$pend, + \"pending_trigger_firing\":\"LegacyDelayed\"}}" \ + | jq -c -f <(printf '%s\n%s\n' "$(cat "$LIB")" "$firing") \ + 2>/dev/null || echo FAILED)" + # (b) The PAYLOAD-CARRYING variant survives with its payload intact. A preservation + # rule written against the unit variants alone could drop `ReceiptEligible`'s + # origin and still satisfy (a) — this is the arm that says the value, not just + # the discriminant, comes through. + receipt="$(printf '%s' "{\"gameState\":{$defs,$pend, + \"pending_trigger_firing\":{\"ReceiptEligible\":{\"token\":3,\"instance\":4,\"source_id\":7}}}}" \ + | jq -c -f <(printf '%s\n%s\n' "$(cat "$LIB")" "$firing") \ + 2>/dev/null || echo FAILED)" + # (c) NEGATIVE CONTROL — the SAME dump with the carrier removed must still derive one, + # or "preserved" would also describe a stamp that stopped working entirely. + derived="$(printf '%s' "{\"gameState\":{$defs,$pend}}" \ + | jq -c -f <(printf '%s\n%s\n' "$(cat "$LIB")" "$firing") \ + 2>/dev/null || echo FAILED)" + + if [ "$kept" = '"LegacyDelayed"' ] \ + && [ "$receipt" = '{"ReceiptEligible":{"token":3,"instance":4,"source_id":7}}' ] \ + && [ "$derived" = '"Ordinary"' ]; then + echo "CONTROL CARRIER_PRESERVED=true existing=$kept receipt=$receipt absent_is_derived=$derived" + return 0 + fi + echo "CONTROL CARRIER_PRESERVED=false existing=$kept receipt=$receipt absent_is_derived=$derived" >&2 + echo " stamping is not additive (or stopped deriving absent carriers) — refusing to stamp" >&2 + return 1 +} + +# arm 6 — PRE-FLIGHT: a carrier whose stack entry has LEFT is pruned from what gets +# written, and the pruned map actually reaches the fixture. +# +# This arm exists because the prune shipped BROKEN in the previous round. The scoping to +# live ids was computed correctly into `$sf_existing`, but the write-back was gated on +# `($sf | length) > 0` — "did we derive any NEW carriers" — so in the one case the prune +# is FOR, a dump whose only change is that an entry left the stack, `$sf` was empty, the +# assignment was skipped, and the stale key survived into the written fixture. The prune +# was computed and then discarded. +# +# Every sub-arm below is keyed on a dump whose live carriers ALREADY exist, because that +# is what forces `$sf` empty and reaches the gate. Arm 1's key-blind comparison cannot +# see this and arm 5 does not construct a departed entry, which is how it got through. +stale_carrier_control() { + local run pruned emptied untouched + run() { # run — stamp it, print the resulting stack_trigger_firings + printf '%s' "$1" \ + | jq -c -f <(printf '%s\n%s\n' "$(cat "$LIB")" \ + 'stamp_trigger_firing | (.gameState.stack_trigger_firings // null)') \ + 2>/dev/null || echo FAILED + } + local objs='"objects":{"7":{"base_trigger_definitions":[{"description":"D"}]}}' + local live='"stack":[{"id":9,"kind":{"type":"TriggeredAbility","data":{"source_id":7,"description":"D"}}}]' + + # (a) THE REGRESSION ITSELF — entry 5 has left the stack, entry 9 is still on it and + # already carries a canonical marker. `$sf` is empty here, which is the gate the + # old form failed at. The stale key must be gone AND the live one must remain. + pruned="$(run "{\"gameState\":{$objs,$live, + \"stack_trigger_firings\":{\"9\":\"LegacyDelayed\",\"5\":\"Ordinary\"}}}")" + # (b) EVERY carrier stale — the rebuilt map is `{}`, and `{}` must still be written. + # A prune that only ever shrinks a non-empty map would pass (a) and fail here. + emptied="$(run "{\"gameState\":{$objs,\"stack\":[], + \"stack_trigger_firings\":{\"5\":\"Ordinary\"}}}")" + # (c) NEGATIVE CONTROL — nothing stale. The map must come through UNCHANGED, or (a) + # and (b) would also be satisfied by a stamp that simply wipes the slot. This is + # also the idempotence check: a re-run of an already-correct fixture writes nothing. + untouched="$(run "{\"gameState\":{$objs,$live, + \"stack_trigger_firings\":{\"9\":\"LegacyDelayed\"}}}")" + + if [ "$pruned" = '{"9":"LegacyDelayed"}' ] \ + && [ "$emptied" = '{}' ] \ + && [ "$untouched" = '{"9":"LegacyDelayed"}' ]; then + echo "CONTROL STALE_CARRIER_PRUNED=true pruned=$pruned all_stale=$emptied unchanged=$untouched" + return 0 + fi + echo "CONTROL STALE_CARRIER_PRUNED=false pruned=$pruned all_stale=$emptied unchanged=$untouched" >&2 + echo " expected pruned={\"9\":\"LegacyDelayed\"} all_stale={} unchanged={\"9\":\"LegacyDelayed\"}" >&2 + echo " a departed stack entry's carrier is being carried forward — refusing to stamp" >&2 + return 1 +} + +# A valid envelope with NO `gameState` must reach the main loop's SKIP path. +# `stamp_trigger_firing` / `stamp_delayed_allocators` both gate on `.gameState` and pass +# such envelopes through untouched, so the stamper must ask nothing of them. The defect +# this pins lived in the SHELL's `ALLOC_NEED` / arm-3 reads, not in the derivation, so +# this control drives the REAL loop through a child invocation — a jq-only probe would +# have stayed green while the script refused an unchanged, valid fixture. +non_gamestate_control() { + [ -z "${STAMP_SELFTEST_CHILD:-}" ] || return 0 # inside the child: do not recurse + local d out crc + d="$(mktemp -d)" || return 1 + printf '%s' '{"schemaVersion":3,"note":"a valid envelope carrying no gameState"}' \ + | gzip -9 -n > "$d/no-gamestate.json.gz" + out="$(STAMP_SELFTEST_CHILD=1 "$0" "$d/no-gamestate.json.gz" 2>&1)"; crc=$? + rm -rf "$d" + # Assert the SKIP FIRED BY NAME. `rc=0` alone is not the claim: a loop that stamped + # nothing and fell through silently would also exit 0, which is a different behaviour + # wearing the same exit code. + if [ "$crc" -eq 0 ] && printf '%s\n' "$out" | grep -q '^SKIP no-gamestate\.json\.gz'; then + echo "CONTROL NON_GAMESTATE_SKIPPED=true" + return 0 + fi + echo "CONTROL NON_GAMESTATE_SKIPPED=false rc=$crc" >&2 + printf '%s\n' "$out" | sed 's/^/ /' >&2 + echo " a gameState-less envelope must reach the SKIP path — the jq passes it through," >&2 + echo " so demanding an allocator repair here refuses an unchanged, valid fixture" >&2 + return 1 +} + +# The staged file MUST be minted in the destination's OWN directory. This script +# rewrites its fixtures IN PLACE, and those fixtures are TRACKED, so a stage on a +# different filesystem makes the final `mv` a copy-then-unlink and an interruption +# truncates a committed fixture — the worst failure mode either fixture script has. +# Measured here: `mktemp -t` resolves to /tmp (device 50) while the fixture directory +# is on /home (device 47), so the two genuinely differ. Same recipe, same fix, and the +# same helper shape as `migrate-dump-fixture.sh` — the sibling this was missed on. +stage_beside() { # stage_beside + mktemp "$(dirname "$1")/.stamp-firing-stage-XXXXXX.json.gz" +} +# Registered stage files are reaped on EXIT/INT/TERM: the explicit `rm -f "$TMP"` calls +# below cannot cover death before the next statement runs, and debris would land in the +# tracked fixture directory. +STAGE_FILES="" +cleanup_stage_files() { + [ -n "$STAGE_FILES" ] || return 0 + # shellcheck disable=SC2086 # deliberate word-splitting over the staged-path list + rm -f $STAGE_FILES + STAGE_FILES="" +} +trap cleanup_stage_files EXIT INT TERM + +# The staging mechanism itself: the stage file must be minted in the destination's OWN +# directory, or the in-place `mv` is not atomic and an interrupted run truncates a +# TRACKED fixture. Compares DIRECTORIES, not devices — device equality is the property +# that makes `mv` atomic but it does NOT discriminate here, because a `-t` revert puts +# the stage in /tmp and any test destination under /tmp shares its device. That trap +# already cost `migrate-dump-fixture.sh` a self-test that could not fail on its subject. +stage_locality_control() { + local d probe + d="$(mktemp -d)" || return 1 + : > "$d/dest.json.gz" + probe="$(stage_beside "$d/dest.json.gz")" + if [ "$(dirname "$probe")" != "$(dirname "$d/dest.json.gz")" ]; then + echo "CONTROL STAGE_BESIDE_DEST=false — stage $(dirname "$probe") vs dest $(dirname "$d/dest.json.gz")" >&2 + echo " a cross-directory stage makes the in-place mv non-atomic; an interrupted run" >&2 + echo " would truncate a tracked fixture" >&2 + rm -f "$probe"; rm -rf "$d"; return 1 + fi + rm -f "$probe"; rm -rf "$d" + echo "CONTROL STAGE_BESIDE_DEST=true" + return 0 +} + +definition_shape_control || exit 1 +carrier_preservation_control || exit 1 +stale_carrier_control || exit 1 +non_gamestate_control || exit 1 +stage_locality_control || exit 1 + +rc=0 +for FIX in "$@"; do + [ -f "$FIX" ] || { echo "no such fixture: $FIX" >&2; rc=1; continue; } + TMP="$(stage_beside "$FIX")" + STAGE_FILES="$STAGE_FILES $TMP" + # CALL-SITE guard, and it is NOT redundant with `stage_locality_control` above. That + # control proves the HELPER returns a beside-destination path; it says nothing about + # whether this line still calls the helper. Measured: revert ONLY this binding to + # `mktemp -t`, leaving `stage_beside` intact, and the control still prints + # STAGE_BESIDE_DEST=true while the run writes with its stage in /tmp — the original + # defect fully restored, past a green control. A property must be asserted where the + # value is BOUND, not only where it is produced. + if [ "$(dirname "$TMP")" != "$(dirname "$FIX")" ]; then + echo "stage not beside destination: $TMP vs $FIX — mv would not be atomic" >&2 + rm -f "$TMP"; rc=1; continue + fi + # `-f` with the lib prepended keeps ONE definition of the derivation. + if ! gzip -dc "$FIX" \ + | jq -c -f <(printf '%s\nstamp_trigger_firing | stamp_delayed_allocators\n' "$(cat "$LIB")") \ + | gzip -9 -n > "$TMP"; then + echo "STAMP FAILED (fail-closed, nothing written): $FIX" >&2 + rm -f "$TMP"; rc=1; continue + fi + + # How many carriers this dump NEEDS, read from the dump itself. A dump that + # needs none is skipped outright: stamping it is a no-op, and a "the bytes + # changed" arm over it would be reporting jq re-serialization rather than a + # stamp — the stale-artifact false pass, inverted. + NEED="$(gzip -dc "$FIX" | jq -c -f <(printf '%s\ntrigger_carrier_count\n' "$(cat "$LIB")"))" + GOT="$(gzip -dc "$TMP" | jq -c '((if .gameState.pending_trigger_firing then 1 else 0 end) + + (.gameState.stack_trigger_firings // {} | length) + + (if .gameState.resolving_trigger_firing then 1 else 0 end))')" + SUMMARY="$(gzip -dc "$TMP" | jq -c '{pending: .gameState.pending_trigger_firing, + stack: (.gameState.stack_trigger_firings // {} | length), + resolving: .gameState.resolving_trigger_firing}')" + + # The allocator repair is a SEPARATE need from the firing carriers: a dump can + # want one and not the other, so a dump with no triggered record is only truly + # a no-op when its allocators are already at or above 1. + # A `.gameState`-less envelope is passed through untouched by `stamp_delayed_allocators` + # (trigger-firing.jq gates on exactly this), so it NEEDS nothing. Without the guard + # `.gameState.next_delayed_trigger_token` is null, `// 0` makes it 0, `< 1` is true, and + # the script demands a repair the jq will never perform — which turned an unchanged, + # valid fixture into an arm-3 failure and made this general-purpose script refuse it. + ALLOC_NEED="$(gzip -dc "$FIX" | jq -c 'if (.gameState // null) == null then 0 + elif ((.gameState.next_delayed_trigger_token // 0) < 1) + or ((.gameState.next_delayed_trigger_instance // 0) < 1) + then 1 else 0 end')" + ALLOC_GOT="$(gzip -dc "$TMP" | jq -c '{tok: .gameState.next_delayed_trigger_token, + inst: .gameState.next_delayed_trigger_instance}')" + + if [ "$NEED" -eq 0 ] && [ "$ALLOC_NEED" -eq 0 ]; then + echo "SKIP $(basename "$FIX") needs=0 carriers, allocators already canonical — nothing to stamp" + rm -f "$TMP"; continue + fi + + # arm 1 — no collateral change: everything except the carrier keys is identical. + A="$(gzip -dc "$TMP" | jq -S -c "$CARRIERS")" + B="$(gzip -dc "$FIX" | jq -S -c "$CARRIERS")" + if [ "$A" = "$B" ]; then ARM1=true; else ARM1=false; fi + # arm 2 — the stamp had teeth: every carrier the dump needs is now present. + # Keyed on CARRIER COUNT, not on byte difference, because gzip/jq + # re-serialization alone can change bytes without stamping anything. + if [ "$GOT" -eq "$NEED" ]; then ARM2=true; else ARM2=false; fi + # arm 3 — the allocator repair landed. Both fields must exist and be >= 1; + # 0 is the value the engine's own coherence validator rejects. Reports `n/a` on a + # `.gameState`-less envelope rather than `false`: there is no allocator to make + # canonical, so the arm has nothing to certify and must not read as a failure. + ARM3="$(gzip -dc "$TMP" | jq -r 'if (.gameState // null) == null then "n/a" + elif ((.gameState.next_delayed_trigger_token // 0) >= 1) + and ((.gameState.next_delayed_trigger_instance // 0) >= 1) + then "true" else "false" end')" + + echo "STAMP $(basename "$FIX") carriers=$SUMMARY needs=$NEED got=$GOT alloc_need=$ALLOC_NEED alloc=$ALLOC_GOT NO_COLLATERAL=$ARM1 CARRIERS_ADDED=$ARM2 ALLOCATORS_CANONICAL=$ARM3" + + # ARM3 is tri-valued (`true`/`false`/`n/a`); only an outright `false` is a failure. + if [ "$ARM1" != true ] || [ "$ARM2" != true ] || [ "$ARM3" = false ]; then + echo " control arms failed for $FIX — not writing" >&2 + rm -f "$TMP"; rc=1; continue + fi + + if [ "$CONTROL" -eq 1 ]; then + rm -f "$TMP" + else + mv "$TMP" "$FIX" + echo " wrote $FIX sha256=$(sha256sum "$FIX" | cut -d' ' -f1)" + fi +done +exit $rc