diff --git a/crates/engine/src/analysis/decision_template.rs b/crates/engine/src/analysis/decision_template.rs index da5998d18d..f8546e9211 100644 --- a/crates/engine/src/analysis/decision_template.rs +++ b/crates/engine/src/analysis/decision_template.rs @@ -212,6 +212,26 @@ pub struct ShortcutDecisionSchema { /// CR 732.1b: the proposed repeat mode. `UntilLethal` for a determinate CR 704.5a / /// CR 704.5c drain; `Fixed(n)` seeds the frontend count picker for an optional loop. pub iteration_count: IterationCount, + /// CR 732.2a: the largest number of repetitions this proposal may legally specify — the + /// minimum over every applicable CR 704 elimination bound and finite-pool bound, over + /// every LIVING player, aggregated per declarable victim, clamped to + /// `MAX_SHORTCUT_CYCLES`. `IterationCount` above is the *suggestion*; this is the + /// *bound*, and they are deliberately separate fields: a proposal that exceeds this + /// contains a conditional action (an in-proposal CR 704.5a / CR 704.5c / CR 104.3c / + /// CR 121.4 elimination would decide what happens next), which CR 732.2a forbids. + /// + /// 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. + /// + /// DELIBERATELY NOT MIRRORED in `client/src/adapter/types.ts::ShortcutDecisionSchema`: + /// the frontend never reads the raw bound, it reads the already-clamped ceiling the + /// engine publishes as `InteractionShortcutCountSpec::Fixed { max }`. Mirroring it + /// would hand the display layer a second number it would have to reconcile — exactly + /// the derive-in-the-frontend the layer rule forbids. + #[serde(default = "default_max_iterations")] + pub max_iterations: u32, /// The open per-iteration decision-points needing pins. EMPTY for a choice-free drain. pub points: Vec, /// CR 702.51a: total untapped creatures the controller may tap for convoke across every @@ -221,6 +241,13 @@ pub struct ShortcutDecisionSchema { pub convoke_tappable_count: usize, } +/// A schema deserialized from a pre-bound snapshot carries no CR 732.2a count bound. The +/// forward-compatible default is the global safety limit, which is what every producer +/// emitted before the field existed — so an old save round-trips byte-equivalently. +fn default_max_iterations() -> u32 { + crate::game::engine::MAX_SHORTCUT_CYCLES +} + // CR 732.2a: `IterationCount` carries no `Default` and its `Fixed(u32)` is a tuple variant // (so a derived `#[default]` cannot apply) — hand-impl the forward-compat deser default the // `#[serde(default)]` on `WaitingFor::LoopShortcut.schema` needs. @@ -228,6 +255,7 @@ impl Default for ShortcutDecisionSchema { fn default() -> Self { Self { iteration_count: IterationCount::Fixed(0), + max_iterations: default_max_iterations(), points: Vec::new(), convoke_tappable_count: 0, } @@ -861,6 +889,9 @@ mod tests { fn shortcut_decision_schema_round_trips_and_defaults() { let schema = ShortcutDecisionSchema { iteration_count: IterationCount::UntilLethal, + // A NARROWED CR 732.2a bound, deliberately not the default: a round-trip that + // carried the default would pass even if the field were dropped from the wire. + max_iterations: 17, points: vec![DecisionPoint { slot: DecisionSlot { source: all_copies(7), @@ -879,16 +910,35 @@ mod tests { convoke_tappable_count: 2, }; let json = serde_json::to_value(&schema).expect("serialize"); + assert_eq!( + json["max_iterations"], 17, + "the CR 732.2a bound must reach the wire — a `#[serde(default)]` field that is \ + never serialized would silently reset to the cap on every reload" + ); let back: ShortcutDecisionSchema = serde_json::from_value(json).expect("deserialize"); assert_eq!(back, schema); assert_eq!( ShortcutDecisionSchema::default(), ShortcutDecisionSchema { iteration_count: IterationCount::Fixed(0), + max_iterations: crate::game::engine::MAX_SHORTCUT_CYCLES, points: vec![], convoke_tappable_count: 0, } ); + // A pre-bound snapshot (no `max_iterations` key at all) must load at the cap, which + // is exactly what every producer emitted before the field existed. + let mut legacy = serde_json::to_value(ShortcutDecisionSchema::default()).unwrap(); + legacy + .as_object_mut() + .expect("schema serializes as an object") + .remove("max_iterations"); + assert_eq!( + serde_json::from_value::(legacy) + .expect("a pre-bound snapshot still deserializes") + .max_iterations, + crate::game::engine::MAX_SHORTCUT_CYCLES + ); } /// Phase-1 `resolve`/gate tests don't consult `key`; give every template an empty diff --git a/crates/engine/src/analysis/loop_check.rs b/crates/engine/src/analysis/loop_check.rs index 0b7911b765..977230efc6 100644 --- a/crates/engine/src/analysis/loop_check.rs +++ b/crates/engine/src/analysis/loop_check.rs @@ -463,11 +463,51 @@ pub(crate) fn live_mandatory_loop_winner( /// (the one non-faller) is. A transient intra-cycle dip that recovers to a /// non-negative NET delta would still kill the winner via the CR 704.5a SBA at low /// absolute life before the extrapolated win — a net-delta check cannot see it. -/// Per-resolution granularity IS SBA granularity here (CR 704.3 checks whenever a -/// player would get priority, between resolutions), and consecutive ring frames are -/// consecutive resolutions (a non-sampling beat clears the ring), so requiring -/// `life[winner]` non-decreasing across the matched window (prior frame → every -/// subsequent ring frame → the live state) is exactly right. Winner draw-from-empty +/// Per-resolution granularity IS SBA granularity here, but NOT because ring frames are +/// consecutive resolutions — they are not, and never were. The shipped CR 603.3b +/// `OrderTriggers` exemption already retains the ring across a non-sampling beat (dump D +/// measured 35 such beats in one drive), and `WaitingFor::is_forced_cascade_window` +/// extends that to every forced pre-priority window (CR 603.3d / CR 603.5 + CR 608.2 / +/// CR 903.9a / CR 704.5j / CR 310.10 / CR 703.1 + CR 117.3a). The invariant this guard +/// actually needs is weaker and true: +/// **every point at which CR 704.5a could fire is either sampled or clears the ring.** +/// CR 704.3 fixes those points: SBAs are checked whenever a player would get priority, +/// and every such point arrives as `WaitingFor::Priority`, which is deliberately not a +/// forced-cascade window and therefore samples or clears. The retained windows are +/// exempt for three DIFFERENT reasons, and the weaker invariant is what covers all +/// three: +/// the between-resolutions members (CR 603.3b / CR 603.3d / CR 903.9a / CR 704.5j / +/// CR 310.10) sit inside the CR 704.3 fixpoint itself, where no life total moves; the +/// MID-resolution member (`OptionalEffectChoice`, CR 603.5 + CR 608.2) is a pause in the +/// middle of a resolution, where life absolutely can move — but CR 608.2 performs no SBA +/// check mid-resolution, so a life change there is not a CR 704.5a point being skipped, +/// it is a life change that the very next CR 704.3 check (a `Priority` window) observes; +/// the TURN-BASED members (CR 703.1 + CR 117.3a — untap CR 502.3, declare attackers +/// CR 508.1/508.1g, declare blockers CR 509.1, cleanup discard CR 514.1) precede the +/// step's own grant of priority (CR 508.2 is the explicit case), and the DECLARATION +/// itself moves no life: untapping, declaring, exerting/enlisting and discarding change +/// no life, and anything that WOULD (an attack trigger) uses the stack and therefore +/// resolves at an observed `Priority` beat. +/// That is a claim about the declaration only, and the two life-moving neighbours it +/// deliberately excludes are why the class is drawn where it is: +/// * CR 508.1h / CR 509.1d put the declaration's COSTS in a separate sub-step +/// ("Costs may include paying mana, tapping permanents, sacrificing permanents, +/// discarding cards, and so on"), and a Phyrexian symbol in an attack or block tax is +/// paid with 2 life (CR 107.4f) — measured in-code: `engine_combat::handle_pay_combat_tax` +/// pays through `casting::pay_unless_cost`, which settles `life_payments` via +/// `life_costs::pay_life_as_cost`. So declaring CAN move life, at +/// `WaitingFor::CombatTaxPayment` — which is deliberately NOT a member and therefore +/// clears the ring. +/// * `AssignCombatDamage` / `AssignBlockerDamage` are likewise NOT members despite being +/// turn-based (CR 510.1c / CR 510.1d): CR 510.2 deals the assigned damage with no +/// intervening priority. That window-keyed exclusion is necessary but NOT sufficient, +/// because the window opens only for a damage DIVISION choice — an unblocked attacker +/// deals CR 510.2 damage with no window at all. The sufficient guard is event-keyed: +/// `GameState::invalidate_loop_ring_on_unobserved_life_move`, called from +/// `game::combat_damage::apply_combat_damage`. +/// +/// So requiring `life[winner]` non-decreasing across the matched window (prior frame → +/// every subsequent ring frame → the live state) is exactly right. Winner draw-from-empty /// is correctly unreachable (a non-faller never crosses a loss SBA). pub(crate) fn winner_life_never_dips(frames: &[&GameState], winner: PlayerId) -> bool { let mut prev: Option = None; @@ -590,6 +630,13 @@ mod tests { PlayerId(n) } + // The CR 704.3 partition `winner_life_never_dips` rests on — `Priority` DISJOINT from + // the retained class, over both priority seats — is asserted by + // `types::game_state::forced_cascade_window_tests::forced_cascade_window_class`, which + // covers it strictly more completely (thirteen members and eight non-members, including both + // `Priority` seats). A second weaker row here would only be a place for the two to + // drift apart. + fn battlefield_creature(state: &mut GameState, id: u64, controller: u8) -> ObjectId { let oid = ObjectId(id); let mut object = GameObject::new( diff --git a/crates/engine/src/analysis/resource.rs b/crates/engine/src/analysis/resource.rs index bd7e5f8c8b..dfb2a79036 100644 --- a/crates/engine/src/analysis/resource.rs +++ b/crates/engine/src/analysis/resource.rs @@ -25,12 +25,13 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use serde::{Deserialize, Serialize}; +use crate::analysis::decision_template::DecisionSlot; use crate::game::game_object::GameObject; use crate::types::ability::{ActivationRestriction, DamageModification}; use crate::types::card_type::{CoreType, Supertype}; use crate::types::counter::CounterType; use crate::types::game_state::{loop_states_equal, GameState, StackEntry, StackEntryKind}; -use crate::types::identifiers::{ObjectId, TriggerFiring}; +use crate::types::identifiers::{CardId, ObjectId, TriggerFiring}; use crate::types::mana::ManaType; use crate::types::phase::Phase; use crate::types::player::{Player, PlayerId}; @@ -483,6 +484,223 @@ impl ResourceVector { out } + /// CR 732.2a + CR 704.5a / CR 704.5c / CR 104.3c + CR 121.4: the largest number of + /// times this per-period delta may legally be repeated in one shortcut proposal. + /// + /// # The convention, and why it stops STRICTLY SHORT + /// + /// `N` is the largest count such that after each of the `N` cycles **no living player + /// has crossed a CR 704 loss threshold**. CR 732.2a forbids a shortcut that contains a + /// conditional action and requires its ending point to be a place a player would + /// receive priority; CR 704.3 checks state-based actions whenever a player would get + /// priority, and a cycle contains several such points. A mid-sequence CR 704.5a death + /// therefore makes the remaining declared choices unmakeable — CR 800.4a removes the + /// seat — which is both a conditional action and an illegal proposal. So the bound is + /// `headroom / magnitude` with headroom measured to *one short of* the threshold. + /// + /// | axis | threshold | headroom for a living `p` | + /// |---|---|---| + /// | life | CR 704.5a (0 or less life) | `life[p] - 1` | + /// | poison | CR 704.5c (ten or more counters) | `9 - poison[p]` | + /// | library | CR 104.3c + CR 121.4 (draw from empty) | `library[p].len()` | + /// + /// # Aggregation per DECLARABLE victim + /// + /// `declarable_victims` is the union of the published `Targets` slots' legal targets — + /// EMPTY for the untargeted class. `slot_magnitude` is the per-period life loss the + /// certificate attributed to each published slot. A declaration may aim **every** slot + /// at **one** opponent, so a declarable victim's life magnitude is the SUM over all + /// slots; that is what makes an all-slots-on-one-seat declaration bounded by + /// construction rather than by a cross-slot check in `validate_pins`. + /// + /// PRECISELY WHAT IS IMPLEMENTED, and how it differs from the specified rule: this + /// sums **every** positive `slot_magnitude` and charges that one total `S` to **every** + /// member of `declarable_victims`. The specified rule is `S(p) = Σ over slots s with + /// p ∈ s.legal_targets` — a per-victim sum. The two coincide exactly when every slot + /// can reach every declarable victim, which is the only shape reachable today + /// (`declarable_victims` arrives as the UNION of the slots' legal targets, and the + /// per-slot sets are not passed in at all — the signature carries no per-slot target + /// information, so the per-victim sum is not computable here). Where they differ — + /// a slot that can only reach seat A, another that can only reach seat B — this + /// charges A with A+B and B with A+B, i.e. it OVER-charges, which yields a SMALLER + /// bound. Conservative, therefore safe, and deliberately so: this is the fail-closed + /// approximation of the specified rule, not the rule itself. **No current test + /// discriminates the two** (every case's slots share identical legal-target sets), so + /// do not read the battery as evidence for the exact rule. Threading per-slot + /// `legal_targets` in (replacing `slot_magnitude: &BTreeMap` with a + /// per-slot `(legal_targets, magnitude)` pairing) is what turns this into the exact + /// §4.2 rule; it would only ever RAISE the bound, so it cannot invalidate an offer + /// this form already permitted. + /// + /// The observed per-period loss and the declared slot magnitude are combined + /// ADDITIVELY, with the observed term floored at zero: `observed.max(0) + S`. Where the + /// two measure the SAME drain — the ring observed the loss the slot causes — the sum + /// DOUBLE-COUNTS and over-charges, returning a smaller bound than strictly necessary + /// (measured: a one-slot drain on a 16-life seat yields **7**, where `max` yielded 15). + /// **7 is the shipped value and it is right**: this signature cannot prove that the + /// observed loss and the slot magnitude are the same drain, so the over-charge is a + /// PRECISION cost, never unsoundness. + /// + /// # SOUNDNESS — unconditional, and what the clamp is for + /// + /// The `max` form this replaced was **CORRECT ONLY IF `L_unattributed(p) == 0`** for + /// every declarable victim — only if every non-proposer loss in the measured period was + /// attributable to a published slot. That premise is **DISCHARGED BY CONSTRUCTION** + /// here: the sum no longer needs it. A victim carrying an untargeted drain of 1 **and** + /// a re-aimable slot of magnitude 1 has a true per-period loss of **2**; `max` returned + /// **1**, overstating the bound 2× and permitting an in-proposal elimination + /// (CR 704.5a) inside a proposed shortcut — exactly the conditional action CR 732.2a + /// forbids. `max` fails OPEN; this form fails CLOSED, which is this repo's convention. + /// + /// The **`.max(0)` clamp is load-bearing and not optional.** `observed_life_loss` + /// negates `self.life`, a per-period NET delta, so its sign is UNCONSTRAINED: a victim + /// who nets a life GAIN yields a negative value. Unclamped, `observed + S` can be `<= 0`, + /// the `narrow` closure never fires (its guard is `magnitude > 0`), and the life axis is + /// silently DISARMED at `MAX_SHORTCUT_CYCLES` — a fail-open in the change whose purpose + /// is closing one. Clamped, a net gain contributes nothing and cannot credit against the + /// slot magnitude either (CR 119.3: each gain and loss adjusts the total as it happens; + /// the net says nothing about order). + /// + /// `declared_life_magnitude >= 0` is a **CONSTRUCTION** fact, not an assumption: its + /// initializer filters `*m > 0` and sums, and the empty sum is `0`. With that, for + /// `observed >= 0` the sum is `>= max(observed, S)`, and for `observed < 0` it equals + /// `S == max(observed, S)` exactly — so this magnitude dominates the `max` form on EVERY + /// input, and `narrow` is monotone non-increasing in its divisor. The bound can only + /// SHRINK. + /// + /// `elimination_bounds_mixed_loss_charges_both_terms` (case (n), split out so its + /// revert-probe is reachable) DISCRIMINATES: `1` under `max`, `0` here. It supersedes + /// the earlier note that every + /// case had `S == 0` or `L_unattributed == 0` and that the battery was therefore + /// non-discriminating on this axis. + /// + /// Option (ii) — threading per-slot `(legal_targets, magnitude)` pairs — repairs `S(p)` + /// only and supplies no attribution of *observed* loss to slots, so it remains the open + /// PRECISION upgrade rather than a soundness prerequisite. + /// + /// The netting residual is a property of `self.life` being a per-period **net** + /// `delta()` output, and is identical under either operator. + /// + /// TREE-SCOPED: the first production consumer lands in a successor branch. This bound is + /// made fail-closed AHEAD of that consumer rather than in it, and **does not depend on + /// that branch's producer guard**. + /// + /// # Uniform over EVERY living player, including the proposer + /// + /// There is deliberately no `p == proposer => unbounded` case: `net_progress_for` reads + /// only the proposer's mana and life, so it is blind to the proposer's own poison and + /// to intra-cycle life dips. A proposer who drains themselves is bounded here like + /// anyone else. An ELIMINATED seat contributes no term at all (CR 800.4a — it is no + /// longer in the game), so a corpse at 1 life cannot pin the bound to zero. + /// + /// # Per-cycle magnitude constancy is a PREMISE, not a proof + /// + /// The bound extrapolates one measured period. Do NOT add a monotone-magnitude + /// conjunct to "fix" that — it would reject every 2-frame window. The backstops are + /// conformance (a cycle whose magnitude changed stops committing) and the live + /// elimination guard during the drive, never an extrapolated total. + /// + /// 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)] + pub(crate) fn elimination_bounds( + &self, + state: &GameState, + declarable_victims: &[PlayerId], + slot_magnitude: &BTreeMap, + ) -> u32 { + let cap = crate::game::engine::MAX_SHORTCUT_CYCLES as i64; + // Every published slot is assumed reachable to every declarable victim, so ONE + // total is charged to each of them (see "PRECISELY WHAT IS IMPLEMENTED" above: + // the conservative, over-charging approximation of the per-victim sum). + let declared_life_magnitude: i64 = + slot_magnitude.values().copied().filter(|m| *m > 0).sum(); + + let mut bound = cap; + let mut narrow = |headroom: i64, magnitude: i64| { + if magnitude > 0 { + bound = bound.min(headroom.max(0) / magnitude); + } + }; + + for p in &state.players { + // CR 800.4a: an eliminated seat has left the game and constrains nothing. + if p.is_eliminated { + continue; + } + // CR 704.5a. A negative life delta is the per-period loss. + let observed_life_loss = -self.life.get(&p.id).copied().unwrap_or(0); + let life_magnitude = if declarable_victims.contains(&p.id) { + // CR 704.5a (MagicCompRules.txt:5492) + CR 732.2a + // (MagicCompRules.txt:6372). Combined + // ADDITIVELY, with the OBSERVED term floored at zero. `max` is correct only + // if `L_unattributed(p) == 0` — every non-proposer loss in the measured + // period attributable to a published slot — and this signature carries no + // per-slot victim attribution with which to discharge that premise. A + // victim carrying an untargeted drain of 1 AND a re-aimable slot of + // magnitude 1 loses 2 per period; `max` returns 1, overstating the bound + // and permitting an in-proposal elimination — the conditional action + // CR 732.2a forbids. + // + // TIGHT **given the information in this signature**: with `d` the slot + // loss actually delivered to `p`, the worst case is `observed + (S - d)` + // for `0 <= d <= S`, whose supremum over the unattributable `d` is + // `observed + S`. + // + // WHY `.max(0)`, AND WHY IT IS NOT OPTIONAL. `observed_life_loss` negates + // `self.life`, a per-period NET delta (`ResourceVector::life`, produced by + // `ResourceVector::delta` via `map_delta`), so its + // sign is UNCONSTRAINED: a victim who nets a life GAIN yields a negative + // value. Unclamped, `observed + S` can then be <= 0, the `narrow` closure + // never fires (its guard is `magnitude > 0`), and the life axis is silently + // DISARMED at MAX_SHORTCUT_CYCLES. Clamped, a net gain contributes nothing + // and cannot credit against the slot magnitude either (CR 119.3, + // MagicCompRules.txt:1065: each gain and loss adjusts the total as it + // happens; the net says nothing about order). + // + // FAIL-CLOSED OVER THE WHOLE DOMAIN, not merely where both terms are + // positive. `declared_life_magnitude` is `>= 0` by construction — its + // initializer filters `*m > 0` and sums, and the empty sum is 0. For + // `observed >= 0`, `observed + S >= max(observed, S)`; for `observed < 0` + // it equals `S == max(observed, S)` exactly. So this magnitude is >= the + // `max` form on EVERY input, and `narrow` is monotone non-increasing in its + // divisor (non-negative numerator), so the returned bound can only SHRINK. + // + // Where `observed` and `S` measure the SAME drain this DOUBLE-COUNTS and + // over-charges (precision loss, never unsoundness) — case (m) in + // `elimination_bounds_conventions` is that shape, 15 -> 7. Accepted: it + // errs toward refusal, and this repo's convention is fail-closed. The + // precision upgrade is per-slot `(legal_targets, magnitude)` attribution. + // + // NOT BOUNDED BY THIS OPERATOR, stated plainly: intra-cycle dips. A period + // that drains 5 and lifelinks 7 reports `observed = -2` while dipping below + // `life - 5` mid-cycle; this charges `0 + S`. That blindness is a property + // of the NET INPUT and is identical under `max` — the operator swap neither + // introduces nor repairs it. The backstops are conformance and the live + // elimination guard during the drive. + observed_life_loss.max(0) + declared_life_magnitude + } else { + observed_life_loss + }; + narrow(p.life as i64 - 1, life_magnitude); + // CR 704.5c. A positive poison delta is the per-period gain. + narrow( + 9 - p.poison_counters as i64, + self.poison.get(&p.id).copied().unwrap_or(0), + ); + // CR 104.3c + CR 121.4. A negative library delta is the per-period drain. + narrow( + p.library.len() as i64, + -self.library_delta.get(&p.id).copied().unwrap_or(0), + ); + } + + bound.clamp(0, cap) as u32 + } + /// CR 732.2a: **controller-scoped** net-progress — the single authority shared /// by Engine A ([`crate::analysis::detect_loop`]) and Engine B /// ([`crate::analysis::candidate_cycles`]). Returns true iff the cycle makes @@ -750,6 +968,117 @@ pub fn board_delta(before: &GameState, after: &GameState) -> BoardDelta { BoardDelta { added, removed } } +/// 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 +/// [`LoopWindowScope::unproven`] and gets byte-identical pre-change behaviour, so the +/// design is FAIL-CLOSED BY CONSTRUCTION — forgetting to thread a proof can only make +/// a predicate more conservative, never less. +/// +/// 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. +#[derive(Debug, Clone, Copy)] +pub(crate) struct LoopWindowScope<'a> { + /// `Some(phase)` iff the caller proved both frames are equal on turn number AND + /// step-granular phase (CR 500.1 turn structure / CR 506.1 combat steps / + /// CR 510.2 the combat-damage step). `None` at any caller whose window CROSSES a + /// phase or step boundary. + phase_invariant: Option, + /// `Some(p)` iff the caller proved the whole window is driven by `p` and no other + /// player receives priority inside the taken shortcut (CR 117.1b: a player may + /// 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], + /// 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]>, +} + +impl LoopWindowScope<'static> { + /// The zero-proof scope. Every 2-arg wrapper passes this, which is what makes the + /// wrappers structurally identity rather than conditionally so. + pub(crate) const fn unproven() -> Self { + Self { + phase_invariant: None, + sole_driver: None, + pinned_slots: &[], + cast_card_ids: None, + } + } +} + +/// CR 510.2 / CR 506.1 / CR 117.1b: the proof a cover pair carries about its own +/// window. SINGLE AUTHORITY — both suppressing firewall callers derive their scope +/// here, so the two [`LoopWindowScope`] populations can never drift apart. +/// +/// `phase_invariant`: `Some(phase)` only when the frames agree on turn number AND +/// step-granular phase AND neither carries a pending extra phase (CR 500.8 can insert +/// a duplicate of the SAME phase inside one turn, which would break "equal phase ⇒ +/// never left it"). Derived LOCALLY from the frames rather than read off a preceding +/// gate, so it is independent of gate ORDER — in +/// [`loop_states_cover_modulo_fodder_growth`] the firewall call PRECEDES +/// `eq_except_growable`. (`extra_turns` is deliberately NOT a conjunct: an extra TURN +/// is taken after the current one and `turn_number` is monotone, so it cannot insert a +/// duplicate phase inside a window whose frames already agree on `turn_number`.) +/// +/// `sole_driver`: `Some(p)` only when BOTH frames' driving sequences are non-empty and +/// every entry in BOTH names controller `p` (CR 117.1b: a player may activate an +/// ability only with priority, and no other player receives priority inside the taken +/// shortcut). Reading only `prior` would mint `Some(p)` for a window whose other frame +/// was driven by someone else — the RELIEVING direction. An empty sequence proves +/// nothing, so it yields `None`, not "nobody drove this". +/// +/// Fail-closed in every branch: a frame pair that proves nothing gets the +/// [`LoopWindowScope::unproven`] values and therefore byte-identical behaviour. +fn window_scope_from_cover_frames<'a>( + pa: &GameState, + pb: &GameState, + pinned_slots: &'a [DecisionSlot], +) -> LoopWindowScope<'a> { + // (p1) same turn, (p2) same step-granular phase, (p3) no pending extra phase in + // either frame (CR 500.8). + let phase_invariant = (pa.turn_number == pb.turn_number + && pa.phase == pb.phase + && pa.extra_phases.is_empty() + && pb.extra_phases.is_empty()) + .then_some(pa.phase); + + // (s1) BOTH sequences non-empty — the `(Some, Some)` arm; (s2) one controller + // across BOTH sequences. + let sole_driver = match ( + pa.last_loop_action_sequence.first(), + pb.last_loop_action_sequence.first(), + ) { + (Some(first), Some(_)) => { + let driver = first.controller; + pa.last_loop_action_sequence + .iter() + .chain(pb.last_loop_action_sequence.iter()) + .all(|ctx| ctx.controller == driver) + .then_some(driver) + } + _ => None, + }; + + LoopWindowScope { + phase_invariant, + sole_driver, + pinned_slots, + // 2b's axis (the PROJECTED covers), derived at its own call site. + cast_card_ids: None, + } +} + /// Karp–Miller-style ω-acceleration (Karp–Miller 1969; Finkel et al. 2021), sound /// GIVEN the in-loop transition relation — the WHOLE beat: top-of-stack resolution /// (CR 608.1) with its resolution-time payments (CR 605.3a / CR 608.2g), trigger @@ -783,6 +1112,44 @@ pub fn board_delta(before: &GameState, after: &GameState) -> BoardDelta { /// 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()) +} + +/// CR 601.2f + CR 601.2a: the set of card ids this loop window's recorded driving +/// sequence touches — a SUPERSET of the true cast set (only `LoopAction::Recast` +/// genuinely casts, CR 601.2a; `Activate` and `TapLandForMana` do not), which is the +/// CONSERVATIVE direction: over-stating the cast set makes `!ids.contains(..)` false +/// more often ⇒ fewer relieved defs ⇒ more vetoes. +/// +/// FAIL-CLOSED ON EMPTY, and this is the whole reason the function exists: an empty +/// `last_loop_action_sequence` means NO RECORDED PROOF, not "this window casts +/// nothing". `Some(vec![])` would assert the latter and relieve EVERY conditioned +/// self-cost static — relief in the forbidden direction. `None` = scan everything. +/// Pinned by `empty_loop_action_sequence_proves_nothing_about_casting`. +fn window_cast_card_ids(state: &GameState) -> Option> { + let ids: Vec = state + .last_loop_action_sequence + .iter() + .map(|ctx| ctx.card_id) + .collect(); + if ids.is_empty() { + None + } else { + Some(ids) + } +} + +/// 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( + prior: &GameState, + current: &GameState, + _scope: LoopWindowScope<'_>, +) -> 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 // and their stack-entry-indexed firing sidecars (the stack is compared separately @@ -832,7 +1199,21 @@ pub(crate) fn loop_states_cover_modulo_growth(prior: &GameState, current: &GameS } // (5) Off-stack fail-closed fire-time condition guard (the second read surface). - if fire_time_conditions_read_projected_resource(current) { + // CR 601.2f: `cast_ids` is bound BEFORE `projected_scope` so NLL keeps the borrow + // live across the call (`LoopWindowScope::cast_card_ids` is `Option<&'a [CardId]>`). + let cast_ids = window_cast_card_ids(current); + // All four fields written explicitly — no functional-update base, so there is no + // `LoopWindowScope<'static>` -> `LoopWindowScope<'_>` variance question to reason + // about, and a future FIFTH field is a compile error that forces a decision rather + // than a silent default. The other three stay at their `unproven()` values: 2b's + // axis is `projected`, and the sibling proofs belong to the sibling covers. + let projected_scope = LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned_slots: &[], + cast_card_ids: cast_ids.as_deref(), + }; + if fire_time_conditions_read_projected_resource_scoped(current, projected_scope) { return false; } @@ -969,10 +1350,39 @@ pub(crate) fn loop_states_cover_modulo_object_growth( } // (3″) No live fire-time observer reads the growing class (§5.3a, S5). - // `None` class context: the offline object-growth path (`detect_loop`) has no single fodder - // representative to gate ETB matchers against, so the firewall keeps its conservative veto on - // every observer (byte-identical to pre-gate behavior). - if fire_time_conditions_read_growing_class(&cf, None) { + // `None` class context: the offline object-growth path (`detect_loop`) has no proven + // class set to gate ETB matchers against, so the firewall keeps its conservative veto on + // every observer whose relief is class-keyed (byte-identical to pre-gate behavior). + // ⚠ The window scope is NOT class-keyed: CR 117.1b (`sole_driver`) and CR 510.2 / CR 506.1 + // (`phase_invariant`) relief IS live here, so this OFFLINE classifier can now emit + // certificates where it previously vetoed. That is the one seam this phase can widen. + // + // NO AUTOMATED DETECTOR WATCHES IT, stated plainly rather than implied. The + // `cargo combo-verify` row-for-row diff was measured at ZERO sensitivity to this seam: + // forcing this predicate to `return true` — its most restrictive possible behavior — + // moved no corpus row at all. That zero is NOT an untested instrument: the same + // invocation, with `detect_loop` forced to `return None`, moves 10 of the 54 rows + // (13 confirmed / 0 failed becomes 3 confirmed / 10 failed), so the row diff can and + // does register change. It is discriminating but not total — 3 confirmed rows survive + // that mutation, i.e. they are certified by a path that never consults `detect_loop`. + // WHY every row is insensitive to THIS seam has NOT been measured, and no mechanism is + // asserted here: the liveness control establishes that the instrument works, not why + // the seam figure is zero. + // + // What bounds the SHIPPED blast radius is not a detector but compile-time exclusion of + // the CALLERS: `loop_states_cover_modulo_object_growth`'s only non-test caller is + // `detect_loop`, whose only non-test callers live in `analysis::corpus`, which is + // `#[cfg(any(test, feature = "combo-verify"))]` — and `combo-verify` is non-default + // (the crate manifest declares no `default` feature at all). Precisely: `detect_loop` + // itself still compiles into the default lib; nothing in a default build CALLS it. + // The `cfg(test)` unit call sites of `loop_states_cover_modulo_object_growth` in this + // file's own `mod tests` are what exercise this line at all; `cargo combo-verify` + // remains worth running as corroboration, but it is NOT evidence about this seam. + if fire_time_conditions_read_growing_class_scoped( + &cf, + None, + window_scope_from_cover_frames(&pa, &pb, &[]), + ) { return false; } @@ -1116,13 +1526,15 @@ fn board_covers_modulo_fodder( /// cost on a clone and measures sustainability empirically, so the offline "models no /// cost ⇒ reject any board-scaling cost keyword" rejector does NOT apply here. /// `detect_loop` keeps the firewall (it stays on the object-growth predicate — T-B1i -/// pins this). NO live/offline caller in 4d-i — exercised only by unit tests + T-B1i. +/// pins this). LIVE, not tree-scoped: called twice at `game::engine`'s `cover_ok` in +/// `try_offer_object_growth_shortcut`, itself invoked from `apply()`'s empty-stack offer +/// hook — so a change here can move a SHIPPED offer verdict. (`elimination_bounds` is the +/// genuinely tree-scoped one; this is not.) /// /// `fodder_class` is a CONTENT authority (a representative `&GameObject`), compared /// LIVE each call via [`fodder_content_eq`] (modulo tapped) — not latched by /// ObjectId, because fodder tokens are not id-stable. Covers any inert fungible token /// class (Saproling, Elf Warrior, Thopter, …), so it builds for the class not a card. -#[cfg_attr(not(test), allow(dead_code))] // 4d-ii wires the live/offline caller; 4d-i exercises via unit tests + T-B1i. pub(crate) fn loop_states_cover_modulo_fodder_growth( prior: &GameState, current: &GameState, @@ -1158,18 +1570,27 @@ pub(crate) fn loop_states_cover_modulo_fodder_growth( return false; } - // No live off-stack / on-stack observer reads the growing class. Pass a representative fodder - // member so the firewall's block(1) can skip an ETB observer whose matcher provably excludes - // the fodder class (CR 603.6a). CR 110.5b: prefer an UNTAPPED member (models the just-entered - // fodder), deterministic id tiebreak; the id is projection-stable so it resolves against the - // flushed-current `cf` the firewall scans. `None` only if the fodder pile is empty in `cf` - // (impossible on the strict-growth fodder path) → conservative veto preserved. - let class_member = all_fodder + // No live off-stack / on-stack observer reads the growing class. Pass the WHOLE proven + // fodder class so the firewall's block(1) can skip an ETB observer whose matcher provably + // excludes EVERY member of it (CR 603.6a). There is deliberately no representative to + // choose: relief is universally quantified over the class, so no member-selection rule + // (and no CR 110.5b tiebreak) is needed or sound here. Order-independence: the + // member-quantified predicates are pure state reads, so `HashSet` iteration order moves + // only the short-circuit point, never the verdict. The ids are projection-stable, so they + // resolve against the flushed-current `cf` the firewall scans; an empty set never relieves + // (the `!is_empty()` guards) → conservative veto preserved. + // ponytail: O(observers x |G|), short-circuiting on the first non-excluding member. If |G| + // ever measures hot, hoist the member-independent conjuncts out of the per-member loop. + let class_members: HashSet = all_fodder .iter() .copied() .filter(|id| cf.objects.contains_key(id)) - .min_by_key(|id| (cf.objects[id].tapped, *id)); - if fire_time_conditions_read_growing_class(&cf, class_member) { + .collect(); + if fire_time_conditions_read_growing_class_scoped( + &cf, + Some(&class_members), + window_scope_from_cover_frames(&pa, &pb, &[]), + ) { return false; } if cf.stack.iter().any(stack_entry_reads_growing_class) { @@ -1620,6 +2041,187 @@ fn eq_except_growable(pa: &GameState, pb: &GameState, grown: &HashSet) && a.last_loop_action_sequence == b.last_loop_action_sequence } +/// CR 732.2a + CR 608.2h + CR 608.2i + CR 608.2j: does this trigger's `execute` body observe the +/// growing class ONLY through a battlefield-entry-ledger condition whose filter PROVABLY +/// cannot count `class_member`? Returns `true` iff so — then the read's value is +/// invariant across the loop's growth and the observer does not observe the loop. +/// +/// SOUNDNESS rests on the SAME disjointness premise as +/// `etb_observer_provably_excludes_class` (the GAP-1 doc on this function's caller): the +/// fodder is the only class that changes across the covered cycle, guaranteed IN ORDER by +/// `game::engine::derived_fodder_class` — which also has a second, display-only caller; +/// the soundness-bearing one is inside the fodder-cover arm — then +/// `board_covers_modulo_fodder` at its ONLY call site, which PRECEDES this call. Do not +/// reorder that gate after the firewall. +/// +/// WHAT THE ONE-REPRESENTATIVE TEST ESTABLISHES, AND WHAT IT DOES NOT (a measured bound, +/// not a generalisation proof — an earlier draft asserted the generalisation and it was +/// FALSE). Fodder membership is `fodder_content_eq`, which routes through +/// `object_content_eq` (`types/game_state.rs`). That function compares exactly +/// 32 `GameObject` fields and does NOT compare `card_types`, `color` or `keywords`. +/// `BattlefieldEntryRecord` (`types/game_state.rs`) has exactly 8 fields, no +/// `..`: object_id / name / core_types / subtypes / supertypes / colors / keywords / +/// controller. +/// COVERED by the fodder relation: `name`, `controller`. +/// NOT COVERED: `core_types`, `subtypes`, `supertypes`, `colors`, +/// `keywords` — and this matcher reads every one of +/// them (restrictions.rs:493 type, :502 color, +/// :507 keyword). +/// `object_id` differs by construction and feeds exactly one predicate, +/// `FilterProp::Another` (restrictions.rs:514), whose verdict is invariant across +/// fodder members because none of them is the ability source. +/// ⇒ ESTABLISHED: the representative's exclusion carries to every fodder member that +/// agrees with it on those five uncompared record fields. +/// ⇒ NOT ESTABLISHED: that fodder members must so agree. Two objects can be +/// `fodder_content_eq` — hence both in the growing class — while differing in exactly +/// the fields this matcher tests. The residual is a member whose +/// type/subtype/supertype/colour/keyword set diverges under an effect that moves none +/// of the 32 compared fields, against a filter reading the diverged field. That is +/// relief for a class whose later members the observer DOES count — the one direction +/// #4603 forbids — so it is a STATED residual, not an accepted one. +/// ⇒ MEASURED, PER AXIS, EACH COUNT WITH ITS POPULATION PREDICATE. Population: all 60 +/// live `QuantityRef::BattlefieldEntriesThisTurn` refs in `data/card-data.json` sha256 +/// f6dfbe98… (recursively 68 `Typed` leaves; NONE has an empty `type_filters`). +/// - `keywords`: `FilterProp::WithKeyword` is 0/60 — but that is a PROP count, NOT a +/// `keywords`-axis count. `TypeFilter::Subtype` also reads `record.keywords` +/// (restrictions.rs:452, the CR 702.73a Changeling branch), and 18 of the 79 +/// type-filter entries are `Subtype`. +/// - `core_types`: read by the other 61 of the 79 entries — Creature 17, Artifact 11, +/// Permanent 11, Non(Land) 11, Land 9, Planeswalker 2. +/// - `subtypes` + `supertypes`: read by those same 18 `Subtype` entries. +/// - `colors`: `FilterProp::HasColor` is 1/60, LIVE. +/// - filter-level `controller` is 0/60 and IRRELEVANT: `controller` IS one of the 32 +/// compared fields, so it cannot diverge inside a fodder class at all. +/// +/// ⇒ FOUR of the five uncompared record fields are read VERDICT-BEARINGLY by a live +/// filter on today's pool. THE RESIDUAL IS REACHABLE, NOT LATENT. The fifth, +/// `supertypes`, is argument-read but verdict-inert (its only consumer is gated on the +/// subtype being `Host`, and none of the 18 live subtype values is `Host`); +/// over-stating it as read is the CONSERVATIVE direction. What is NOT measured and NOT +/// excluded is the other half: whether a per-member characteristic-changing effect +/// exists that moves NONE of the 32 compared fields (`name` among them). +/// Undischarged, deliberately. Re-derive if `data/card-data.json` is regenerated. +/// DO NOT restate this as "all fodder members' records differ only in `object_id`". That +/// sentence is false, and it was shipped once already as the closure of a review finding. +/// +/// ⛔ ARG-EQUIVALENCE PIN — THE LOAD-BEARING SOUNDNESS PREMISE, AND THE REASON THERE IS +/// NO SEPARATE "is this filter evaluable?" CONJUNCT. This predicate must call +/// `battlefield_entry_matches_filter` with arguments EQUIVALENT to the resolver's own +/// call at game/quantity.rs:3426-3432 (inside `resolve_per_player_scalar`, +/// game/quantity.rs:5354; the whole `BattlefieldEntriesThisTurn` resolver arm is +/// :3411-3436) — same record source, same `filter`, the ability controller for `player`, +/// the same `all_creature_types`, and `Some()`. +/// +/// GIVEN THAT, THE INVARIANT IS: this predicate asks THE SAME MATCHER the resolver will +/// ask, about the NEW class member. A `false` verdict therefore means each member the +/// loop creates contributes 0 TO THE TALLY WHATEVER THE TALLY'S ABSOLUTE VALUE IS — +/// invariance under growth, which is all the soundness argument needs. Do NOT restate +/// this as "an unanswerable filter makes the tally a constant 0": restrictions.rs's +/// `ledger_filter_is_evaluable` doc does say that, but restrictions.rs:519-526 documents +/// the exception in the same file — under `TargetFilter::Or` an unsupported leaf turns a +/// LOUD constant 0 into a SILENT PARTIAL COUNT, and `Or` is live in this class (4 of 60 +/// refs). Invariance-under-growth is `Or`-proof; constant-0 is not. Relieving an +/// unanswerable filter is therefore CORRECT, not merely harmless, and gating on +/// `ledger_filter_is_evaluable` would refuse a sound relief (measured benefit 0/60, +/// measured cost 0/60). Asserted by `ledger_exclusion_is_precise_and_fail_closed` arms +/// (vi) and (vii). If the argument shapes ever diverge, this pin is what breaks first — +/// do not "simplify" the call by dropping `source.id` or by substituting the scoped +/// player for the controller. +/// +/// NOT A VISITOR, deliberately (#4603 error direction): an INCOMPLETE `QuantityRef` +/// collector is unsound HERE, because "every collected read excludes" is vacuously true +/// over a set that missed one. Instead, FOUR fail-closed conjuncts, each of which keeps +/// the conservative veto whenever it cannot prove its half: +/// (0) NO ACTIVATION RESTRICTIONS on this def: `exec.activation_restrictions.is_empty()`. +/// LOAD-BEARING, and conjunct (a) does NOT cover it — `ability_definition_axes` +/// destructures `activation_restrictions: _` (ability_scan.rs:4238), so the scan is +/// BLIND to it and the clone-and-rescan would return `false` even with a +/// class-MATCHING `ActivationRestriction::RequiresCondition` on the same def. +/// Measured cost: ZERO — no trigger `execute` in the card pool carries any +/// (positive control: 3195 on `abilities`). +/// (a) SOLE-SOURCE by single-field clone-and-rescan: clone the def, set +/// `condition = None`, and re-run `ability_definition_reads_sibling_mutable_for_loop`. +/// Only if THAT is `false` is `condition` the def's only sibling read — so no effect +/// body, cost, sub-ability or other field hides a second read this predicate never +/// looked at. +/// (b) SHAPE by a SINGLE-LEVEL pattern match with `_ => false`. No recursion, therefore +/// no totality obligation: a compound (`And`/`Or`/`Not`), an rhs-position read, a +/// non-`QuantityCheck` variant, or a non-`BattlefieldEntriesThisTurn` ref all fall +/// to `_` and KEEP the veto. `rhs` must be `Fixed` so it cannot smuggle a second +/// board read. +/// (c) EXCLUSION delegated verbatim to the ledger's own fire-time matcher +/// `restrictions::battlefield_entry_matches_filter` — the SAME matcher, with the +/// SAME arguments (see the ARG-EQUIVALENCE PIN), that +/// `QuantityRef::BattlefieldEntriesThisTurn` resolves through. NOT +/// `matches_target_filter`: game/quantity.rs:1069-1085 documents that it is not a +/// superset of the ledger matcher (entry-time snapshot vs live object), so its +/// `false` can coexist with a fire-time `true` — relief in the forbidden direction. +/// The resolver's scoped-player test is a separate AND conjunct +/// (game/quantity.rs:3425), so a `false` here excludes the member for EVERY scoped +/// player and no `PlayerScope` resolution is required. +fn execute_ledger_condition_provably_excludes_class( + exec: &crate::types::ability::AbilityDefinition, + state: &GameState, + class_member: ObjectId, + source: &GameObject, +) -> bool { + use crate::types::ability::{AbilityCondition, QuantityExpr, QuantityRef}; + + // (0) the firewall is BLIND to activation restrictions (ability_scan.rs:4238) — + // fail closed. + if !exec.activation_restrictions.is_empty() { + return false; + } + // (a) sole-source by single-field clone-and-rescan. + let mut probe = exec.clone(); + probe.condition = None; + if crate::game::ability_scan::ability_definition_reads_sibling_mutable_for_loop(&probe) { + return false; + } + // (b) shape — single level, `_ => false` via let-else. + let Some(AbilityCondition::QuantityCheck { + lhs: + QuantityExpr::Ref { + qty: QuantityRef::BattlefieldEntriesThisTurn { filter, .. }, + }, + rhs: QuantityExpr::Fixed { .. }, + .. + }) = exec.condition.as_ref() + else { + return false; + }; + // (c) exclusion — fail-closed if the member is gone from the scanned frame. + // ARG-EQUIVALENCE PIN: these five arguments mirror game/quantity.rs:3426-3432. + let Some(member_obj) = state.objects.get(&class_member) else { + return false; + }; + let probe_record = crate::game::restrictions::battlefield_entry_record_for(member_obj); + // The `std::iter::once` is LOAD-BEARING: it guarantees the iterator is never empty, + // so `.all()` cannot be vacuously `true` — the classic fail-open shape for an + // `.all()` guard. Do not "optimise" it away when a real record exists. Both + // authorities are required because the class member is chosen from `all_fodder` and + // can be a pre-existing object that never went through `record_battlefield_entry` + // (so real-records-only would be inert), while a Layer-4 type change can make the + // live object differ from its genuine entry-time snapshot (so synthesized-only would + // ignore the real record). + std::iter::once(&probe_record) + .chain( + state + .battlefield_entries_this_turn + .iter() + .filter(|r| r.object_id == class_member), + ) + .all(|r| { + !crate::game::restrictions::battlefield_entry_matches_filter( + r, + filter, + source.controller, + &state.all_creature_types, + Some(source.id), + ) + }) +} + /// §5.3a firewall (BLOCKER-S1 + S5 + MAJOR-A): does ANY live off-stack fire-time /// observer read the growing class (the axis-2 `sibling` read)? Scans, on the /// FLUSHED current: (1) trigger conditions AND `execute` bodies; (2) [S5] EVERY @@ -1632,7 +2234,24 @@ fn eq_except_growable(pa: &GameState, pb: &GameState, grown: &HashSet) /// ability-body stores. Fail-closed on every surface it cannot classify. fn fire_time_conditions_read_growing_class( state: &GameState, - class_member: Option, + class_members: Option<&HashSet>, +) -> bool { + fire_time_conditions_read_growing_class_scoped( + state, + class_members, + LoopWindowScope::unproven(), + ) +} + +/// Scoped sibling of [`fire_time_conditions_read_growing_class`] — see +/// [`LoopWindowScope`]. Reads `scope.phase_invariant` (CR 510.2 / CR 506.1, blocks (1) +/// and (5b)) and `scope.sole_driver` (CR 117.1b, block (2)); every such guard sits +/// inside an `if let Some(..)`, so [`LoopWindowScope::unproven`] still reaches none of +/// them and the 2-arg wrapper stays identity (`scoped_wrappers_are_identity`). +fn fire_time_conditions_read_growing_class_scoped( + state: &GameState, + class_members: Option<&HashSet>, + scope: LoopWindowScope<'_>, ) -> bool { use crate::game::ability_scan as scan; // (1) Trigger fire-time conditions (CR 603.4) AND effect bodies. @@ -1654,29 +2273,60 @@ fn fire_time_conditions_read_growing_class( if !crate::game::triggers::trigger_definition_functions_in_zone(def, obj.zone) { continue; } + // CR 510.2 / CR 506.1: a trigger whose event cannot occur in the window's + // invariant phase never fires inside the loop, so it does not observe the + // growing class. Fail-closed: `phase_invariant: None` (the caller proved + // nothing) keeps the conservative veto. + if let Some(phase) = scope.phase_invariant { + if crate::game::triggers::trigger_event_unreachable_in_phase(def, phase) { + continue; + } + } // CR 603.2 / CR 603.6a: an enters-the-battlefield observer whose entry matcher - // PROVABLY excludes the growing fodder `class_member` never fires on the loop's + // PROVABLY excludes EVERY member of `class_members` never fires on the loop's // per-cycle token creation, so it does NOT observe the loop — skip it rather than // veto. GAP-1 (soundness + ordering, load-bearing): this is sound only because the // fodder is the ONLY class that changed across the covered cycle, guaranteed IN ORDER // by (a) `game::engine::derived_fodder_class`'s single-new-battlefield-object rule - // (engine.rs:1996) on the FIRST accept-time frame pair, and (b) - // `board_covers_modulo_fodder`'s all-zones stable-partition content-equality - // (resource.rs:1058, asserted at resource.rs:1145) on the SECOND cover frame pair — - // which PRECEDES this firewall call (resource.rs:1156). Do not reorder that gate + // on the FIRST accept-time frame pair — that fn also has a second, display-only + // caller; the soundness-bearing one is inside the fodder-cover arm — and (b) + // `board_covers_modulo_fodder`'s all-zones stable-partition content-equality, at + // its ONLY call site, on the SECOND cover frame pair, which PRECEDES this firewall + // call. Do not reorder that gate // after the firewall. GAP-2 (block(1)-ONLY, deliberate FAIL-CLOSED residual): only // this printed-trigger surface is gated. Block (5b)'s - // `granted_keyword_triggers_in_zone` (triggers.rs:440) CAN synthesize granted ETB + // `granted_keyword_triggers_in_zone` (`game/triggers.rs`) CAN synthesize granted ETB // triggers carrying matchers; a granted ETB observer disjoint from the fodder stays // UN-gated and still conservatively vetoes. That is a scoping choice (fail-closed), // not an impossibility claim — the other surfaces (statics/anthems that scale with // |G| continuously, activated bodies that fire on activation, pending stores) do not // fire on the fodder *entering* via a `valid_card` matcher, so gating them would be // unsound. - if let Some(member) = class_member { - if crate::game::triggers::etb_observer_provably_excludes_class( - def, state, member, obj.id, - ) { + if let Some(members) = class_members { + // CR 603.6a (MagicCompRules.txt:2599): relief requires the entry matcher to + // provably exclude EVERY member of the growing class, not one representative. + // The one-representative test was unsound in the ACCEPTING direction: this + // function's own doc measures that fodder equivalence + // (`object_content_eq`, `types/game_state.rs`, 32 compared fields) does NOT + // compare `card_types`, `color` or `keywords`, so two members can differ on + // exactly the axes a `valid_card` matcher reads. + // `!is_empty()` is LOAD-BEARING and mirrors the `std::iter::once` guard in + // `execute_ledger_condition_provably_excludes_class`: an empty set must not + // make `.all()` vacuously true. NOTE the def-kind test lives INSIDE the closure + // (`etb_observer_provably_excludes_class` opens with + // `matches!(def.mode, ChangesZone | ChangesZoneAll)`), and `Iterator::all` + // on an empty set returns `true` WITHOUT invoking it — so without this + // guard the `continue` fires for every def of every mode. + // Order-independence: both member-quantified predicates are pure state + // reads, so `HashSet` iteration order moves only the short-circuit point, + // never the verdict. + if !members.is_empty() + && members.iter().all(|&member| { + crate::game::triggers::etb_observer_provably_excludes_class( + def, state, member, obj.id, + ) + }) + { continue; } } @@ -1696,18 +2346,32 @@ fn fire_time_conditions_read_growing_class( // content, not provenance). This is what lets Intruder Alarm's `untap all // creatures` (a `SetTapState{Typed{Creature}}` body) relax under the // CR 732.2a `Typed`-precision firewall so the canary can OFFER. - if def - .execute - .as_ref() - .is_some_and(|a| scan::ability_definition_reads_sibling_mutable_for_loop(a)) - { - return true; + if let Some(exec) = def.execute.as_ref() { + // CR 608.2h + CR 608.2i + CR 608.2j: a ledger read whose filter provably + // cannot count the growing fodder has a value invariant across the loop's + // growth, so this def does not observe the loop — skip it rather than veto. + // Fail-closed on `class_members: None` (the OFFLINE cover passes `None` and + // is therefore untouched BY this narrowing — note that the CR 117.1b / + // CR 510.2 scope guards above are NOT class_members-gated and DO reach it). + if scan::ability_definition_reads_sibling_mutable_for_loop(exec) + && !class_members.is_some_and(|members| { + !members.is_empty() + && members.iter().all(|&m| { + execute_ledger_condition_provably_excludes_class( + exec, state, m, obj, + ) + }) + }) + { + return true; + } } } } // (2) S5: EVERY ability def on a functioning battlefield permanent, any kind. - // ponytail: this ability-BODY scan is scoped to the battlefield (an activated - // ability functions only there, CR 602.5a), so an OFF-battlefield source's + // ponytail: this ability-BODY scan is scoped to the battlefield (CR 113.6 + // (MagicCompRules.txt:771): "Abilities of all other objects usually function only + // while that object is on the battlefield"), so an OFF-battlefield source's // |G|-reading activated-ability effect body is unscanned. Reachability is very // low and the dominant failure mode — a |G|-scaled monotone pump — keeps the loop // unbounded (not a false COVER on unboundedness). Upgrade path: 4a-live / B3 must @@ -1719,11 +2383,61 @@ fn fire_time_conditions_read_growing_class( if obj.zone != Zone::Battlefield || obj.is_phased_out() { continue; } - if obj - .abilities - .iter() - .any(scan::ability_definition_reads_sibling_mutable_for_loop) - { + if obj.abilities.iter().any(|ability| { + // CR 117.1b + CR 732.2c: no player but the sole driver receives priority + // inside the taken shortcut, so a FOREIGN-controlled activated ability + // cannot be activated during the window and cannot read the growing class. + // CR 605.3a bounds this: a mana ability is activatable outside the priority + // rule (while another player casts a spell or activates an ability), so it + // is NOT relieved and keeps vetoing. + // PER-ABILITY, never per-object: another surface on the same object (a + // trigger body, block (1)) must keep vetoing. + // Fail-closed on `sole_driver: None` (the caller proved nothing). + let relieved = scope.sole_driver.is_some_and(|driver| { + // CR 117.1b (MagicCompRules.txt:930) is a statement about ACTIVATED + // abilities only: "a player may activate an activated ability any time + // they have priority". A `Spell`/`BeginGame`/`Database`/`Mulligan`-kind + // def is not reached through the priority rule at all, so a priority-based + // rationale can say nothing about it and must not relieve it. Same + // authority `layers.rs` uses to decide "this def is activatable". + // + // Measured on `data/card-data.json` (name-keyed object, 35 516 keys, + // 22 634 `abilities[]` entries): 9 797 of them are NOT `Activated` + // (`{Spell 9768, BeginGame 27, Mulligan 2}`), so this conjunct is not a + // no-op. Narrowing to entries that syntactically carry one of the 17 + // `sibling: true` `QuantityRef` tags in `ability_scan.rs`: 1 465 + // entries, 769 of them non-`Activated`. That 1 465/769 pair is an + // ESTIMATE of the at-risk class, NOT a bound in either direction — the + // predicate over-counts (a tagged ref need not reach the scan's sibling + // axis) and under-counts (the scan also flags sibling reads from + // non-`QuantityRef` surfaces and from every `Axes::CONSERVATIVE` subtree). + ability.kind == crate::types::ability::AbilityKind::Activated + && obj.controller != driver + && !crate::game::mana_abilities::is_mana_ability(ability) + // CR 602.2 (MagicCompRules.txt:2527): "Only an object's controller (or + // its owner, if it doesn't have a controller) can activate its + // activated ability UNLESS THE OBJECT SPECIFICALLY SAYS OTHERWISE." + // `activator_filter` is that "otherwise": with `All` or `Opponent` the + // SOLE DRIVER may activate this FOREIGN permanent's ability while + // holding priority inside the window, so `obj.controller != driver` + // does not imply unreachability. + // + // Fail closed on ANY `Some(..)`, never on an enumeration of the two + // widening variants. `PlayerFilter` (`types/ability.rs`) has 25 + // variants; enumerating would make THIS site assert that the other 23 + // leave a foreign ability unreachable — a claim nothing forces anyone + // to re-verify when variant 26 lands. `is_none()` asserts nothing about + // any variant: it keys on CR 602.2's own predicate, whether the object + // says otherwise AT ALL. Note `player_may_begin_activating`'s + // `Some(_) => player == source_controller` catch-all (`casting.rs`) + // NARROWS an unmodeled variant to controller-only, so that surface is a + // silent under-model of a future widening variant and must not be + // inherited here. LATENT on today's pool, deliberately: 45 defs carry + // `activator_filter`, 0 of which are growing-class-read candidates. + && ability.activator_filter.is_none() + }); + !relieved && scan::ability_definition_reads_sibling_mutable_for_loop(ability) + }) { return true; } } @@ -1820,6 +2534,14 @@ fn fire_time_conditions_read_growing_class( continue; } for def in crate::game::triggers::granted_keyword_triggers_in_zone(state, obj) { + // CR 510.2 / CR 506.1: same phase-unreachability relief as block (1). The + // guard is per-`def` and applies to any trigger definition, however it was + // produced. Fail-closed on `phase_invariant: None`. + if let Some(phase) = scope.phase_invariant { + if crate::game::triggers::trigger_event_unreachable_in_phase(&def, phase) { + continue; + } + } if def .condition .as_ref() @@ -2443,6 +3165,17 @@ fn stack_entry_resolution_choice_freedom( /// `game::triggers` still pins the flagged set so a NEW projected-reading /// granted-keyword condition surfaces as a review signal. fn fire_time_conditions_read_projected_resource(state: &GameState) -> bool { + fire_time_conditions_read_projected_resource_scoped(state, LoopWindowScope::unproven()) +} + +/// Scoped sibling of [`fire_time_conditions_read_projected_resource`] — see +/// [`LoopWindowScope`]. Reads `scope.cast_card_ids` (CR 601.2f, block (iii-static)); +/// that guard sits inside an `is_some_and`, so [`LoopWindowScope::unproven`] never +/// reaches it and the 2-arg wrapper stays identity (`scoped_wrappers_are_identity`). +fn fire_time_conditions_read_projected_resource_scoped( + state: &GameState, + scope: LoopWindowScope<'_>, +) -> bool { // (i) Trigger fire-time intervening-if conditions (CR 603.4). `active_trigger_ // definitions` is the liveness authority (CR 702.26b phased-out + CR 114.4 // command-zone gate) that deliberately does NOT filter by `condition`. @@ -2510,6 +3243,24 @@ fn fire_time_conditions_read_projected_resource(state: &GameState) -> bool { if !crate::game::functioning_abilities::static_functions_in_zone(obj, def) { continue; } + // CR 601.2f vs CR 604.1 / CR 613.1: a self-cost modifier on a card the + // window provably never casts cannot modify any cost paid inside the + // window, so its condition's read of a projected resource is not an + // observation of the loop. Fail-closed on `cast_card_ids: None` (no proof + // ⇒ scan everything); `Some(&[])` can never arise (see + // `window_cast_card_ids`). + if matches!( + def.mode, + crate::types::statics::StaticMode::ModifyCost { .. } + ) && matches!( + def.affected, + Some(crate::types::ability::TargetFilter::SelfRef) + ) && scope + .cast_card_ids + .is_some_and(|ids| !ids.contains(&obj.card_id)) + { + continue; + } if def .condition .as_ref() @@ -6012,15 +6763,18 @@ mod tests { fire_time_conditions_read_growing_class(&build(disjoint.clone()), None), "None class context: even a disjoint ETB observer keeps the conservative veto" ); - // (a) DISJOINT + `Some(member)`: the gate skips the observer ⇒ NOT vetoed. + // (a) DISJOINT + `Some(class)`: the gate skips the observer ⇒ NOT vetoed. assert!( - !fire_time_conditions_read_growing_class(&build(disjoint), Some(member)), - "a provably-disjoint ETB observer is skipped when a fodder representative is supplied" + !fire_time_conditions_read_growing_class( + &build(disjoint), + Some(&HashSet::from([member])) + ), + "a provably-disjoint ETB observer is skipped when the proven class is supplied" ); - // (b) MATCHING (broad matcher matches the fodder) + `Some(member)`: still vetoed — the + // (b) MATCHING (broad matcher matches the fodder) + `Some(class)`: still vetoed — the // gate only skips PROVABLY-disjoint observers. assert!( - fire_time_conditions_read_growing_class(&build(broad), Some(member)), + fire_time_conditions_read_growing_class(&build(broad), Some(&HashSet::from([member]))), "a broad ETB observer whose matcher matches the fodder still vetoes" ); } @@ -6046,6 +6800,158 @@ mod tests { ); } + /// ITEM A — a FOREIGN, NON-`Activated` sibling-reading def is NOT relieved by + /// `sole_driver`. CR 117.1b licenses relief only for ACTIVATED abilities ("a player + /// may activate an activated ability any time they have priority"); a `Spell`-kind + /// def is not reached through the priority rule at all, so a priority-based rationale + /// can say nothing about it. + /// + /// The subject and the MATCHED POSITIVE CONTROL come from ONE builder, so the only + /// variable between them is `kind` — which is what makes the subject's veto + /// attributable to `kind` rather than to some other surface on the board. + /// + /// REVERT-PROBE: delete `ability.kind == AbilityKind::Activated &&` from block (2)'s + /// `relieved` closure ⇒ the subject is relieved too ⇒ the subject assertion FAILS, + /// deterministically. + #[test] + fn foreign_non_activated_ability_is_not_relieved_by_sole_driver() { + use crate::game::ability_scan as scan; + use crate::types::ability::{AbilityDefinition, AbilityKind}; + use std::sync::Arc; + + // ONE builder ⇒ subject and control are byte-identical except `kind`. + let build = |kind: AbilityKind| { + let mut state = GameState::new_two_player(7); + let observer = inert_token(&mut state, 950, 1, "Foreign Observer"); + let def = AbilityDefinition::new(kind, sibling_reading_effect()); + state.objects.get_mut(&observer).unwrap().abilities = Arc::new(vec![def]); + (state, observer) + }; + // `LoopWindowScope` derives `Copy`, so one binding serves both calls. + let driver_scope = LoopWindowScope { + phase_invariant: None, + sole_driver: Some(PlayerId(0)), + pinned_slots: &[], + cast_card_ids: None, + }; + + let (subject, observer) = build(AbilityKind::Spell); + // ---- REACH-GUARDS: all of them, before any outcome assertion ---- + { + let obj = &subject.objects[&observer]; + assert_eq!(obj.abilities.len(), 1); + assert_eq!(obj.abilities[0].kind, AbilityKind::Spell); + assert!( + scan::ability_definition_reads_sibling_mutable_for_loop(&obj.abilities[0]), + "reach-guard: the scan must SEE the sibling axis, else the row proves nothing \ + (subsumes the `Effect::Unimplemented => Axes::NONE` vacuity)" + ); + assert!( + !crate::game::mana_abilities::is_mana_ability(&obj.abilities[0]), + "reach-guard: CR 605.3a is NOT what carries this row's verdict" + ); + assert_eq!(obj.zone, Zone::Battlefield); + assert!(!obj.is_phased_out()); + assert!( + obj.trigger_definitions.is_empty(), + "reach-guard: block (1) must be silent, so the verdict is attributable to block (2)" + ); + assert_ne!( + obj.controller, + PlayerId(0), + "reach-guard: the observer really is FOREIGN" + ); + } + // ---- SUBJECT ---- + assert!( + fire_time_conditions_read_growing_class_scoped(&subject, None, driver_scope), + "CR 117.1b licenses relief only for ACTIVATED abilities; a Spell-kind def is not \ + reached through the priority rule at all" + ); + // ---- MATCHED POSITIVE CONTROL: the ONLY variable is `kind` ---- + let (control, _) = build(AbilityKind::Activated); + assert!( + !fire_time_conditions_read_growing_class_scoped(&control, None, driver_scope), + "control: the identical def at kind=Activated IS relieved — so the subject's veto is \ + attributable to `kind` and not to some unrelated surface on this board" + ); + } + + /// ITEM E — a FOREIGN `Activated` def carrying an `activator_filter` is NOT relieved. + /// CR 602.2: "Only an object's controller (or its owner, if it doesn't have a + /// controller) can activate its activated ability UNLESS THE OBJECT SPECIFICALLY SAYS + /// OTHERWISE." `activator_filter` is that "otherwise", so `obj.controller != driver` + /// does not imply the sole driver cannot activate it inside the window. + /// + /// The guard fails closed on ANY `Some(..)` rather than on an enumeration of the + /// widening variants, so this row's subject uses one representative (`All`) and the + /// claim under test is the `is_none()` predicate, not that variant. + /// + /// REVERT-PROBE: delete `&& ability.activator_filter.is_none()` ⇒ the subject is + /// relieved ⇒ the subject assertion FAILS. + #[test] + fn foreign_activator_filter_ability_is_not_relieved_by_sole_driver() { + use crate::game::ability_scan as scan; + use crate::types::ability::{AbilityDefinition, AbilityKind, PlayerFilter}; + use std::sync::Arc; + + let build = |activator_filter: Option| { + let mut state = GameState::new_two_player(7); + let observer = inert_token(&mut state, 951, 1, "Foreign Widened Observer"); + let mut def = AbilityDefinition::new(AbilityKind::Activated, sibling_reading_effect()); + def.activator_filter = activator_filter; // `pub` field on `AbilityDefinition` + state.objects.get_mut(&observer).unwrap().abilities = Arc::new(vec![def]); + (state, observer) + }; + let driver_scope = LoopWindowScope { + phase_invariant: None, + sole_driver: Some(PlayerId(0)), + pinned_slots: &[], + cast_card_ids: None, + }; + + let (subject, observer) = build(Some(PlayerFilter::All)); + { + let obj = &subject.objects[&observer]; + assert_eq!(obj.abilities.len(), 1); + assert_eq!(obj.abilities[0].kind, AbilityKind::Activated); + assert!( + obj.abilities[0].activator_filter.is_some(), + "reach-guard: the subject must actually carry the widening field" + ); + assert!( + scan::ability_definition_reads_sibling_mutable_for_loop(&obj.abilities[0]), + "reach-guard: the scan must SEE the sibling axis, else the row proves nothing" + ); + assert!( + !crate::game::mana_abilities::is_mana_ability(&obj.abilities[0]), + "reach-guard: CR 605.3a is NOT what carries this row's verdict" + ); + assert_eq!(obj.zone, Zone::Battlefield); + assert!(!obj.is_phased_out()); + assert!( + obj.trigger_definitions.is_empty(), + "reach-guard: block (1) must be silent, so the verdict is attributable to block (2)" + ); + assert_ne!( + obj.controller, + PlayerId(0), + "reach-guard: the observer really is FOREIGN" + ); + } + assert!( + fire_time_conditions_read_growing_class_scoped(&subject, None, driver_scope), + "CR 602.2: an `activator_filter` is the object saying otherwise, so the sole \ + driver MAY activate this foreign ability inside the window" + ); + let (control, _) = build(None); + assert!( + !fire_time_conditions_read_growing_class_scoped(&control, None, driver_scope), + "control: the identical def with `activator_filter: None` IS relieved — so the \ + subject's veto is attributable to that field alone" + ); + } + /// R-s4-objfield (two-sided): a non-grown object's §5.2c ADD field (`intensity`) /// accumulates while the board grows ⇒ REJECT; held constant ⇒ COVER. /// Revert-failing: dropping `intensity` from `object_content_eq` flips the REJECT @@ -6773,4 +7679,2181 @@ mod tests { "a life LOSS yields no batched gain δ" ); } + + /// A battlefield permanent carrying ONE `TriggerMode::Phase` trigger whose step + /// (`Phase::End`) the state is NOT in — the "phase-gated observer" population. + /// CR 500.1: phases and steps proceed in a fixed order, so a window + /// that provably never leaves `PreCombatMain` never reaches this trigger's step. + /// That is exactly the population a populated `LoopWindowScope::phase_invariant` + /// proof can change the answer on, which is why the identity row asserts here. + fn phase_gated_observer_board(condition: crate::types::ability::TriggerCondition) -> GameState { + use crate::types::ability::TriggerDefinition; + use crate::types::triggers::TriggerMode; + + let mut state = GameState::new_two_player(7); + state.phase = Phase::PreCombatMain; + let id = bf_object(&mut state, 100); + state.objects.get_mut(&id).unwrap().trigger_definitions = + vec![TriggerDefinition::new(TriggerMode::Phase) + .phase(Phase::End) + .condition(condition)] + .into(); + state + } + + /// Phase 1a (Seam A). Each of the three CR 732.2a window predicates keeps its + /// 2-arg/1-arg name as a **1-line wrapper** delegating to a `_scoped` sibling with + /// [`LoopWindowScope::unproven`], so pre-change neutrality is STRUCTURAL + /// (`f(a,b) ≡ f_scoped(a,b, unproven())`) rather than something each caller has + /// to re-establish. This row pins that identity over five populations, including + /// the phase-gated observer board named in `phase_gated_observer_board`. + /// + /// NON-VACUITY (trap 7 — the instrument must be able to return both values): + /// every predicate is asserted at a population where it answers `true` AND at one + /// where it answers `false`, and the row asserts the collected answer vectors + /// directly. A constant `_scoped` body — the failure a bare `a == b` identity + /// check cannot see — fails the vector assertions. + /// + /// REVERT-PROBE (live at this phase): stop a wrapper delegating (restore the old + /// inline body, or have it pass anything other than `unproven()`) ⇒ the matching + /// arm's `assert_eq!` fails. Since the growing-class firewall now READS + /// `phase_invariant` / `sole_driver`, "make `unproven()` populate a field" is a live + /// probe too: the phase-gated observer board below is precisely the population a + /// populated `phase_invariant` changes the answer on, so a non-`None` `unproven()` + /// breaks the identity here rather than silently. + #[test] + fn scoped_wrappers_are_identity() { + use crate::types::ability::TriggerCondition; + + // (1)/(2) cover pairs: one that covers, one that does not (an extra permanent + // breaks gate (1)'s board equality) — so the cover predicate is exercised at + // both answers. + let (cover_prior, cover_current) = cover_base(); + let (nocover_prior, nocover_current) = { + let (p, mut c) = cover_base(); + bf_object(&mut c, 900); + (p, c) + }; + + // (3) a benign board: neither firewall fires. + let benign = GameState::new_two_player(7); + // (4) phase-gated SIBLING observer: `ControlsType` is a live board census ⇒ the + // growing-class firewall vetoes, the projected-resource firewall does not. + let sibling_observer = phase_gated_observer_board(TriggerCondition::ControlsType { + filter: TargetFilter::Any, + }); + // (5) phase-gated PROJECTED observer: "if you gained life this turn" reads a + // projected player axis ⇒ the projected firewall vetoes. + let projected_observer = + phase_gated_observer_board(TriggerCondition::GainedLife { minimum: 1 }); + + let cover = |prior: &GameState, current: &GameState| { + 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 must be its _scoped sibling at unproven()" + ); + plain + }; + let growing = |state: &GameState| { + let plain = fire_time_conditions_read_growing_class(state, None); + assert_eq!( + plain, + fire_time_conditions_read_growing_class_scoped( + state, + None, + LoopWindowScope::unproven() + ), + "fire_time_conditions_read_growing_class must be its _scoped sibling at unproven()" + ); + plain + }; + let projected = |state: &GameState| { + let plain = fire_time_conditions_read_projected_resource(state); + assert_eq!( + plain, + fire_time_conditions_read_projected_resource_scoped( + state, + LoopWindowScope::unproven() + ), + "fire_time_conditions_read_projected_resource must be its _scoped sibling at unproven()" + ); + plain + }; + + assert_eq!( + [ + cover(&cover_prior, &cover_current), + cover(&nocover_prior, &nocover_current) + ], + [true, false], + "the cover predicate must answer BOTH ways across the two pairs — a constant \ + implementation would satisfy identity alone" + ); + assert_eq!( + [ + growing(&benign), + growing(&sibling_observer), + growing(&projected_observer) + ], + [false, true, false], + "the growing-class firewall vetoes on the sibling observer only" + ); + assert_eq!( + [ + projected(&benign), + projected(&sibling_observer), + projected(&projected_observer) + ], + [false, false, true], + "the projected-resource firewall vetoes on the projected observer only" + ); + } + + /// Candidate windows for the Seam A cast proof, each paired with its EXPECTED + /// `is_forced_cascade_window` membership. + /// + /// The `bool` is what makes drift loud in BOTH directions, and it exists because the + /// caller previously derived its obligation by FILTERING this list through the very + /// predicate under test: deleting a member then silently shrank the proof obligation + /// and left the row green (measured — a reviewer's revert probe deleted seven members + /// and the row still passed). With an expected-membership column, deleting a member + /// fails its `true` row and adding one of the listed non-members fails its `false` + /// row. The list is the authority; the predicate is the thing being measured against + /// it. A member absent from here is still simply never proved — see the `ponytail:` + /// note on the caller for that residual and its upgrade path. + /// + /// `on_board` must be objects that really exist ON THE BATTLEFIELD in the caller's + /// state and `in_hand` a card that really exists in hand: the turn-based windows + /// carry object references the per-viewer legal-action enumerator dereferences, and + /// each reference has a zone the window implies — untap candidates, the exerting / + /// enlisting attacker and the enlist-eligible creature are battlefield permanents + /// (CR 502.3 / CR 508.1g), while `DiscardToHandSize` names cards in hand (CR 514.1). + /// Passing a hand card as an untap candidate measures a window no rules path can + /// produce. + /// + /// Same requirement, one level deeper: a window whose payload the enumerator needs + /// but the fixture leaves at `Default::default()` produces ZERO actions of any kind, + /// so "it enumerates no cast" is inert rather than measured. `attacker` is an + /// OPPOSING battlefield creature the caller has also entered into `state.combat`, so + /// the CR 509.1 window offers a real block. The caller's per-window reach-guard is + /// what keeps that requirement enforced instead of documented. + fn cast_proof_candidate_windows( + on_board: [ObjectId; 2], + in_hand: ObjectId, + attacker: ObjectId, + ) -> Vec<(&'static str, crate::types::game_state::WaitingFor, bool)> { + use crate::types::game_state::WaitingFor; + vec![ + ( + "Priority{active} — CR 704.3 SBA point; NOT exempt, and the positive control", + WaitingFor::Priority { + player: PlayerId(0), + }, + false, + ), + ( + "Priority{non-active} — same, and the sampler's ring-clearing arm", + WaitingFor::Priority { + player: PlayerId(1), + }, + false, + ), + ( + "RedistributeLifeTotals — a window that CAN MOVE LIFE, so never exempt", + WaitingFor::RedistributeLifeTotals { + player: PlayerId(0), + options: Vec::new(), + }, + false, + ), + ( + "AssignCombatDamage — turn-based (CR 510.1) but CR 510.2 deals the damage \ + with no intervening priority, so it MOVES LIFE", + WaitingFor::AssignCombatDamage { + player: PlayerId(0), + attacker_id: on_board[0], + total_damage: 2, + blockers: Vec::new(), + assignment_modes: Vec::new(), + trample: None, + defending_player: PlayerId(1), + attack_target: crate::game::combat::default_attack_target(), + pw_loyalty: None, + pw_controller: None, + }, + false, + ), + ( + "CombatTaxPayment — CR 508.1j / CR 509.1f cost sub-step; a Phyrexian tax \ + symbol is paid with 2 life (CR 107.4f), so it MOVES LIFE", + WaitingFor::CombatTaxPayment { + player: PlayerId(0), + context: crate::types::game_state::CombatTaxContext::Attacking, + total_cost: crate::types::mana::ManaCost::Cost { + shards: vec![crate::types::mana::ManaCostShard::PhyrexianWhite], + generic: 0, + }, + per_creature: Vec::new(), + pending: crate::types::game_state::CombatTaxPending::Attack { + attacks: Vec::new(), + bands: Vec::new(), + }, + }, + false, + ), + ( + "OrderTriggers (CR 603.3b)", + WaitingFor::OrderTriggers { + player: PlayerId(0), + // TWO summaries, matching the two-trigger group the caller puts in + // `state.pending_trigger_order`: `order_triggers_candidates` is keyed + // on this length and yields nothing at length 0, and + // `handle_order_triggers` rejects any order whose length disagrees + // with the pending group. CR 603.3b needs a real choice — with ONE + // trigger `begin_trigger_ordering` auto-orders the group + // (`g.triggers.len() <= 1 => g.ordered = true`) and + // `build_next_order_triggers_prompt` only ever returns an UNORDERED + // group, so no rules path opens this window over a singleton and + // `order: [0]` is the only legal answer rather than an ordering. + // The two members must also differ, or the order-independence check + // auto-orders them too; each `description` mirrors the group's + // `PendingTrigger.description`, which is what the real builder copies + // into the summary. + triggers: vec![ + crate::types::game_state::PendingTriggerSummary { + source_id: on_board[0], + source_name: "Test Bear 0".to_string(), + description: "you gain 1 life".to_string(), + }, + crate::types::game_state::PendingTriggerSummary { + source_id: on_board[1], + source_name: "Test Bear 1".to_string(), + description: "you gain 2 life".to_string(), + }, + ], + }, + true, + ), + ( + "TriggerTargetSelection (CR 603.3d)", + WaitingFor::TriggerTargetSelection { + player: PlayerId(0), + trigger_controller: None, + trigger_event: None, + trigger_events: Vec::new(), + target_slots: Vec::new(), + mode_labels: Vec::new(), + target_constraints: Vec::new(), + // CR 603.3d: one legal target for the current slot. The enumerator + // for this window maps `current_legal_targets` directly to + // `ChooseTarget`, so an empty progress makes the window offer nothing + // at all and the cast-zero below unreadable. + selection: crate::types::game_state::TargetSelectionProgress { + current_legal_targets: vec![TargetRef::Object(on_board[0])], + ..Default::default() + }, + source_id: None, + description: None, + }, + true, + ), + ( + "OptionalEffectChoice (CR 603.5 + CR 608.2d)", + WaitingFor::OptionalEffectChoice { + player: PlayerId(0), + source_id: on_board[0], + description: None, + may_trigger_key: None, + }, + true, + ), + ( + "CommanderZoneChoice (CR 903.9a)", + WaitingFor::CommanderZoneChoice { + player: PlayerId(0), + commander_id: ObjectId(2), + current_zone: Zone::Graveyard, + }, + true, + ), + ( + "ChooseLegend (CR 704.5j)", + WaitingFor::ChooseLegend { + player: PlayerId(0), + legend_name: "Delianfel, Prayerful Herald".to_string(), + candidates: on_board.to_vec(), + }, + true, + ), + ( + "BattleProtectorChoice (CR 310.10 + CR 704.5w / CR 704.5x)", + WaitingFor::BattleProtectorChoice { + player: PlayerId(0), + battle_id: ObjectId(5), + candidates: vec![PlayerId(1)], + }, + true, + ), + // CR 703.1 turn-based members. CR 117.3a puts every one of them strictly + // before the active player receives priority, so CR 117.1a / CR 305.1 bar + // a cast or land play at each just as they do at the SBA members above. + ( + "UntapChoice (CR 502.3 + CR 117.3a)", + WaitingFor::UntapChoice { + player: PlayerId(0), + // CR 502.3 untaps PERMANENTS: the candidates must be on the + // battlefield, not a card in hand. + candidates: on_board.to_vec(), + chosen_not_to_untap: Vec::new(), + }, + true, + ), + ( + "ChooseUntapSubset (CR 502.3)", + WaitingFor::ChooseUntapSubset { + player: PlayerId(0), + group: on_board.to_vec(), + // CR 502.3 cap. `max: 1` over a 2-permanent group keeps the + // variant's `group.len() > max` invariant AND admits a real + // non-empty choice — with `max: 0` the only legal selection is the + // empty one, so "this window enumerates no cast" would be a + // degenerate zero rather than a measured one. + max: 1, + }, + true, + ), + ( + "DeclareAttackers (CR 508.1)", + WaitingFor::DeclareAttackers { + player: PlayerId(0), + valid_attacker_ids: on_board.to_vec(), + // CR 506.2: in a two-player game the NONACTIVE player is the defending + // player, and only that player (plus their planeswalkers and the + // battles they protect) may be attacked. `default_attack_target()` is + // `Player(PlayerId(0))`, i.e. P0's own creatures attacking P0, which + // the simulation filter rejects for every non-empty proposal. The + // guard below then passes on the decline alone (measured: the window + // offered `[DeclareAttackers { attacks: [], bands: [] }]`). The + // opposing seat is what makes it offer a GENUINE attack. + valid_attack_targets: vec![crate::game::combat::AttackTarget::Player( + PlayerId(1), + )], + valid_attack_targets_by_attacker: None, + attacker_constraints: Default::default(), + }, + true, + ), + ( + "ExertChoice (CR 508.1g + CR 701.43d)", + WaitingFor::ExertChoice { + player: PlayerId(0), + // CR 701.43d exerts an ATTACKING permanent. + attacker: on_board[0], + remaining: Vec::new(), + }, + true, + ), + ( + "EnlistChoice (CR 508.1g + CR 702.154b)", + WaitingFor::EnlistChoice { + player: PlayerId(0), + attacker: on_board[0], + // CR 702.154a taps another untapped creature you control — a + // battlefield permanent, and a DIFFERENT one from the attacker. + eligible: vec![on_board[1]], + remaining: Vec::new(), + }, + true, + ), + ( + "DeclareBlockers (CR 509.1)", + WaitingFor::DeclareBlockers { + player: PlayerId(0), + valid_blocker_ids: on_board.to_vec(), + // CR 509.1a: a real "this creature may block that attacker" pairing. + // `blocker_actions` enumerates block proposals strictly from this + // map, so an empty map leaves only the decline (the empty + // declaration) — measured: with `state.combat` present but this map + // empty the guard below passes on the decline alone. Populating it is + // what makes the window offer a GENUINE block, which is what the + // cast-zero is supposed to be measured against. + valid_block_targets: on_board + .iter() + .map(|&blocker| (blocker, vec![attacker])) + .collect(), + block_requirements: Default::default(), + blocker_constraints: Default::default(), + }, + true, + ), + ( + "DiscardToHandSize (CR 514.1 + CR 514.3)", + WaitingFor::DiscardToHandSize { + player: PlayerId(0), + count: 1, + // CR 514.1 discards from HAND — the one window whose object + // reference is correctly a hand card. + cards: vec![in_hand], + }, + true, + ), + ] + } + + /// Seam A's `cast_card_ids: Some(&[])` proof, pinned as a row. + /// + /// CR 117.1a (a spell is cast only with priority) and CR 305.1 (a land is played + /// only with priority) say no cast or land-play can happen at a window where + /// nobody holds priority. This row measures that claim against the engine's own + /// legal-action enumerator instead of trusting it: on ONE board it enumerates the + /// deliberate class (`CastSpell` / `PlayLand` / `ActivateAbility`) at a `Priority` + /// window and at every window `is_forced_cascade_window` currently exempts. + /// + /// The exempt set is DERIVED from a candidate list that includes non-members, rather + /// than hardcoded. That is what keeps the revert-probe live: widening the predicate + /// widens what this row has to prove. Measured — with a hardcoded exempt-only list, + /// adding `Priority` to the class left this row green, because the legal-action + /// enumerator never consults the predicate. + /// + /// FAILS LOUDLY ON CLASS DRIFT IN BOTH DIRECTIONS. Deriving the obligation by + /// FILTERING the candidate list through `is_forced_cascade_window` — the predicate + /// under test — was itself a hole: deleting a member just shrank the loop, and a + /// reviewer's revert probe deleting seven members left this row GREEN. Each candidate + /// now carries its EXPECTED membership and that expectation is asserted before the + /// cast proof runs, so DELETING a member fails its `true` row and ADDING one of the + /// enumerated non-members (`Priority` either seat, `RedistributeLifeTotals`, + /// `AssignCombatDamage`, `CombatTaxPayment`) fails its `false` row. + /// + /// ponytail: a brand-new `WaitingFor` variant added to the predicate but to no list + /// is still silent. Closing that needs an exhaustive 127-arm `WaitingFor` destructure; + /// deliberately not built, because the load-bearing non-members are enumerated here + /// and `is_forced_cascade_window`'s FAIL-CLOSED fall-through makes a forgotten variant + /// a conservative miss rather than a soundness hole. Upgrade path if that changes: + /// mirror `types::game_state::_gamestate_partition_is_total`'s no-`..` destructure + /// over `WaitingFor` so the build breaks when a variant is added. + /// + /// That mechanism did its job when the class was widened to the CR 703.1 turn-based + /// actions: the seven new members (CR 502.3 untap, CR 508.1 / CR 508.1g declare + /// attackers + exert/enlist, CR 509.1 declare blockers, CR 514.1 cleanup discard) + /// were added to the candidate list and re-measured, and none of them enumerates a + /// `CastSpell` / `PlayLand` / `ActivateAbility`. So `cast_card_ids: Some(&[])` still + /// holds for a window retained across a turn boundary: untapping, declaring, + /// exerting/enlisting and discarding to hand size are not casts, and CR 117.3a + /// grants nobody the priority CR 117.1a / CR 305.1 require. + /// + /// NON-VACUITY (trap 7 — a zero from an instrument that cannot return non-zero): + /// the `Priority` arm runs FIRST and is asserted NON-EMPTY on the same board, so + /// the zeros below are proved zeros, not an inert enumerator. The exempt set is + /// also asserted non-empty, so "no exempt window admits a cast" cannot pass by the + /// class being empty. + /// + /// ⚠️ SCOPE LIMIT (stated, not implied): this row enumerates the legal actions + /// available **at** a window. It therefore cannot see the `apply_action` bypass + /// class — the handful of `GameAction`s that early-return before the ring clear + /// (`ReorderHand`, `Concede`, `Debug`, `GrantDebugPermission`, + /// `RevokeDebugPermission`, `CancelAutoPass`, `SetPhaseStops`, + /// `SetPriorityPassingMode`). That class is discharged separately by enumeration: + /// none of those actions casts a spell or plays a land, so the proof is unaffected. + /// + /// REVERT-PROBE: add `WaitingFor::Priority { .. }` to `is_forced_cascade_window`'s + /// `matches!` ⇒ a `Priority` window becomes "exempt", casts become admissible + /// inside a retained window, and the `Some(&[])` proof this row pins is false. + #[test] + fn no_exempt_window_admits_a_cast() { + use crate::types::actions::GameAction; + use crate::types::game_state::WaitingFor; + + let mut state = GameState::new_two_player(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), + }; + // A land in hand makes the deliberate class REACHABLE on this board (CR 305.1 + CR 305.2: + // main phase, empty stack, the active player holds priority, land drop unused). + // It is also the ONLY correct object for `DiscardToHandSize` (CR 514.1 discards + // from hand) — every other window below names a battlefield permanent. + let in_hand = crate::game::zones::create_object( + &mut state, + CardId(701), + PlayerId(0), + "Forest".to_string(), + Zone::Hand, + ); + state + .objects + .get_mut(&in_hand) + .unwrap() + .card_types + .core_types + .push(CoreType::Land); + // Two real battlefield creatures. CR 502.3 untap candidates, the CR 508.1g + // exerting attacker and its CR 702.154a enlist-eligible partner are all + // permanents; passing the hand card for those built windows no rules path can + // produce, and the per-viewer enumerator dereferences every one of them. + let on_board = [0u64, 1].map(|i| { + let id = crate::game::zones::create_object( + &mut state, + CardId(710 + i), + PlayerId(0), + format!("Test Bear {i}"), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.power = Some(2); + obj.toughness = Some(2); + obj.base_power = Some(2); + obj.base_toughness = Some(2); + id + }); + + // CR 509.1a: a real attacking creature CONTROLLED BY THE OPPONENT and + // entered into `state.combat`, so the CR 509.1 window below is answerable. The + // blocker-action enumerator runs every proposal through the engine's own + // `handle_declare_blockers`, which errors out with "No combat state (attackers + // not declared)" when `state.combat` is `None` — every candidate is then filtered + // away and the window offers nothing at all. + let attacker = crate::game::zones::create_object( + &mut state, + CardId(730), + PlayerId(1), + "Test Ogre".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&attacker).unwrap(); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.power = Some(2); + obj.toughness = Some(2); + obj.base_power = Some(2); + obj.base_toughness = Some(2); + } + state.combat = Some(crate::game::combat::CombatState { + attackers: vec![crate::game::combat::AttackerInfo::attacking_player( + attacker, + PlayerId(0), + )], + ..Default::default() + }); + + // CR 603.3b: one unordered group, matching the two-summary `OrderTriggers` + // window. `handle_order_triggers` reads the group (not the window) for the + // permutation length and rejects the submission outright without it, so the + // window's candidates would be filtered out and its zero rendered inert. + // + // TWO members, and DIFFERENT ones. `begin_trigger_ordering` auto-orders any + // group that is a singleton or `group_is_order_independent`, and only an + // unordered group ever becomes a prompt — so a one-trigger group, or two + // triggers with identical normalized abilities, is a window no rules path can + // open. Distinct life amounts make the group order-dependent by the engine's + // own conservative identity check, which is the reachable shape. Both stay + // inert: no targets, no modes, no resolution choice. + let inert_life_trigger = |source_id, value, description: &str| { + // `single` (not a struct literal) supplies the CR 603.7 firing identity: + // `TriggerFiring::Ordinary`. A literal would leave the field's `#[default]` + // `UnknownLegacy`, which is reserved for persisted records whose install + // receipt cannot be reconstructed — never for a freshly built trigger. + crate::game::triggers::PendingTriggerContext::single( + crate::game::triggers::PendingTrigger { + source_id, + controller: PlayerId(0), + condition: None, + ability: Box::new(ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value }, + player: TargetFilter::Controller, + }, + vec![], + source_id, + PlayerId(0), + )), + timestamp: 0, + target_constraints: Vec::new(), + distribute: None, + trigger_event: None, + modal: None, + mode_abilities: Vec::new(), + // The real prompt builder COPIES this into the summary + // (`description.clone().unwrap_or_default()`), so a `None` here + // under a described summary is a state the engine cannot produce. + description: Some(description.to_string()), + may_trigger_origin: None, + subject_match_count: None, + die_result: None, + }, + ) + }; + state.pending_trigger_order = Some(crate::types::game_state::PendingTriggerOrder { + groups: vec![crate::types::game_state::TriggerOrderGroup { + controller: PlayerId(0), + triggers: vec![ + inert_life_trigger(on_board[0], 1, "you gain 1 life"), + inert_life_trigger(on_board[1], 2, "you gain 2 life"), + ], + ordered: false, + }], + resume_after_ordering: None, + }); + + let deliberate = |s: &GameState| -> Vec { + crate::ai_support::legal_actions(s) + .into_iter() + .filter(|a| { + matches!( + a, + GameAction::CastSpell { .. } + | GameAction::PlayLand { .. } + | GameAction::ActivateAbility { .. } + ) + }) + .collect() + }; + + // POSITIVE CONTROL, asserted before any zero is read. + let at_priority = deliberate(&state); + assert!( + !at_priority.is_empty(), + "reach-guard: the enumerator must return a deliberate action at a Priority \ + window on this board, else every zero below is an inert instrument" + ); + + // CLASS-DRIFT GATE, run before the cast proof: every candidate's membership must + // be what the list says it is. A deleted member reds its `true` row here; an + // added non-member reds its `false` row. + let candidates = cast_proof_candidate_windows(on_board, in_hand, attacker); + for (why, window, expected_member) in &candidates { + assert_eq!( + window.is_forced_cascade_window(), + *expected_member, + "CLASS DRIFT — `is_forced_cascade_window` disagrees with the candidate \ + table on {why}. Expected member = {expected_member}." + ); + } + let (members, non_members): (usize, usize) = candidates.iter().fold( + (0, 0), + |(m, n), (_, _, e)| if *e { (m + 1, n) } else { (m, n + 1) }, + ); + assert!( + members > 0 && non_members > 0, + "reach-guard: both halves of the table must be populated — a one-sided table \ + is satisfiable by a constant predicate; got {members} members / \ + {non_members} non-members" + ); + + for (why, window, expected_member) in candidates { + if !expected_member { + continue; + } + state.waiting_for = window; + // PER-WINDOW REACH-GUARD. The zero below is only evidence if the enumerator + // is live AT THIS WINDOW. A member whose fixture is under-populated (a + // `Default::default()` where the enumerator needs real data) yields zero + // deliberate actions because it yields zero actions AT ALL — an inert + // instrument, not a measured absence. Measured on the pre-guard fixtures, + // three members were exactly that: `OrderTriggers` (no `pending_trigger_order` + // group ⇒ no valid permutation), `TriggerTargetSelection` (empty + // `current_legal_targets`) and `DeclareBlockers` (`state.combat: None` ⇒ every + // proposal rejected by the simulation filter). + assert!( + !crate::ai_support::legal_actions(&state).is_empty(), + "{why} must offer at least one legal answer, else the zero below is inert" + ); + let found = deliberate(&state); + assert!( + found.is_empty(), + "{why} holds no priority (CR 117.1a / CR 305.1), so it must admit no \ + CastSpell/PlayLand/ActivateAbility; got {found:?}" + ); + } + } + + // ----------------------------------------------------------------------- + // CR 704 elimination bound (§4.2) + // ----------------------------------------------------------------------- + + /// `n` living players, seat `i` at `lives[i]`. Poison and library stay at their + /// constructor defaults unless a case sets them. + fn bound_board(lives: &[i32]) -> GameState { + let mut state = GameState::new( + crate::types::format::FormatConfig::free_for_all(), + lives.len() as u8, + 7, + ); + for (p, &life) in state.players.iter_mut().zip(lives) { + p.life = life; + } + state + } + + /// A per-period delta carrying `losses[i]` life loss on seat `i` (0 = no term). + fn life_loss_delta(losses: &[(u8, i64)]) -> ResourceVector { + let mut v = ResourceVector::default(); + for &(seat, magnitude) in losses { + v.life.insert(PlayerId(seat), -magnitude); + } + v + } + + fn slot(index: u8) -> DecisionSlot { + DecisionSlot { + source: crate::types::game_state::YieldTarget::AllCopies { + card_id: CardId(u64::from(index) + 900), + trigger_description: None, + }, + index, + } + } + + fn slot_magnitudes(magnitudes: &[i64]) -> BTreeMap { + magnitudes + .iter() + .enumerate() + .map(|(i, &m)| (slot(i as u8), m)) + .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. + /// + /// 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), + 19 + ); + // (c) poison 0, Δ5 ⇒ 1. Kills `(10 - poison) / Δ` (= 2): CR 704.5c loses at TEN, so + // the headroom is 9, and 2 cycles would already have delivered 10. + { + let mut v = ResourceVector::default(); + v.poison.insert(PlayerId(1), 5); + assert_eq!( + v.elimination_bounds(&bound_board(&[40, 40]), &[], &no_slots), + 1 + ); + } + // (d) library 8, Δ2 ⇒ 4. Kills `(L - 1) / Δ` (= 3): CR 104.3c/CR 121.4 lose on the + // DRAW FROM EMPTY, not on reaching one card, so all 8 cards may legally go. + { + let mut state = bound_board(&[40, 40]); + state.players[1].library = (0..8).map(|i| ObjectId(1000 + i)).collect(); + let mut v = ResourceVector::default(); + v.library_delta.insert(PlayerId(1), -2); + assert_eq!(v.elimination_bounds(&state, &[], &no_slots), 4); + } + // (e) two living at 40 and 12, Δ1 each ⇒ 11. Kills max-instead-of-min. + assert_eq!( + life_loss_delta(&[(0, 1), (1, 1)]).elimination_bounds( + &bound_board(&[40, 12]), + &[], + &no_slots + ), + 11 + ); + // (f) life 5000, Δ1 ⇒ 1000. Kills a missing clamp to MAX_SHORTCUT_CYCLES. + assert_eq!( + life_loss_delta(&[(1, 1)]).elimination_bounds( + &bound_board(&[40, 5000]), + &[], + &no_slots + ), + crate::game::engine::MAX_SHORTCUT_CYCLES + ); + // (g) CR 800.4a: an ELIMINATED seat at life 1 must not lower N — PAIRED with the + // same seat un-eliminated, which DOES (trap 7: the zero has a non-zero control). + { + let mut alive = bound_board(&[40, 1, 40]); + let delta = life_loss_delta(&[(1, 1), (2, 1)]); + assert_eq!( + delta.elimination_bounds(&alive, &[], &no_slots), + 0, + "control: while that seat is IN the game it pins the bound to 0" + ); + alive.players[1].is_eliminated = true; + assert_eq!( + delta.elimination_bounds(&alive, &[], &no_slots), + 39, + "an eliminated seat has left the game and constrains nothing" + ); + } + // (h) the PROPOSER at life 3 losing 1/cycle ⇒ N <= 2. Kills the deleted + // `p == proposer => unbounded` special case: `net_progress_for` reads only the + // proposer's mana and life, so it cannot see this at all. + assert!( + life_loss_delta(&[(0, 1)]).elimination_bounds(&bound_board(&[3, 40]), &[], &no_slots) + <= 2 + ); + // (i) the PROPOSER gaining 3 poison/cycle from 0 ⇒ N <= 3. Same defect on the axis + // `net_progress_for` is entirely blind to. + { + let mut v = ResourceVector::default(); + v.poison.insert(PlayerId(0), 3); + assert!(v.elimination_bounds(&bound_board(&[40, 40]), &[], &no_slots) <= 3); + } + // (j) observed drain on P3 only, lives P1/P2/P3 = 12/13/28, ONE published slot of + // magnitude 1 whose legal targets are every opponent ⇒ 11. Kills the + // observed-victim-only bound (which returns 27, P3's own headroom): the + // declaration may aim the slot at P1 instead. Paired with the untargeted twin. + { + let board = bound_board(&[69, 12, 13, 28]); + let delta = life_loss_delta(&[(3, 1)]); + let victims = [PlayerId(1), PlayerId(2), PlayerId(3)]; + assert_eq!( + delta.elimination_bounds(&board, &victims, &slot_magnitudes(&[1])), + 11 + ); + assert_eq!( + delta.elimination_bounds(&board, &[], &no_slots), + 27, + "with NO declarable victims only the observed victim constrains the bound" + ); + } + // (k) TWO published slots, each magnitude 1, both able to name any opponent ⇒ each + // declarable victim's magnitude is 2 ⇒ N == 5. Kills a per-slot (non-aggregated) + // bound, which returns 11 and would let a both-slots-on-P1 declaration kill P1 + // at cycle 6 — inside the proposal. + { + let board = bound_board(&[69, 12, 13, 28]); + let victims = [PlayerId(1), PlayerId(2), PlayerId(3)]; + assert_eq!( + ResourceVector::default().elimination_bounds( + &board, + &victims, + &slot_magnitudes(&[1, 1]) + ), + 5 + ); + } + // (l) a 12-life seat at Δ1 ⇒ N == 11, and cycle TWELVE is the killing cycle. The + // off-by-one stated as an arithmetic identity, not a comment. + { + let board = bound_board(&[40, 12]); + let n = life_loss_delta(&[(1, 1)]).elimination_bounds(&board, &[], &no_slots); + assert_eq!(n, 11); + assert_eq!( + board.players[1].life as i64 - (i64::from(n) + 1), + 0, + "cycle N+1 = 12 is the one that reaches 0 life (CR 704.5a)" + ); + } + // (m) the dump-C shape: ONE slot of magnitude 1 over every opponent, lives + // 77/20/20/16, and an OBSERVED loss of 1 on P3 — the same drain, measured twice. + // ⇒ N == 7 under the clamped-additive operator. This is the DOUBLE-COUNT case: + // `observed` and `S` measure one drain, so charging `0.max(1) + 1 == 2` to P3 + // over-charges and returns 7 where `max` returned 15. Accepted — it errs toward + // REFUSAL, and this repo's convention is fail-closed. + // Its untargeted twin stays at 15, so the pair now DISCRIMINATES (7 vs 15) where + // under `max` both read 15 — strictly stronger than before. + // REVERT-PROBE: restore `observed_life_loss.max(declared_life_magnitude)` ⇒ this + // assertion flips 7 → 15 ⇒ FAILS. + { + let board = bound_board(&[77, 20, 20, 16]); + let delta = life_loss_delta(&[(3, 1)]); + let victims = [PlayerId(1), PlayerId(2), PlayerId(3)]; + assert_eq!( + delta.elimination_bounds(&board, &victims, &slot_magnitudes(&[1])), + 7, + "the slot magnitude and the observed loss may be the SAME drain, but this \ + signature cannot prove it, so both are charged: `0.max(1) + 1 == 2` over \ + P3's headroom of 15 gives 7" + ); + assert_eq!( + delta.elimination_bounds(&board, &[], &no_slots), + 15, + "untargeted twin: with no published slot the victim arm is never taken, so \ + the board still bounds at 15 — this is what makes the pair discriminating" + ); + } + // (n) lives in its OWN #[test] below — see + // `elimination_bounds_mixed_loss_charges_both_terms`. Case (m) above shares + // its revert-probe (the same `max` restoration) and panics FIRST, which made + // (n)'s documented probe unreachable while they sat in one test fn. + // (o) NET-GAIN victim — the `.max(0)` clamp's own discriminator. P1 GAINS 2 life + // per period (`life_loss_delta` with a NEGATIVE loss), so + // `observed_life_loss = -2`, while ONE published slot of magnitude 1 can be + // re-aimed at them. The declared slot still constrains: charged magnitude is + // `max(-2, 0) + 1 == 1` ⇒ `(10 - 1) / 1 == 9`. + // + // WHY THIS ROW EXISTS: without `.max(0)` the charge is `-2 + 1 == -1`, so + // `elimination_bounds`' `narrow` closure never fires for P1 (its guard is + // `magnitude > 0`) and the bound stays at MAX_SHORTCUT_CYCLES — the life axis + // silently DISARMED on exactly the input that needs it. Asserting the cap here + // would lock that fail-open in behind a green test. + // REVERT-PROBE: delete `.max(0)` from `elimination_bounds`' `life_magnitude` + // operator ⇒ this assertion flips 9 → MAX_SHORTCUT_CYCLES ⇒ FAILS. + // + // NOT bounded by the clamp, disclosed: intra-cycle dips. `self.life` is a + // per-period NET delta, so a period draining 5 and lifelinking 7 also reports + // `observed = -2` while dipping below `life - 5` mid-cycle. That blindness is a + // property of the INPUT and is identical under `max`. + { + let board = bound_board(&[40, 10]); + let delta = life_loss_delta(&[(1, -2)]); + let victims = [PlayerId(1)]; + // REACH-GUARD (kept from the in-flight row): no P0 term exists, so the value + // below cannot be the cap-or-not for an unrelated seat's reason. + assert!(!delta.life.contains_key(&PlayerId(0))); + assert_eq!( + delta.elimination_bounds(&board, &victims, &slot_magnitudes(&[1])), + 9, + "a NET-GAIN victim is still bounded by the re-aimable slot: the observed \ + term is clamped to 0 and cannot credit against the declared magnitude" + ); + } + } + + /// Case (n) of the `elimination_bounds` battery, in its OWN `#[test]` so its + /// revert-probe is independently REACHABLE: case (m) shares the probe (restore + /// `observed_life_loss.max(declared_life_magnitude)`) and panics first at 15 vs 7, + /// so (n)'s assertion never executed under its own stated probe while they were + /// one test fn. + /// + /// MIXED-LOSS regression. The observed drain and the published slot are DIFFERENT + /// losses (an untargeted 1 plus a re-aimable 1), so P1's true per-period loss is 2 + /// against a headroom of 1 ⇒ NO legal repetition exists. `max` returned 1 here, + /// offering one iteration that takes P1 from 2 to 0 — an in-proposal elimination + /// (CR 704.5a), exactly the conditional action CR 732.2a forbids. This is the row + /// that proves the operator swap is a soundness fix and not a re-labelling. + /// + /// REVERT-PROBE: restore `observed_life_loss.max(declared_life_magnitude)` ⇒ the + /// subject assertion flips 0 → 1 ⇒ FAILS (and the positive control above it still + /// passes, isolating the flip to the operator). + #[test] + fn elimination_bounds_mixed_loss_charges_both_terms() { + let no_slots: BTreeMap = BTreeMap::new(); + let board = bound_board(&[40, 2]); + let delta = life_loss_delta(&[(1, 1)]); + let victims = [PlayerId(1)]; + // PAIRED POSITIVE CONTROL, first: the same board with NO published slot bounds + // at 1, so the instrument provably returns non-zero here and the 0 below is a + // VERDICT rather than a dead path. + assert_eq!( + delta.elimination_bounds(&board, &[], &no_slots), + 1, + "positive control: with no published slot the observed drain of 1 over P1's \ + headroom of 1 permits exactly one repetition" + ); + assert_eq!( + delta.elimination_bounds(&board, &victims, &slot_magnitudes(&[1])), + 0, + "MIXED LOSS: an untargeted drain of 1 AND a re-aimable slot of magnitude 1 \ + cost P1 2 per period against a headroom of 1, so no legal repetition \ + exists; `max` returned 1 and permitted an in-proposal elimination" + ); + } + + /// A conditioned SELF-cost-modifying static (CR 601.2f) on a card sitting in + /// `zone`, whose condition reads a PROJECTED player resource (life gained this + /// turn). This is dump-D's Mortality Spear shape: a `ModifyCost` whose `affected` + /// is `SelfRef`, visible from a never-cast-from zone. + fn conditioned_self_cost_static_board(zone: Zone, card_id: u64) -> GameState { + use crate::types::ability::{ + Comparator, PlayerScope, QuantityExpr, QuantityRef, StaticCondition, StaticDefinition, + TargetFilter, + }; + use crate::types::mana::ManaCost; + use crate::types::statics::{CostModifyMode, StaticMode}; + + let mut state = GameState::new_two_player(7); + state.phase = Phase::PreCombatMain; + let oid = ObjectId(500); + let mut object = crate::game::game_object::GameObject::new( + oid, + CardId(card_id), + PlayerId(0), + "Conditioned Cost Static".to_string(), + zone, + ); + object.static_definitions = vec![StaticDefinition::new(StaticMode::ModifyCost { + mode: CostModifyMode::Reduce, + amount: ManaCost::NoCost, + spell_filter: None, + dynamic_count: None, + }) + .affected(TargetFilter::SelfRef) + .condition(StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::LifeGainedThisTurn { + player: PlayerScope::Controller, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + }) + .active_zones(vec![ + Zone::Hand, + Zone::Stack, + Zone::Command, + Zone::Graveyard, + Zone::Exile, + Zone::Library, + Zone::Battlefield, + ])] + .into(); + state.objects.insert(oid, object); + if zone == Zone::Battlefield { + state.battlefield.push_back(oid); + } + state + } + + /// X4-1 — CR 601.2f. A conditioned SELF-cost modifier on a card the window + /// provably never casts cannot modify any cost paid inside the window, so its + /// condition's projected read is not an observation of the loop. Asserted across + /// FOUR never-cast-from zones, each with its own positive control: the UNSCOPED + /// call (`cast_card_ids: None`, no proof) still vetoes in all four. + /// + /// REVERT-PROBES: + /// * delete the `continue` ⇒ all four scoped assertions FAIL. + /// * drop the `ModifyCost` conjunct ⇒ the `Continuous` sibling below is wrongly + /// relieved ⇒ FAILS. + /// * drop the `Some(TargetFilter::SelfRef)` conjunct ⇒ the affects-others sibling + /// below is wrongly relieved ⇒ FAILS. + #[test] + fn a_conditioned_cost_static_in_a_zone_the_window_never_casts_from_does_not_observe() { + use crate::types::ability::TargetFilter; + use crate::types::statics::StaticMode; + + // A card id the window's driving sequence does NOT contain. + let never_cast = [CardId(999)]; + + for zone in [Zone::Library, Zone::Hand, Zone::Graveyard, Zone::Exile] { + let state = conditioned_self_cost_static_board(zone, 500); + + // POSITIVE CONTROL for this zone: with NO proof the firewall still vetoes. + assert!( + fire_time_conditions_read_projected_resource(&state), + "X4-1 control ({zone:?}): `cast_card_ids: None` is NO PROOF, so the \ + conservative veto must be preserved" + ); + + let scope = LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned_slots: &[], + cast_card_ids: Some(&never_cast), + }; + assert!( + !fire_time_conditions_read_projected_resource_scoped(&state, scope), + "X4-1 ({zone:?}): CR 601.2f — the window provably never casts this card, \ + so its self-cost modifier cannot modify any cost paid inside the window" + ); + } + + // NON-BLANKET siblings, both in the SAME never-cast-from zone with the SAME + // proof: only a `ModifyCost` + `SelfRef` static may be relieved. + let mut not_modify_cost = conditioned_self_cost_static_board(Zone::Library, 500); + { + let obj = not_modify_cost.objects.get_mut(&ObjectId(500)).unwrap(); + let mut defs: Vec<_> = obj.static_definitions.iter_all().cloned().collect(); + defs[0].mode = StaticMode::Continuous; + obj.static_definitions = defs.into(); + } + let scope = LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned_slots: &[], + cast_card_ids: Some(&never_cast), + }; + assert!( + fire_time_conditions_read_projected_resource_scoped(¬_modify_cost, scope), + "X4-1: a NON-`ModifyCost` static with the same condition is NOT a cost \ + modifier, so CR 601.2f's argument does not apply — keep vetoing" + ); + + let mut affects_others = conditioned_self_cost_static_board(Zone::Library, 500); + { + let obj = affects_others.objects.get_mut(&ObjectId(500)).unwrap(); + let mut defs: Vec<_> = obj.static_definitions.iter_all().cloned().collect(); + defs[0].affected = Some(TargetFilter::Any); + obj.static_definitions = defs.into(); + } + assert!( + fire_time_conditions_read_projected_resource_scoped(&affects_others, scope), + "X4-1: a cost modifier affecting OTHER objects can modify a cost paid in the \ + window even though its own card is never cast — keep vetoing" + ); + } + + /// X4-2 — the matched negative that kills the lazy-but-unsound X4. The SAME static + /// on a card whose id IS in the window's cast set keeps vetoing: the window does + /// cast it, so its self-cost modifier does apply inside the window. + /// + /// REVERT-PROBE: replace the guard with a bare `ModifyCost ⇒ continue` ⇒ FAILS. + #[test] + fn a_cost_static_on_a_card_the_loop_recasts_still_vetoes() { + let state = conditioned_self_cost_static_board(Zone::Hand, 500); + let recast = [CardId(500)]; + let scope = LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned_slots: &[], + cast_card_ids: Some(&recast), + }; + assert!( + fire_time_conditions_read_projected_resource_scoped(&state, scope), + "X4-2: CR 601.2f — the window DOES cast this card, so its conditioned \ + self-cost modifier is read inside the window and must keep vetoing" + ); + + // PAIRED POSITIVE (same board, one variable — the cast set): a different id is + // relieved, so the assertion above is not a constant. + let other = [CardId(501)]; + let relieved_scope = LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned_slots: &[], + cast_card_ids: Some(&other), + }; + assert!( + !fire_time_conditions_read_projected_resource_scoped(&state, relieved_scope), + "X4-2 paired positive: the identical board with the card OUT of the cast set \ + IS relieved — the only variable is membership" + ); + } + + /// X4-5 — THE `:1038` BINDING EXPRESSION, pinned through the PRODUCTION entry point. + /// + /// X4-4 tests [`window_cast_card_ids`] directly and X4-1 uses a hand-built scope, so + /// neither pins the premise *"conjunct (5) derives `cast_card_ids` from + /// `window_cast_card_ids(current)`, fail-closed"*. Measured: writing + /// `Some(cast_ids.as_deref().unwrap_or(&[]))` at that binding re-opens the fail-open + /// and every other X4 row still passes. This row closes that gap: it drives + /// [`loop_states_cover_modulo_growth`] — the real 2-arg production predicate, which + /// `loop_check.rs` calls with NO non-empty-sequence precondition — over a covering + /// frame pair carrying a library-visible conditioned self-cost static. + /// + /// MATCHED PAIR, one variable (the recorded driving sequence): + /// * half A — EMPTY sequence ⇒ no proof ⇒ the guard is fail-closed ⇒ conjunct (5) + /// rejects the cover. + /// * half B — a one-entry sequence naming a DIFFERENT card ⇒ proof ⇒ relieved ⇒ the + /// cover holds. + /// + /// REVERT-PROBES, both measured to flip half A: + /// * bind `Some(cast_ids.as_deref().unwrap_or(&[]))` instead of `cast_ids.as_deref()`. + /// * make `window_cast_card_ids` return `Some(ids)` unconditionally. + #[test] + fn empty_sequence_keeps_the_projected_cost_veto_through_the_production_cover() { + use crate::types::ability::{ + Comparator, PlayerScope, QuantityExpr, QuantityRef, StaticCondition, StaticDefinition, + TargetFilter, + }; + use crate::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + use crate::types::mana::ManaCost; + use crate::types::statics::{CostModifyMode, StaticMode}; + + const STATIC_CARD: CardId = CardId(90); + const DRIVER_CARD: CardId = CardId(64); + + // A library-resident conditioned SELF-cost static, added identically to BOTH + // frames so it cannot perturb the board-equality conjuncts (1)-(4). + let add_static = |state: &mut GameState| { + let oid = ObjectId(700); + let mut object = crate::game::game_object::GameObject::new( + oid, + STATIC_CARD, + PlayerId(0), + "Library Cost Static".to_string(), + Zone::Library, + ); + object.static_definitions = vec![StaticDefinition::new(StaticMode::ModifyCost { + mode: CostModifyMode::Reduce, + amount: ManaCost::NoCost, + spell_filter: None, + dynamic_count: None, + }) + .affected(TargetFilter::SelfRef) + .condition(StaticCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::LifeGainedThisTurn { + player: PlayerScope::Controller, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + }) + .active_zones(vec![Zone::Library, Zone::Hand, Zone::Stack])] + .into(); + state.objects.insert(oid, object); + }; + + // REACH-GUARD: the untouched pair covers, so any `false` below is caused by the + // static and not by an upstream conjunct. + let (bare_prior, bare_current) = cover_base(); + assert!( + loop_states_cover_modulo_growth(&bare_prior, &bare_current), + "reach-guard: the base frame pair must COVER, else conjuncts (1)-(4) dominate" + ); + + // ── half A: empty driving sequence ⇒ NO PROOF ⇒ the veto survives ── + let (mut prior, mut current) = cover_base(); + add_static(&mut prior); + add_static(&mut current); + assert!( + current.last_loop_action_sequence.is_empty(), + "half A precondition: no recorded driving sequence" + ); + assert!( + !loop_states_cover_modulo_growth(&prior, ¤t), + "half A: an EMPTY `last_loop_action_sequence` proves NOTHING about what the \ + window casts, so the conditioned self-cost static must keep its veto and \ + conjunct (5) must reject. `Some(&[])` here would assert `this window casts \ + nothing` and relieve every such static — the forbidden direction." + ); + + // ── half B: a real one-entry sequence naming a DIFFERENT card ⇒ relieved ── + let ctx = LoopActionContext { + card_id: DRIVER_CARD, + controller: PlayerId(0), + action: LoopAction::Recast { + from_zone: Zone::Hand, + uses_buyback: BuybackUsage::Used, + }, + convoke: None, + pins: Vec::new(), + }; + prior.last_loop_action_sequence = vec![ctx.clone()]; + current.last_loop_action_sequence = vec![ctx]; + assert_ne!(DRIVER_CARD, STATIC_CARD); + assert!( + loop_states_cover_modulo_growth(&prior, ¤t), + "half B: with the cast set PROVEN and the static's card outside it, CR 601.2f \ + says the modifier cannot apply inside the window ⇒ the cover holds" + ); + } + + /// X4-4 — [`window_cast_card_ids`]'s emptiness contract, called DIRECTLY so no cover + /// conjunct can dominate it. An empty `last_loop_action_sequence` means NO RECORDED + /// PROOF, not "this window casts nothing": `Some(vec![])` would assert the latter + /// and relieve EVERY conditioned self-cost static. + /// + /// REVERT-PROBE: replace `if ids.is_empty() { None } else { Some(ids) }` with a bare + /// `Some(ids)` ⇒ assertion (1) FAILS while (2) still passes ⇒ the probe is isolated + /// to the emptiness test. + /// + /// ⛔ WHAT THIS ROW DOES NOT CLAIM: it does not assert "and the X4-1 static still + /// vetoes". That half is carried by X4-1's own UNSCOPED arm + /// (`LoopWindowScope::unproven()` has `cast_card_ids: None`, measured `true` on all + /// four zones). The end-to-end property is the COMPOSITION of two directly-tested + /// seams — X4-4 (`empty ⇒ None`) and X4-1 (`None ⇒ veto`) — and is stated as a + /// composition, not asserted as a third row. + #[test] + fn empty_loop_action_sequence_proves_nothing_about_casting() { + use crate::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + let mut state = GameState::new_two_player(7); + assert!(state.last_loop_action_sequence.is_empty()); + assert_eq!( + window_cast_card_ids(&state), + None, + "(1) an empty driving sequence is NO PROOF — `Some(vec![])` would assert \ + `this window casts nothing` and relieve every conditioned self-cost static" + ); + + // (2) PAIRED POSITIVE. `action` is not load-bearing here (the derivation reads + // only `card_id`); `Recast` is the cheapest to construct. + state.last_loop_action_sequence = vec![LoopActionContext { + card_id: CardId(64), + controller: PlayerId(0), + action: LoopAction::Recast { + from_zone: Zone::Hand, + uses_buyback: BuybackUsage::Used, + }, + convoke: None, + pins: Vec::new(), + }]; + assert_eq!( + window_cast_card_ids(&state), + Some(vec![CardId(64)]), + "(2) a one-entry sequence yields exactly that card id" + ); + } + + /// X4-3 — the REAL 4-player Dina/Conqueror capture (`dina_conqueror_4p.json.gz`), + /// loaded through the production restore chokepoint + /// `PersistedGameState::into_game_state`. It carries dump-D obj 90 **Mortality + /// Spear** in P0's LIBRARY: a conditioned `ModifyCost` static whose `affected` is + /// `SelfRef` and whose `active_zones` make it visible from the library — exactly + /// X4's subject, on a board nobody synthesized. + /// + /// MEASURED on this board (which is what makes the flip attributable): the Spear's + /// static is the **ONLY** projected-resource-reading fire-time surface in the entire + /// dump — 1 static, 0 trigger conditions — so the unscoped `true` is caused by it + /// alone and the scoped `false` cannot come from anything else. + /// + /// ⛔ NO OFFER CLAIM IS MADE HERE. 2b's deliverable-visible acceptance is that it + /// changes nothing observable (an empty `combo-verify` rowdiff); this row asserts the + /// SEAM, not a shortcut offer. + /// + /// REVERT-PROBE: delete X4's `continue` in + /// `fire_time_conditions_read_projected_resource_scoped` block (iii-static) ⇒ the + /// scoped half returns `true` ⇒ FAILS. Both directions are probed in this one row: + /// the unscoped call is the positive control for the scoped call. + #[test] + fn dina_untargeted_drain_4p_cover_is_not_vetoed_by_a_library_cost_static() { + use crate::types::ability::TargetFilter; + use crate::types::statics::StaticMode; + use std::io::Read; + + let gz = include_bytes!("../../tests/fixtures/dina_conqueror_4p.json.gz"); + 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"); + let raw: GameState = serde_json::from_value(envelope["gameState"].clone()) + .expect("the real 4p gameState must deserialize into the current GameState"); + let state = + crate::types::game_state::PersistedGameState::Raw(Box::new(raw)).into_game_state(); + + // ── reach-guards: the X4 subject really is present, in a never-cast-from zone ── + let spear = state + .objects + .get(&ObjectId(90)) + .expect("dump-D obj 90 is present"); + assert_eq!(spear.name, "Mortality Spear"); + assert_eq!( + spear.zone, + Zone::Library, + "the subject is visible from a zone the window never casts from" + ); + let subjects: Vec<_> = state + .objects + .values() + .filter(|o| { + o.static_definitions.iter_all().any(|d| { + matches!(d.mode, StaticMode::ModifyCost { .. }) + && matches!(d.affected, Some(TargetFilter::SelfRef)) + && d.condition.is_some() + }) + }) + .map(|o| (o.id, o.name.clone(), o.zone)) + .collect(); + assert_eq!( + subjects.len(), + 1, + "ATTRIBUTION reach-guard: the dump must carry EXACTLY ONE conditioned \ + self-cost static, else the flip below is not attributable to it; got \ + {subjects:?}" + ); + + // ── POSITIVE CONTROL: with no proof, the real board vetoes ── + assert!( + fire_time_conditions_read_projected_resource(&state), + "X4-3 control: `cast_card_ids: None` is NO PROOF, so the real 4p board must \ + keep its conservative veto" + ); + + // ── the window provably casts something else (any id but the Spear's) ── + let spear_card = spear.card_id; + let cast = [CardId(spear_card.0 + 1)]; + assert!(!cast.contains(&spear_card)); + let scope = LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned_slots: &[], + cast_card_ids: Some(&cast), + }; + assert!( + !fire_time_conditions_read_projected_resource_scoped(&state, scope), + "X4-3: CR 601.2f — the window provably never casts Mortality Spear, so its \ + library-visible self-cost modifier cannot modify any cost paid inside the \ + window and must not veto the cover. \ + ⛔ PRE-REGISTERED FAILURE BRANCH: if this fails, name the NEXT rejecting \ + surface (the measurement above says the Spear is the only one) and its call \ + count in the PR body, and STOP — do not widen the guard." + ); + + // ── non-blanket: the SAME board with the Spear IN the cast set keeps vetoing ── + let recast = [spear_card]; + let recast_scope = LoopWindowScope { + phase_invariant: None, + sole_driver: None, + pinned_slots: &[], + cast_card_ids: Some(&recast), + }; + assert!( + fire_time_conditions_read_projected_resource_scoped(&state, recast_scope), + "X4-3 matched negative: a window that DOES cast the Spear keeps its veto — \ + the only variable is cast-set membership" + ); + } + + /// A Saproling creature token, the fodder class 2c's rows exclude or match. + fn saproling_class_member(state: &mut GameState) -> ObjectId { + let oid = ObjectId(800); + let mut object = crate::game::game_object::GameObject::new( + oid, + CardId(0), + PlayerId(0), + "Saproling".to_string(), + Zone::Battlefield, + ); + object.card_types.core_types = vec![CoreType::Creature]; + object.card_types.subtypes = vec!["Saproling".to_string()]; + object.color = vec![crate::types::mana::ManaColor::Green]; + object.is_token = true; + state.objects.insert(oid, object); + state.battlefield.push_back(oid); + oid + } + + /// The ability source the ledger read belongs to (the observer permanent). + fn ledger_observer_source(state: &mut GameState) -> ObjectId { + let oid = ObjectId(801); + let mut object = crate::game::game_object::GameObject::new( + oid, + CardId(801), + PlayerId(0), + "BBFU10 Bystander".to_string(), + Zone::Battlefield, + ); + object.card_types.core_types = vec![CoreType::Creature]; + state.objects.insert(oid, object); + state.battlefield.push_back(oid); + oid + } + + /// Parse `oracle` and hand back the first trigger's `execute` body — the exact + /// `AbilityDefinition` block (1) scans. + fn trigger_execute_from_oracle(oracle: &str) -> crate::types::ability::AbilityDefinition { + let parsed = crate::parser::parse_oracle_text( + oracle, + "BBFU10 Bystander", + &[], + &["Creature".to_string()], + &[], + ); + parsed + .triggers + .first() + .and_then(|t| t.execute.as_deref()) + .cloned() + .expect("the constructed oracle must parse a trigger execute body") + } + + /// K4-N3 + NW-2 — the CR 608.2i + CR 608.2j exclusion predicate, SEVEN arms, both polarities on + /// every axis. Each `false` arm is paired with a `true` arm in the same row, so a + /// constant implementation fails at least one. + /// + /// REVERT-PROBES, one per conjunct (each named with the arm it flips): + /// * (ii) disable conjunct (c) ⇒ verbatim Park Heights Pegasus is wrongly relieved ⇒ + /// (ii) FAILS. (a) is measured to PASS for Pegasus, so (c) is the only conjunct + /// carrying its refusal. + /// * (iii) drop conjunct (0) ⇒ FAILS. This is NW-2: the scan destructures + /// `activation_restrictions: _` (ability_scan.rs:4238), so conjunct (a) returns + /// `false` and the predicate would wrongly return `true` with a class-MATCHING + /// `ActivationRestriction::RequiresCondition` on the very def being relieved. + /// * (iv) replace conjunct (b)'s `_ => false` with `_ => true` ⇒ FAILS. + /// * (v) drop conjunct (a) ⇒ FAILS. + /// * (vi) flip the matcher's `FilterProp` fail-closed `_ => false` + /// (restrictions.rs:515) to `_ => true` ⇒ the `FaceDown` filter now matches the + /// record ⇒ relief is refused ⇒ FAILS. + /// * (vii) swap conjunct (c)'s call to `matches_target_filter`, or drop + /// `Some(source.id)` ⇒ the verdict diverges from the resolver's ⇒ FAILS. + #[test] + fn ledger_exclusion_is_precise_and_fail_closed() { + use crate::types::ability::{ + AbilityCondition, Comparator, FilterProp, PlayerScope, QuantityExpr, QuantityRef, + TargetFilter, TypeFilter, TypedFilter, + }; + + let mut state = GameState::new_two_player(7); + state.phase = Phase::PreCombatMain; + let member = saproling_class_member(&mut state); + let source_id = ledger_observer_source(&mut state); + let source = state.objects[&source_id].clone(); + + let ledger_condition = |filter: TargetFilter| AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::BattlefieldEntriesThisTurn { + player: PlayerScope::Controller, + filter, + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + }; + let typed = |t: TypeFilter, props: Vec| { + TargetFilter::Typed(TypedFilter { + type_filters: vec![t], + controller: None, + properties: props, + }) + }; + + // The fixture-C shape: a ledger read in `execute.condition` whose body is a plain + // fixed draw, so `condition` is the def's ONLY sibling read. + const FIXTURE_C: &str = "Whenever this creature deals damage to a player, draw a card if you had two or more artifacts enter the battlefield under your control this turn."; + let mut exec_artifact = trigger_execute_from_oracle(FIXTURE_C); + // Reach-guard: the parsed shape is the one conjunct (b) matches. + assert!( + matches!( + exec_artifact.condition, + Some(AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::BattlefieldEntriesThisTurn { .. } + }, + rhs: QuantityExpr::Fixed { .. }, + .. + }) + ), + "reach-guard: fixture C must parse into the single-level shape conjunct (b) \ + accepts, else every arm below tests conjunct (b)'s `_` arm instead; got {:?}", + exec_artifact.condition + ); + + // ── (i) TRUE — an Artifact ledger filter provably cannot count a Saproling ── + assert!( + execute_ledger_condition_provably_excludes_class( + &exec_artifact, + &state, + member, + &source + ), + "(i) CR 608.2j: `Typed{{Artifact}}` cannot count a creature token, so the \ + read's value is invariant across the loop's growth" + ); + + // ── (ii) FALSE — verbatim Park Heights Pegasus GENUINELY matches ── + let db = crate::test_support::shared_card_db(); + let pegasus = db + .face_index + .get("park heights pegasus") + .expect("Park Heights Pegasus is in the integration card fixtures"); + assert_eq!(pegasus.triggers.len(), 1, "(ii) reach-guard: one trigger"); + let pegasus_exec = pegasus.triggers[0] + .execute + .as_deref() + .expect("(ii) reach-guard: the trigger carries an execute body") + .clone(); + assert!( + !execute_ledger_condition_provably_excludes_class( + &pegasus_exec, + &state, + member, + &source + ), + "(ii) the printed card's `Typed{{Creature}}` ledger filter DOES count a \ + Saproling creature token, so relief must be REFUSED — conjunct (c) is the \ + only conjunct carrying this refusal" + ); + + // ── (iii) NW-2: FALSE when the def carries an activation restriction ── + // The firewall never reads that field, so this must be a PROGRAMMATIC fixture: + // measured, 0 trigger `execute` bodies in the card pool carry one (positive + // control: 3195 on `abilities[]`), so no parser path can build it. + let mut restricted = exec_artifact.clone(); + restricted + .activation_restrictions + .push(ActivationRestriction::RequiresCondition { + condition: Some(crate::types::ability::ParsedCondition::QuantityComparison { + lhs: QuantityExpr::Ref { + qty: QuantityRef::BattlefieldEntriesThisTurn { + player: PlayerScope::Controller, + filter: TargetFilter::Typed(TypedFilter::creature()), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 2 }, + }), + }); + assert!( + !execute_ledger_condition_provably_excludes_class(&restricted, &state, member, &source), + "(iii) NW-2: the two defs differ in EXACTLY that one field — the scan is blind \ + to it (`activation_restrictions: _`), so conjunct (0) is the only closure for \ + a class-MATCHING activation restriction on the def being relieved" + ); + + // ── (iv) FALSE when the condition is a COMPOUND (conjunct b's `_` arm) ── + let mut compound = exec_artifact.clone(); + compound.condition = Some(AbilityCondition::And { + conditions: vec![ledger_condition(typed(TypeFilter::Artifact, vec![]))], + }); + assert!( + !execute_ledger_condition_provably_excludes_class(&compound, &state, member, &source), + "(iv) conjunct (b) is single-level with `_ => false`: an `And`/`Or`/`Not` \ + wrapper keeps the veto rather than recursing without a totality obligation" + ); + + // ── (v) FALSE when a SECOND sibling read hides in the effect body (conjunct a) ── + const FIXTURE_TWO_READS: &str = "Whenever this creature deals damage to a player, draw a card for each creature you control if you had two or more artifacts enter the battlefield under your control this turn."; + let two_reads = trigger_execute_from_oracle(FIXTURE_TWO_READS); + assert!( + !execute_ledger_condition_provably_excludes_class(&two_reads, &state, member, &source), + "(v) conjunct (a): with the `condition` cleared the def STILL reads the board, \ + so `condition` is not its sole sibling source and no exclusion proof about \ + `condition` alone can license relief" + ); + + // ── (vi) TRUE for an UNEVALUABLE filter — invariance under growth ── + // `FilterProp::FaceDown` is live (1/60, tunnel tipster) and outside + // `ledger_filter_is_evaluable`'s allow-list. The matcher answers `false` for + // every record, so each new class member adds 0 TO THE TALLY WHATEVER THE + // TALLY'S VALUE IS — which is all soundness needs. Do NOT restate this as "the + // tally is a constant 0": under `Or` an unsupported leaf yields a SILENT PARTIAL + // COUNT instead (restrictions.rs:519-526), and `Or` is live 4/60. + exec_artifact.condition = Some(ledger_condition(typed( + TypeFilter::Creature, + vec![FilterProp::FaceDown], + ))); + assert!( + execute_ledger_condition_provably_excludes_class( + &exec_artifact, + &state, + member, + &source + ), + "(vi) an unanswerable filter is relieved because relief is CORRECT here: the \ + same matcher the resolver asks answers `false` for the new member, so the \ + tally is invariant under growth" + ); + + // ── (vii) ARG-EQUIVALENCE PIN: the predicate's verdict IS the resolver's ── + let creature_filter = typed(TypeFilter::Creature, vec![]); + exec_artifact.condition = Some(ledger_condition(creature_filter.clone())); + let record = + crate::game::restrictions::battlefield_entry_record_for(&state.objects[&member]); + let resolver_shaped = !crate::game::restrictions::battlefield_entry_matches_filter( + &record, + &creature_filter, + source.controller, + &state.all_creature_types, + Some(source.id), + ); + assert_eq!( + execute_ledger_condition_provably_excludes_class( + &exec_artifact, + &state, + member, + &source + ), + resolver_shaped, + "(vii) ⛔ ARG-EQUIVALENCE PIN: conjunct (c) must ask the SAME matcher the \ + CR 608.2i resolver asks (`QuantityRef::BattlefieldEntriesThisTurn`), with \ + the ability CONTROLLER for `player` and `Some(source.id)` for the `Another` \ + exclusion. Swapping in `matches_target_filter`, or dropping `source.id`, \ + makes the two verdicts diverge and this arm fails." + ); + assert!( + !resolver_shaped, + "(vii) reach-guard: the resolver-shaped call must answer MATCH for a creature \ + filter vs a creature token, else the equality above is vacuously true on two \ + `true`s" + ); + + // ── (viii) ARG-EQUIVALENCE PIN, the `Some(source.id)` ARGUMENT specifically ── + // `FilterProp::Another` is `source_id.is_some_and(|s| record.object_id != s)`. + // The class member is NOT the ability source, so with the source id supplied the + // matcher answers MATCH and relief must be REFUSED. Dropping `Some(source.id)` to + // `None` makes `Another` answer `false`, the filter stops matching, and relief is + // wrongly GRANTED — so this arm flips to FAIL on exactly that one-argument change, + // which arms (i)-(vii) cannot see (none of their filters carries a `FilterProp`). + exec_artifact.condition = Some(ledger_condition(typed( + TypeFilter::Creature, + vec![FilterProp::Another], + ))); + assert_ne!( + member, source.id, + "(viii) reach-guard: the class member must NOT be the ability source, else \ + `Another` excludes it for the wrong reason" + ); + assert!( + !execute_ledger_condition_provably_excludes_class( + &exec_artifact, + &state, + member, + &source + ), + "(viii) with `Some(source.id)` supplied, `Typed{{Creature,[Another]}}` MATCHES \ + the class member (it is another object), so relief must be refused. Dropping \ + that argument silently changes the verdict — the ARG-EQUIVALENCE PIN." + ); + + // ── (ix) conjunct (b)'s `rhs: Fixed` REQUIREMENT, pinned ── + // The shape match reads `lhs` and conjunct (c) only interrogates the lhs filter, so + // an rhs-position board read would go completely unexamined. Requiring `rhs: Fixed` + // is what forecloses that: a comparison whose rhs is itself a `QuantityRef` falls to + // conjunct (b)'s `_` arm and KEEPS the veto. Dropping the requirement flips this + // arm — no other arm carries a non-`Fixed` rhs, and conjunct (a) cannot catch it + // (the clone-and-rescan clears the whole `condition`, rhs included). + exec_artifact.condition = Some(AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { + qty: QuantityRef::BattlefieldEntriesThisTurn { + player: PlayerScope::Controller, + filter: typed(TypeFilter::Artifact, vec![]), + }, + }, + comparator: Comparator::LE, + rhs: QuantityExpr::Ref { + qty: QuantityRef::ObjectCount { + filter: typed(TypeFilter::Creature, vec![]), + }, + }, + }); + assert!( + !execute_ledger_condition_provably_excludes_class( + &exec_artifact, + &state, + member, + &source + ), + "(ix) an rhs-position board read is never interrogated by conjunct (c), so \ + conjunct (b)'s `rhs: Fixed` requirement must keep the veto" + ); + } + + /// ITEM B-1 — relief requires the ledger filter to provably exclude **EVERY** member + /// of the growing class, not one representative (CR 603.6a). The one-representative + /// test was unsound in the ACCEPTING direction: fodder equivalence + /// (`object_content_eq`) does NOT compare `card_types`, so two members of one class + /// can differ on exactly the axis a `Typed{Artifact}` ledger filter reads. + /// + /// FIXTURE ORDERING IS LOAD-BEARING. The EXCLUDING member is `ObjectId(800)` (the + /// Saproling creature token) and the divergent NON-excluding member is `ObjectId(802)` + /// (an artifact token), so `800` is the min by `ObjectId` AND the untapped-first + /// collapse key's winner. The deleted production collapse + /// (`min_by_key(|id| (tapped, *id))`) therefore picks the EXCLUDING member, which is + /// what makes the revert-probe flip on every run rather than half of them. + /// + /// REVERT-PROBE (deterministic): replace + /// `!members.is_empty() && members.iter().all(f)` in the ledger gate with the + /// single-representative collapse this edit removes — + /// `members.iter().min_by_key(|id| (state.objects[id].tapped, **id)).is_some_and(f)` — + /// ⇒ only `ObjectId(800)` is consulted, it excludes, relief is granted, the veto + /// disappears ⇒ this assertion FAILS. (`members.iter().min().is_some_and(f)` is + /// equivalent here because both members are untapped, asserted below.) + #[test] + fn ledger_exclusion_requires_every_class_member() { + let mut state = GameState::new_two_player(7); + state.phase = Phase::PreCombatMain; + + // The representative the old collapse would have chosen: a CREATURE token, which a + // `Typed{Artifact}` ledger filter provably cannot count. + let excluding = saproling_class_member(&mut state); // ObjectId(800) + + // A second member of the SAME fodder class that diverges on `core_types` — a + // field `object_content_eq` does not compare — and which the SAME filter DOES + // count. + let divergent = ObjectId(802); + { + let mut object = crate::game::game_object::GameObject::new( + divergent, + CardId(0), + PlayerId(0), + "Saproling".to_string(), + Zone::Battlefield, + ); + object.card_types.core_types = vec![CoreType::Artifact]; + object.color = vec![crate::types::mana::ManaColor::Green]; + object.is_token = true; + state.objects.insert(divergent, object); + state.battlefield.push_back(divergent); + } + + let source_id = ledger_observer_source(&mut state); + let source = state.objects[&source_id].clone(); + const FIXTURE_C: &str = "Whenever this creature deals damage to a player, draw a card if you had two or more artifacts enter the battlefield under your control this turn."; + let exec_artifact = trigger_execute_from_oracle(FIXTURE_C); + state + .objects + .get_mut(&source_id) + .unwrap() + .trigger_definitions + .push( + crate::types::ability::TriggerDefinition::new(TriggerMode::ChangesZone) + .destination(Zone::Battlefield) + .execute(exec_artifact.clone()), + ); + + // ── REACH-GUARDS, all before any outcome assertion ── + assert!( + crate::game::ability_scan::ability_definition_reads_sibling_mutable_for_loop( + &exec_artifact + ), + "reach-guard: the execute body must read the sibling axis, else the ledger \ + gate's first conjunct is false and this row proves nothing" + ); + assert!( + excluding < divergent, + "reach-guard: the EXCLUDING member must be the min by ObjectId, so the reverted \ + single-representative collapse provably picks it" + ); + assert!( + !state.objects[&excluding].tapped && !state.objects[&divergent].tapped, + "reach-guard: both members untapped, so the collapse key's `tapped` component \ + is inert and `min()` and `min_by_key(tapped, id)` agree" + ); + assert_ne!( + state.objects[&excluding].card_types.core_types, + state.objects[&divergent].card_types.core_types, + "reach-guard: the two members must DIVERGE on the axis the filter reads — that \ + divergence is the whole premise (`object_content_eq` does not compare it)" + ); + // The representative ALONE really does exclude, so this row isolates the + // QUANTIFIER and not the predicate. + assert!( + execute_ledger_condition_provably_excludes_class( + &exec_artifact, + &state, + excluding, + &source + ), + "reach-guard: the representative alone DOES exclude — otherwise the veto below \ + would be attributable to the predicate rather than to the quantifier" + ); + // ...and the divergent member alone does NOT. + assert!( + !execute_ledger_condition_provably_excludes_class( + &exec_artifact, + &state, + divergent, + &source + ), + "reach-guard: the divergent member is genuinely NOT excluded — an artifact IS \ + counted by a `Typed{{Artifact}}` ledger filter" + ); + + // ── MATCHED POSITIVE CONTROL: the one-member class IS relieved ── + let single = HashSet::from([excluding]); + assert!( + !fire_time_conditions_read_growing_class(&state, Some(&single)), + "control: a proven class of JUST the excluding member is relieved, so the \ + subject's veto below is attributable to the second member alone" + ); + + // ── SUBJECT: adding the divergent member must restore the veto ── + let both = HashSet::from([excluding, divergent]); + assert!( + fire_time_conditions_read_growing_class(&state, Some(&both)), + "CR 603.6a: relief requires the filter to provably exclude EVERY member; the \ + second member is an artifact the `Typed{{Artifact}}` ledger read DOES count, \ + so the observer genuinely observes the loop and the veto must survive" + ); + } + + /// FIREWALL block-(1) EMPTY-SET vacuity guard, TWO fixtures — one per gate (the + /// ETB-entry-matcher gate and the battlefield-entry-ledger gate), so a firing arm + /// is ATTRIBUTABLE to the gate it names. + /// + /// WHY TWO FIXTURES (this supersedes a single-fixture design that could not attribute): + /// both gates are probed by the same call shape, so on a fixture carrying BOTH an + /// ETB-gate-eligible matcher and a ledger-gate-eligible execute body either probe drives + /// the call to `false`, arm 1 panics first, and arm 2 never runs. Arm 1 must therefore be + /// INSENSITIVE to the ledger probe, and the only way to be insensitive to a guard inside + /// `if let Some(exec) = def.execute` is to carry `execute: None`. Splitting the two + /// surfaces across two objects of ONE state does not work either: the intervening-if + /// veto is an unconditional `return true` whenever its object is reached, so such a + /// state is DETERMINISTICALLY GREEN under the ledger probe on every visit order — + /// non-discriminating, not nondeterministic. + /// + /// The def-kind test (`matches!(def.mode, ChangesZone | ChangesZoneAll)`) is the `.all()` + /// closure's BODY, and `Iterator::all` returns `true` on an empty set WITHOUT invoking + /// the closure — which is why an empty set must never reach either quantifier, and why a + /// ledger-shaped def is NOT immune to the ETB probe. + #[test] + fn empty_class_member_set_does_not_relieve() { + // "another nontoken Wizard you control" — triple-disjoint from a P0 Saproling token. + let disjoint = TargetFilter::Typed( + TypedFilter::creature() + .subtype("Wizard".to_string()) + .controller(ControllerRef::You) + .properties(vec![FilterProp::NonToken, FilterProp::Another]), + ); + + // ── FIXTURE 1: ETB gate. Board cloned from + // `etb_observer_gate_skips_only_provably_disjoint_observer`, whose DISJOINT + + // `Some(member)` arm already proves this matcher EXCLUDES this member. + let mut etb_state = GameState::new_two_player(7); + let etb_member = inert_token(&mut etb_state, 900, 0, "Saproling"); + { + let o = etb_state.objects.get_mut(&etb_member).unwrap(); + o.card_types.core_types = vec![CoreType::Creature]; + o.card_types.subtypes = vec!["Saproling".to_string()]; + o.is_token = true; + } + let etb_observer = inert_token(&mut etb_state, 910, 1, "Eminence Observer"); + let etb_condition = TriggerCondition::ControlsType { + filter: TargetFilter::Typed(TypedFilter::creature()), + }; + etb_state + .objects + .get_mut(&etb_observer) + .unwrap() + .trigger_definitions + .push( + // NO `.execute(..)`: `TriggerDefinition::new` leaves `execute: None`, so + // block (1)'s `if let Some(exec) = def.execute` is never entered and the + // LEDGER guard cannot influence this fixture. That is the attribution property. + TriggerDefinition::new(TriggerMode::ChangesZone) + .destination(Zone::Battlefield) + .valid_card(disjoint.clone()) + .condition(etb_condition.clone()), + ); + + // ── FIXTURE 2: ledger gate. Board + execute body lifted from + // `ledger_exclusion_is_precise_and_fail_closed` arm (i), which already + // measures this exact body as EXCLUDING ObjectId(800). + const LEDGER_ARTIFACT_ORACLE: &str = "Whenever this creature deals damage to a player, draw a card if you had two or more artifacts enter the battlefield under your control this turn."; + let mut ledger_state = GameState::new_two_player(7); + ledger_state.phase = Phase::PreCombatMain; + let ledger_member = saproling_class_member(&mut ledger_state); // ObjectId(800) + let ledger_observer = ledger_observer_source(&mut ledger_state); // ObjectId(801) + let exec_artifact = trigger_execute_from_oracle(LEDGER_ARTIFACT_ORACLE); + ledger_state + .objects + .get_mut(&ledger_observer) + .unwrap() + .trigger_definitions + .push( + // NO `.valid_card(..)`. IN UNMUTATED CODE this means the ETB gate cannot + // `continue` past this def: the non-empty guard passes, so the closure runs, + // and `etb_observer_provably_excludes_class` requires `def.valid_card + // .is_some()`. NOTE THE SCOPE — that conjunct is the `.all()` closure's BODY, + // and under the ETB probe `all()` on an empty set returns `true` WITHOUT + // invoking it, so `continue` DOES fire there. Arm 2's attribution does not + // rest on immunity to the ETB probe; it rests on ARM ORDER (arm 1 fires + // first, with the ETB message). + TriggerDefinition::new(TriggerMode::ChangesZone) + .destination(Zone::Battlefield) + .execute(exec_artifact.clone()), + ); + + // ── REACH-GUARDS, all before any outcome assertion ──────────────────────────── + // (1) each fixture's veto surface is one the firewall's scan actually SEES + // (subsumes the `Effect::Unimplemented => Axes::NONE` vacuity). + assert!( + crate::game::ability_scan::trigger_condition_reads_sibling_mutable(&etb_condition), + "reach-guard: fixture 1's intervening-if must read the sibling axis, else the \ + intervening-if veto never fires and arm 1 proves nothing" + ); + assert!( + crate::game::ability_scan::ability_definition_reads_sibling_mutable_for_loop( + &exec_artifact + ), + "reach-guard: fixture 2's execute body must read the sibling axis, else the ledger \ + gate's first conjunct is false and arm 2 proves nothing" + ); + // (2) MATCHED CONTROLS — with a NON-EMPTY proven class each gate RELIEVES, so the + // empty-set vetoes below are attributable to `!is_empty()` and nothing else. + let etb_class = std::collections::HashSet::from([etb_member]); + let ledger_class = std::collections::HashSet::from([ledger_member]); + assert!( + !fire_time_conditions_read_growing_class(&etb_state, Some(&etb_class)), + "control: a PROVEN one-member class lets the ETB gate skip this provably \ + disjoint observer" + ); + assert!( + !fire_time_conditions_read_growing_class(&ledger_state, Some(&ledger_class)), + "control: a PROVEN one-member class lets the ledger gate exclude this \ + Artifact-filtered read" + ); + + // ── ARM 1 (B-2a) — block (1) ETB gate ───────────────────────────────────────── + assert!( + fire_time_conditions_read_growing_class(&etb_state, Some(&HashSet::new())), + "BLOCK-(1) ETB GATE: an EMPTY class set proves nothing, so \ + `members.iter().all(..)` must not be vacuously true — deleting \ + `!members.is_empty() &&` from the ETB gate makes it `continue` past every \ + trigger def regardless of its `TriggerMode`, because the def-kind test lives \ + inside the closure and `all()` never calls it on an empty set. This fixture \ + carries `execute: None`, so the LEDGER guard cannot affect it: if THIS message \ + appears, the ETB guard is the one that was removed" + ); + // ── ARM 2 (B-2b) — block (1) ledger gate ────────────────────────────────────── + assert!( + fire_time_conditions_read_growing_class(&ledger_state, Some(&HashSet::new())), + "BLOCK-(1) LEDGER GATE: same vacuity, other site — deleting \ + `!members.is_empty() &&` from the ledger gate makes the inner `all()` vacuously \ + true, `is_some_and` true, which negates to `false` and drops the veto. \ + ATTRIBUTION rests on ARM ORDER, not on immunity: under the ETB probe arm 1 \ + above fires FIRST with the ETB message, so this message can only appear when \ + the ledger guard is the one that was removed. (In UNMUTATED code this fixture \ + also cannot be skipped by the ETB gate — it carries no `valid_card`, which \ + `etb_observer_provably_excludes_class` requires — but that is a property of the \ + unmutated closure body, which an empty set short-circuits past.)" + ); + } + + /// G6-1 — ROUTER BYTE-IDENTITY. `counter_growth_is_observed` (`:2923`) and + /// `life_growth_is_observed` (`:2946`) are ROUTERS, not suppressors: a `true` there + /// selects the O(N) discrete driver and the offer still forms. They keep the 2-arg + /// wrappers (`LoopWindowScope::unproven()`), so the phase-unreachability narrowing + /// must NOT reach them — a `{Phase, End}` observer scanned at `PreCombatMain` still + /// reports OBSERVED at both routers even though the identically-shaped observer IS + /// relieved at the two suppressing covers (rows X2-1 / X2-2). + /// + /// REVERT-PROBE: switch either router to its `_scoped` sibling with a populated + /// `phase_invariant` ⇒ the matching assertion flips to `false` ⇒ FAILS. + #[test] + fn observedness_callers_literal_expectation() { + use crate::types::ability::TriggerCondition; + + // A SIBLING (growing-class) observer gated on a step the state is not in. + let sibling = phase_gated_observer_board(TriggerCondition::ControlsType { + filter: TargetFilter::Any, + }); + assert_eq!(sibling.phase, Phase::PreCombatMain); + assert!( + counter_growth_is_observed(&sibling), + "G6-1: the counter router must stay byte-identical — a phase-unreachable \ + observer is still OBSERVED here, because routing true only picks the \ + discrete driver (it never suppresses the offer)" + ); + + // A PROJECTED (life) observer gated on the same unreachable step. + let projected = phase_gated_observer_board(TriggerCondition::GainedLife { minimum: 1 }); + assert!( + life_growth_is_observed(&projected), + "G6-1: the life router must stay byte-identical for the same reason" + ); + + // PAIRED NEGATIVE (so the instrument provably returns both answers): a board + // with no observer at all reports NOT observed at both routers. + let benign = GameState::new_two_player(7); + assert!(!counter_growth_is_observed(&benign)); + assert!(!life_growth_is_observed(&benign)); + } + + /// X1-3 — [`window_scope_from_cover_frames`] is FAIL-CLOSED on every conjunct, and + /// each `None` assertion is PAIRED with the `Some` it degenerates from, so the + /// instrument provably returns both answers on both axes. + /// + /// REVERT-PROBES, one per conjunct: + /// * drop the all-equal fold over the two sequences (return the first controller) ⇒ + /// the heterogeneous `sole_driver == None` assertion FAILS. + /// * drop the both-frames requirement (read only `pa`) ⇒ the one-empty-sequence + /// `sole_driver == None` assertion FAILS. + /// * drop the `extra_phases` conjunct (CR 500.8) ⇒ the `phase_invariant == None` + /// assertion FAILS while the turn/phase ones still pass. + /// * drop the turn-number conjunct ⇒ the differing-turn assertion FAILS. + #[test] + fn window_scope_is_fail_closed_on_a_heterogeneous_window() { + use crate::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + + fn ctx(controller: u8) -> LoopActionContext { + LoopActionContext { + card_id: CardId(64), + controller: PlayerId(controller), + action: LoopAction::Recast { + from_zone: Zone::Hand, + uses_buyback: BuybackUsage::Used, + }, + convoke: None, + pins: Vec::new(), + } + } + + // Baseline frame pair: same turn, same step-granular phase, no extra phases, + // both sequences driven by P0. + let base = || { + let mut s = GameState::new_two_player(7); + s.turn_number = 13; + s.phase = Phase::PreCombatMain; + s.last_loop_action_sequence = vec![ctx(0)]; + s + }; + + // ── `sole_driver` — CR 117.1 ── + let (pa, pb) = (base(), base()); + assert_eq!( + window_scope_from_cover_frames(&pa, &pb, &[]).sole_driver, + Some(PlayerId(0)), + "PAIRED POSITIVE: a homogeneous single-driver window proves CR 117.1's premise" + ); + + // (s2) heterogeneous ACROSS the two frames — the case a `pa`-only read would + // mint `Some(P0)` for, which is the relieving direction #4603 forbids. + 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, + None, + "(s2) a two-controller window proves nothing about who holds priority" + ); + + // (s2) heterogeneous WITHIN one frame. + 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, + None, + "(s2) an interleaved sequence is fail-closed" + ); + + // (s1) an EMPTY sequence proves nothing — not "nobody drove this". + let mut pb_empty = base(); + pb_empty.last_loop_action_sequence.clear(); + assert_eq!( + window_scope_from_cover_frames(&pa, &pb_empty, &[]).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, + Some(Phase::PreCombatMain), + "PAIRED POSITIVE: agreeing frames with no extra phase prove the window's phase" + ); + + // (p3) CR 500.8: a queued extra phase can duplicate the SAME phase inside one + // turn, so "equal phase" no longer implies "never left it". + let mut pb_extra = base(); + pb_extra + .extra_phases + .push(crate::types::game_state::ExtraPhase { + anchor: Phase::PreCombatMain, + phase: Phase::PreCombatMain, + attacker_restriction: None, + attacker_restriction_source: None, + }); + assert_eq!( + window_scope_from_cover_frames(&pa, &pb_extra, &[]).phase_invariant, + None, + "(p3) CR 500.8: a pending extra phase breaks `equal phase ⇒ never left it`" + ); + + // (p1) different turns. + let mut pb_turn = base(); + pb_turn.turn_number = 14; + assert_eq!( + window_scope_from_cover_frames(&pa, &pb_turn, &[]).phase_invariant, + None, + "(p1) frames from different turns bound nothing about one window's phase" + ); + + // (p2) different step-granular phases. + let mut pb_phase = base(); + pb_phase.phase = Phase::PostCombatMain; + assert_eq!( + window_scope_from_cover_frames(&pa, &pb_phase, &[]).phase_invariant, + None, + "(p2) a window that crosses a phase boundary is not phase-invariant" + ); + } } diff --git a/crates/engine/src/game/combat_damage.rs b/crates/engine/src/game/combat_damage.rs index fae0989678..bb19f9b76d 100644 --- a/crates/engine/src/game/combat_damage.rs +++ b/crates/engine/src/game/combat_damage.rs @@ -1151,6 +1151,13 @@ pub(crate) fn apply_combat_damage( assignments: &[(ObjectId, DamageAssignment)], ) -> Vec { let mut events = Vec::new(); + // CR 510.2 + CR 732.2a: the pre-batch life totals, so the loop-detection ring can be + // invalidated on the DAMAGE EVENT rather than on a `WaitingFor` window that an + // unblocked attacker never opens. Snapshotted per batch, not hoisted: first-strike + // and regular damage are two separate CR 510.2 events (`:131` / `:188`) and a + // double-strike attacker must be caught at the first, not only the second. See + // `GameState::invalidate_loop_ring_on_unobserved_life_move`. + let lives_before: Vec = state.players.iter().map(|p| p.life).collect(); // CR 510.2: accumulates per-player, per-source damage for this step only. // `(player, [(source_id, amount)], step_total)`. type PerPlayerCombatDamage = (crate::types::player::PlayerId, Vec<(ObjectId, u32)>, u32); @@ -1340,6 +1347,16 @@ pub(crate) fn apply_combat_damage( // --- Phase D: Fire prevention riders once per shield (CR 615.5 + CR 615.13) --- fire_combat_prevention_riders(state, &prevention_tally, &mut events); + // CR 510.2 + CR 732.2a: the simultaneous batch is the EVENT the loop-ring life + // prohibition keys on. Placed LAST on purpose — the batch moves life in three + // places, and only a call here sees all three: Phase C's + // `apply_damage_after_replacement` (CR 120.3a), the per-source lifelink gain + // (CR 119.3 + CR 702.15b) above, and a prevention rider's `runtime_execute` + // (CR 615.5) fired on the line before. One guard in this shared function rather + // than one at each caller (`:131` first strike, `:188` regular), so a third caller + // added later inherits it. + state.invalidate_loop_ring_on_unobserved_life_move(&lives_before); + events } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index c25dad004f..ca01b30572 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -511,7 +511,8 @@ fn reconcile_terminal_result(state: &mut GameState, result: &mut ActionResult) { // above (CR 704.3 ordering), so a player ALREADY at 0 life loses via the real // 704.5a SBA first and this never preempts or double-fires a legitimate win — it // only fires when the game would otherwise grind on (high victim life, or mid-drain - // before 0). The `!GameOver` guard makes it idempotent across the :196/:200 calls. + // before 0). The `!GameOver` guard makes it idempotent across the two + // `reconcile_terminal_result` calls in `apply` (`:326` and `:330`). if !matches!(state.waiting_for, WaitingFor::GameOver { .. }) && matches!(state.waiting_for, WaitingFor::Priority { .. }) // a player would get priority (CR 704.3) // CR 732.2a: the mandatory-loop game-ending shortcut is gated behind the @@ -713,7 +714,15 @@ fn interactive_loop_bridge(state: &mut GameState, result: &mut ActionResult) { let WaitingFor::Priority { player: proposer } = state.waiting_for else { unreachable!("interactive bridge only runs during priority") }; - let schema = build_shortcut_schema(&[], certificate.win_kind, state, proposer); + // CR 732.2a: a non-targeted drain publishes no decision points, and this path + // states no narrowed CR 704 count bound — `UntilLethal` is terminated by the + // real SBA, not by a caller-supplied count, so the ceiling stays the global + // safety limit. + let schema = build_shortcut_schema( + Vec::new(), + shortcut_iteration_count(certificate.win_kind), + MAX_SHORTCUT_CYCLES, + ); state.waiting_for = WaitingFor::LoopShortcut { proposer, predicted_winner: Some(winner), @@ -935,21 +944,17 @@ fn shortcut_iteration_count( } } -/// CR 732.2a: build the READ-side decision schema for a loop-shortcut offer. `pins` is the -/// carried single-authority decision list (`build_recast_template` output for the object-growth -/// path; `&[]` for a non-targeted drain) — never re-derived here. Legal sets come from live -/// engine queries (`is_convoke_eligible`); the frontend computes nothing. -fn build_shortcut_schema( +/// CR 732.2a: reify a carried pin list into the READ-side decision points an offer publishes. +/// `pins` is the single-authority decision list (`build_recast_template` output for the +/// object-growth path; empty for a non-targeted drain) — never re-derived here. Legal sets come +/// from live engine queries (`is_convoke_eligible`); the frontend computes nothing. +fn pinned_decisions_to_points( pins: &[crate::analysis::decision_template::PinnedDecision], - win_kind: crate::analysis::loop_check::WinKind, state: &GameState, controller: PlayerId, -) -> crate::analysis::decision_template::ShortcutDecisionSchema { - use crate::analysis::decision_template::{ - DecisionPoint, DecisionPointKind, PinnedDecision, ShortcutDecisionSchema, - }; - let points: Vec = pins - .iter() +) -> Vec { + use crate::analysis::decision_template::{DecisionPoint, DecisionPointKind, PinnedDecision}; + pins.iter() .filter_map(|pin| match pin { // CR 603.3b: trigger ordering is not a loop-declaration choice — no read-side peer. PinnedDecision::Order { .. } => None, @@ -1025,7 +1030,23 @@ fn build_shortcut_schema( kind: DecisionPointKind::UnlessBreak, }), }) - .collect(); + .collect() +} + +/// CR 732.2a: assemble a loop-shortcut offer's READ-side schema from its already-reified +/// decision `points`, its proposed repeat mode, and its CR 704 count bound. +/// +/// `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. +fn build_shortcut_schema( + points: Vec, + iteration_count: crate::analysis::decision_template::IterationCount, + max_iterations: u32, +) -> crate::analysis::decision_template::ShortcutDecisionSchema { + use crate::analysis::decision_template::{DecisionPointKind, ShortcutDecisionSchema}; // CR 702.51a: engine-owned total of untapped convoke-eligible creatures across every // ConvokeTaps point — the frontend renders this directly instead of re-deriving it from // `points` (display-layer purity). Identical predicate/sum to the deleted React reduce. @@ -1037,7 +1058,8 @@ fn build_shortcut_schema( }) .sum(); ShortcutDecisionSchema { - iteration_count: shortcut_iteration_count(win_kind), + iteration_count, + max_iterations, points, convoke_tappable_count, } @@ -2579,11 +2601,13 @@ fn try_offer_object_growth_shortcut( // recast, else `[]` (a multi-activation period carries no convoke pin). Legal sets are derived // against the live offer-time board. let schema_template = build_recast_template(&seq[0]); + // CR 732.2a: an UNBOUNDED object-growth offer is not repeated a CR 704-limited number of + // 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( - &schema_template.decisions, - certificate.win_kind, - state, - caster, + pinned_decisions_to_points(&schema_template.decisions, state, caster), + shortcut_iteration_count(certificate.win_kind), + MAX_SHORTCUT_CYCLES, ); Some((certificate, schema)) } @@ -2843,6 +2867,19 @@ struct LoopShortcutOffer<'a> { schema: &'a crate::analysis::decision_template::ShortcutDecisionSchema, } +/// CR 732.2a (MagicCompRules.txt:6372) + CR 800.4a (MagicCompRules.txt:6408): reject a +/// shortcut declaration and hand priority back to the next living seat — the manual-play +/// handback every reject path in `handle_declare_shortcut` lands on. Single +/// authority: a sixth reject path added later cannot forget to sync +/// `result.waiting_for`. +fn reject_shortcut_declaration(state: &mut GameState, result: &mut ActionResult) { + priority::reset_priority(state); + state.waiting_for = WaitingFor::Priority { + player: living_priority_seat(state), + }; + result.waiting_for = state.waiting_for.clone(); +} + /// CR 732.2a: the proposer declared the loop shortcut. Build the public proposal and open /// the APNAP accept-or-shorten window over the proposer's living opponents (turn order). No /// opponents (solitaire / all eliminated) ⇒ take the shortcut immediately. @@ -2872,23 +2909,39 @@ 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 let Some(t) = &template { - if !offer.schema.points.is_empty() { - 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) + 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() - { - priority::reset_priority(state); - // CR 800.4a: hand priority to the next living seat. - state.waiting_for = WaitingFor::Priority { - player: living_priority_seat(state), - }; - result.waiting_for = state.waiting_for.clone(); + { + 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 @@ -2907,12 +2960,27 @@ fn handle_declare_shortcut( crate::analysis::decision_template::IterationCount::Fixed(n) if *n > MAX_SHORTCUT_CYCLES => { - priority::reset_priority(state); - // CR 800.4a: hand priority to the next living seat. - state.waiting_for = WaitingFor::Priority { - player: living_priority_seat(state), - }; - result.waiting_for = state.waiting_for.clone(); + reject_shortcut_declaration(state, &mut result); + return Ok(result); + } + // CR 732.2a: the per-offer CR 704 bound, enforced at the same single authority as the + // global cap. A `Fixed(n)` above `max_iterations` would contain a conditional action — + // some living player crosses a CR 704.5a / CR 704.5c / CR 104.3c loss threshold inside + // the proposal, and what happens next depends on that — so it is not a legal shortcut. + crate::analysis::decision_template::IterationCount::Fixed(n) + if *n > offer.schema.max_iterations => + { + reject_shortcut_declaration(state, &mut result); + return Ok(result); + } + // CR 732.2a: `UntilLethal` names no count at all, so it can only be legal when the + // offer states no narrowed bound. An offer that DID narrow its bound is one whose + // 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 => + { + reject_shortcut_declaration(state, &mut result); return Ok(result); } // Under-cap `Fixed` and `UntilLethal` (period-bounded by `shortcut_drive_period`) @@ -3704,14 +3772,30 @@ fn pass_priority_once_with_pipeline( && matches!(wf, WaitingFor::Priority { player } if player == state.active_player) { state.record_loop_detect_sample(); - } else if !matches!(wf, WaitingFor::OrderTriggers { .. }) { + } else if !wf.is_forced_cascade_window() { state.loop_detect_ring.clear(); } - // CR 603.3b + CR 732.2a: leave the ring intact on the mandatory trigger-ordering - // window — ordering simultaneous triggers is a forced step of putting them on the - // stack (staged in pending_trigger_order, so the stack is momentarily shrunk/empty - // here), not a settle or deliberate break. Preserving the Priority{active} samples - // across the beat lets a self-refilling multi-trigger loop reach CR 732.2a detection. + // CR 603.3b/603.3d/603.5/608.2/903.9a + CR 703.1/117.3a + CR 732.2a: leave the + // ring intact on every FORCED PRE-PRIORITY window, not just trigger ordering. + // `is_forced_cascade_window` is the single authority for that class (the other + // clear site, `apply_action`, consults the same predicate); it holds exactly the + // windows at which no player has priority — the forced steps of putting triggers + // on the stack / finishing a resolution, plus the CR 703.1 turn-based actions + // CR 117.3a places before the step's own grant of priority — so answering one is + // never a settle or a deliberate break. The stack is momentarily shrunk or empty + // at these windows (an ordering batch is staged in `pending_trigger_order`; a + // mid-resolution "may" pause has already popped its entry; a turn-based window + // opens between phases with the stack drained), so without this arm the + // accumulated `Priority{active}` samples would be discarded and a self-refilling + // multi-trigger loop could never reach CR 732.2a detection. The turn-based + // members buy RING SURVIVAL across a turn boundary — necessary but not yet + // sufficient for the cross-turn shortcut CR 732.2a contemplates ("may even cross + // multiple turns"), because `loop_states_equal` still compares `turn_number` + // (via `impl PartialEq for GameState`, un-neutralized by `normalize_for_loop` and + // `project_out_resources`), so no cross-turn pair certifies today. The measured + // justification is the wipe itself: without these members the Fantastic Four dump + // force-clears the ring once per 99-beat turn period at declare-attackers, + // capping it at 2 frames where the widened class reaches 13. } // No else-branch: a bare handoff or an empty-stack pass-to-advance-phase does NOT // touch the ring (leave-intact), so accumulation survives the inter-resolution beats. @@ -4446,10 +4530,31 @@ fn apply_action( // cascade (OrderTriggers is the forced CR 603.3b placement of simultaneous triggers, // not a deliberate action). Every other action (cast/activate/play-land) is a // deliberate break and still invalidates the ring. + // + // CR 603.3d / CR 603.5 + CR 608.2 / CR 903.9a / CR 703.1 + CR 117.3a: the second + // conjunct keys on the + // WINDOW BEING ANSWERED, not on the action, because `state.waiting_for` has not been + // reduced yet here — the very next statement reads `state.waiting_for.acting_player()` + // for `semantic_actor`. Answering a forced pre-priority window is not a deliberate + // break of the cascade (no player had priority to break it with), so the ring must + // survive the answer as well as the prompt; the sampler at the other clear site + // consults the same `is_forced_cascade_window` authority. Keying on the window rather + // than the action also covers every answering variant at once — an action-keyed list + // would need `ChooseTarget`, `SelectTargets`, `DecideOptionalEffect` AND + // `DecideOptionalEffectAndRemember`, and would silently miss the next one added. + // Widening the class to the CR 703.1 turn-based windows makes that the decisive + // argument rather than a convenience one: the same conjunct picked up + // `DeclareAttackers`, `DeclareBlockers`, `ChooseUntap`, `ChooseExert`, `ChooseEnlist` + // and the `SelectCards` that answers `DiscardToHandSize` with no edit here — and + // `SelectCards` in particular is answer-overloaded across a dozen unrelated windows, + // so an action-keyed list could not have expressed the class correctly at all. + // `PassPriority` keeps its own action-side exemption because it is answered at a + // `Priority` window, which is deliberately NOT in the forced class. if !matches!( action, GameAction::PassPriority | GameAction::OrderTriggers { .. } - ) { + ) && !state.waiting_for.is_forced_cascade_window() + { state.loop_detect_ring.clear(); } diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index b7826e2004..0d769c6c2f 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -2445,10 +2445,45 @@ fn loop_shortcut_projection( } let count = match schema.iteration_count { crate::analysis::decision_template::IterationCount::Fixed(suggested) => { + // 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. + // + // CR 704.5a (MagicCompRules.txt:5492): `elimination_bounds` returns `0` to + // mean "no legal repetition exists and the caller must not offer". A + // published offer carrying + // `0` is an authority violation, not a number to repair — clamping it to `1` + // renders a one-iteration offer whose single iteration eliminates a player + // mid-proposal. Reject it in EVERY build: a `debug_assert!` disappears from + // release, which is precisely where the clamp is what the player sees. + // + // THIS GUARD IS ALSO LOAD-BEARING AGAINST A PANIC, not merely against a bad + // offer. With the lower clamp replaced by `.min(MAX_SHORTCUT_CYCLES)` below, + // a `0` authority yields `max == 0`, and `suggested.clamp(1, max)` is then + // `Ord::clamp(1, 0)`, whose `assert!(min <= max)` is a PLAIN assert that + // survives release (measured: an `-O` build of `5u32.clamp(1, 0)` panics with + // `min > max. min = 1, max = 0`). Removing this guard turns a malformed + // restored dump into an engine panic. + // + // LATENT, NOT LIVE (measured at this head): no in-tree producer can reach this + // arm with `0`. `build_shortcut_schema` (`game/engine.rs`) has exactly two call + // sites and both pass `MAX_SHORTCUT_CYCLES`; the per-viewer projection in + // `game/visibility.rs` only re-projects an existing schema's value; and + // `ShortcutDecisionSchema::default().max_iterations == default_max_iterations() + // == MAX_SHORTCUT_CYCLES` (`analysis/decision_template.rs`), which is also the + // `#[serde(default)]` for a pre-bound save. The only way `0` + // arrives is a LOADED/PERSISTED authority that explicitly serializes it. This + // guard is therefore the fail-closed twin of item E: a latent hole shut before + // it opens. + if schema.max_iterations == 0 { + return Err(InteractionReasonCode::InvalidAuthorityState); + } + let max = schema.max_iterations.min(MAX_SHORTCUT_CYCLES); InteractionShortcutCountSpec::Fixed { min: 1, - max: MAX_SHORTCUT_CYCLES, - suggested: suggested.clamp(1, MAX_SHORTCUT_CYCLES), + max, + suggested: suggested.clamp(1, max), } } crate::analysis::decision_template::IterationCount::UntilLethal => { diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index b63966f93f..2e8362de5b 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -1081,9 +1081,13 @@ fn entered_object_perturbs_quantity_ref( // that one reads the ENTRY-TIME record snapshot, so a // `FilterProp::WithKeyword` whose keyword a Layer-6 effect later removes, // or a controller-bearing filter under a non-`Controller` `player` scope, - // can still under-trigger. Neither is reachable from any producer today - // (all emit a bare `Typed`/`Or[Typed]`); the upgrade is a plain `=> true` - // if one becomes reachable. + // can still under-trigger. Neither is reachable from any producer today — + // measured over `data/card-data.json`: `WithKeyword` is 0/60 refs and a + // filter-level `controller` is 0/60. Property-bearing shapes ARE live, though: + // 13 of 60 REFS carry a `FilterProp` (10 `Typed[Another]`, 1 `Or[4x Another]`, + // 1 `HasColor`, 1 `FaceDown`), which is 16 property-bearing LEAVES (the one + // `Or` contributes 4). The upgrade is a plain `=> true` if either divergence + // case becomes reachable. | QuantityRef::BattlefieldEntriesThisTurn { filter, .. } => { matches_target_filter(state, entered.id, filter, ctx) } diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index 488291e6f6..bc74be690f 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -364,6 +364,29 @@ pub fn record_sacrifice( } } +/// CR 608.2i: the entry-time snapshot record [`record_battlefield_entry`] pushes for +/// `obj`. Extracted (behaviour-identical, field-for-field) so a READ-ONLY caller — the +/// CR 732.2a loop firewall's class-exclusion test — can ask +/// [`battlefield_entry_matches_filter`] about an object without `&mut GameState`. +/// `record_battlefield_entry` is its other caller, so the field list has ONE authority. +pub(crate) fn battlefield_entry_record_for( + obj: &GameObject, +) -> crate::types::game_state::BattlefieldEntryRecord { + crate::types::game_state::BattlefieldEntryRecord { + object_id: obj.id, + name: obj.name.clone(), + core_types: obj.card_types.core_types.clone(), + subtypes: obj.card_types.subtypes.clone(), + supertypes: obj.card_types.supertypes.clone(), + colors: obj.color.clone(), + // CR 403.3: snapshot the object's keywords at entry time. This is the + // printed/base + counter-granted keyword set (pre-layer; see the field doc + // on BattlefieldEntryRecord.keywords for the documented Layer-6 limitation). + keywords: obj.keywords.clone(), + controller: obj.controller, + } +} + /// CR 403.3: Record a battlefield entry snapshot for data-driven ETB condition queries. pub fn record_battlefield_entry( state: &mut crate::types::game_state::GameState, @@ -376,19 +399,7 @@ pub fn record_battlefield_entry( return; } - let record = crate::types::game_state::BattlefieldEntryRecord { - object_id, - name: obj.name.clone(), - core_types: obj.card_types.core_types.clone(), - subtypes: obj.card_types.subtypes.clone(), - supertypes: obj.card_types.supertypes.clone(), - colors: obj.color.clone(), - // CR 403.3: snapshot the object's keywords at entry time. This is the - // printed/base + counter-granted keyword set (pre-layer; see the field doc - // on BattlefieldEntryRecord.keywords for the documented Layer-6 limitation). - keywords: obj.keywords.clone(), - controller: obj.controller, - }; + let record = battlefield_entry_record_for(obj); state.battlefield_entries_this_turn.push(record); } @@ -538,16 +549,19 @@ pub(crate) fn battlefield_entry_matches_filter( /// against a `BattlefieldEntryRecord`? /// /// The record is an entry-time snapshot carrying only `object_id / name / core_types / subtypes / -/// supertypes / colors / keywords / controller` (`types/game_state.rs:1586-1606`). Every other +/// supertypes / colors / keywords / controller` (`types/game_state.rs:1650-1670`). Every other /// characteristic a `FilterProp` can name is live-object state the snapshot never captured, so the -/// matcher fails closed at `:517` and the whole tally reads a silent constant 0. Measured: 98 +/// matcher fails closed at its `FilterProp` arm (`:515`) and its outer `TargetFilter` arm +/// (`:544`), and the whole tally reads a silent constant 0 — but see the `Or` exception +/// documented at `:519-526`: an `Or` with one unsupported leaf yields a SILENT PARTIAL COUNT +/// instead. Measured: 98 /// `FilterProp` variants exist (`types/ability.rs:3609-4251`); the matcher answers 4. /// /// This is an ALLOW-LIST, deliberately not an exhaustive `match`. A `FilterProp` added later is /// absent from the list and therefore defaults to "not evaluable" — the conservative side, which /// yields an honest `Effect::Unimplemented` at the parser guard and an honest `Unhandled` in the /// coverage classifier. A deny-list would need exhaustiveness; a positive allow-list does not. -/// The list must name exactly the props the matcher answers at `:504-516`; the binder is +/// The list must name exactly the props the matcher answers at `:502-514`; the binder is /// `ledger_guard_agrees_with_matcher` (test, below). /// /// Upgrade path, ascending cost: `HasSupertype` and `Named` are answerable from `record.supertypes` @@ -561,7 +575,7 @@ pub(crate) fn ledger_filter_is_evaluable(filter: &TargetFilter) -> bool { match filter { TargetFilter::Any => true, TargetFilter::Typed(typed) => { - // CR 109.5: `entry_controller_matches` (`:408-418`) answers only these two. + // CR 109.5: `entry_controller_matches` (`fn` at `:406`) answers only these two. typed .controller .as_ref() @@ -576,12 +590,12 @@ pub(crate) fn ledger_filter_is_evaluable(filter: &TargetFilter) -> bool { ) }) } - // CR 608.2i: mirrors the matcher's monotone connectives (`:540-545`); every leaf must be + // CR 608.2i: mirrors the matcher's monotone connectives (`:538-543`); every leaf must be // answerable, otherwise the composite silently drops one. TargetFilter::Or { filters } | TargetFilter::And { filters } => { filters.iter().all(ledger_filter_is_evaluable) } - // Everything else is the matcher's `_ => false` at `:546`, including the anti-monotone + // Everything else is the matcher's outer `_ => false` at `:544`, including the anti-monotone // `TargetFilter::Not`. _ => false, } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 4e7f3cb1c5..0e6f01058d 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -4,11 +4,11 @@ use crate::database::synthesis::KeywordTriggerInstaller; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AdditionalCostOrigin, BounceSelection, CardTypeSetSource, CastManaSpentMetric, ChosenAttribute, CommanderOwnership, - ControllerRef, CopyRetargetPermission, DelayedTriggerCondition, Effect, ModalChoice, - ObjectScope, OriginConstraint, PlayerFilter, PtValue, QuantityExpr, QuantityRef, RenownSubject, - ResolvedAbility, SacrificeCost, TargetFilter, TargetRef, TributeOutcome, TriggerCondition, - TriggerConstraint, TriggerDefinition, TriggerDefinitionOccurrenceRef, TriggerDefinitionRef, - TriggerEntry, TriggerGrantProducerKey, TypeFilter, TypedFilter, + ControllerRef, CopyRetargetPermission, DamageKindFilter, DelayedTriggerCondition, Effect, + ModalChoice, ObjectScope, OriginConstraint, PlayerFilter, PtValue, QuantityExpr, QuantityRef, + RenownSubject, ResolvedAbility, SacrificeCost, TargetFilter, TargetRef, TributeOutcome, + TriggerCondition, TriggerConstraint, TriggerDefinition, TriggerDefinitionOccurrenceRef, + TriggerDefinitionRef, TriggerEntry, TriggerGrantProducerKey, TypeFilter, TypedFilter, }; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; @@ -2378,13 +2378,15 @@ pub(crate) fn trigger_definition_functions_in_zone(def: &TriggerDefinition, zone /// class that changes across the covered cycle: /// /// 1. the FIRST accept-time frame pair's single-new-battlefield-object is guaranteed by -/// `game::engine::derived_fodder_class` (engine.rs:1996 — it returns `None` if more than one -/// object entered the battlefield that cycle, so a `Some` fodder class means the fodder was -/// the sole entrant); and +/// `game::engine::derived_fodder_class` (it returns `None` if more than one object entered the +/// battlefield that cycle, so a `Some` fodder class means the fodder was the sole entrant; +/// note it also has a second, display-only caller — the soundness-bearing one is inside the +/// fodder-cover arm); and /// 2. the SECOND cover frame pair's "only the fodder partition grows" is guaranteed SOLELY by -/// `analysis::resource::board_covers_modulo_fodder` (resource.rs:1058), whose all-zones -/// stable-partition content-equality is asserted at resource.rs:1145 — which PRECEDES the -/// firewall call at resource.rs:1156. A reader/refactor must not reorder the +/// `analysis::resource::board_covers_modulo_fodder`, whose all-zones +/// stable-partition content-equality is enforced by its own return value at its ONLY call +/// site — which PRECEDES the firewall call in the same function. A reader/refactor +/// must not reorder the /// `board_covers_modulo_fodder` gate after the firewall: the disjointness argument here /// relies on it having already proven that nothing but the fodder entered. /// @@ -2427,6 +2429,55 @@ pub(crate) fn etb_observer_provably_excludes_class( } } +/// CR 510.2 / CR 506.1 (+ CR 500.1 for the phase list): can this trigger's event occur +/// while the loop window sits in `phase`? Returns `true` iff it PROVABLY cannot — then +/// the trigger never fires inside the window and does not observe the growing class. +/// +/// Exhaustive dispatch on [`TriggerMode`] with a fail-closed `_ => false` arm: a mode +/// this predicate cannot classify KEEPS its veto. That wildcard is the deliberate +/// error-direction deviation — a future mode is swallowed into *conservatism*, never +/// into relief. +/// +/// ⛔ SHAPE IS PINNED ON BOTH ARMS. +/// (1) The `Phase` arm is STRICT inequality. `def.phase == Some(phase)` MUST return +/// `false`, and widening it to relieve `p == phase` ("it already triggered this +/// phase, so it cannot trigger again") is a SOUNDNESS change, not a precision one: +/// CR 117.3a puts beginning-of-phase abilities ON THE STACK before the active +/// player receives the priority at which CR 732.2a lets a shortcut be proposed, and +/// CR 608.2h determines an on-stack ability's information AT RESOLUTION — inside +/// the window. Such a refinement needs a stack-emptiness proof no caller supplies. +/// (2) The combat-damage arm REQUIRES `damage_kind == CombatOnly`. CR 510.2 confines +/// combat damage to the combat damage step (extra combat damage steps are still +/// `Phase::CombatDamage`), which is exactly what makes the arm sound; a +/// `damage_kind: Any` trigger can fire on NONCOMBAT damage in any phase, so +/// dropping the requirement would relieve observers that genuinely fire in the +/// window. +/// Both pins are asserted by `trigger_event_unreachable_in_phase_shape_is_pinned`. +pub(crate) fn trigger_event_unreachable_in_phase(def: &TriggerDefinition, phase: Phase) -> bool { + match def.mode { + // CR 500.1 / CR 506.1: a phase/step-keyed trigger's event is the arrival of + // that phase or step, which cannot occur inside a window proven invariant at a + // DIFFERENT one. `def.phase == None` proves nothing ⇒ keep the veto. + TriggerMode::Phase => def.phase.is_some_and(|p| p != phase), + // CR 510.2: the whole CR 120.2a combat-damage family. Combat damage is dealt + // only by the combat damage step's turn-based action, so a `CombatOnly` filter + // cannot match any damage event inside a window invariant at another step. + TriggerMode::DamageDone + | TriggerMode::DamageDoneOnce + | TriggerMode::DamageAll + | TriggerMode::DamageDealtOnce + | TriggerMode::DamageDoneOnceByController + | TriggerMode::DamageReceived + | TriggerMode::DamagePreventedOnce + | TriggerMode::ExcessDamage + | TriggerMode::ExcessDamageAll => { + def.damage_kind == DamageKindFilter::CombatOnly && phase != Phase::CombatDamage + } + // Fail-closed: every mode this predicate cannot classify keeps its veto. + _ => false, + } +} + fn live_battlefield_source_was_present_at_event(event: &GameEvent, source_id: ObjectId) -> bool { !matches!( event, @@ -36641,6 +36692,87 @@ pub mod tests { "Azula's copy trigger must NOT fire when she is not attacking (CR 603.4)" ); } + + /// X2-3 — `trigger_event_unreachable_in_phase` is FAIL-CLOSED on the modes it + /// cannot classify, and still answers `true` for the two families it can. Both + /// polarities live in one row, so a constant implementation fails an arm. + /// + /// REVERT-PROBE: replace the predicate's `_ => false` arm with `_ => true` ⇒ the + /// `ChangesZone` assertion FAILS while the two `true` assertions still pass, so the + /// probe is isolated to the fail-closed arm. + #[test] + fn trigger_event_unreachable_in_phase_is_fail_closed() { + // Unclassifiable mode: an ETB observer's event can occur in any phase, so the + // predicate must NOT claim unreachability — the veto is kept. + let mut etb = TriggerDefinition::new(TriggerMode::ChangesZone); + etb.destination = Some(Zone::Battlefield); + assert!( + !trigger_event_unreachable_in_phase(&etb, Phase::PreCombatMain), + "CR 603.6a: a zone-change observer is unclassifiable by phase ⇒ fail closed" + ); + + // Classified family 1 — CR 500.1 / CR 506.1 phase-keyed. + let mut end_step = TriggerDefinition::new(TriggerMode::Phase); + end_step.phase = Some(Phase::End); + assert!( + trigger_event_unreachable_in_phase(&end_step, Phase::PreCombatMain), + "an end-step trigger's event cannot occur in a precombat-main window" + ); + + // Classified family 2 — CR 510.2 combat damage. + let mut combat_damage = TriggerDefinition::new(TriggerMode::DamageDone); + combat_damage.damage_kind = DamageKindFilter::CombatOnly; + assert!( + trigger_event_unreachable_in_phase(&combat_damage, Phase::PreCombatMain), + "CR 510.2: combat damage is dealt only in the combat damage step" + ); + } + + /// X2-4a + X2-4b — the ⛔ ANTI-COLLAPSE PIN on both classified arms. Each arm + /// carries its own paired positive, so the row proves the SHAPE and not merely + /// that the function returns something. + /// + /// REVERT-PROBES, one per arm: + /// * arm 1 (X2-4a): widen the `Phase` arm from `p != phase` to `def.phase.is_some()` + /// (or to any `p == phase` relief) ⇒ the first assertion FAILS. It fails for a + /// SOUNDNESS reason, not to protect a test: CR 117.3a puts a beginning-of-phase + /// ability on the stack BEFORE the shortcut's priority and CR 608.2h reads its + /// information at resolution, inside the window. + /// * arm 2 (X2-4b): drop the `damage_kind == CombatOnly` requirement ⇒ the third + /// assertion FAILS. A `damage_kind: Any` trigger fires on NONCOMBAT damage in any + /// phase, so classifying it would relieve an observer that genuinely fires. + #[test] + fn trigger_event_unreachable_in_phase_shape_is_pinned() { + // ── arm 1: the Phase arm is STRICT inequality ── + let mut precombat = TriggerDefinition::new(TriggerMode::Phase); + precombat.phase = Some(Phase::PreCombatMain); + assert!( + !trigger_event_unreachable_in_phase(&precombat, Phase::PreCombatMain), + "X2-4a PIN: `p == phase` must NOT be relieved (CR 117.3a + CR 603.3 + CR 608.2h)" + ); + assert!( + trigger_event_unreachable_in_phase(&precombat, Phase::CombatDamage), + "X2-4a paired positive: the same def IS unreachable at a different phase" + ); + + // ── arm 2: the damage arm REQUIRES `CombatOnly` ── + let mut any_damage = TriggerDefinition::new(TriggerMode::DamageDone); + any_damage.damage_kind = DamageKindFilter::Any; + assert!( + !trigger_event_unreachable_in_phase(&any_damage, Phase::PreCombatMain), + "X2-4b PIN: `damage_kind: Any` can fire on noncombat damage in any phase" + ); + let mut combat_only = TriggerDefinition::new(TriggerMode::DamageDone); + combat_only.damage_kind = DamageKindFilter::CombatOnly; + assert!( + trigger_event_unreachable_in_phase(&combat_only, Phase::PreCombatMain), + "X2-4b paired positive: `CombatOnly` IS unreachable outside CR 510.2's step" + ); + assert!( + !trigger_event_unreachable_in_phase(&combat_only, Phase::CombatDamage), + "X2-4b: `CombatOnly` is reachable IN the combat damage step (CR 510.2)" + ); + } } /// Regression tests for the foundational trigger double-fire defect diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index e4b091ebd9..a1fa2ab8a1 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -787,6 +787,11 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState certificate: certificate.clone(), schema: ShortcutDecisionSchema { iteration_count: schema.iteration_count.clone(), + // CR 732.2a: the count bound is derived from PUBLIC board state (life, + // poison, library sizes over the living players), so it carries through + // the per-viewer projection unredacted — only hidden-info legal targets + // are rewritten above. + max_iterations: schema.max_iterations, points, convoke_tappable_count, }, diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 5c28c8694d..4f5b397fd4 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -25991,7 +25991,7 @@ fn smart_ass_they_may_reveal_hand_binds_to_defending_player() { /// Sibling case for `smart_ass_they_may_reveal_hand_binds_to_defending_player`: /// a "they may" pronoun under a DIFFERENT relative-player scope /// (`ControllerRef::TargetPlayer`, stamped by a "deals combat damage to a -/// player" condition — CR 120.3) must still resolve to `TriggeringPlayer` +/// player" condition — CR 120.1) must still resolve to `TriggeringPlayer` /// (the damaged player), not fall into the new `DefendingPlayer` arm. Guards /// the scope routing in `resolve_they_pronoun`: the two `if` checks read the /// same `Option` field and are mutually exclusive by diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 03ca073ed9..61acbdc840 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -11097,6 +11097,221 @@ impl WaitingFor { pub fn allows_cancel_cast(&self) -> bool { self.has_pending_cast() && !matches!(self, WaitingFor::ManaSourceSelection { .. }) } + + /// CR 603.3b / CR 603.3d / CR 603.5 + CR 608.2d / CR 903.9a / CR 704.5j / CR 310.10 / + /// CR 703.1 + CR 117.3a + CR 704.3: the windows the ENGINE forces open before the + /// next grant of priority. Two sources feed the class — the windows that open + /// between (or during) a resolution and the next priority, and the turn-based + /// actions CR 703.1 performs automatically when a step or phase begins or ends + /// ("Turn-based actions don't use the stack"), which CR 117.3a puts strictly + /// BEFORE the active player receives priority. **No player has priority at any of + /// them**, which is the single property every consumer relies on: + /// + /// * Answering one is never a *deliberate* break of a mandatory cascade — the + /// player is being forced to make a choice, not choosing to act. That is why the + /// CR 732.2a loop-detection ring is RETAINED across them rather than discarded. + /// * CR 704.3 checks state-based actions whenever a player *would* get priority, + /// and every priority point arrives as [`WaitingFor::Priority`] — never a member + /// here — so retaining across these windows skips no SBA check. + /// * Nothing can be cast or played here: casting a spell (CR 117.1a) and playing a + /// land (CR 305.1) both require priority. This is what keeps a retained window's + /// proved-empty cast set (`analysis::resource::LoopWindowScope::cast_card_ids`) + /// sound. + /// + /// Members, with the rule that puts each one before priority: + /// * [`WaitingFor::OrderTriggers`] — CR 603.3b, ordering simultaneous triggers is a + /// forced part of putting them on the stack (the pre-existing exemption). + /// * [`WaitingFor::TriggerTargetSelection`] — CR 603.3d, target choice is part of + /// that same pre-priority step. + /// * [`WaitingFor::OptionalEffectChoice`] — CR 603.5 + CR 608.2d, a "may" pause + /// happens mid-resolution. + /// * [`WaitingFor::CommanderZoneChoice`] — CR 903.9a, the commander-zone choice + /// *is* a state-based action, and CR 704.3 checks SBAs before priority is granted. + /// * [`WaitingFor::ChooseLegend`] — CR 704.5j, the legend rule ("that player chooses + /// one of them") *is* a state-based action, answered inside the same CR 704.3 + /// fixpoint before priority is granted. + /// * [`WaitingFor::BattleProtectorChoice`] — CR 310.10 (which says in so many words + /// "This is a state-based action") + CR 704.5w / CR 704.5x, likewise answered + /// inside the CR 704.3 fixpoint. + /// + /// Those SBA members (the commander-zone, legend and battle-protector choices) are + /// the COMPLETE set of player-choice pauses `game::sba` opens inside the SBA + /// fixpoint (`check_commander_zone_return`, `check_legend_rule`, + /// `check_battle_protector`) — that sub-class is derived from the enumeration, not + /// from a recollection of which ones happened to come up. + /// + /// TURN-BASED-ACTION members (CR 703.1 + CR 117.3a: "No player receives priority + /// during the untap step", and the active player receives priority only *after* + /// turn-based actions have been dealt with): + /// * [`WaitingFor::UntapChoice`] / [`WaitingFor::ChooseUntapSubset`] — CR 502.3, + /// "the active player determines which permanents they control will untap ... + /// This turn-based action doesn't use the stack", inside the untap step where + /// CR 117.3a grants nobody priority at all. + /// * [`WaitingFor::DeclareAttackers`] — CR 508.1, "the active player declares + /// attackers. This turn-based action doesn't use the stack"; priority arrives + /// only afterwards at CR 508.2. + /// * [`WaitingFor::ExertChoice`] / [`WaitingFor::EnlistChoice`] — CR 508.1g, the + /// optional "as it attacks" costs are chosen *within* that same CR 508.1 + /// declaration (CR 701.43d for exert, CR 702.154b for enlist). + /// * [`WaitingFor::DeclareBlockers`] — CR 509.1, "the defending player declares + /// blockers. This turn-based action doesn't use the stack". + /// * [`WaitingFor::DiscardToHandSize`] — CR 514.1, "they discard enough cards to + /// reduce their hand size to that number. This turn-based action doesn't use the + /// stack", in the cleanup step where CR 514.3 normally grants no priority. + /// + /// What this class buys is RING SURVIVAL across a turn boundary, and that is + /// **necessary but not yet sufficient** for the cross-turn shortcut CR 732.2a + /// contemplates ("may even cross multiple turns"). Measured, on the Fantastic Four + /// dump: without these members the ring is force-wiped once per 99-beat turn period, + /// keyed on `DeclareAttackers` (with `DiscardToHandSize` masked behind it), holding + /// the ring at 2 frames; with them it accumulates to 13. That wipe is the concrete + /// defect this class removes. + /// + /// It does NOT by itself make a cross-turn pair certifiable, and this doc must not be + /// read as claiming so: `loop_states_equal` delegates to `impl PartialEq for + /// GameState`, which compares `turn_number`, `active_player` and `phase`, and neither + /// `normalize_for_loop` nor `analysis::resource::project_out_resources` neutralizes + /// any of the three. Measured on two retained frames one turn apart: self-vs-self + /// `true` (the positive control that the comparator is live), turn-bumped `false`, + /// phase-changed `false`. So no pair spanning a turn certifies today; the empty + /// normalization set is the deliberate, ratified design, and widening it is a + /// separate decision with its own soundness burden. + /// + /// A member missing from here is fail-closed but is still a class hole: a loop whose + /// cascade passes through that window can never accumulate a ring, so it can never + /// reach CR 732.2a detection. + /// + /// EXCLUDED BY THE CLASS'S OWN LIFE-MOVING PROHIBITION, not by a new carve-out: + /// [`WaitingFor::AssignCombatDamage`] and [`WaitingFor::AssignBlockerDamage`] are + /// turn-based per CR 510.1 / CR 510.1c / CR 510.1d, but CR 510.2 then deals that + /// damage with **no intervening priority** ("No player has the chance to cast + /// spells or activate abilities between the time combat damage is assigned and the + /// time it's dealt"). Exempting the assignment window therefore lets a retained + /// frame pair straddle the life change — exactly the case the existing prohibition + /// bars (`RedistributeLifeTotals` is out for the same reason; see + /// `forced_cascade_window_class` and the + /// `analysis::loop_check::winner_life_never_dips` invariant the exemption rests + /// on). A combat-damage loop consequently still fails to retain: a conservative + /// MISS, never a wrong certification. + /// + /// That window-keyed exclusion is **necessary but not sufficient**, and the + /// sufficient guard lives elsewhere: the assignment window opens ONLY when a damage + /// DIVISION choice is required (`game::combat_damage` auto-assigns for an unblocked + /// attacker, a single blocker, or a blocked attacker with no current blockers), so an + /// unblocked attacker deals CR 510.2 damage with **no window to exclude**. The + /// authority that covers that case is event-keyed, not window-keyed: + /// [`GameState::invalidate_loop_ring_on_unobserved_life_move`], called from + /// `game::combat_damage::apply_combat_damage` on the life delta the batch produced. + /// Both exclusions are kept: this one is a cheap upstream fence, the event-keyed one + /// is the complete one. + /// + /// ALSO EXCLUDED UNDER THE SAME RULE — [`WaitingFor::CombatTaxPayment`]. The + /// declaration windows above move no life, but CR 508.1h / CR 509.1d put the + /// declaration's COSTS in a separate sub-step ("Costs may include paying mana, + /// tapping permanents, sacrificing permanents, discarding cards, and so on"), and a + /// Phyrexian symbol in an attack or block tax is paid with 2 life (CR 107.4f) at + /// CR 508.1j / CR 509.1f — `game::engine_combat::handle_pay_combat_tax` pays through + /// `game::casting::pay_unless_cost`, which settles Phyrexian `life_payments` via + /// `game::life_costs::pay_life_as_cost`. So the tax window CAN move life and must + /// stay out, exactly as `RedistributeLifeTotals` does. It falls through to `false` + /// below and needs no arm; the [`CombatTaxContext`] anchor and the + /// `engine_combat::handle_declare_attackers` tax pause are where the class boundary + /// between "declaring" (a member) and "paying to declare" (not a member) is drawn. + /// + /// FAIL-CLOSED: the other 114 variants fall through to `false`, and `false` keeps + /// the ring-clearing behaviour, so a newly added variant defaults to the safe side + /// without anyone remembering to update this list. + pub fn is_forced_cascade_window(&self) -> bool { + matches!( + self, + WaitingFor::OrderTriggers { .. } + | WaitingFor::TriggerTargetSelection { .. } + | WaitingFor::OptionalEffectChoice { .. } + | WaitingFor::CommanderZoneChoice { .. } + | WaitingFor::ChooseLegend { .. } + | WaitingFor::BattleProtectorChoice { .. } + // CR 502.3 + CR 117.3a: the untap-step turn-based action; no player + // receives priority during the untap step at all. + | WaitingFor::UntapChoice { .. } + | WaitingFor::ChooseUntapSubset { .. } + // CR 508.1: declaring attackers is a turn-based action that doesn't use + // the stack; CR 508.2 grants priority only after it. CR 508.1g folds the + // optional "as it attacks" costs (CR 701.43d exert, CR 702.154b enlist) + // into that same declaration. + | WaitingFor::DeclareAttackers { .. } + | WaitingFor::ExertChoice { .. } + | WaitingFor::EnlistChoice { .. } + // CR 509.1: declaring blockers is likewise a turn-based action that + // doesn't use the stack. + | WaitingFor::DeclareBlockers { .. } + // CR 514.1 + CR 514.3: discarding to maximum hand size is the cleanup + // step's turn-based action, and no player normally gets priority there. + | WaitingFor::DiscardToHandSize { .. } + ) + } + + /// Look-at-top-N states whose legal selections cannot be captured by the + /// candidate enumerator (it lists only {empty, full-in-original-order, + /// singletons}), so the multiplayer legality gate would wrongly reject a + /// legal reordered or partial selection. For these, `apply()` is the real + /// validation boundary and validates the submitted selection structurally + /// (see handle_resolution_choice); the server bypasses its enumeration gate. + /// + /// - CR 701.22a / CR 701.25a: scry/surveil keep the chosen cards on top + /// "in any order" — any duplicate-free subset, in any order, is legal. + /// - Dig (look at N, keep some): the handler enforces the keep_count / + /// up_to constraint, uniqueness, and the selectable-cards filter, and + /// preserves the chosen order for library-destined keeps. + pub fn accepts_freeform_card_selection(&self) -> bool { + matches!( + self, + WaitingFor::ScryChoice { .. } + | WaitingFor::ArrangePlanarDeckTopChoice { .. } + | WaitingFor::SurveilChoice { .. } + | WaitingFor::DigChoice { .. } + ) + } + + pub fn accepts_freeform_counter_move_distribution(&self) -> bool { + matches!(self, WaitingFor::MoveCountersDistribution { .. }) + } + + /// CR 107.1c: "Remove any number of counters" has a combinatorial legal + /// space (any per-type subset 0..=available, including the empty set) that + /// the coarse AI candidate enumerator (`counter_removal_candidates`, which + /// offers only "remove all" and "remove none") cannot fully cover. The + /// server bypasses its enumeration gate for this state so a human's + /// intermediate submission (e.g. "remove 2 of 3") is not wrongly rejected; + /// `apply()` (the `RemoveCountersChoice` handler) is the real validation + /// boundary via `validate_counter_selection`. + pub fn accepts_freeform_counter_removal(&self) -> bool { + matches!(self, WaitingFor::RemoveCountersChoice { .. }) + } + + /// Combat-damage assignment whose legal divisions cannot be captured by the + /// candidate enumerator. `candidates.rs` lists exactly one + /// `AssignCombatDamage` candidate (the greedy trample-through split), so the + /// multiplayer legality gate would wrongly reject every other legal division + /// — e.g. keeping excess on the blocker instead of trampling it through + /// (CR 702.19b), or any of the freely-chosen splits across multiple blockers + /// (CR 510.1c/d). The combinatorial space of legal divisions is too large to + /// enumerate, so `apply()` (handle_assign_combat_damage) is the real + /// validation boundary: it enforces total conservation, blocker membership, + /// and the CR 702.19b lethal-before-excess precondition, and rejects illegal + /// submissions. The server bypasses its enumeration gate for these. + pub fn accepts_freeform_combat_damage_assignment(&self) -> bool { + matches!(self, WaitingFor::AssignCombatDamage { .. }) + } + + /// CR 510.1d + CR 702.22k: A blocker's free division of its combat damage + /// among the attackers it blocks cannot be captured by the candidate + /// enumerator (the combinatorial space of legal divisions is too large to + /// enumerate), so the server bypasses its enumeration gate for this state + /// and `apply()` (handle_assign_blocker_damage) is the real validation + /// boundary: it enforces total conservation and blocked-attacker membership. + pub fn accepts_freeform_blocker_damage_assignment(&self) -> bool { + matches!(self, WaitingFor::AssignBlockerDamage { .. }) + } } /// CR 102.1 + CR 500.1: which turn boundary ends an auto-pass session. @@ -18584,6 +18799,57 @@ impl GameState { self.loop_detect_ring.push_back(snapshot); } + /// 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. + /// + /// CR 510.2 deals assigned combat damage with "No player has the chance to cast + /// spells or activate abilities between the time combat damage is assigned and the + /// time it's dealt", so the resulting life change is a CR 704.5a point that no + /// CR 704.3 check (which arrives only as [`WaitingFor::Priority`]) separates from the + /// retained frames. Two frames straddling it would be compared as if the life + /// movement had been observed, which is what + /// `analysis::loop_check::winner_life_never_dips` relies on being impossible. + /// + /// Keyed on the EVENT — the life delta the batch actually produced — and NOT on a + /// [`WaitingFor`] variant. The window-keyed exclusion of + /// [`WaitingFor::AssignCombatDamage`] / [`WaitingFor::AssignBlockerDamage`] from + /// [`WaitingFor::is_forced_cascade_window`] is necessary but NOT sufficient: that + /// window opens only when a damage DIVISION choice is required (`game::combat_damage` + /// auto-assigns for an unblocked attacker, a single blocker, or a blocked attacker + /// with no current blockers), so an unblocked attacker moves a life total with no + /// window at all. An event-keyed guard covers every life-moving sub-path of the + /// batch — CR 120.3a damage, CR 702.15b lifelink, and a CR 615.5 prevention rider — + /// including ones added later. + /// + /// CR 119.3 adjusts a life total in EITHER direction, hence `!=` rather than `<`: a + /// batch that both drains and lifelinks back inside one CR 510.2 event is exactly the + /// dip-and-recover shape this prohibition exists to bar. + /// + /// Fail-safe by construction: clearing can only SHRINK the prior set every + /// offer/crown/draw path is gated on (`game::engine::find_live_loop_winner` and the + /// Path-B / Path-C bridges all scan `loop_detect_ring` for a satisfying prior), so + /// this can produce a conservative MISS and never a wrong certification. + /// + /// `lives_before` is positional over `self.players`, the fixed seat vector — seats are + /// never removed (elimination sets `Player::is_eliminated`) and the sole caller + /// snapshots the same vector inside one call, so the lengths agree today. A LENGTH + /// mismatch is nevertheless treated as an unobserved move: `zip` truncates to the + /// shorter slice, so a short snapshot would silently skip tail seats and RETAIN the + /// ring — the one direction the "clearing can only SHRINK the prior set" guarantee + /// forbids. One comparison keeps that guarantee structural instead of contractual. + /// (CR 119.3, `MagicCompRules.txt:1065`, is the rule the life comparison implements.) + pub(crate) fn invalidate_loop_ring_on_unobserved_life_move(&mut self, lives_before: &[i32]) { + if self.players.len() != lives_before.len() + || self + .players + .iter() + .zip(lives_before) + .any(|(p, &before)| p.life != before) + { + self.loop_detect_ring.clear(); + } + } + /// CR 732.2a: record that an unbounded (net-progress) loop under `controller` /// pumps `axes`. The single write authority for `unbounded_resources` — /// every producer routes through here, never mutating the map inline. Two @@ -19746,6 +20012,308 @@ fn default_pile_source_battlefield() -> PileSource { PileSource::Battlefield } +#[cfg(test)] +mod forced_cascade_window_tests { + use super::*; + + fn certificate() -> crate::analysis::loop_check::LoopCertificate { + crate::analysis::loop_check::LoopCertificate { + unbounded: vec![crate::analysis::resource::ResourceAxis::Life(PlayerId(1))], + win_kind: crate::analysis::loop_check::WinKind::LethalDamage, + mandatory: false, + residual_board_delta: crate::analysis::resource::BoardDelta::default(), + } + } + + /// CR 603.3b / CR 603.3d / CR 603.5 + CR 608.2d / CR 903.9a / CR 704.5j / + /// CR 310.10 / CR 703.1 + CR 117.3a: the membership matrix for + /// [`WaitingFor::is_forced_cascade_window`], asserted in BOTH directions — + /// thirteen members and eight non-members, each named. + /// + /// The class is defined by "no player has priority here", and each half of the + /// matrix pins a different consequence: + /// + /// * The TRUE half is what lets the CR 732.2a loop-detection ring survive a + /// forced pre-priority window and the action that answers it. Its SBA members + /// are the complete enumeration of `game::sba`'s in-fixpoint player choices; + /// its CR 703.1 turn-based members are the ones a turn boundary is paved with + /// (CR 502.3 untap, CR 508.1/508.1g attackers, CR 509.1 blockers, CR 514.1 + /// cleanup discard). Keeping the ring alive across those is **necessary but not + /// yet sufficient** for the cross-turn shortcut CR 732.2a contemplates ("may even + /// cross multiple turns"): `loop_states_equal` still compares `turn_number`, so no + /// cross-turn pair certifies today. What the membership buys, measured, is that the + /// Fantastic Four dump stops force-wiping the ring once per 99-beat turn period at + /// declare-attackers (2 frames held → 13). A missing member is fail-closed but is a + /// class hole, because a cascade passing through it can never accumulate a ring and + /// so can never reach CR 732.2a detection. + /// * The FALSE half is load-bearing for soundness, not tidiness. + /// `Priority{..}` must stay out: every point at which a CR 704.5a + /// state-based action can fire arrives as `Priority` (CR 704.3), so admitting + /// it would break the "every such point either samples or clears the ring" + /// invariant that `analysis::loop_check::winner_life_never_dips` rests on. + /// That invariant is the whole reason the per-resolution sampler's three + /// outcomes (SAMPLE at `Priority{active}`, RETAIN at a forced window, CLEAR + /// everywhere else) are sound: it needs only DISJOINTNESS of `Priority` from + /// the retained class, never the false claim that consecutive ring frames are + /// consecutive resolutions. Both priority seats are asserted, since the + /// non-active seat is the sampler's clear arm. + /// `RedistributeLifeTotals` must stay out because it is a window that CAN + /// MOVE LIFE — a life-moving window may never be exempt, or a retained frame + /// pair could straddle an unobserved life change. `AssignCombatDamage` is out + /// under that SAME rule even though CR 510.1 makes it turn-based: CR 510.2 + /// deals the assigned damage with "no player has the chance to cast spells or + /// activate abilities" in between, so no priority separates the window from the + /// life change it causes. `CombatTaxPayment` is out under that same rule and is + /// the sharpest case, because the window it interrupts (`DeclareAttackers`) IS a + /// member: CR 508.1j / CR 509.1f make paying to declare a separate sub-step, and + /// a Phyrexian tax symbol is paid with 2 life (CR 107.4f). Declaring moves no + /// life; paying to declare can. `LoopShortcut` / + /// `RespondToShortcut` must stay out because their answers + /// (`DeclareShortcut` / `RespondToShortcut`) are deliberate protocol actions + /// whose ring-clear the bounded drive depends on. + /// + /// NON-VACUITY: neither half is empty, so a constant implementation (always + /// `true` or always `false`) fails one of the two loops. REVERT-PROBE: adding + /// `Priority{..}` to the `matches!` flips the `Priority` rows here AND breaks + /// the sampler invariant; adding `RedistributeLifeTotals` or + /// `AssignCombatDamage` flips its row here; dropping ANY of the thirteen members + /// — SBA or turn-based — flips that member's TRUE row here (each was observed to + /// panic individually). + /// + /// This row owns the RULES invariant only. Runtime WIRING — that the sampler and + /// `apply_action` really consult this predicate — is proved by + /// `loop_shortcut.rs::two_site_retention_survives_a_prompt_and_its_answer`, and + /// that no exempt window admits a cast by + /// `analysis::resource`'s `no_exempt_window_admits_a_cast`. + #[test] + fn forced_cascade_window_class() { + let forced: Vec<(&str, WaitingFor)> = vec![ + ( + "OrderTriggers (CR 603.3b, the shipped exemption)", + WaitingFor::OrderTriggers { + player: PlayerId(0), + triggers: Vec::new(), + }, + ), + ( + "TriggerTargetSelection (CR 603.3d)", + WaitingFor::TriggerTargetSelection { + player: PlayerId(0), + trigger_controller: None, + trigger_event: None, + trigger_events: Vec::new(), + target_slots: Vec::new(), + mode_labels: Vec::new(), + target_constraints: Vec::new(), + selection: Default::default(), + source_id: None, + description: None, + }, + ), + ( + "OptionalEffectChoice (CR 603.5 + CR 608.2d)", + WaitingFor::OptionalEffectChoice { + player: PlayerId(0), + source_id: ObjectId(1), + description: None, + may_trigger_key: None, + }, + ), + ( + "CommanderZoneChoice (CR 903.9a — it IS a state-based action)", + WaitingFor::CommanderZoneChoice { + player: PlayerId(0), + commander_id: ObjectId(2), + current_zone: Zone::Graveyard, + }, + ), + ( + "ChooseLegend (CR 704.5j — the legend rule IS a state-based action)", + WaitingFor::ChooseLegend { + player: PlayerId(0), + legend_name: "Delianfel, Prayerful Herald".to_string(), + candidates: vec![ObjectId(3), ObjectId(4)], + }, + ), + ( + "BattleProtectorChoice (CR 310.10 + CR 704.5w / CR 704.5x — likewise an SBA)", + WaitingFor::BattleProtectorChoice { + player: PlayerId(0), + battle_id: ObjectId(5), + candidates: vec![PlayerId(1)], + }, + ), + ( + "UntapChoice (CR 502.3 turn-based + CR 117.3a — no priority in the untap step)", + WaitingFor::UntapChoice { + player: PlayerId(0), + candidates: vec![ObjectId(6)], + chosen_not_to_untap: Vec::new(), + }, + ), + ( + "ChooseUntapSubset (CR 502.3 bounded untap choice, same no-priority step)", + WaitingFor::ChooseUntapSubset { + player: PlayerId(0), + group: vec![ObjectId(6), ObjectId(7)], + max: 1, + }, + ), + ( + "DeclareAttackers (CR 508.1 turn-based; CR 508.2 grants priority only after)", + WaitingFor::DeclareAttackers { + player: PlayerId(0), + valid_attacker_ids: Vec::new(), + valid_attack_targets: Vec::new(), + valid_attack_targets_by_attacker: None, + attacker_constraints: Default::default(), + }, + ), + ( + "ExertChoice (CR 508.1g optional attack cost + CR 701.43d, inside CR 508.1)", + WaitingFor::ExertChoice { + player: PlayerId(0), + attacker: ObjectId(8), + remaining: Vec::new(), + }, + ), + ( + "EnlistChoice (CR 508.1g optional attack cost + CR 702.154b, inside CR 508.1)", + WaitingFor::EnlistChoice { + player: PlayerId(0), + attacker: ObjectId(8), + eligible: vec![ObjectId(9)], + remaining: Vec::new(), + }, + ), + ( + "DeclareBlockers (CR 509.1 turn-based, doesn't use the stack)", + WaitingFor::DeclareBlockers { + player: PlayerId(0), + valid_blocker_ids: Vec::new(), + valid_block_targets: Default::default(), + block_requirements: Default::default(), + blocker_constraints: Default::default(), + }, + ), + ( + "DiscardToHandSize (CR 514.1 turn-based + CR 514.3 — no priority in cleanup)", + WaitingFor::DiscardToHandSize { + player: PlayerId(0), + count: 1, + cards: vec![ObjectId(10)], + }, + ), + ]; + + let not_forced: Vec<(&str, WaitingFor)> = vec![ + ( + "Priority{active} — CR 704.3 SBA point, must sample or clear", + WaitingFor::Priority { + player: PlayerId(0), + }, + ), + ( + "Priority{non-active} — same, and the ring-clearing arm", + WaitingFor::Priority { + player: PlayerId(1), + }, + ), + ( + "RedistributeLifeTotals — a window that CAN MOVE LIFE", + WaitingFor::RedistributeLifeTotals { + player: PlayerId(0), + options: Vec::new(), + }, + ), + ( + "AssignCombatDamage — turn-based (CR 510.1) but CR 510.2 deals the damage \ + with no intervening priority, so it MOVES LIFE", + WaitingFor::AssignCombatDamage { + player: PlayerId(0), + attacker_id: ObjectId(11), + total_damage: 2, + blockers: Vec::new(), + assignment_modes: Vec::new(), + trample: None, + defending_player: PlayerId(1), + attack_target: crate::game::combat::default_attack_target(), + pw_loyalty: None, + pw_controller: None, + }, + ), + ( + "AssignBlockerDamage — same CR 510.1d / CR 510.2 life-moving exclusion", + WaitingFor::AssignBlockerDamage { + player: PlayerId(0), + blocker_id: ObjectId(12), + total_damage: 2, + attackers: vec![ObjectId(11)], + }, + ), + ( + "CombatTaxPayment — CR 508.1j / CR 509.1f put the declaration's COSTS in a \ + separate sub-step, and a Phyrexian tax symbol is paid with 2 life \ + (CR 107.4f), so it MOVES LIFE and must stay OUT even though the \ + DeclareAttackers window it interrupts is a member", + WaitingFor::CombatTaxPayment { + player: PlayerId(0), + context: CombatTaxContext::Attacking, + // The life-moving shape itself: {W/P} is payable with 2 life + // (CR 107.4f), which is what makes this window a life-mover. + total_cost: crate::types::mana::ManaCost::Cost { + shards: vec![crate::types::mana::ManaCostShard::PhyrexianWhite], + generic: 0, + }, + per_creature: Vec::new(), + pending: CombatTaxPending::Attack { + attacks: Vec::new(), + bands: Vec::new(), + }, + }, + ), + ( + "LoopShortcut — answered by the deliberate DeclareShortcut", + WaitingFor::LoopShortcut { + proposer: PlayerId(0), + predicted_winner: Some(PlayerId(0)), + certificate: certificate(), + schema: Default::default(), + }, + ), + ( + "RespondToShortcut — answered by the deliberate RespondToShortcut", + WaitingFor::RespondToShortcut { + player: PlayerId(1), + remaining_players: Vec::new(), + proposal: crate::analysis::loop_check::ShortcutProposal { + proposer: PlayerId(0), + predicted_winner: Some(PlayerId(0)), + count: crate::analysis::decision_template::IterationCount::UntilLethal, + unbounded: vec![crate::analysis::resource::ResourceAxis::Life(PlayerId(1))], + win_kind: crate::analysis::loop_check::WinKind::LethalDamage, + template: None, + }, + }, + ), + ]; + + assert!( + !forced.is_empty() && !not_forced.is_empty(), + "both halves must be populated — a one-sided matrix is satisfiable by a constant" + ); + for (why, wf) in &forced { + assert!( + wf.is_forced_cascade_window(), + "{why} is a forced pre-priority window and must be a member" + ); + } + for (why, wf) in ¬_forced { + assert!(!wf.is_forced_cascade_window(), "{why} must NOT be a member"); + } + } +} + #[cfg(test)] mod drain_stack_reentrancy_tests { use super::*; @@ -25363,4 +25931,81 @@ mod tests { ); assert_eq!(state.transient_continuous_effects.len(), 1); } + + /// GAP 3 — `invalidate_loop_ring_on_unobserved_life_move` must treat a SHORT + /// `lives_before` snapshot as an unobserved move. `zip` truncates to the shorter + /// slice, so without the length disjunct a short snapshot silently skips the tail + /// seats and RETAINS the ring — the one direction the doc's "clearing can only + /// SHRINK the prior set" guarantee forbids. (CR 119.3 is the rule the life + /// comparison implements.) + /// + /// The second arm is the ZERO-CENSUS POSITIVE CONTROL: a FULL-length snapshot with + /// EQUAL lives must RETAIN, which proves the instrument can return "not cleared" and + /// that arm 1's clear is a verdict rather than an unconditional wipe. + /// + /// REVERT-PROBE: delete `self.players.len() != lives_before.len() ||` ⇒ arm 1's `zip` + /// yields nothing over the tail, `any` is false, the ring survives ⇒ arm 1 FAILS while + /// arm 2 still passes, so the probe is isolated to the length disjunct. + #[test] + fn unobserved_life_move_invalidates_on_a_short_snapshot() { + let seed = |state: &mut GameState| { + state.record_loop_detect_sample(); + assert!( + !state.loop_detect_ring.is_empty(), + "reach-guard: the ring must be seeded, else both arms below are vacuous" + ); + }; + + // ── ARM 1 (SUBJECT): a SHORT snapshot whose covered prefix AGREES on life ── + let mut short_state = GameState::new_two_player(7); + assert!( + short_state.players.len() >= 2, + "reach-guard: the board needs a tail seat for a short snapshot to skip" + ); + seed(&mut short_state); + let short: Vec = vec![short_state.players[0].life]; + assert!( + short.len() < short_state.players.len(), + "reach-guard: the snapshot really is SHORT — this is the whole premise" + ); + assert_eq!( + short_state.players[0].life, short[0], + "reach-guard: the COVERED prefix agrees on life, so a `zip`-only comparison \ + finds no difference and the clear below can only come from the length test" + ); + short_state.invalidate_loop_ring_on_unobserved_life_move(&short); + assert!( + short_state.loop_detect_ring.is_empty(), + "a SHORT `lives_before` cannot prove the tail seats did not move, so the ring \ + must be dropped; `zip` would silently truncate and retain it" + ); + + // ── ARM 2 (POSITIVE CONTROL): FULL length, EQUAL lives ⇒ RETAINED ── + let mut equal_state = GameState::new_two_player(7); + seed(&mut equal_state); + let full: Vec = equal_state.players.iter().map(|p| p.life).collect(); + assert_eq!( + full.len(), + equal_state.players.len(), + "reach-guard: this arm's snapshot is FULL length" + ); + equal_state.invalidate_loop_ring_on_unobserved_life_move(&full); + assert!( + !equal_state.loop_detect_ring.is_empty(), + "positive control: a full-length snapshot with unchanged lives observed NO \ + move, so the ring must be RETAINED — this is what proves arm 1's clear is a \ + verdict and not an unconditional wipe" + ); + + // ── ARM 3: FULL length, a life that MOVED ⇒ cleared (the pre-existing contract) ── + let mut moved_state = GameState::new_two_player(7); + seed(&mut moved_state); + let before: Vec = moved_state.players.iter().map(|p| p.life).collect(); + moved_state.players[1].life -= 1; + moved_state.invalidate_loop_ring_on_unobserved_life_move(&before); + assert!( + moved_state.loop_detect_ring.is_empty(), + "an observed life move on a full-length snapshot still clears the ring" + ); + } } diff --git a/crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz b/crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz new file mode 100644 index 0000000000..8e65bac052 Binary files /dev/null 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 new file mode 100644 index 0000000000..9dfde0af4f Binary files /dev/null and b/crates/engine/tests/fixtures/dina_conqueror_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 new file mode 100644 index 0000000000..cde75b40a9 Binary files /dev/null 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 new file mode 100644 index 0000000000..7a0afdc831 Binary files /dev/null and b/crates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gz differ diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 3afc6332b4..30e8179b51 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -33,9 +33,9 @@ use engine::types::interaction::{ InteractionOpportunityResponse, InteractionOutcomeCode, InteractionPresentationSurface, InteractionPreviewRequest, InteractionPreviewStatus, InteractionReasonCode, InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, - InteractionShortcutDecision, InteractionShortcutPin, InteractionShortcutPointKind, - InteractionShortcutResponseCode, InteractionSubmission, PreviewRequestId, - MAX_INTERACTION_LIST_LEN, + InteractionShortcutCountSpec, InteractionShortcutDecision, InteractionShortcutPin, + InteractionShortcutPointKind, InteractionShortcutResponseCode, InteractionSubmission, + PreviewRequestId, MAX_INTERACTION_LIST_LEN, }; use engine::types::mana::{ManaColor, ManaCost, ManaCostShard, ManaType, ManaUnit}; use engine::types::match_config::MatchPhase; @@ -2327,6 +2327,133 @@ fn trigger_sequence_materializes_arbitrary_permutations_larger_than_four() { ); } +/// NEW-1 — a published CR 732.2a offer carrying `max_iterations: 0` is REJECTED, not +/// clamped. `elimination_bounds` returns `0` to mean "no legal repetition exists and the +/// caller must not offer" (CR 704.5a), so repairing it to `1` would render a +/// one-iteration offer whose single iteration eliminates a player mid-proposal. +/// +/// LATENT, NOT LIVE: no in-tree producer can emit `0` here — `build_shortcut_schema`'s two +/// call sites both pass `MAX_SHORTCUT_CYCLES`, the per-viewer projection copies an existing +/// value, and both `Default` and the `#[serde(default)]` resolve to the cap. Hand-assigning +/// `max_iterations: 0` IS the loaded/persisted-authority seat, which is exactly the shape a +/// restored dump can carry. This row is therefore a latent-hole guard, not a live-bug +/// reproduction. +/// +/// REVERT-PROBE, and note the FAILURE MODE: delete +/// `if schema.max_iterations == 0 { return Err(..) }` ⇒ post-edit `max` is +/// `0u32.min(1000) == 0`, so `suggested.clamp(1, 0)` trips `Ord::clamp`'s +/// `assert!(min <= max)` and **PANICS** (`min > max. min = 1, max = 0`). That assert is a +/// PLAIN assert, so it survives release — the guard is load-bearing against an engine +/// panic on a malformed restored dump, not merely against a bad offer. The probe flips RED +/// by panic, not by a value mismatch. +#[test] +fn loop_shortcut_zero_max_iterations_is_rejected_not_clamped() { + let shortcut_state = |max_iterations: u32| { + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: Some(P0), + certificate: engine::analysis::loop_check::LoopCertificate { + unbounded: Vec::new(), + win_kind: engine::analysis::loop_check::WinKind::LethalDamage, + mandatory: false, + residual_board_delta: engine::analysis::resource::BoardDelta::default(), + }, + schema: engine::analysis::decision_template::ShortcutDecisionSchema { + iteration_count: engine::analysis::decision_template::IterationCount::Fixed(2), + max_iterations, + ..Default::default() + }, + }; + bind(&mut state, "loop-zero-bound"); + state + }; + + // ── PAIRED CONTROL, first: the byte-identical schema at the DEFAULT bound projects a + // shortcut schema. Without this the rejection below could be the whole window being + // unsupported for an unrelated reason. + let control = shortcut_state(ShortcutDecisionSchema::default().max_iterations); + let control_view = priority_view(&control); + let InteractionOpportunityResponse::Schema { + spec: InteractionResponseSpec::Shortcut { .. }, + .. + } = &control_view.opportunities[0].response + else { + panic!( + "control: the same window at the default bound must project a shortcut schema, \ + else this row's rejection is not attributable to `max_iterations`" + ); + }; + + // ── SUBJECT: the only variable is `max_iterations: 0`. + let subject = shortcut_state(0); + assert_eq!( + priority_view(&subject).availability, + InteractionAvailability::Unsupported { + reason: InteractionReasonCode::InvalidAuthorityState, + }, + "CR 704.5a: `max_iterations == 0` means NO legal repetition exists, so the offer is \ + an authority violation to reject — not a number to clamp back up to 1" + ); +} + +/// CR-12 — the picker's ceiling is the offer's OWN narrowed CR 732.2a bound, never the +/// raw global safety limit. Before this row the file only ever asserted the default bound, +/// so a projection that ignored `max_iterations` entirely would have stayed green. +/// +/// Disclosed: an over-bound `suggested` is CLAMPED, not rejected. That is correct — +/// `suggested` is a hint, `max_iterations` is the authority. +/// +/// REVERT-PROBE: change `let max = schema.max_iterations.min(MAX_SHORTCUT_CYCLES)` back to +/// `MAX_SHORTCUT_CYCLES` ⇒ `max` becomes the global cap ⇒ this assertion FAILS. +#[test] +fn loop_shortcut_narrowed_max_iterations_bounds_the_picker() { + let mut state = GameState::new_two_player(42); + state.waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: Some(P0), + certificate: engine::analysis::loop_check::LoopCertificate { + unbounded: Vec::new(), + win_kind: engine::analysis::loop_check::WinKind::LethalDamage, + mandatory: false, + residual_board_delta: engine::analysis::resource::BoardDelta::default(), + }, + schema: engine::analysis::decision_template::ShortcutDecisionSchema { + // A NARROWED bound, i.e. what `elimination_bounds` produces on a real board. + iteration_count: engine::analysis::decision_template::IterationCount::Fixed(9), + max_iterations: 3, + ..Default::default() + }, + }; + bind(&mut state, "loop-narrowed-bound"); + + // Reach-guard: the narrowed bound really is BELOW the global cap, else `min(..)` and + // the global cap coincide and the row cannot discriminate. + assert!( + 3 < ShortcutDecisionSchema::default().max_iterations, + "reach-guard: the narrowed bound must be strictly below the global cap" + ); + + let view = priority_view(&state); + let InteractionOpportunityResponse::Schema { + spec: InteractionResponseSpec::Shortcut { count, .. }, + .. + } = &view.opportunities[0].response + else { + panic!("loop shortcut uses a shortcut schema"); + }; + assert_eq!( + *count, + InteractionShortcutCountSpec::Fixed { + min: 1, + max: 3, + suggested: 3, + }, + "CR 732.2a: the picker's ceiling is the offer's own narrowed bound (3), and an \ + over-bound `suggested` (9) is clamped down to it rather than rejected" + ); +} + #[test] fn loop_shortcut_number_schema_accepts_a_fixed_count_above_one() { let mut state = GameState::new_two_player(42); @@ -2341,8 +2468,8 @@ fn loop_shortcut_number_schema_accepts_a_fixed_count_above_one() { }, schema: engine::analysis::decision_template::ShortcutDecisionSchema { iteration_count: engine::analysis::decision_template::IterationCount::Fixed(2), - points: Vec::new(), - convoke_tappable_count: 0, + // No narrowed CR 732.2a bound — `Default` carries the global cap. + ..Default::default() }, }; bind(&mut state, "loop-count"); @@ -2395,6 +2522,8 @@ fn loop_shortcut_schema_and_materializer_cover_every_decision_point_kind() { }, schema: ShortcutDecisionSchema { iteration_count: IterationCount::Fixed(2), + // No narrowed CR 732.2a bound — the global cap, as every offer states today. + max_iterations: ShortcutDecisionSchema::default().max_iterations, points: vec![ DecisionPoint { slot: slot(0), diff --git a/crates/engine/tests/integration/loop_shortcut.rs b/crates/engine/tests/integration/loop_shortcut.rs index 8d43143b31..bdba6d1fab 100644 --- a/crates/engine/tests/integration/loop_shortcut.rs +++ b/crates/engine/tests/integration/loop_shortcut.rs @@ -1514,6 +1514,8 @@ 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. + max_iterations: ShortcutDecisionSchema::default().max_iterations, points: vec![DecisionPoint { slot: slot.clone(), kind: DecisionPointKind::Targets { @@ -2892,11 +2894,19 @@ fn object_growth_library_observer_does_not_suppress_offer() { // Sanity: Kodama really is in the library (not the battlefield), so any offer // must come from correctly IGNORING it, not from it having been removed. + let kodama_obj = &runner.state().objects[&kodama]; assert_eq!( - runner.state().objects.get(&kodama).unwrap().zone, + kodama_obj.zone, Zone::Library, "the growing-class observer must sit in the library for this to discriminate", ); + assert_eq!( + kodama_obj.trigger_definitions.len(), + 1, + "reach-guard: the observer must have PARSED — a misparse leaves zero trigger \ + defs and the offer below forms for the wrong reason (nothing to ignore); got {}", + kodama_obj.trigger_definitions.len() + ); let outcome = runner .cast(sprout) @@ -3947,6 +3957,8 @@ 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. + max_iterations: ShortcutDecisionSchema::default().max_iterations, points: vec![DecisionPoint { slot, kind: DecisionPointKind::Targets { @@ -4211,11 +4223,21 @@ const PLAIN_DRAW_TRIGGER_ORACLE: &str = "Flying, trample\nWhenever this creature deals combat damage to a player, draw a card."; /// The passing 51st Sprout Swarm / Witherbloom object-growth row plus exactly ONE -/// extra P0 battlefield permanent carrying `bystander_oracle`. Returns the final -/// `WaitingFor` plus the bystander's id, so the caller can reach-guard its zone. -fn object_growth_with_bystander(bystander_oracle: &str) -> (GameRunner, ObjectId) { +/// extra battlefield permanent, controlled by `bystander_controller`, carrying +/// `bystander_oracle`. Returns the final `WaitingFor` plus the bystander's id, so the +/// caller can reach-guard its zone. +/// +/// `phase` parameterises the loop window's step (CR 500.1 / CR 506.1), which is what +/// the CR 510.2 phase-unreachability rows key on; `bystander_controller` parameterises +/// the observer's controller, which is what the CR 117.1b sole-driver rows key on. Both +/// axes exist so a row can move exactly ONE variable against its own control. +fn object_growth_with_bystander_at( + phase: Phase, + bystander_controller: PlayerId, + bystander_oracle: &str, +) -> (GameRunner, ObjectId) { let mut scenario = GameScenario::new(); - scenario.at_phase(Phase::PreCombatMain); + scenario.at_phase(phase); scenario.add_creature_from_oracle( P0, "Witherbloom, the Balancer", @@ -4224,7 +4246,13 @@ fn object_growth_with_bystander(bystander_oracle: &str) -> (GameRunner, ObjectId WITHERBLOOM_AFFINITY_ORACLE, ); let bystander = scenario - .add_creature_from_oracle(P0, "BBFU10 Bystander", 2, 2, bystander_oracle) + .add_creature_from_oracle( + bystander_controller, + "BBFU10 Bystander", + 2, + 2, + bystander_oracle, + ) .id(); let mut fodder = Vec::new(); for _ in 0..4 { @@ -4258,6 +4286,11 @@ fn object_growth_with_bystander(bystander_oracle: &str) -> (GameRunner, ObjectId (runner, bystander) } +/// The shipped two-call-site shape: P0's own bystander, precombat main. +fn object_growth_with_bystander(bystander_oracle: &str) -> (GameRunner, ObjectId) { + object_growth_with_bystander_at(Phase::PreCombatMain, P0, bystander_oracle) +} + /// T16 (BB-FU10 RULING deliverable). With Step 0c applied, a shipped /// battlefield-entry-ledger observer anywhere on a functioning battlefield /// SUPPRESSES a CR 732.2a object-growth offer that fires without it. @@ -4269,16 +4302,31 @@ fn object_growth_with_bystander(bystander_oracle: &str) -> (GameRunner, ObjectId /// growing class — the one error direction `ability_scan`'s ADD-1 contract /// forbids. /// -/// **`BB-FU10-N` is the narrowing follow-up that will flip assertion (1) back to -/// an offer** (gate the veto on whether the observer's filter can actually match -/// the growing class, mirroring `etb_observer_provably_excludes_class`). Do NOT -/// "fix" this test by deleting it — update it when `BB-FU10-N` lands. +/// **`BB-FU10-N` SHIPPED IN THIS COMMIT.** Assertion (1) is now an **OFFER**. The +/// flip's mechanism is **X2 phase/step unreachability** (CR 510.2 / CR 506.1; +/// CR 500.1 for the phase list), *not* filter-matching: Park Heights Pegasus's +/// ledger filter is `Typed{Creature}`, which genuinely **does** match a Saproling +/// token, so gating the veto on filter-match leaves this card vetoed — measured by +/// rebuilding the same board with a `Typed{Artifact}` ledger filter, which still +/// vetoed at BASE. The card's trigger is `damage_kind: CombatOnly` and the loop +/// window is `PreCombatMain`, so the observer cannot fire inside the window. The +/// shallow filter-match narrowing now also ships (a `QuantityCheck`-shaped ledger read +/// sitting directly in a block-(1) trigger's `execute.condition`, proven sole-source by +/// single-field clone-and-rescan); rows `K4-N1`/`K4-N2` are its matched pair. Measured on +/// the current card pool that shape matches exactly ONE printed card — this one — which +/// it correctly REFUSES, because `Typed{Creature}` genuinely counts a Saproling creature +/// token. Everything the shallow form cannot reach — `trigger.condition` observers (21 +/// cards), statics (16), abilities (13), `casting_options` (4), replacements (1), compound +/// conditions, rhs-position reads, blocks (2)/(3)/(5b) — remains **`BB-FU10-N2`**. /// -/// REVERT-PROBE: set the `BattlefieldEntriesThisTurn` arm's `sibling` back to -/// `false` in `game/ability_scan.rs` → (1) FAILS (the offer returns). Measured -/// both directions; the (2) control is granted in BOTH builds. +/// REVERT-PROBE: delete X2's `continue` in +/// `fire_time_conditions_read_growing_class_scoped` block (1) ⇒ this row returns to +/// a veto and FAILS. The (2) control is granted in BOTH builds. **Second, +/// independent probe:** make `trigger_event_unreachable_in_phase` return `false` +/// unconditionally ⇒ the same failure ⇒ the *predicate*, not the plumbing, carries +/// the flip. #[test] -fn object_growth_ledger_observer_bystander_suppresses_offer() { +fn object_growth_phase_unreachable_ledger_observer_does_not_suppress_offer() { use engine::types::zones::Zone; // (2) ANTI-VACUITY CONTROL first: an otherwise byte-identical board whose @@ -4319,13 +4367,1924 @@ fn object_growth_ledger_observer_bystander_suppresses_offer() { "(3) reach-guard: exactly one trigger definition carries the ledger read" ); - // (1) THE VETO — the disclosed, sound post-0c behaviour. + // (1) THE OFFER — X2's phase-unreachability relief (CR 510.2 / CR 506.1). + match &runner.state().waiting_for { + WaitingFor::LoopShortcut { + certificate, + predicted_winner, + .. + } => { + assert!( + certificate.unbounded.contains(&ResourceAxis::TokensCreated), + "(1) the detected loop's unbounded axis must be TokensCreated, got {:?}", + certificate.unbounded + ); + assert_eq!( + *predicted_winner, None, + "(1) this is an Advantage offer, not a predicted win" + ); + } + other => panic!( + "(1) CR 510.2 / CR 506.1: Park Heights Pegasus's combat-damage trigger cannot \ + fire inside a PreCombatMain loop window, so it must NOT suppress the \ + CR 732.2a object-growth offer; got {other:?}" + ), + } +} + +/// HF-X2-a (hostile fixture for X2-1) — the SAME Park Heights Pegasus board with the +/// loop window at `Phase::CombatDamage`. There the observer's combat-damage event IS +/// reachable (CR 510.2), `trigger_event_unreachable_in_phase` returns `false`, and the +/// conservative veto is preserved. Paired with X2-1 this is a matched pair moving +/// exactly ONE variable: the window's phase. +/// +/// The control half proves the board still detects a loop at this step, so the subject +/// half's no-offer is a real veto and not a dead harness. +/// +/// REVERT-PROBE: drop the `phase != Phase::CombatDamage` conjunct from the damage arm +/// ⇒ the subject half flips to an offer ⇒ FAILS. +#[test] +fn combat_damage_step_ledger_observer_still_suppresses_offer() { + use engine::types::zones::Zone; + + // Control: the plain-draw bystander on the same board at the same step. + let (control_runner, _) = + object_growth_with_bystander_at(Phase::CombatDamage, P0, PLAIN_DRAW_TRIGGER_ORACLE); + assert!( + matches!( + control_runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "HF-X2-a REACH-GUARD: the loop must still be detected and offered at \ + Phase::CombatDamage, else the subject half below proves nothing. \ + (Pre-registered STOP branch: if this fails, report the rejecting gate and \ + DROP HF-X2-a — X2-4a/X2-4b keep the phase-keying proof.) got {:?}", + control_runner.state().waiting_for + ); + + // Subject: Pegasus, whose CombatOnly trigger IS reachable in this step. + let (runner, bystander) = + object_growth_with_bystander_at(Phase::CombatDamage, P0, PARK_HEIGHTS_PEGASUS_ORACLE); + + // (3) reach-guards — block (2) hard-skips non-battlefield zones, and this row's claim is + // about ONE named TRIGGER surface: without these the veto could arrive from a surface the + // row does not name (wrong-attribution vacuity). + let obj = &runner.state().objects[&bystander]; + assert_eq!( + obj.zone, + Zone::Battlefield, + "reach-guard: block (2) hard-skips non-battlefield zones, so a veto from this \ + bystander would not be attributable to it at all" + ); + assert_eq!( + obj.trigger_definitions.len(), + 1, + "reach-guard: this row's claim is about ONE named trigger surface; got {}", + obj.trigger_definitions.len() + ); + assert!( + obj.abilities.is_empty(), + "reach-guard: this row's claim is about ONE named TRIGGER surface; the bystander \ + also carries {} ability def(s) {:?}, so a veto here would not be attributable to \ + the trigger", + obj.abilities.len(), + obj.abilities.iter().map(|a| a.kind).collect::>(), + ); + + assert!( + !matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }), + "CR 510.2: in the combat damage step the observer's event IS reachable, so the \ + veto must be preserved; got {:?}", + runner.state().waiting_for + ); +} + +/// Smuggler's Share, verbatim (Scryfall `cards/named?exact=`), behind the harness's +/// shared `"Flying, trample\n"` keyword prefix so subject and control differ ONLY in +/// the ledger clause. Its trigger is `TriggerMode::Phase` with `phase: End`. +const SMUGGLERS_SHARE_ORACLE: &str = "Flying, trample\nAt the beginning of each end step, draw a card for each opponent who drew two or more cards this turn, then create a Treasure token for each opponent who had two or more lands enter the battlefield under their control this turn."; + +/// X2-2 — a SECOND trigger mode reaches the same relief. Smuggler's Share's +/// `{Phase, End}` observer cannot fire inside a `PreCombatMain` loop window +/// (CR 500.1 / CR 506.1), so it must not suppress the CR 732.2a offer. +/// +/// REVERT-PROBE: delete X2's `TriggerMode::Phase` arm (or widen it to `p == phase`) +/// ⇒ the veto returns ⇒ FAILS. +#[test] +fn smugglers_share_end_step_observer_does_not_suppress_offer() { + use engine::types::zones::Zone; + + // (2) ANTI-VACUITY CONTROL, granted in BOTH builds. + let (control_runner, _) = object_growth_with_bystander(PLAIN_DRAW_TRIGGER_ORACLE); + assert!( + matches!( + control_runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "(2) control: a plain draw-trigger bystander must not suppress the offer" + ); + + let (runner, bystander) = object_growth_with_bystander(SMUGGLERS_SHARE_ORACLE); + + // (3) reach-guards — block (1) hard-skips non-battlefield zones. + let obj = &runner.state().objects[&bystander]; + assert_eq!(obj.zone, Zone::Battlefield); + assert_eq!( + obj.trigger_definitions.len(), + 1, + "(3) reach-guard: exactly one trigger definition carries the ledger read" + ); + + match &runner.state().waiting_for { + WaitingFor::LoopShortcut { certificate, .. } => assert!( + certificate.unbounded.contains(&ResourceAxis::TokensCreated), + "(1) unbounded axis must be TokensCreated, got {:?}", + certificate.unbounded + ), + other => panic!( + "(1) CR 500.1 / CR 506.1: an end-step observer cannot fire inside a \ + precombat-main loop window, so it must not suppress the offer; got {other:?}" + ), + } +} + +/// HF-X2-c (hostile fixture for X2-2) — the SAME Smuggler's Share board with the loop +/// window at `Phase::End`. Now `def.phase == Some(End) == phase`, the ⛔ PINNED strict +/// inequality returns `false`, and the veto is preserved. That refusal is a SOUNDNESS +/// bound, not conservatism for its own sake: per CR 117.3a the end-step ability is put +/// on the stack BEFORE the priority at which CR 732.2a lets a shortcut be proposed, and +/// CR 608.2h determines its information at resolution — inside the window. +/// +/// REVERT-PROBE: widen the `Phase` arm to `def.phase.is_some()` ⇒ this flips to an +/// offer ⇒ FAILS. +#[test] +fn end_step_window_end_step_observer_still_suppresses_offer() { + use engine::types::zones::Zone; + + let (control_runner, _) = + object_growth_with_bystander_at(Phase::End, P0, PLAIN_DRAW_TRIGGER_ORACLE); + assert!( + matches!( + control_runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "HF-X2-c REACH-GUARD: the loop must still be detected and offered at Phase::End, \ + else the subject half proves nothing. (Pre-registered STOP branch: if this \ + fails, report the rejecting gate and DROP HF-X2-c.) got {:?}", + control_runner.state().waiting_for + ); + + let (runner, bystander) = + object_growth_with_bystander_at(Phase::End, P0, SMUGGLERS_SHARE_ORACLE); + + // (3) reach-guards — see the sibling row: the veto must be attributable to the ONE + // named trigger surface, not to some other surface on this bystander. + let obj = &runner.state().objects[&bystander]; + assert_eq!( + obj.zone, + Zone::Battlefield, + "reach-guard: block (2) hard-skips non-battlefield zones" + ); + assert_eq!( + obj.trigger_definitions.len(), + 1, + "reach-guard: this row's claim is about ONE named trigger surface; got {}", + obj.trigger_definitions.len() + ); + assert!( + obj.abilities.is_empty(), + "reach-guard: this row's claim is about ONE named TRIGGER surface; the bystander \ + also carries {} ability def(s) {:?}", + obj.abilities.len(), + obj.abilities.iter().map(|a| a.kind).collect::>(), + ); + + assert!( + !matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }), + "CR 117.3a + CR 608.2h: an end-step observer in an END-STEP window keeps its \ + veto — the strict-inequality pin; got {:?}", + runner.state().waiting_for + ); +} + +/// The Prydwen, Steel Flagship, verbatim (Scryfall `cards/named?exact=`), behind the +/// harness's shared keyword prefix. Its ETB matcher is `nontoken artifact you control`, +/// which is triple-disjoint from a P0 Saproling creature TOKEN. +const PRYDWEN_ORACLE: &str = "Flying, trample\nFlying\nWhenever another nontoken artifact you control enters, create a 2/2 white Human Knight creature token with \"This token gets +2/+2 as long as an artifact entered the battlefield under your control this turn.\"\nCrew 2"; + +/// The SAME card with its ETB matcher widened from `nontoken artifact` to `creature`, +/// which genuinely DOES match the loop's Saproling fodder. +const PRYDWEN_BROAD_ORACLE: &str = "Flying, trample\nFlying\nWhenever another creature you control enters, create a 2/2 white Human Knight creature token with \"This token gets +2/+2 as long as an artifact entered the battlefield under your control this turn.\"\nCrew 2"; + +/// K3-1 + HF-K3 — REGRESSION LOCK on the already-shipped +/// `etb_observer_provably_excludes_class` narrowing (no code changes in this commit). +/// A matched pair one matcher-noun apart: the disjoint `nontoken artifact` matcher is +/// skipped (CR 603.6a) and the offer forms; widening it to `creature` makes it +/// genuinely match the Saproling fodder and the veto returns. +/// +/// REVERT-PROBE (K3-1): delete the `etb_observer_provably_excludes_class` call in +/// `fire_time_conditions_read_growing_class_scoped` block (1) ⇒ the offer disappears ⇒ +/// FAILS. It is NOT the `ability_scan` `sibling` flip — measured, that does not flip +/// this row. +#[test] +fn prydwen_artifact_matcher_bystander_does_not_suppress_offer() { + use engine::types::zones::Zone; + + let (runner, bystander) = object_growth_with_bystander(PRYDWEN_ORACLE); + + // (3) reach-guards, ALL BEFORE the offer match. On a positive (offer-forming) row a + // parse failure yields NO observer at all, which would make the offer trivially green — + // these guards are what make that vacuity mode loud. `Crew` is a keyword, not an + // `abilities[]` entry, so the ONE surface here is the ETB trigger. + let obj = &runner.state().objects[&bystander]; + assert_eq!( + obj.zone, + Zone::Battlefield, + "reach-guard: block (2) hard-skips non-battlefield zones" + ); + assert_eq!( + obj.trigger_definitions.len(), + 1, + "reach-guard: the ETB observer must have PARSED — a misparse leaves zero trigger \ + defs and the offer below forms for the wrong reason; got {}", + obj.trigger_definitions.len() + ); + assert!( + obj.abilities.is_empty(), + "reach-guard: this row's claim is about ONE named TRIGGER surface; the bystander \ + also carries {} ability def(s) {:?}", + obj.abilities.len(), + obj.abilities.iter().map(|a| a.kind).collect::>(), + ); + + match &runner.state().waiting_for { + WaitingFor::LoopShortcut { certificate, .. } => assert!( + certificate.unbounded.contains(&ResourceAxis::TokensCreated), + "K3-1: unbounded axis must be TokensCreated, got {:?}", + certificate.unbounded + ), + other => panic!( + "K3-1 CR 603.6a: an ETB matcher provably disjoint from the fodder class must \ + not suppress the offer; got {other:?}" + ), + } + + // HF-K3: the genuinely-matching sibling keeps its veto. + let (broad_runner, broad_bystander) = object_growth_with_bystander(PRYDWEN_BROAD_ORACLE); + + // (3) reach-guards on the BROAD half — this is a veto row, so the veto must be + // attributable to the ONE named trigger surface and not to any other. + let broad_obj = &broad_runner.state().objects[&broad_bystander]; + assert_eq!( + broad_obj.zone, + Zone::Battlefield, + "reach-guard: block (2) hard-skips non-battlefield zones" + ); + assert_eq!( + broad_obj.trigger_definitions.len(), + 1, + "reach-guard: this row's claim is about ONE named trigger surface; got {}", + broad_obj.trigger_definitions.len() + ); + assert!( + broad_obj.abilities.is_empty(), + "reach-guard: this row's claim is about ONE named TRIGGER surface; the bystander \ + also carries {} ability def(s) {:?}", + broad_obj.abilities.len(), + broad_obj + .abilities + .iter() + .map(|a| a.kind) + .collect::>(), + ); + + assert!( + !matches!( + broad_runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "HF-K3: an ETB matcher that DOES match the Saproling fodder must keep vetoing; \ + got {:?}", + broad_runner.state().waiting_for + ); +} + +/// A non-mana activated ability whose body reads a live board aggregate +/// (`QuantityRef::ObjectCount`). `ability_scan`'s `ObjectCount` arm self-asserts +/// `sibling: true` BEFORE it inspects the filter, so this surface vetoes regardless of +/// whose creatures the filter names — which is exactly why CR 117.1b (whose PRIORITY +/// the window belongs to), not the filter, is X1's relief axis. +const AGGREGATE_ACTIVATED_ORACLE: &str = + "Flying, trample\n{2}: Draw a card for each creature you control."; + +/// Circle of Dreams Druid's mana ability, verbatim (Scryfall `cards/named?exact=`), +/// behind the shared keyword prefix — the same `ObjectCount` aggregate read on a MANA +/// ability, which CR 605.3a keeps activatable without priority. +const AGGREGATE_MANA_ORACLE: &str = "Flying, trample\n{T}: Add {G} for each creature you control."; + +/// A SECOND, non-activated class-reading surface on the same object: a trigger whose +/// body carries the same `ObjectCount` aggregate. `TriggerMode::Attacks` is +/// unclassifiable by phase, so X2 cannot relieve it either. +const AGGREGATE_TWO_SURFACE_ORACLE: &str = "Flying, trample\n{2}: Draw a card for each creature you control.\nWhenever this creature attacks, draw a card for each creature you control."; + +/// X1-2 — CR 117.1b's relief is keyed on the OBSERVER'S CONTROLLER, and the matched +/// pair moves exactly that one variable. The DRIVER'S OWN class-reading activated +/// ability keeps vetoing (the driver holds priority inside its own shortcut and can +/// activate it); the identical ability under an OPPONENT is relieved. +/// +/// REVERT-PROBE: invert the `obj.controller != driver` comparison ⇒ the two halves swap +/// ⇒ BOTH assertions FAIL. +#[test] +fn driver_own_activated_ability_still_vetoes() { + use engine::types::ability::AbilityKind; + use engine::types::zones::Zone; + + // PAIRED POSITIVE first: the same ability under an OPPONENT is relieved. + let (foreign_runner, _) = + object_growth_with_bystander_at(Phase::PreCombatMain, P1, AGGREGATE_ACTIVATED_ORACLE); + assert!( + matches!( + foreign_runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "X1 PAIRED POSITIVE (CR 117.1b + CR 732.2c): no player but the sole driver \ + receives priority inside the taken shortcut, so an OPPONENT's activated \ + ability cannot read the growing class and must not suppress the offer; got {:?}", + foreign_runner.state().waiting_for + ); + + // SUBJECT: byte-identical board, ability under the DRIVER. + let (own_runner, bystander) = + object_growth_with_bystander_at(Phase::PreCombatMain, P0, AGGREGATE_ACTIVATED_ORACLE); + + // (3) reach-guards — the veto must come from the ONE named ACTIVATED-ability surface. + // `kind == Activated` is what item A makes load-bearing on the very relief this row + // exercises, and `trigger_definitions.is_empty()` keeps block (1) silent so the verdict + // is attributable to block (2). + let obj = &own_runner.state().objects[&bystander]; + assert_eq!( + obj.zone, + Zone::Battlefield, + "reach-guard: block (2) hard-skips non-battlefield zones" + ); + assert_eq!( + obj.abilities.len(), + 1, + "reach-guard: exactly one ability surface; got {:?}", + obj.abilities.iter().map(|a| a.kind).collect::>() + ); + assert_eq!( + obj.abilities[0].kind, + AbilityKind::Activated, + "reach-guard: X1's relief is stated for ACTIVATED abilities only, so this row's \ + subject must BE one; got {:?}", + obj.abilities[0].kind + ); + assert!( + obj.trigger_definitions.is_empty(), + "reach-guard: block (1) must be silent, so the verdict is attributable to block \ + (2); got {} trigger def(s)", + obj.trigger_definitions.len() + ); + + assert!( + !matches!( + own_runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "X1-2: the DRIVER's own class-reading activated ability must keep vetoing — the \ + driver does hold priority inside its own window; got {:?}", + own_runner.state().waiting_for + ); +} + +/// HF-X1-a — CR 605.3a BOUNDS X1. A mana ability is activatable outside the priority +/// rule (while another player is casting a spell or activating an ability), so an +/// OPPONENT's class-reading MANA ability is NOT relieved and keeps vetoing. The paired +/// positive is the identical aggregate read on a NON-mana ability under the same +/// opponent, which IS relieved — so the only variable is `is_mana_ability`. +/// +/// REVERT-PROBE: delete the `!is_mana_ability(..)` conjunct ⇒ the mana half is relieved +/// ⇒ FAILS. +#[test] +fn foreign_mana_ability_still_vetoes() { + use engine::types::ability::AbilityKind; + use engine::types::zones::Zone; + + // PAIRED POSITIVE: the same aggregate read on a NON-mana ability, same controller. + let (nonmana_runner, _) = + object_growth_with_bystander_at(Phase::PreCombatMain, P1, AGGREGATE_ACTIVATED_ORACLE); + assert!( + matches!( + nonmana_runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "HF-X1-a PAIRED POSITIVE: an opponent's NON-mana activated ability is relieved" + ); + + let (mana_runner, bystander) = + object_growth_with_bystander_at(Phase::PreCombatMain, P1, AGGREGATE_MANA_ORACLE); + + // (3) reach-guards. The row's WHOLE claim is the CR 605.3a mana carve-out, so nothing + // short of proving the def IS a mana ability makes the veto attributable to it. + let obj = &mana_runner.state().objects[&bystander]; + assert_eq!( + obj.zone, + Zone::Battlefield, + "reach-guard: block (2) hard-skips non-battlefield zones" + ); + assert_eq!( + obj.abilities.len(), + 1, + "reach-guard: exactly one ability surface; got {:?}", + obj.abilities.iter().map(|a| a.kind).collect::>() + ); + assert_eq!( + obj.abilities[0].kind, + AbilityKind::Activated, + "reach-guard: a mana ability is an ACTIVATED ability; got {:?}", + obj.abilities[0].kind + ); + assert!( + engine::game::mana_abilities::is_mana_ability(&obj.abilities[0]), + "reach-guard: this row's entire claim is the CR 605.3a mana carve-out, so the def \ + must actually BE a mana ability — otherwise the veto is attributable to the \ + ordinary foreign-activated path and the row proves nothing" + ); + assert!( + obj.trigger_definitions.is_empty(), + "reach-guard: block (1) must be silent, so the verdict is attributable to block \ + (2); got {} trigger def(s)", + obj.trigger_definitions.len() + ); + + assert!( + !matches!( + mana_runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "HF-X1-a CR 605.3a: a mana ability is activatable without priority, so an \ + opponent's class-reading MANA ability must keep vetoing; got {:?}", + mana_runner.state().waiting_for + ); +} + +/// NW-1' — X1's relief is PER-ABILITY and PER-SURFACE, never per-object. The two halves +/// carry the SAME opponent-controlled object; half B adds one extra surface (a trigger +/// whose body carries the same `ObjectCount` aggregate, scanned by block (1), which X1 +/// does not touch and which `TriggerMode::Attacks` leaves unclassifiable for X2). Half A +/// offering is what proves half B's veto comes from the second surface and not from the +/// object's mere presence. +/// +/// This is also the closure for the §I `ActivationRestriction` composition hazard at +/// the offer level: the firewall never reads `activation_restrictions` +/// (`ability_scan.rs:4238` destructures it as `_`), so a row keyed on that field would +/// be dominated. This row instead asserts the property the revert-probes actually flip. +/// +/// REVERT-PROBE: widen X1's relief from the per-ability test to the whole object (skip +/// the object in block (2) AND block (1)) ⇒ half B flips to an offer ⇒ FAILS. +#[test] +fn foreign_object_second_surface_still_vetoes_after_x1() { + use engine::types::ability::AbilityKind; + use engine::types::zones::Zone; + + // half A: the relieved surface alone ⇒ offer. + let (one_surface, _) = + object_growth_with_bystander_at(Phase::PreCombatMain, P1, AGGREGATE_ACTIVATED_ORACLE); + assert!( + matches!( + one_surface.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "NW-1' half A: with ONLY the foreign activated ability, X1 relieves and the \ + offer forms — so half B's veto is attributable to the added surface" + ); + + // half B: the same object plus one more class-reading surface ⇒ veto. + let (two_surface, bystander) = + object_growth_with_bystander_at(Phase::PreCombatMain, P1, AGGREGATE_TWO_SURFACE_ORACLE); + let obj = &two_surface.state().objects[&bystander]; + assert_eq!( + obj.trigger_definitions.len(), + 1, + "NW-1' reach-guard: the second surface really is a trigger definition" + ); + assert_eq!( + obj.zone, + Zone::Battlefield, + "NW-1' reach-guard: block (2) hard-skips non-battlefield zones" + ); + assert_eq!( + obj.abilities.len(), + 1, + "NW-1' reach-guard: the FIRST surface is exactly one ability def; got {:?}", + obj.abilities.iter().map(|a| a.kind).collect::>() + ); + assert_eq!( + obj.abilities[0].kind, + AbilityKind::Activated, + "NW-1' reach-guard: half A's relieved surface is an ACTIVATED ability, so half B's \ + first surface must be the same one; got {:?}", + obj.abilities[0].kind + ); + assert!( + !matches!( + two_surface.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "NW-1': X1 relieves the ABILITY, not the OBJECT — another class-reading surface \ + on the same permanent must keep vetoing; got {:?}", + two_surface.state().waiting_for + ); +} + +// =========================================================================== +// PR-7 Phase 1b — CR 732.2a loop-detect ring retention across a FORCED +// pre-priority window and the action that answers it. +// +// Both rows LOAD a committed real 4-player dump through the production restore +// chokepoint (`PersistedGameState::Raw(..).into_game_state()`, the same path the +// server's `from_persisted` and WASM's `decode_restored_game_state` funnel +// through) and DRIVE through the public `apply()` boundary. Synthetic +// `GameScenario` boards are deliberately NOT used here: the property under test +// is an accumulation across dozens of real beats. +// =========================================================================== + +fn gunzip_dump(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 +} + +fn restore_dump(json: &str) -> GameState { + let envelope: serde_json::Value = + serde_json::from_str(json).expect("dump envelope parses as JSON"); + let raw: GameState = serde_json::from_value(envelope["gameState"].clone()) + .expect("the real 4p gameState must deserialize into the current GameState"); + engine::types::game_state::PersistedGameState::Raw(Box::new(raw)).into_game_state() +} + +/// Opponents the ENGINE considers living. `Player::is_eliminated` is the authority the +/// CR 732.2a detector uses when it builds its `living` set — `eliminated_players` and +/// `life > 0` are not sufficient on their own, so this reads the field the detector reads. +fn engine_live_opponents(state: &GameState, of: PlayerId) -> Vec { + state + .players + .iter() + .filter(|p| p.id != of && !p.is_eliminated) + .map(|p| p.id) + .collect() +} + +/// Actions a dump driver must never take: they end the game or bypass the reducer, and a +/// generic "first legal action" driver otherwise picks them and fakes a result. +fn dump_driver_forbids(a: &GameAction) -> bool { + matches!(a, GameAction::Concede { .. } | GameAction::Debug(_)) +} + +/// The seat that can act on this beat plus its legal actions, read through the same +/// per-viewer enumerator the multiplayer transport uses. `WaitingFor::acting_player` is +/// the engine's own answer to "whose beat is this", so it is tried first; the all-seat +/// scan is the fallback and costs roughly 4x per beat. +fn dump_beat_actor(state: &GameState) -> Option<(PlayerId, Vec)> { + if let Some(p) = state.waiting_for.acting_player() { + let (actions, _costs, _grouped) = engine::ai_support::legal_actions_for_viewer(state, p); + if !actions.is_empty() { + return Some((p, actions)); + } + } + for p in state.players.iter().map(|p| p.id) { + let (actions, _costs, _grouped) = engine::ai_support::legal_actions_for_viewer(state, p); + if !actions.is_empty() { + return Some((p, actions)); + } + } + None +} + +/// One beat of the drain-loop drive policy: at `Priority` ALWAYS pass (the mandatory +/// triggers resolve and re-trigger — that IS the loop; casting here wanders off it), and +/// answer every other prompt, preferring a target choice aimed at `pin`. +/// +/// Returns the beat's `GameEvent`s so a caller can key on what the beat actually DID +/// (`CombatDamageDealtToPlayer` / `DamageDealt` for the CR 510.2 rows) instead of +/// inferring it from phase and life deltas. Callers that only need liveness ignore it. +fn dump_drive_one_beat( + state: &mut GameState, + pin: Option, +) -> Result, String> { + let Some((who, actions)) = dump_beat_actor(state) else { + return Err(format!("no legal actor at {:?}", state.waiting_for)); + }; + let chosen = if matches!(state.waiting_for, WaitingFor::Priority { .. }) { + actions + .iter() + .find(|a| matches!(a, GameAction::PassPriority)) + .cloned() + } else { + pin.and_then(|t| { + actions.iter().find(|a| { + matches!(a, GameAction::SelectTargets { targets } + if targets.iter().any(|r| matches!(r, TargetRef::Player(p) if *p == t))) + }) + }) + .or_else(|| { + actions + .iter() + .find(|a| !matches!(a, GameAction::PassPriority) && !dump_driver_forbids(a)) + }) + .or_else(|| actions.iter().find(|a| !dump_driver_forbids(a))) + .cloned() + }; + let Some(action) = chosen else { + return Err(format!("empty action list at {:?}", state.waiting_for)); + }; + apply(state, who, action.clone()) + .map(|r| r.events) + .map_err(|e| format!("apply err ({action:?}): {e:?}")) +} + +/// Phase 1b, BOTH clear sites. The CR 732.2a ring must survive (i) the forced +/// pre-priority window itself — the sampler's clear arm, which BASE took for every +/// window except `OrderTriggers` — and (ii) the action that ANSWERS that window, which +/// `apply_action`'s deliberate-break clear discarded unconditionally. +/// +/// Fixture: `dellian_emblem_conqueror_4p.json.gz`, the real 4p Delianfel/Bloodthirsty +/// Conqueror drain (P0 69 / P1 12 / P2 13 / P3 28, all four living, stack 152, ring 0, +/// `loop_detection: Interactive`). It ships AT a `TriggerTargetSelection` window, which is +/// precisely the window class BASE wiped. +/// +/// NON-VACUITY: the fixture drives hundreds of real beats (it is NOT a saved-offer board +/// that halts at beat 0), and the BASE measurement is the positive control that the +/// instrument CAN report a large ring — it reports 16 once only ONE opponent is left +/// alive, while measuring exactly 1 over the whole ≥2-living stretch. A `>= 5` assertion +/// over that same stretch cannot pass on a BASE tree. +/// +/// REVERT-PROBES (MEASURED outcomes, not predicted ones): +/// ⓐ restore `apply_action`'s clear to its action-only form (drop the +/// `!state.waiting_for.is_forced_cascade_window()` conjunct) ⇒ (i) FAILS FIRST — and (ii) +/// and (iii) are never reached. The prediction that ⓐ would leave (i) passing was WRONG, +/// and the reason is the interlock between the two sites: with the answer clearing the +/// ring, the drive can never carry >= 2 frames INTO a forced window either, so the +/// sampler half has nothing left to retain. The two clear sites are therefore not +/// independently observable on this fixture — ⓐ still proves a one-site fix is inert, +/// just at (i) rather than at (ii). +/// ⓑ restore the sampler's `!matches!(wf, WaitingFor::OrderTriggers { .. })` arm ⇒ (i) +/// FAILS. +/// +/// DISCRIMINANT GUARD on (ii): `apply_action` has a PRE-EXISTING action-side exemption for +/// `GameAction::OrderTriggers`, so if the window (ii) happens to catch were an +/// `OrderTriggers` window, (ii) would be satisfied without the new window-keyed conjunct +/// being consulted at all. The window's discriminant is therefore captured alongside +/// `(before, after)` and asserted to be something else — a fixture or engine change that +/// drifts (ii) onto an `OrderTriggers` window fails loudly instead of going quietly +/// vacuous. +#[test] +fn two_site_retention_survives_a_prompt_and_its_answer() { + let json = gunzip_dump(include_bytes!( + "../fixtures/dellian_emblem_conqueror_4p.json.gz" + )); + let mut state = restore_dump(&json); + + // Reach guards on the loaded board — every assertion below is meaningless without them. + assert!( + state.loop_detection.samples(), + "reach-guard: the dump must load with a SAMPLING loop-detection mode, else the \ + ring is never populated and every retention assertion is vacuous; got {:?}", + state.loop_detection + ); + assert_eq!( + engine_live_opponents(&state, P0).len(), + 3, + "reach-guard: the dump must load with 3 living opponents" + ); + 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, not restored" + ); + assert!( + matches!(state.waiting_for, WaitingFor::TriggerTargetSelection { .. }), + "reach-guard: the dump ships AT a TriggerTargetSelection window (CR 603.3d), the \ + window class BASE wiped; got {:?}", + state.waiting_for + ); + + let pin = engine_live_opponents(&state, P0).first().copied(); + + // (i) the `:3457` sampler half: a forced pre-priority window observed with an + // ALREADY-ACCUMULATED ring (>= 2 frames, so a single fresh sample cannot explain it). + let mut prompt_ring: Option = None; + // (ii) the `apply_action` half: the ring across the ANSWER to such a window, with the + // window itself so the row can prove it was not the pre-exempt `OrderTriggers`. + let mut answer_ring: Option<(WaitingFor, usize, usize)> = None; + // (iii) the ≥2-living stretch, where BASE measured a maximum of exactly 1. + let mut max_ring_two_or_more_living = 0usize; + + for _ in 0..400 { + if engine_live_opponents(&state, P0).len() >= 2 { + max_ring_two_or_more_living = + max_ring_two_or_more_living.max(state.loop_detect_ring.len()); + } + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + break; + } + let forced = state.waiting_for.is_forced_cascade_window(); + let before = state.loop_detect_ring.len(); + let window = (forced && before >= 2).then(|| state.waiting_for.clone()); + if forced && before >= 2 { + prompt_ring.get_or_insert(before); + } + if dump_drive_one_beat(&mut state, pin).is_err() { + break; + } + if let (Some(window), None) = (window, answer_ring.as_ref()) { + answer_ring = Some((window, before, state.loop_detect_ring.len())); + } + // Every assertion below is already satisfiable — stop driving. The drive is the + // expensive part of this row (a per-beat legal-action enumeration on a 152-entry + // stack), and continuing past the evidence buys nothing. + if prompt_ring.is_some() && answer_ring.is_some() && max_ring_two_or_more_living >= 5 { + break; + } + } + + let observed = prompt_ring.unwrap_or_else(|| { + panic!( + "(i) CR 603.3d: no forced pre-priority window was ever reached carrying an \ + accumulated ring of >= 2 frames. BASE behaviour (the sampler clearing at every \ + non-OrderTriggers window) is exactly this; max ring seen at >= 2 living was {max_ring_two_or_more_living}" + ) + }); + assert!( + observed >= 2, + "(i) the ring must be RETAINED across the forced window itself" + ); + + let (window, before, after) = answer_ring.expect( + "(ii) the drive must have applied the answer to a forced window that carried an \ + accumulated ring — otherwise the apply_action half is untested", + ); + assert!( + !matches!(window, WaitingFor::OrderTriggers { .. }), + "(ii) DISCRIMINANT GUARD: `apply_action` already exempts `GameAction::OrderTriggers` \ + on the ACTION side, so an OrderTriggers window would satisfy the survival assertion \ + below without the window-keyed conjunct ever being consulted. The window measured \ + here must be one of the newly exempt classes; got {}", + window.variant_name() + ); + assert!( + after >= before, + "(ii) CR 603.3d + CR 732.2a: answering a forced pre-priority window is not a \ + deliberate break, so the accumulated ring must SURVIVE the answer; \ + ring went {before} -> {after}. Dropping the \ + `!state.waiting_for.is_forced_cascade_window()` conjunct at apply_action's clear \ + reproduces this failure — measured, it takes (i) down first, because the answer-side \ + clear also stops the ring ever reaching a forced window with >= 2 frames." + ); + + assert!( + max_ring_two_or_more_living >= 5, + "(iii) two full periods of the drain need 2k+1 = 5 retained frames while >= 2 \ + opponents are still alive; BASE measured exactly 1 over that stretch. Got \ + {max_ring_two_or_more_living}" + ); +} + +/// 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 +/// with exactly ONE living opponent, which is the only shape `loop_check`'s +/// `nonfallers.len() == 1` crown gate admits. +/// +/// ARM CHOICE (this is the anti-vacuity decision, stated explicitly): the fixture ships AT +/// `WaitingFor::LoopShortcut`, so loading it and asserting the saved payload would drive +/// **0 beats** and prove nothing — the assertion would read the offer the dump was saved +/// with. This row therefore DECLINES the saved offer first, forcing the detector to +/// RE-DERIVE the crown from live beats, and asserts a non-zero driven beat count before +/// asserting anything about the payload. `revive_decline` (reviving P1/P2 to three living +/// opponents) is FORBIDDEN here: at three living opponents the crown gate short-circuits +/// and there is no crown left to assert. +/// +/// REVERT-PROBES: ⓐ implement the withdrawn "count + clear_loop_detect_ring + Path-A +/// early-return" remedy ⇒ C's crown disappears as soon as a `TriggerTargetSelection` +/// enters the accumulation — this row is the measured reason that remedy stays withdrawn; +/// ⓑ narrow `find_live_loop_winner` to the first prior frame only ⇒ the crown is lost. +#[test] +fn dump_c_still_crowns_at_one_living_opponent_after_pause_retention() { + let json = gunzip_dump(include_bytes!( + "../fixtures/tenacity_exquisite_blood_4p.json.gz" + )); + let mut state = restore_dump(&json); + + assert!( + state.loop_detection.samples(), + "reach-guard: sampling mode required; got {:?}", + state.loop_detection + ); + assert_eq!( + engine_live_opponents(&state, P0), + vec![PlayerId(3)], + "reach-guard: dump C ships with EXACTLY ONE living opponent (P3) — the only \ + population the CR 732.2a crown gate admits" + ); + let WaitingFor::LoopShortcut { proposer, .. } = state.waiting_for.clone() else { + panic!( + "reach-guard: dump C ships AT a saved offer; got {:?}", + state.waiting_for + ) + }; + assert_eq!(proposer, P0); + + // Discard the saved offer so the detector must re-derive it from live beats. + apply(&mut state, proposer, GameAction::DeclineShortcut).expect("decline the saved offer"); + + let pin = engine_live_opponents(&state, P0).first().copied(); + let mut beats = 0usize; + for _ in 0..200 { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + break; + } + if let Err(why) = dump_drive_one_beat(&mut state, pin) { + panic!("drive stopped after {beats} beats: {why}"); + } + beats += 1; + } + + // ANTI-VACUITY CONTROL: a zero here means the row asserted the dump's saved offer + // instead of a re-derived one, and both revert-probes above would be inert. The + // exact count is reported (not just `> 0`) so a change in how far the detector has + // to drive to re-derive the crown surfaces as a diff rather than passing silently. + assert_eq!( + beats, 6, + "the `c decline` arm re-derives the crown after a measured 6 driven beats — a 0 here \ + would mean the row read the dump's saved offer back instead of re-deriving one, \ + which is what makes both revert-probes above live" + ); + + let WaitingFor::LoopShortcut { + predicted_winner, + certificate, + schema, + .. + } = state.waiting_for.clone() + else { + panic!( + "the crown must survive pause retention; after {beats} beats waiting_for was {:?}", + state.waiting_for + ) + }; + assert_eq!(predicted_winner, Some(P0), "the crown still names P0"); + assert_eq!(certificate.win_kind, WinKind::LethalDamage); + assert_eq!( + certificate.unbounded, + vec![ResourceAxis::Life(P0), ResourceAxis::Life(PlayerId(3))], + "the re-derived certificate names the same two life axes as BASE" + ); + assert!( + schema.points.is_empty(), + "a choice-free drain publishes no decision points" + ); + assert_eq!(schema.iteration_count, IterationCount::UntilLethal); +} + +/// Seam D (CR 732.2a): a `template: None` declaration against a NON-EMPTY schema BYPASSES the +/// declare-time pin firewall entirely — `predictability_gate` and `validate_pins` are simply not +/// run, because there is no template to run them against. That bypass is legitimate for exactly +/// one drive shape: the object-growth route, which re-derives its template from +/// `state.last_loop_action_sequence` 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. +/// +/// This row is the two-conjunct guard's matched pair, on ONE fixture and ONE schema so nothing +/// but the sequence differs between the halves: +/// +/// * EMPTY sequence ⇒ fail-closed manual-play handback (Priority), APNAP never opens. +/// * NON-EMPTY sequence ⇒ APNAP opens unchanged — the reach-guard proving the guard is not a +/// blanket "reject every `template: None`", which would break every shipped object-growth +/// declaration. +/// +/// REVERT-PROBE: delete the `None if state.last_loop_action_sequence.is_empty()` arm ⇒ the first +/// half opens `RespondToShortcut` and FAILS. Drop the sequence conjunct instead (reject on +/// `template.is_none()` alone) ⇒ the second half FAILS. +#[test] +fn template_none_against_a_pin_consuming_schema_falls_back_to_manual_play() { + use engine::types::game_state::{BuybackUsage, LoopAction, LoopActionContext}; + use engine::types::identifiers::CardId; + + let source = YieldTarget::ThisObject { + source_id: ObjectId(1), + incarnation: None, + trigger_description: None, + }; + let schema = ShortcutDecisionSchema { + iteration_count: IterationCount::UntilLethal, + max_iterations: ShortcutDecisionSchema::default().max_iterations, + points: vec![DecisionPoint { + slot: DecisionSlot { source, index: 0 }, + kind: DecisionPointKind::Targets { + legal_targets: vec![TargetRef::Player(P1)], + min_targets: 1, + max_targets: 1, + ordered: true, + }, + }], + convoke_tappable_count: 0, + }; + + let declare_with_sequence = |sequence: Vec| -> WaitingFor { + let (mut runner, _kickoff) = setup_3p_draw(LoopDetectionMode::Interactive); + runner.state_mut().last_loop_action_sequence = sequence; + runner.state_mut().waiting_for = WaitingFor::LoopShortcut { + proposer: P0, + predicted_winner: Some(P0), + certificate: synthetic_lethal_cert(), + schema: schema.clone(), + }; + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: None, + }) + .expect("declare dispatch succeeds (a rejection is a manual fallback, not an error)"); + runner.state().waiting_for.clone() + }; + + // The object-growth route's routing signal: a captured recast context. Only its PRESENCE + // matters to the guard, which is exactly the discriminant `materialize` dispatches on. + let recast = LoopActionContext { + card_id: CardId(7), + controller: P0, + action: LoopAction::Recast { + from_zone: engine::types::zones::Zone::Hand, + uses_buyback: BuybackUsage::Used, + }, + convoke: None, + pins: Vec::new(), + }; + + let empty_sequence = declare_with_sequence(Vec::new()); + assert!( + matches!(empty_sequence, WaitingFor::Priority { .. }), + "CR 732.2a: a pin-consuming schema declared with NO template and NO re-derivable \ + sequence must fail closed to manual play, not open APNAP; got {empty_sequence:?}" + ); + + let with_sequence = declare_with_sequence(vec![recast]); + assert!( + matches!(with_sequence, WaitingFor::RespondToShortcut { .. }), + "reach-guard: the object-growth route re-derives its template from the sequence and \ + must keep opening APNAP — the guard is two-conjunct, not a blanket template-None \ + rejection; got {with_sequence:?}" + ); +} + +/// A loop-free board that carries the CR 732.2a ring ACROSS TURN BOUNDARIES and still +/// refuses to offer — the no-false-positive control for widening +/// [`WaitingFor::is_forced_cascade_window`] to the CR 703.1 turn-based actions. +/// +/// CR 732.2a says a proposed shortcut "may even cross multiple turns", which is why the +/// class now retains across CR 502.3 untap / CR 508.1 declare attackers / CR 509.1 +/// declare blockers / CR 514.1 cleanup discard. Retention is NECESSARY BUT NOT YET +/// SUFFICIENT for that: `loop_states_equal` still compares `turn_number`, so no +/// cross-turn pair certifies today — which is exactly what the ATTRIBUTION half below +/// measures. The risk that widening introduces is the +/// mirror image of the bug it fixes: a ring that now survives turn cycling might +/// accumulate on an ordinary board and certify a loop that isn't there. +/// +/// FIXTURE (loop-free by construction, and hostile on purpose): P0 has an upkeep ticker +/// ("At the beginning of your upkeep, you gain 1 life"), a drain cleric (gain ⇒ each +/// opponent loses 1) and a "may draw" scribe (opponent loses life ⇒ optional draw). Each +/// of P0's upkeeps runs a FINITE 3-deep cascade — nothing re-triggers the ticker — yet the +/// per-turn shape is drain-like (P1 loses 1 every other turn), which is exactly the shape +/// a naive detector would mistake for a loop. The cascade ends at the scribe's CR 603.5 +/// "may" pause, which leaves the stack already popped, so the sampler's clear arm never +/// fires on the tail resolution and the accumulated frames survive into the rest of the +/// turn. That is what makes cross-turn retention observable on a board with no loop at +/// all. +/// +/// NON-VACUITY, both halves, measured (300 beats, 23 turns): +/// * POSITIVE — the ring really is retained across turn boundaries: at beat 58 the drive +/// sits at a `DeclareAttackers` window in turn 6 holding 4 frames whose OLDEST was +/// sampled in turn 4. Without the widening that frame cannot exist: BASE clears at +/// `DeclareAttackers`. So the widening is demonstrably LIVE on this board. +/// * DISCRIMINANT GUARD — `OptionalEffectChoice` is a PRE-EXISTING member of the class and +/// also occurs on this board (10 times). The retention witness is therefore required to +/// be one of the NEWLY exempt turn-based windows; an `OptionalEffectChoice` witness +/// would satisfy the row without the widening being consulted at all. +/// * NEGATIVE — no `LoopShortcut` / `RespondToShortcut` is ever raised, even after the ring +/// saturates at all 16 frames (measured: reached by turn 22, spanning ~9 turn boundaries). +/// * ATTRIBUTION — the decline is a MEASURED comparison failure, not an absent one. The +/// engine's own recurrence gate `loop_states_equal_modulo_resources` reports FALSE on the +/// oldest/newest retained pair, while reporting TRUE on the oldest against itself (the +/// positive control that the comparator is live on this data, trap 7). The monotone axis +/// is named and asserted: each turn's CR 504.1 draw strictly shrinks the library, so no +/// two retained frames can be the same position. Measured at the witness beat: P0's +/// library 60 → 59, P1's 59 → 58. (Honest scope: equalizing library and hand alone does +/// NOT flip the gate to true — turn number and life differ too. The library shrink is +/// asserted as a monotone non-recurrence witness, not as the sole cause.) +/// +/// REVERT-PROBE (measured, not predicted): delete the CR 703.1 turn-based members from +/// `is_forced_cascade_window` and the POSITIVE half fails — the retention witness is never +/// found, because `apply_action` clears the ring on the very first `DeclareAttackers` of +/// each turn. +#[test] +fn drawgo_ring_spans_turns_but_never_offers() { + 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 Upkeep Ticker", + 2, + 2, + "At the beginning of your upkeep, you gain 1 life.", + ); + scenario.add_creature_from_oracle(P0, "Test Drain Cleric", 2, 2, DRAIN_CLERIC); + scenario.add_creature_from_oracle( + P0, + "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(P0, &refs); + scenario.with_library_top(P1, &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, making every \ + assertion below vacuous; got {:?}", + state.loop_detection + ); + assert_eq!( + state.loop_detect_ring.len(), + 0, + "reach-guard: the board must start with an EMPTY ring — every frame below is \ + accumulated by this drive" + ); + + // The cross-turn retention witness: (window name, live turn, oldest frame's turn, + // ring before the answer, ring after it, oldest frame, newest frame). + let mut witness: Option<(String, u32, u32, usize, usize, GameState, GameState)> = None; + let mut offer_at: Option<(usize, String)> = None; + 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); + } + let name = state.waiting_for.variant_name().to_string(); + if matches!( + state.waiting_for, + WaitingFor::LoopShortcut { .. } | WaitingFor::RespondToShortcut { .. } + ) { + offer_at = Some((beat, name)); + break; + } + // The witness must be a NEWLY exempt CR 703.1 turn-based window, never the + // pre-existing `OptionalEffectChoice` member (see DISCRIMINANT GUARD above). + let turn_based = matches!( + state.waiting_for, + WaitingFor::UntapChoice { .. } + | WaitingFor::ChooseUntapSubset { .. } + | WaitingFor::DeclareAttackers { .. } + | WaitingFor::ExertChoice { .. } + | WaitingFor::EnlistChoice { .. } + | WaitingFor::DeclareBlockers { .. } + | WaitingFor::DiscardToHandSize { .. } + ); + 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(); + (front.turn_number < state.turn_number).then_some((front, back)) + }) + .flatten(); + let live_turn = state.turn_number; + if dump_drive_one_beat(&mut state, None).is_err() { + break; + } + if let Some((front, back)) = pair { + witness = Some(( + name, + live_turn, + front.turn_number, + before, + state.loop_detect_ring.len(), + (*front).clone(), + (*back).clone(), + )); + } + } + + assert!( + turns_seen.len() >= 3, + "reach-guard: the drive must cross at least 2 full turn boundaries for a \ + cross-turn claim to mean anything; saw turns {turns_seen:?}" + ); + + let (window, live_turn, frame_turn, before, after, oldest, newest) = witness.expect( + "POSITIVE HALF: no CR 703.1 turn-based window (CR 502.3 / CR 508.1 / CR 509.1 / \ + CR 514.1) was ever reached holding >= 4 frames whose oldest was sampled in an \ + EARLIER turn. That is precisely BASE behaviour — dropping the turn-based \ + members from `is_forced_cascade_window` reproduces this failure, because \ + `apply_action` then clears the ring at the first declare-attackers of every \ + turn. Without this witness the no-offer assertion below is vacuous.", + ); + assert!( + after >= before, + "answering the forced turn-based window {window} must not discard the ring \ + (CR 703.1 + CR 117.3a: no player had priority there, so the answer is not a \ + deliberate break); ring went {before} -> {after}" + ); + assert!( + frame_turn < live_turn, + "the retained frame must predate the live turn; frame turn {frame_turn}, live \ + turn {live_turn}" + ); + + assert!( + offer_at.is_none(), + "NO-FALSE-POSITIVE: this board has no loop — each upkeep runs a FINITE cascade and \ + nothing re-triggers the ticker — so no CR 732.2a shortcut may ever be offered, \ + however many frames the widened class lets the ring carry across turns. Got an \ + offer at {offer_at:?} (witness: {window} in turn {live_turn} held a turn-{frame_turn} frame)" + ); + + // ATTRIBUTION: the decline is a measured comparison FAILURE on a live comparator, + // not an absent comparison. + assert!( + loop_states_equal_modulo_resources(&oldest, &oldest), + "positive control (trap 7): the engine's recurrence gate must report TRUE on a \ + retained frame against itself, else the FALSE asserted next is an inert \ + instrument rather than a measured non-recurrence" + ); + assert!( + !loop_states_equal_modulo_resources(&oldest, &newest), + "the turn-{frame_turn} and turn-{} frames must NOT compare recurrent — that \ + comparison failing is WHY no offer forms", + newest.turn_number + ); + for (i, (old_p, new_p)) in oldest.players.iter().zip(newest.players.iter()).enumerate() { + assert!( + new_p.library.len() < old_p.library.len(), + "CR 504.1: every turn's draw strictly shrinks each library, which is the \ + monotone axis that makes two retained frames un-recurrable. P{i} went \ + {} -> {} across the retained window", + old_p.library.len(), + new_p.library.len() + ); + } +} + +// =========================================================================== +// CR 510.2 EVENT-KEYED loop-ring invalidation. +// +// `WaitingFor::AssignCombatDamage` / `AssignBlockerDamage` are excluded from +// `is_forced_cascade_window` because CR 510.2 deals the assigned damage with no +// intervening priority. That WINDOW-keyed exclusion is necessary but NOT +// sufficient: the window opens only when a damage DIVISION choice is required +// (`game::combat_damage`: "Auto-assign for unblocked, single blocker, or +// blocked-but-no-current-blockers"). An UNBLOCKED attacker moves a life total +// with NO window to exclude — and with the CR 703.1 turn-based members now in +// the class, `DeclareAttackers` / `DeclareBlockers` no longer clear the ring +// either, so the ring rides straight through the life change. +// +// The sufficient guard is `GameState::invalidate_loop_ring_on_unobserved_life_move`, +// called at the end of `apply_combat_damage` — the CR 510.2 batch itself. +// =========================================================================== + +fn player_life(state: &GameState, p: PlayerId) -> i32 { + state + .players + .iter() + .find(|pl| pl.id == p) + .map(|pl| pl.life) + .expect("seat exists") +} + +/// `dump_drive_one_beat`, but it actually fights. +/// +/// MEASURED, and the reason this helper exists: at a CR 508.1 / CR 509.1 declaration the +/// generic driver takes the FIRST legal action, and that is the EMPTY declaration — +/// 14 `DeclareAttackers` windows over 400 beats produced +/// `DeclareAttackers { attacks: [], bands: [] }` every time and ZERO combat damage. A row +/// about CR 510.2 driven by that policy is vacuous by construction. Here the largest +/// non-empty declaration wins, so the attack and the block both really happen; every +/// other window keeps the shared policy. +fn combat_drive_one_beat(state: &mut GameState) -> Result, String> { + if matches!( + state.waiting_for, + WaitingFor::DeclareAttackers { .. } | WaitingFor::DeclareBlockers { .. } + ) { + if let Some((who, actions)) = dump_beat_actor(state) { + let biggest = actions + .iter() + .filter_map(|a| match a { + GameAction::DeclareAttackers { attacks, .. } => Some((attacks.len(), a)), + GameAction::DeclareBlockers { assignments } => Some((assignments.len(), a)), + _ => None, + }) + .max_by_key(|(n, _)| *n) + .filter(|(n, _)| *n > 0) + .map(|(_, a)| a.clone()); + if let Some(action) = biggest { + return apply(state, who, action.clone()) + .map(|r| r.events) + .map_err(|e| format!("apply err ({action:?}): {e:?}")); + } + } + } + dump_drive_one_beat(state, None) +} + +/// The shared board for both CR 510.2 rows. P0 runs the same loop-FREE upkeep cascade +/// `drawgo_ring_spans_turns_but_never_offers` uses (ticker → drain cleric → "may" scribe), +/// which is what accumulates a CR 732.2a ring at all; the trio carries Defender so the +/// only creature that can attack is the dedicated 3/3, making the combat shape of each +/// row a deliberate fixture property rather than an artifact of which creature the driver +/// happened to declare. +/// +/// `p1_wall` gives P1 a single 0/20 blocker. With it, the attack is BLOCKED and CR 510.2 +/// moves no player's life (creature-only damage). Without it, the attacker is UNBLOCKED +/// and CR 510.2 moves P1's life with no assignment window — CR 510.1c's window needs 2+ +/// blockers to divide damage among. +fn combat_ring_board(p1_wall: bool) -> GameState { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + // Deep life totals on BOTH seats: the drive must outlast several turn cycles of + // combat damage plus the cleric's drain, and a CR 704.5a death would end the game + // and silently truncate every assertion below. + scenario.with_life(P0, 400); + scenario.with_life(P1, 400); + scenario.add_creature_from_oracle( + P0, + "Test Upkeep Ticker", + 2, + 2, + "Defender\nAt the beginning of your upkeep, you gain 1 life.", + ); + scenario.add_creature_from_oracle( + P0, + "Test Drain Cleric", + 2, + 2, + &format!("Defender\n{DRAIN_CLERIC}"), + ); + scenario.add_creature_from_oracle( + P0, + "Test May Scribe", + 2, + 2, + "Defender\nWhenever an opponent loses life, you may draw a card.", + ); + scenario.add_creature(P0, "Test Lone Attacker", 3, 3); + if p1_wall { + // 0 power so the trade kills nothing and the block repeats every turn cycle; + // toughness 20 so the 3/3 never kills it either. Defender is load-bearing, not + // flavour: without it the wall attacks on P1's turn, is still TAPPED on P0's, and + // cannot block — measured, the attack then went through unblocked and the row + // silently became a duplicate of the unblocked one. + scenario.add_creature_from_oracle(P1, "Test Wall", 0, 20, "Defender"); + } + // CR 504.1: both players draw every turn, so the libraries must outlast the drive — + // a deck-out would end the game and truncate the row. + 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; + runner.state().clone() +} + +/// HIGH-1: the CR 510.2 damage EVENT clears the CR 732.2a ring even though no +/// `AssignCombatDamage` window ever opens. +/// +/// FIXTURE: P0 attacks with one unblocked 3/3 into an empty board while the loop-free +/// upkeep cascade keeps the ring populated. CR 510.1c's assignment window needs a +/// division choice, so on this board it never opens at all — which is exactly the hole +/// the window-keyed exclusion leaves and the reason the fence has to be event-keyed. +/// +/// ASSERTIONS, in the order they discharge each other: +/// 1. REACH-GUARD — the drive reaches a CR 510.2 beat that damaged P1 while the ring +/// already carried >= 2 frames. Without that, "the ring is empty afterwards" is +/// unobservable: an already-empty ring would satisfy it. +/// 2. DISCRIMINANT GUARD — no `AssignCombatDamage` / `AssignBlockerDamage` window is +/// observed anywhere in the drive. This row's whole point is that the damage lands +/// with NO window; a fixture drift that introduces a division choice would make the +/// window-keyed exclusion sufficient and the row vacuous, so it fails loudly instead. +/// 3. LIFE-MOVE WITNESS — P1's life strictly decreased across that beat, so assertion 4 +/// is about a real CR 119.3 / CR 120.3a life movement. +/// 4. THE DELIVERABLE — the ring is empty immediately after the beat. +/// +/// REVERT-PROBE (measured, recorded in the handoff report): delete the +/// `invalidate_loop_ring_on_unobserved_life_move` call from `apply_combat_damage` ⇒ +/// assertion 4 FAILS while 1–3 still PASS, which is what proves 4 is the discriminator. +#[test] +fn unblocked_attacker_damage_clears_the_loop_ring_with_no_window() { + let mut state = combat_ring_board(false); + + assert!( + state.loop_detection.samples(), + "reach-guard: a non-sampling mode never populates the ring; got {:?}", + state.loop_detection + ); + assert_eq!( + state.loop_detect_ring.len(), + 0, + "reach-guard: every frame below is accumulated by this drive, not preloaded" + ); + + let mut assignment_window: Option = None; + // (beat, ring before, ring after, P1 life before, P1 life after, combat damage) + let mut witness: Option<(usize, usize, usize, i32, i32, u32)> = None; + let mut max_ring = 0usize; + let mut damage_beats = 0usize; + + for beat in 0..400usize { + if matches!( + state.waiting_for, + WaitingFor::AssignCombatDamage { .. } | WaitingFor::AssignBlockerDamage { .. } + ) { + assignment_window.get_or_insert_with(|| state.waiting_for.variant_name().to_string()); + } + let before = state.loop_detect_ring.len(); + max_ring = max_ring.max(before); + let life_before = player_life(&state, P1); + let Ok(events) = combat_drive_one_beat(&mut state) else { + break; + }; + let dealt: u32 = events + .iter() + .filter_map(|e| match e { + GameEvent::CombatDamageDealtToPlayer { + player_id, + total_damage, + .. + } if *player_id == P1 => Some(*total_damage), + _ => None, + }) + .sum(); + if dealt > 0 { + damage_beats += 1; + if witness.is_none() && before >= 2 { + witness = Some(( + beat, + before, + state.loop_detect_ring.len(), + life_before, + player_life(&state, P1), + dealt, + )); + // The evidence is complete; the rest of the drive only costs time. The + // window guard has already covered every beat up to here, and the loop + // below re-checks the settled window once more. + break; + } + } + } + if matches!( + state.waiting_for, + WaitingFor::AssignCombatDamage { .. } | WaitingFor::AssignBlockerDamage { .. } + ) { + assignment_window.get_or_insert_with(|| state.waiting_for.variant_name().to_string()); + } + + let (beat, before, after, life_before, life_after, dealt) = witness.unwrap_or_else(|| { + panic!( + "reach-guard: no CR 510.2 beat dealt combat damage to P1 while the ring held \ + >= 2 frames, so the clear below would be unobservable. Combat-damage beats \ + seen: {damage_beats}; max ring: {max_ring}. ATTRIBUTION: if \ + `is_forced_cascade_window` no longer exempts \ + `DeclareAttackers`/`DeclareBlockers`, the ring is wiped before combat and \ + this guard reds first — read that as a class-membership regression, not as a \ + failure of the combat-damage fence below" + ) + }); + + assert!( + assignment_window.is_none(), + "DISCRIMINANT GUARD: this row exists because an UNBLOCKED attacker deals CR 510.2 \ + damage with NO window — CR 510.1c's assignment window opens only for a division \ + choice. A {assignment_window:?} window means the fixture drifted into the case \ + the window-keyed exclusion already covers, making the row vacuous." + ); + assert!( + life_after < life_before, + "LIFE-MOVE WITNESS: CR 120.3a — {dealt} combat damage to P1 must have reduced its \ + life; got {life_before} -> {life_after} at beat {beat}" + ); + assert!( + before >= 2, + "reach-guard: the ring must carry >= 2 frames INTO the damage beat; got {before}" + ); + assert_eq!( + after, 0, + "CR 510.2 + CR 704.5a: the damage batch moved a life total with no intervening \ + priority, so the ring accumulated before it may not be compared across it — it \ + must be EMPTY after the beat. Ring went {before} -> {after} at beat {beat} \ + (P1 {life_before} -> {life_after}). Deleting the \ + `invalidate_loop_ring_on_unobserved_life_move` call from `apply_combat_damage` \ + reproduces this failure: with `DeclareAttackers` / `DeclareBlockers` in the \ + forced-cascade class and no assignment window ever opening, nothing else clears \ + here." + ); +} + +/// The matched negative: CR 510.2 damage that moves NO player's life leaves the ring +/// alone. Same board, but P1 fields a single 0/20 wall, so the 3/3 is blocked and the +/// whole batch is creature-to-creature. +/// +/// This pins the `p.life != before` predicate as load-bearing. Replacing it with an +/// unconditional `clear()` — "clear on every combat damage" — still passes the row above +/// but flips this one to FAIL, and would be a needless retention regression on every +/// board where creatures merely trade. +/// +/// NON-VACUITY: the witness requires a beat that BOTH dealt combat damage to a creature +/// (`DamageDealt { target: Object, is_combat: true }`) AND left every player's life +/// unchanged, with the ring already carrying >= 2 frames. A beat where no combat happened +/// cannot satisfy it, so the row cannot pass by the attack never occurring. The single +/// blocker also keeps CR 510.1c's division window shut, matching the row above. +#[test] +fn creature_only_combat_damage_leaves_the_loop_ring_intact() { + let mut state = combat_ring_board(true); + + assert!( + state.loop_detection.samples(), + "reach-guard: a non-sampling mode never populates the ring; got {:?}", + state.loop_detection + ); + + // (beat, ring before, ring after, creature damage dealt) + let mut witness: Option<(usize, usize, usize, u32)> = None; + let mut max_ring = 0usize; + let mut creature_damage_beats = 0usize; + + for beat in 0..400usize { + let before = state.loop_detect_ring.len(); + max_ring = max_ring.max(before); + let lives_before: Vec = state.players.iter().map(|p| p.life).collect(); + let Ok(events) = combat_drive_one_beat(&mut state) else { + break; + }; + let to_creatures: u32 = events + .iter() + .filter_map(|e| match e { + GameEvent::DamageDealt { + target: TargetRef::Object(_), + amount, + is_combat: true, + .. + } => Some(*amount), + _ => None, + }) + .sum(); + let lives_after: Vec = state.players.iter().map(|p| p.life).collect(); + if to_creatures > 0 && lives_after == lives_before { + creature_damage_beats += 1; + if witness.is_none() && before >= 2 { + witness = Some((beat, before, state.loop_detect_ring.len(), to_creatures)); + break; + } + } + } + + let (beat, before, after, dealt) = witness.unwrap_or_else(|| { + panic!( + "reach-guard: no beat dealt CR 510.2 damage to a creature with every player's \ + life unchanged while the ring held >= 2 frames. Creature-damage beats seen: \ + {creature_damage_beats}; max ring: {max_ring}. ATTRIBUTION: if \ + `is_forced_cascade_window` no longer exempts \ + `DeclareAttackers`/`DeclareBlockers`, the ring is wiped before combat and \ + this guard reds first — read that as a class-membership regression, not as a \ + failure of the combat-damage fence below" + ) + }); + + assert!( + after >= before, + "CR 119.3: no player's life moved in this CR 510.2 batch ({dealt} damage, all of it \ + to creatures), so there is nothing for the loop-ring prohibition to fence and the \ + accumulated ring must SURVIVE. Ring went {before} -> {after} at beat {beat}. \ + Replacing the `p.life != before` predicate in \ + `invalidate_loop_ring_on_unobserved_life_move` with an unconditional `clear()` \ + reproduces this failure." + ); +} + +// =========================================================================== +// X1-1 — CR 117.1b on a REAL 4-player dump. +// =========================================================================== + +/// Sprout Swarm in P0's hand in the dump-A capture. +const X1_SPROUT: ObjectId = ObjectId(64); +/// An untapped P0 fodder Saproling to convoke for the {G}. +const X1_FODDER: ObjectId = ObjectId(421); + +/// X1-1 (⛔ the §H.2-gated row). The real 4-player Witherbloom / Sprout Swarm / +/// Lumaret capture: P0 drives a Saproling object-growth loop while three opponents sit +/// on utility lands whose activated abilities read the growing class, plus P0's own +/// Jadar (a `{Phase, End}` observer). Pre-fix the CR 732.2a firewall vetoed and no offer +/// surfaced. +/// +/// ⛔ BLOCKING PRECONDITIONS (plan §H.2), MEASURED BEFORE THIS ROW WAS WRITTEN, at the +/// C-2 firewall call on this exact board: +/// * `scope.sole_driver == Some(PlayerId(0))` — the driving player. X1's own key. +/// * `scope.phase_invariant == Some(PreCombatMain)` — the value is REPORTED here, not +/// pre-asserted: asserting a literal on a loaded dump would smuggle in an unverified +/// premise. The row asserts only that the guard was reachable. +/// * `trigger_event_unreachable_in_phase(, PreCombatMain) == true` — SUFFICIENCY, not just reachability: +/// the dump's veto set spans BOTH classes (4 of 5 blockers are X1-class opponent +/// lands, the 5th is Jadar in the X2 class), so the offer needs both guards to fire. +/// Instrument control on the same run: 48 `true` / 208 `false` over the board's +/// trigger population, so the predicate is not constant. +/// +/// ⛔ HONEST EVIDENCE BASIS: BASE is a measured no-offer trajectory whose FIRST veto was +/// object 75. First-veto evidence bounds NOTHING about the remaining veto set — the +/// firewall returns on the first `true` (13 `return true` sites in +/// `fire_time_conditions_read_growing_class_scoped`). The offer-level assertion below is +/// what carries this row's claim; the BASE figure is provenance, not proof. +/// +/// REVERT-PROBE: delete the `obj.controller != driver` conjunct in block (2) ⇒ the +/// opponents' utility-land abilities veto again ⇒ the offer disappears ⇒ FAILS. +#[test] +fn witherbloom_lumaret_4p_offers_with_opponent_utility_lands() { + use engine::types::ability::AbilityKind; + use engine::types::game_state::LoopDetectionMode; + use engine::types::zones::Zone; + + let mut state = restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/witherbloom_sprout_lumaret_4p.json.gz" + ))); + state.loop_detection = LoopDetectionMode::On; + + // ── fixture preconditions (hold in BOTH revert modes ⇒ the offer is non-vacuous) ── + assert!( + matches!(state.waiting_for, WaitingFor::Priority { player } if player == P0), + "fixture precondition: ordinary P0 priority pre-cast, got {:?}", + state.waiting_for + ); + assert_eq!( + state + .objects + .get(&X1_SPROUT) + .map(|o| (o.name.as_str(), o.zone)), + Some(("Sprout Swarm", Zone::Hand)), + "fixture precondition: Sprout Swarm is in P0's hand" + ); + let fodder = state.objects.get(&X1_FODDER).expect("fodder present"); + assert!( + fodder.name == "Saproling" && fodder.controller == P0 && !fodder.tapped, + "fixture precondition: an untapped P0 Saproling to convoke" + ); + // The X1 class is really present: opponents control battlefield permanents. + let foreign_permanents = state + .battlefield + .iter() + .filter(|id| state.objects.get(id).is_some_and(|o| o.controller != P0)) + .count(); + assert!( + foreign_permanents >= 3, + "fixture precondition: the dump carries opponent-controlled permanents (the X1 \ + class); got {foreign_permanents}" + ); + + // ── SHAPE, not just a count. This offer rests on the X1 (`obj.controller != driver`) + // relief, and item A narrows that relief to `kind == AbilityKind::Activated` with + // `activator_filter.is_none()`. A bare `foreign_permanents >= 3` count cannot tell + // whether the relieved population is the one item A governs; this does. + let foreign_ability_kinds: Vec = state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .filter(|o| o.controller != P0) + .flat_map(|o| o.abilities.iter().map(|a| a.kind)) + .collect(); + assert!( + !foreign_ability_kinds.is_empty(), + "fixture precondition: the foreign battlefield ability population must be NON-EMPTY, \ + else item A's `kind == Activated` narrowing has nothing to act on here and this \ + row's offer is not evidence about X1 at all" + ); + assert!( + foreign_ability_kinds + .iter() + .all(|k| *k == AbilityKind::Activated), + "fixture precondition: every foreign battlefield ability def must be `Activated` — \ + item A relieves ONLY that kind, so a non-`Activated` def here would keep vetoing \ + and the offer would be attributable to something else; got {foreign_ability_kinds:?}" + ); + assert!( + state + .battlefield + .iter() + .filter_map(|id| state.objects.get(id)) + .filter(|o| o.controller != P0) + .all(|o| o.abilities.iter().all(|a| a.activator_filter.is_none())), + "fixture precondition: no foreign def carries an `activator_filter` — item E refuses \ + relief on ANY `Some(..)`, so one here would suppress this offer" + ); + let jadar = state + .objects + .get(&engine::types::identifiers::ObjectId(75)) + .expect("fixture precondition: object 75 is present"); + assert_eq!( + (jadar.name.as_str(), jadar.zone, jadar.controller), + ("Jadar, Ghoulcaller of Nephalia", Zone::Battlefield, P0), + "fixture precondition: the driver-side observer this row names" + ); + assert_eq!( + jadar.trigger_definitions.len(), + 1, + "fixture precondition: Jadar carries exactly one trigger definition; got {}", + jadar.trigger_definitions.len() + ); + + let outcome = GameRunner::from_state(state) + .cast(X1_SPROUT) + .accept_optional() + .convoke_with(&[X1_FODDER]) + .commit() + .resolve(); + + // ── reach-guard: the cast really resolved and grew the class ── + assert_eq!( + outcome.zone_of(X1_SPROUT), + Zone::Hand, + "reach-guard: Buyback returned Sprout Swarm to P0's hand" + ); + + // ── DISCRIMINATOR: the CR 732.2a offer surfaces ── + match outcome.final_waiting_for() { + WaitingFor::LoopShortcut { + proposer, + predicted_winner, + certificate, + .. + } => { + assert_eq!(*proposer, P0, "the driver proposes"); + assert_eq!( + *predicted_winner, None, + "an Advantage offer has no predicted winner" + ); + assert_eq!( + certificate.win_kind, + WinKind::Advantage, + "CR 732.2a: this is a beneficial (advantage) loop, not a mandatory win" + ); + assert!( + certificate.unbounded.contains(&ResourceAxis::TokensCreated), + "the unbounded axes must include TokensCreated, got {:?}", + certificate.unbounded + ); + } + other => panic!( + "X1-1: CR 117.1b — no player but the sole driver receives priority inside the \ + taken shortcut, so the opponents' utility-land abilities cannot read the \ + growing class and must not suppress the offer; got {other:?}. \ + ⛔ PRE-REGISTERED STOP BRANCH: do NOT widen X1's conjunct, X2's arms, or any \ + downstream gate to manufacture this offer. Run the veto-enumeration \ + diagnostic (convert the 13 `return true` sites in \ + `fire_time_conditions_read_growing_class_scoped` to log-and-continue, replay, \ + record every vetoing object id and its block), name the next rejecter and its \ + call count in the PR body, and STOP." + ), + } +} + +// =========================================================================== +// K4 — CR 608.2i + CR 608.2j ledger-FILTER exclusion (the shallow BB-FU10-N narrowing). +// Every fixture carries the harness's shared `"Flying, trample\n"` keyword prefix, so +// subject and control differ ONLY in the ledger clause. +// =========================================================================== + +/// FIXTURE C (PRIMARY) — measured `mode=DamageDone`, `phase=null`, `damage_kind=Any`, +/// `constraint=null`. `damage_kind: Any` is what makes this pair STRUCTURALLY independent +/// of the CR 510.2 phase relief, whose damage arm requires `CombatOnly`. +const LEDGER_ARTIFACT_FILTER_ORACLE: &str = "Flying, trample\nWhenever this creature deals damage to a player, draw a card if you had two or more artifacts enter the battlefield under your control this turn."; + +/// FIXTURE D (PRIMARY) — fixture C with one Oracle noun changed. Measured: the two +/// serialized trigger definitions are 984 bytes each and differ at exactly TWO token +/// positions — `filter.type_filters[0]` and the humanized `description` string — both +/// projections of that ONE noun, and `description` is a display string no scan predicate +/// reads. +const LEDGER_CREATURE_FILTER_ORACLE: &str = "Flying, trample\nWhenever this creature deals damage to a player, draw a card if you had two or more creatures enter the battlefield under your control this turn."; + +/// FIXTURE A (CORROBORATING) — a DIFFERENT `TriggerMode`. Measured `mode=Phase`, +/// `phase=PreCombatMain`, `damage_kind=Any`, `constraint=OnlyDuringYourTurn`. Its +/// independence from the phase relief rests on the ⛔ STRICT-INEQUALITY pin +/// (`p != phase`, so `PreCombatMain` in a `PreCombatMain` window is NOT relieved) — hence +/// corroborating rather than primary. +const PHASE_LEDGER_ARTIFACT_FILTER_ORACLE: &str = "Flying, trample\nAt the beginning of your precombat main phase, draw a card if you had two or more artifacts enter the battlefield under your control this turn."; + +/// FIXTURE B (CORROBORATING) — fixture A one Oracle noun apart. +const PHASE_LEDGER_CREATURE_FILTER_ORACLE: &str = "Flying, trample\nAt the beginning of your precombat main phase, draw a card if you had two or more creatures enter the battlefield under your control this turn."; + +/// K4-N1 (PRIMARY) — CR 608.2i + CR 608.2j. A ledger observer whose entry filter PROVABLY cannot +/// count the growing fodder has a read whose value is invariant across the loop's growth, +/// so it does not observe the loop and must not suppress the CR 732.2a offer. +/// +/// ATTRIBUTION, structural rather than argued: +/// * the CR 510.2 relief cannot move this row — `damage_kind: Any` (measured) can never +/// satisfy its damage arm, which requires `CombatOnly` (pinned by +/// `trigger_event_unreachable_in_phase_shape_is_pinned` arm 2), and `mode: DamageDone` +/// never reaches its Phase arm. +/// * the CR 117.1b relief cannot move it — the bystander is the DRIVER'S OWN. +/// ⇒ the flip is attributable to the ledger-filter narrowing alone. +/// +/// REVERT-PROBES: (1) delete the `&& !class_members.is_some_and(..)` guard ⇒ veto ⇒ FAILS. +/// (2) make `execute_ledger_condition_provably_excludes_class` return `false` +/// unconditionally ⇒ the same failure ⇒ the PREDICATE, not the plumbing, carries the flip. +#[test] +fn noncombat_damage_ledger_observer_whose_filter_excludes_the_class_does_not_suppress_offer() { + use engine::types::zones::Zone; + + // (2) ANTI-VACUITY CONTROL, granted in BOTH builds. + let (control_runner, _) = object_growth_with_bystander(PLAIN_DRAW_TRIGGER_ORACLE); + assert!( + matches!( + control_runner.state().waiting_for, + WaitingFor::LoopShortcut { .. } + ), + "(2) control: a plain draw-trigger bystander must not suppress the offer" + ); + + let (runner, bystander) = object_growth_with_bystander(LEDGER_ARTIFACT_FILTER_ORACLE); + + // (3) reach-guards. + let obj = &runner.state().objects[&bystander]; + assert_eq!(obj.zone, Zone::Battlefield); + assert_eq!( + obj.trigger_definitions.len(), + 1, + "(3) reach-guard: exactly one trigger definition carries the ledger read" + ); + + match &runner.state().waiting_for { + WaitingFor::LoopShortcut { certificate, .. } => assert!( + certificate.unbounded.contains(&ResourceAxis::TokensCreated), + "(1) unbounded axis must be TokensCreated, got {:?}", + certificate.unbounded + ), + other => panic!( + "(1) CR 608.2j: a `Typed{{Artifact}}` entry filter cannot count a Saproling \ + creature token, so the observer's read is invariant across the loop's growth \ + and must not suppress the offer; got {other:?}. \ + ⛔ PRE-REGISTERED FAILURE BRANCH: report the NEXT rejecter by name and its \ + call count and STOP — do not widen a conjunct to manufacture the offer. \ + Conjunct (a) is measured to pass; the remaining candidates in order are (c) \ + and the offer-path gates downstream of the firewall." + ), + } +} + +/// K4-N2 (PRIMARY) — THE ROW THAT KILLS THE LAZY-BUT-UNSOUND NARROWING. Fixture D is +/// fixture C with one Oracle noun changed, and its `Typed{Creature}` filter GENUINELY +/// counts the Saproling creature token the loop creates each cycle. So the veto must +/// survive. +/// +/// This pair IS the acceptance criterion: a correct narrowing moves K4-N1 and not this +/// row; a blanket relaxation moves both; an inert guard moves neither. +/// +/// REVERT-PROBE: make conjunct (c) unconditionally `true` (a blanket relaxation) ⇒ this +/// row flips to an offer ⇒ FAILS. +#[test] +fn noncombat_damage_ledger_observer_whose_filter_matches_the_class_still_suppresses_offer() { + use engine::types::zones::Zone; + + let (runner, bystander) = object_growth_with_bystander(LEDGER_CREATURE_FILTER_ORACLE); + + // (3) reach-guards. Anti-vacuity for a VETO row: the sibling POSITIVE + // `noncombat_damage_ledger_observer_whose_filter_excludes_the_class_does_not_suppress_offer` + // shows the same board DOES offer when the filter excludes, so this row's veto is + // attributable to the filter and not to the board. + let obj = &runner.state().objects[&bystander]; + assert_eq!( + obj.zone, + Zone::Battlefield, + "reach-guard: block (1) hard-skips non-battlefield zones" + ); + assert_eq!( + obj.trigger_definitions.len(), + 1, + "reach-guard: exactly one trigger definition carries the ledger read; got {}", + obj.trigger_definitions.len() + ); + assert!( + obj.abilities.is_empty(), + "reach-guard: this row's claim is about ONE named TRIGGER surface; the bystander \ + also carries {} ability def(s) {:?}", + obj.abilities.len(), + obj.abilities.iter().map(|a| a.kind).collect::>(), + ); + + assert!( + !matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }), + "CR 608.2j: a `Typed{{Creature}}` entry filter DOES count a Saproling creature \ + token, so the observer genuinely observes the loop and must keep vetoing; got {:?}", + runner.state().waiting_for + ); +} + +/// K4-N4a (CORROBORATING) — the same relief through a DIFFERENT `TriggerMode`, which is +/// what proves it keys on the ledger FILTER and not on any one trigger shape. +/// +/// ⚠ Independence from the CR 510.2 relief is CONDITIONAL on the ⛔ strict-inequality pin +/// (`p != phase`): fixture A is `phase: Some(PreCombatMain)` in a `PreCombatMain` window, +/// so the phase arm answers `false` and cannot classify it. Hence corroborating. +#[test] +fn phase_reachable_ledger_observer_whose_filter_excludes_the_class_does_not_suppress_offer() { + use engine::types::zones::Zone; + + let (runner, bystander) = object_growth_with_bystander(PHASE_LEDGER_ARTIFACT_FILTER_ORACLE); + + // (3) reach-guards, ALL BEFORE the offer match. This is a POSITIVE row: a parse failure + // yields no observer at all and the offer would form trivially, so these guards are what + // make that vacuity mode loud. + let obj = &runner.state().objects[&bystander]; + assert_eq!( + obj.zone, + Zone::Battlefield, + "reach-guard: block (1) hard-skips non-battlefield zones" + ); + assert_eq!( + obj.trigger_definitions.len(), + 1, + "reach-guard: the ledger observer must have PARSED — a misparse leaves zero trigger \ + defs and the offer below forms for the wrong reason; got {}", + obj.trigger_definitions.len() + ); + assert!( + obj.abilities.is_empty(), + "reach-guard: this row's claim is about ONE named TRIGGER surface; the bystander \ + also carries {} ability def(s) {:?}", + obj.abilities.len(), + obj.abilities.iter().map(|a| a.kind).collect::>(), + ); + + match &runner.state().waiting_for { + WaitingFor::LoopShortcut { certificate, .. } => assert!( + certificate.unbounded.contains(&ResourceAxis::TokensCreated), + "K4-N4a: unbounded axis must be TokensCreated, got {:?}", + certificate.unbounded + ), + other => panic!( + "K4-N4a CR 608.2j: a phase-REACHABLE observer whose entry filter excludes the \ + fodder must not suppress the offer; got {other:?}" + ), + } +} + +/// K4-N4b (CORROBORATING) — fixture B, one Oracle noun from K4-N4a, keeps its veto. +/// +/// REVERT-PROBE: make conjunct (c) unconditional ⇒ flips ⇒ FAILS. +#[test] +fn phase_reachable_ledger_observer_whose_filter_matches_the_class_still_suppresses_offer() { + use engine::types::zones::Zone; + + let (runner, bystander) = object_growth_with_bystander(PHASE_LEDGER_CREATURE_FILTER_ORACLE); + + // (3) reach-guards. Anti-vacuity for a VETO row: the sibling POSITIVE + // `phase_reachable_ledger_observer_whose_filter_excludes_the_class_does_not_suppress_offer` + // shows the same board DOES offer when the filter excludes. + let obj = &runner.state().objects[&bystander]; + assert_eq!( + obj.zone, + Zone::Battlefield, + "reach-guard: block (1) hard-skips non-battlefield zones" + ); + assert_eq!( + obj.trigger_definitions.len(), + 1, + "reach-guard: exactly one trigger definition carries the ledger read; got {}", + obj.trigger_definitions.len() + ); + assert!( + obj.abilities.is_empty(), + "reach-guard: this row's claim is about ONE named TRIGGER surface; the bystander \ + also carries {} ability def(s) {:?}", + obj.abilities.len(), + obj.abilities.iter().map(|a| a.kind).collect::>(), + ); + assert!( !matches!(runner.state().waiting_for, WaitingFor::LoopShortcut { .. }), - "(1) CR 732.2a: a live observer reading the battlefield-entry ledger must \ - VETO the object-growth certificate; got {:?}. If BB-FU10-N (the narrowing \ - follow-up) has landed, this assertion is expected to flip back to an OFFER \ - — update it, do not delete the test.", + "K4-N4b: the matching half of the corroborating pair must keep vetoing; got {:?}", runner.state().waiting_for ); }