diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 1a35510ad6..aea648333b 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -5383,6 +5383,7 @@ mod tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Prepare), + parse_warnings: vec![], } } diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 1f7f720675..3de9b0076d 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -6,6 +6,7 @@ mod evoke; pub mod filter; mod payment_continuation; mod prospective_mana; +mod shortcut_efficacy; mod swarm; mod targeted_exchange; @@ -1352,25 +1353,48 @@ fn has_activatable_sacrifice_for_mana(state: &GameState) -> bool { && mana_actions_include_meaningful_sacrifice(state, &activatable_object_mana_actions(state)) } +/// The mana activations [`has_activatable_sacrifice_for_mana`] counts, as +/// items rather than as a yes/no. +/// +/// SINGLE AUTHORITY for the predicate. `smart_shortcut_response`'s stage 2 has +/// to classify exactly the actions stage 1 counted, and stage 1 counts these — +/// which the flat `legal_actions` list structurally cannot contain (they live +/// in `legal_actions_by_object` only). Re-testing the same shape at that call +/// site would be a parallel copy free to drift, so the call site consumes this +/// iterator and `mana_actions_include_meaningful_sacrifice` is defined as its +/// emptiness. +fn meaningful_sacrifice_mana_actions<'a>( + state: &'a GameState, + object_mana_actions: &'a [GameAction], +) -> impl Iterator { + object_mana_actions.iter().filter(move |action| { + matches!( + action, + GameAction::ActivateAbility { + source_id, + ability_index, + } if activate_ability_is_meaningful_priority(state, *source_id, *ability_index) + ) + }) +} + /// Slice-taking core of [`has_activatable_sacrifice_for_mana`]: given a /// precomputed activatable mana-action sweep, true iff any action is a /// meaningful (sacrifice-for-mana) mana activation. Extracted so /// `auto_pass_recommended` can compute the sweep ONCE and share it between the /// G1 beneficial-mana-tap hold (rung 5) and this rung-9 sac check, avoiding the /// PR #5229 double-evaluation of the mana-action sweep. +/// +/// `Iterator::any(p)` and `.filter(p).next().is_some()` agree on every input +/// (both are the first-match short-circuit over the same predicate), so this +/// re-expression leaves the loop-firewall and auto-pass answers unchanged. fn mana_actions_include_meaningful_sacrifice( state: &GameState, object_mana_actions: &[GameAction], ) -> bool { - object_mana_actions.iter().any(|action| { - matches!( - action, - GameAction::ActivateAbility { - source_id, - ability_index, - } if activate_ability_is_meaningful_priority(state, *source_id, *ability_index) - ) - }) + meaningful_sacrifice_mana_actions(state, object_mana_actions) + .next() + .is_some() } /// True when `actions` contains a priority action that materially changes the @@ -1387,24 +1411,22 @@ pub fn has_meaningful_priority_action(state: &GameState, actions: &[GameAction]) || has_activatable_sacrifice_for_mana(state) } -/// PR-7 Phase 4c (LOW-2): CR 732.2b/c + CR 104.4b — an AI opponent polled on an -/// OPTIONAL loop shortcut. The offer is raised only for optional loops the polled -/// player CAN break (a Path A optional drain, `WaitingFor::LoopShortcut`); the win -/// is a loss condition for every polled opponent (single-faller: see -/// `interactive_loop_bridge`'s Path A gate, CR 104.2a). SELF-PRESERVATION: if this -/// player has a meaningful priority action (a way to break the loop), name -/// `Shorten{0}` — the engine realizes Shorten as a real `WaitingFor::Priority` -/// window (`game::engine::apply_action`'s `RespondToShortcut(Shorten)` arm) where -/// it can act — rather than Accept its own loss. No meaningful action ⇒ Accept -/// (nothing to do). Single authority for all 3 `RespondToShortcut` emission sites -/// (engine `candidates.rs` + phase-ai `projection.rs`/`search.rs`) so the -/// self-preservation heuristic can't drift between them. Reuses the exact -/// `no_living_player_has_meaningful_priority_action` probe recipe -/// (`game::engine`), scoped to the single polled player. -pub fn smart_shortcut_response( +/// The probe [`smart_shortcut_response`] folds over: `state` re-parked at +/// `polled_player`'s priority with auto-pass cleared and layers flushed, plus +/// that state's flat priority actions. Mirrors +/// `game::engine`'s `no_living_player_has_meaningful_priority_action` recipe, +/// scoped to the single polled player. +/// +/// Public because the shortcut tests' reach-guards must assert on THIS list and +/// THIS state. A private copy of the recipe in a test silently starts measuring +/// a different action set — and a different `has_meaningful_priority_action` +/// answer, since that predicate's sacrifice-for-mana rung only fires while +/// `waiting_for` is `Priority`, which is true of the probe state and false of +/// the `RespondToShortcut` state the caller holds. +pub fn shortcut_probe( state: &GameState, polled_player: PlayerId, -) -> crate::analysis::loop_check::ShortcutResponse { +) -> (casting::PriorityCastProbe, Vec) { let mut probe_state = state.clone(); probe_state.auto_pass.clear(); probe_state.priority_player = polled_player; @@ -1414,11 +1436,167 @@ pub fn smart_shortcut_response( layers::flush_layers(&mut probe_state); let probe = casting::PriorityCastProbe::from_flushed_state(probe_state, polled_player); let actions = flat_priority_actions_with_probe(probe.state(), Some(&probe)); - if has_meaningful_priority_action(probe.state(), &actions) { + (probe, actions) +} + +/// The actions [`smart_shortcut_response`]'s stage 2 classifies, given a probe +/// state and the flat priority list stage 1 read. +/// +/// COVERAGE INVARIANT — stage 2 must classify every action stage 1 counted as +/// meaningful. The flat list does not satisfy that on its own: +/// [`has_meaningful_priority_action`] is a disjunction, and its second rung +/// ([`has_activatable_sacrifice_for_mana`]) reads `state`, not `actions`, so it +/// counts sacrifice-for-mana activations that `legal_actions` structurally omits +/// (they live in `legal_actions_by_object` only — see issue #544). Folding stage +/// 2 over the flat list alone therefore lets a seat whose ONLY meaningful action +/// is such an activation Accept BY OMISSION: an action stage 2 never sees +/// reaches no arm, so `shortcut_efficacy`'s fail-closed `_ => MayInterfere` +/// default cannot protect it, and a false Accept is the direction the module doc +/// says can lose a game. +/// +/// `activatable_object_mana_actions` is gated on `waiting_for` — the probe state +/// is re-parked at `Priority`, which is the same gate +/// `has_activatable_sacrifice_for_mana` passes there, so the two stages read the +/// SAME sweep. The added items come from +/// [`meaningful_sacrifice_mana_actions`], the single authority stage 1's rung is +/// also defined in terms of; re-testing the shape here would be a parallel copy +/// free to drift. +/// +/// Deliberately does NOT add the whole mana sweep, only the subset stage 1 +/// counts: a plain land tap is not a meaningful priority action to stage 1, and +/// widening past that would buy `Shorten`s stage 1 never asked for. +/// +/// Public for the same reason [`shortcut_probe`] is: the shortcut tests' +/// coverage row must assert on THIS set, and a private copy of the recipe would +/// silently start measuring a different one. +pub fn stage_two_action_set( + probe_state: &GameState, + flat_actions: &[GameAction], +) -> Vec { + let mana_actions = activatable_object_mana_actions(probe_state); + flat_actions + .iter() + .chain(meaningful_sacrifice_mana_actions( + probe_state, + &mana_actions, + )) + .cloned() + .collect() +} + +/// PR-7 Phase 4c (LOW-2): CR 732.2b/c + CR 104.4b — an AI opponent polled on an +/// OPTIONAL or bounded loop shortcut. The win is a loss condition for every +/// polled opponent (single-faller: see `interactive_loop_bridge`'s Path A gate, +/// CR 104.2a). +/// +/// SINGLE AUTHORITY, and what it does NOT cover. MEASURED — production (i.e. +/// non-`#[cfg(test)]`) code under `crates/*/src/` builds a +/// `GameAction::RespondToShortcut` value at exactly four places: +/// * `ai_support::candidates::candidate_actions_broad_with_probe`, +/// * `phase_ai::projection::resolve_choice`, +/// * `phase_ai::search::fallback_action` — these three are AI seats and all +/// route here, so the heuristic cannot drift between them; and +/// * `game::interaction::materialize_shortcut_reply_response`, which turns a +/// HUMAN player's submitted `InteractionShortcutReply` into the action. That +/// one is deliberately NOT covered: running a stated human choice through an +/// AI heuristic would overwrite it. It is also the only site that can emit a +/// non-zero `at_iteration` — every AI site emits `Shorten { at_iteration: 0 }`. +/// +/// `candidate_actions_broad_with_probe` additionally routes +/// `WaitingFor::RespondToPrecastCopyShortcut` here and maps the answer onto +/// `PrecastCopyShortcutResponse`, so this function answers BOTH accept-or-shorten +/// windows. That is intended: both windows ask the identical question — is a real +/// priority window worth taking here — so a seat whose only action cannot touch +/// the loop should decline both. `shorten_efficacy.rs`'s +/// `v8_precast_window_takes_the_same_efficacy_answer` measures that window rather +/// than assuming it. +/// +/// TWO STAGES. +/// +/// **Stage 1 — POSSIBILITY, byte-identical to the shipped predicate.** Reuses +/// the exact `no_living_player_has_meaningful_priority_action` probe recipe +/// (`game::engine`), scoped to the single polled player. No meaningful priority +/// action ⇒ Accept, exactly as before. `has_meaningful_priority_action` is +/// wired into the CR 732.5 NON-COMPULSION rule — +/// "No player can be forced to perform an action that would end a loop other +/// than actions called for by objects involved in the loop" — and is untouched +/// here. +/// +/// **Stage 2 — EFFICACY (AI POLICY, no CR licence claimed).** CR 732.2b grants +/// an unconditioned accept-or-shorten option and CR 732.2c requires only that a +/// shortening player's next choice be +/// *different*, which a fetchland activation already satisfies; nothing in the +/// Comprehensive Rules states an efficacy criterion. This stage is therefore +/// policy, and it declines to burn a real priority window on a response that +/// cannot change the outcome: +/// - arm (A): the offer's own predicted result already crowns this seat, so +/// shortening moves the game away from a win it already holds; +/// - arm (B): every action stage 1 counted as meaningful is confined to this +/// seat's own resources (`shortcut_efficacy::any_action_may_interfere`), so +/// no available choice touches the loop. Scoped to what stage 1 counted, not +/// to "every action this seat could take": the stage-2 set is a SUPERSET of, +/// and never smaller than, what stage 1 counted — the flat list enters +/// whole, so it also carries actions the stage-1 fold +/// (`flat_actions_have_meaningful_priority`) does not count as meaningful, +/// `PassPriority` and mana-ability activations among them, and extra items +/// can only push toward `MayInterfere`. See the coverage invariant at the +/// call below; the wider phrasing would claim coverage of shapes neither +/// stage enumerates. +/// +/// Otherwise the seat still Shortens and gets its window +/// (`game::engine::apply_action`'s `RespondToShortcut(Shorten)` arm). +/// +/// READ-ORDER: the proposal is read off the ORIGINAL `state`, before +/// [`shortcut_probe`] re-parks its clone at `Priority` — the probe state carries +/// no offer at all, so reading the crown from it would make arm (A) dead code. +pub fn smart_shortcut_response( + state: &GameState, + polled_player: PlayerId, +) -> crate::analysis::loop_check::ShortcutResponse { + // Both accept-or-shorten windows are named, so neither is answered by + // accident. The wildcard is not removable — `WaitingFor` has 128 variants and + // enumerating 126 `=> None` arms here would be noise, not a guard — and this + // two-named-arms + `_` shape is the module's existing idiom for the same + // question (`game::precast_copy_shortcut::normalize_untrusted_restore`, + // `::rekey_after_trusted_restore`). + let crowned_winner = match &state.waiting_for { + WaitingFor::RespondToShortcut { proposal, .. } => proposal.predicted_winner, + // STRUCTURAL, not an oversight: `RespondToPrecastCopyShortcut` carries no + // proposal summary and therefore no `predicted_winner` field, so the + // pre-cast route has no crown to read and arm (A) is inapplicable rather + // than skipped. Stage 1 and arm (B) do apply, and both run below. + WaitingFor::RespondToPrecastCopyShortcut { .. } => None, + _ => None, + }; + + let (probe, actions) = shortcut_probe(state, polled_player); + if !has_meaningful_priority_action(probe.state(), &actions) { + // CR 732.2c: nothing to do — agree to take the shortcut. + return crate::analysis::loop_check::ShortcutResponse::Accept; + } + + // Arm (A). Keyed on `predicted_winner`, never `proposer`: CR 732.2a lets a + // player propose a shortcut whose outcome crowns someone else, and the + // proposer is excluded from the APNAP response queue anyway. + if crowned_winner == Some(polled_player) { + return crate::analysis::loop_check::ShortcutResponse::Accept; + } + + // Arm (B). No bounded/unbounded exemption: an `IterationCount::Fixed(n)` + // offer ends the game just as surely as an `UntilLethal` one, so both + // classes take the identical rule. + // + // The fold reads [`stage_two_action_set`], never the bare flat `actions` — + // see that function for the coverage invariant and what folding over the + // flat list alone would silently Accept. + if shortcut_efficacy::any_action_may_interfere( + probe.state(), + polled_player, + &stage_two_action_set(probe.state(), &actions), + ) { // CR 732.2b: name an earlier stopping point — take my window instead of losing. crate::analysis::loop_check::ShortcutResponse::Shorten { at_iteration: 0 } } else { - // CR 732.2c: nothing to do — agree to take the shortcut. crate::analysis::loop_check::ShortcutResponse::Accept } } diff --git a/crates/engine/src/ai_support/shortcut_efficacy.rs b/crates/engine/src/ai_support/shortcut_efficacy.rs new file mode 100644 index 0000000000..ad2fde44da --- /dev/null +++ b/crates/engine/src/ai_support/shortcut_efficacy.rs @@ -0,0 +1,3992 @@ +// engine-citation-gate: symbol anchors only +//! Controller-relative confinement of a polled player's priority window — +//! stage 2 of [`super::smart_shortcut_response`]. +//! +//! CITATION FORM: rule NUMBER only, matching the enrolled test sibling +//! `tests/integration/shorten_efficacy.rs`. The number IS the greppable +//! heading — `grep '^400.1' docs/MagicCompRules.txt` resolves any citation +//! below. `docs/MagicCompRules.txt` is gitignored and re-fetched per checkout, +//! so a line anchor pins a citation to whichever rules revision its author +//! happened to hold. +//! +//! # 1. This is AI POLICY, not a rule +//! +//! CR 732.2b grants an **unconditioned** binary +//! option: "Each other player, in turn order starting after the player who +//! suggested the shortcut, may either accept the proposed sequence, or shorten +//! it by naming a place where they will make a game choice that's different +//! than what's been proposed." There is no criterion, no outcome test, and no +//! efficacy test; the rule explicitly does not even require the shortening +//! player to say what the new choice will be. CR 732.2c adds the only +//! obligation, and it is downstream: "the player who now has priority must make +//! a different game choice than what was originally proposed for that player." +//! Activating a fetchland IS a different game choice, so a fetchland Shorten +//! fully satisfies it. +//! +//! Nothing in the Comprehensive Rules therefore grounds this module's decision. +//! It is an AI policy: a seat whose only available response cannot touch the +//! loop spends its window achieving nothing, and the engine should not burn a +//! real priority window on it. Every CR number below is cited **only for what +//! its text says**, and is used as an *input* the classifier reads (which zones +//! are per-player, who owns what) — never as a licence for the decision. +//! +//! # 2. Fail-closed direction, and why the wildcard is conservative HERE +//! +//! [`WindowReach::MayInterfere`] is the default for every unrecognized shape. +//! That direction is deliberate and is the opposite of the discipline +//! `game::ability_scan` enforces on itself. `ability_scan`'s default is +//! `Axes::NONE` ("this ability reads nothing"), which is its *unsafe* +//! direction — a newly added reader classified inert would ride a false +//! auto-resolution — so that module forbids wildcards outright. Here the +//! default says "this action might interfere", which can produce only a **false +//! `Shorten`** (the polled seat takes a priority window it did not need — it +//! costs beats, never a game) and **never a false `Accept` from a shape it does +//! not recognize** (a seat accepting its own loss while holding a real out). +//! The qualifier is load-bearing; see the residual below. An unknown nesting +//! container is interference regardless of what it nests, so there is no +//! nested-carrier set and no recursion-arm set left to drift, and no +//! hand-maintained allowlist to rot. +//! +//! That argument covers unrecognized *shapes*, and it leaves exactly one +//! residual uncovered: a clause the PARSER silently swallows never becomes a +//! shape here at all, so the ability this module is handed looks strictly MORE +//! confined than the printed card is — and narrowing apparent reach is the +//! dangerous direction, because it produces a false `Accept`, not a false +//! `Shorten`. The residual is stated structurally rather than pinned to a card, +//! because a card witness is only valid until the classifier moves under it: +//! this doc previously named `Invoke Justice[0]` — a lone +//! [`Effect::ChangeZone`] (`origin: Graveyard`, `target: Typed{controller: +//! You}`, no cost, no `sub_ability`) whose printed sentence continues "then +//! distribute four +1/+1 counters among any number of creatures and/or Vehicles +//! target player controls" — and the entry gate added below has since +//! reclassified it `MayInterfere`, because its battlefield entry is not +//! provably tapped. That narrowed the residual; it did not close it. What +//! remains live is the same shape at a destination the entry gate does not +//! constrain: a non-battlefield destination, or a provably tapped entry. No +//! widening of this module can reach any of it — it cannot read a clause that +//! never arrived, so the residual is owned by the parser, not the classifier. +//! +//! The structural guard is the compiler, not a test: +//! [`ability_window_reach`] destructures [`AbilityDefinition`] **without +//! `..`**, and every allowlisted [`Effect`] arm is likewise `..`-free, so a new +//! field on the definition or on an allowlisted variant fails to compile until +//! it is classified. Precedent: `game::ability_scan::ability_definition_axes`. +//! +//! # 3. `analysis::ability_graph::collect_effects` is deliberately NOT used +//! +//! Two independent reasons, one mechanism. Its inner recursion ends `_ => {}` +//! (`analysis/ability_graph.rs`, whose own doc says "a wildcard covers the leaf +//! variants") — silent fail-open. And it walks **4 of `AbilityDefinition`'s 38 +//! fields** (`effect`, `sub_ability`, `else_ability`, `mode_abilities`); it +//! never inspects `cost` at all, so `Sacrifice` / `Discard` / `Mill` / +//! `ExileMaterials` costs — every one of which can reach another player's +//! resources — would be structurally invisible. + +use crate::game::game_object::GameObject; +use crate::types::ability::{ + AbilityCost, AbilityDefinition, ControllerRef, Effect, FilterProp, PlayerFilter, + SearchDestinationSplit, TargetFilter, +}; +use crate::types::actions::GameAction; +use crate::types::game_state::GameState; +use crate::types::identifiers::ObjectId; +use crate::types::player::PlayerId; +use crate::types::zones::{EtbTapState, Zone}; + +/// How far a single available action can reach, relative to the player who +/// would take it. +/// +/// A fourth axis, orthogonal to the three the engine already carries: +/// `ai_support::FlatPriorityActionClass` classifies *action shape*, +/// `game::ability_rw::WriteScope` classifies *object identity*, and +/// `game::ability_scan::Axes` classifies *AST reads*. This one classifies +/// **controller-relative confinement**. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WindowReach { + /// Everything this action can touch belongs to the player taking it. + OwnResourcesOnly, + /// This action can reach something outside the actor's own resources — + /// or its shape is not recognized, which is treated identically. + MayInterfere, +} + +impl WindowReach { + fn of(own_resources_only: bool) -> Self { + if own_resources_only { + Self::OwnResourcesOnly + } else { + Self::MayInterfere + } + } + + /// Absorbing fold: one interfering component makes the whole interfering. + fn or(self, other: Self) -> Self { + match (self, other) { + (Self::OwnResourcesOnly, Self::OwnResourcesOnly) => Self::OwnResourcesOnly, + _ => Self::MayInterfere, + } + } + + fn may_interfere(self) -> bool { + matches!(self, Self::MayInterfere) + } +} + +/// True only when every object this filter can match is PROVEN to belong to the +/// acting player. +/// +/// CR 400.1: "Each player has their own library, hand, and graveyard. +/// The other zones are shared by all players." A bare `Zone` reference is +/// therefore ambiguous by rule — it can never answer *whose* zone — so the +/// player-qualified authority has to come from the filter. CR 400.3 +/// names exactly three zones ("If an object would go to any library, graveyard, +/// or hand other than its owner's, it goes to its owner's corresponding zone") +/// and CR 108.3 defines the owner, which is why owner/controller, not +/// zone, is the axis this predicate reads. +/// +/// Unproven is not owned: everything else (`Any`, `None`, `Player`, `Opponent`, +/// `ParentTarget*`, anaphors, specific ids) is `false`. +/// +/// SCOPE, and the name is wider than what this proves. Both `true` arms are +/// CONTROL arms, not ownership arms: CR 109.5 defines "you"/"your" on an object +/// as its CONTROLLER, so `SelfRef`, `Controller` and `Typed{controller: You}` all +/// resolve through control, and CR 110.2 makes owner and controller +/// independent. Ownership is proven separately and at BOARD level by +/// [`actor_owns_everything_they_control`], which +/// [`any_action_may_interfere`] applies before it folds any action; this +/// predicate is sound only underneath that conjunct. +fn filter_is_actor_owned(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::SelfRef | TargetFilter::Controller => true, + TargetFilter::Typed(typed) => { + typed.controller == Some(ControllerRef::You) + || typed.properties.iter().any(|prop| { + matches!( + prop, + FilterProp::Owned { + controller: ControllerRef::You + } + ) + }) + } + // An `Or` matches an object when ANY leg does, so every leg must be + // proven; an `And` matches only when ALL legs do, so one proven leg is + // enough. The emptiness guards keep a degenerate `filters: []` from + // being proven by a vacuous `all()`. + TargetFilter::Or { filters } => { + !filters.is_empty() && filters.iter().all(filter_is_actor_owned) + } + TargetFilter::And { filters } => filters.iter().any(filter_is_actor_owned), + _ => false, + } +} + +/// Does the actor OWN every permanent they control? +/// +/// [`filter_is_actor_owned`] proves CONTROL, because CR 109.5 defines "you" and +/// "your" on an object as its controller. CR 110.2 makes owner and controller +/// independent axes ("A permanent's owner is the same as the owner of the card +/// that represents it"), so a controlled-but-not-owned permanent breaks every +/// place this module treats a control proof as a confinement proof. The sharp +/// one is CR 701.21a: "To sacrifice a permanent, its controller moves it from the +/// battlefield directly to its OWNER'S graveyard" — so the allowlisted +/// `AbilityCost::Sacrifice` of a permanent the actor controls but does not own +/// puts a card into ANOTHER player's graveyard, which is that player's own zone +/// per CR 400.1 ("Each player has their own library, hand, and graveyard") and +/// therefore reach by construction. +/// +/// SET-LEVEL BY NECESSITY, not by preference. Threading ownership per matched +/// object is structurally impossible at the filter layer: `TargetFilter::Controller` +/// is a bare unit variant carrying no id at all, and `TargetFilter::Typed` is a +/// SET PREDICATE describing a class rather than naming a member, so neither can be +/// resolved to the objects it would match without evaluating it against the board. +/// The single variant that does carry an id — `SpecificObject { id }` — already +/// falls to `filter_is_actor_owned`'s `_` arm and returns `false`. So the only +/// place the ownership fact can be discharged is the board, once, over every +/// object the actor controls. +/// +/// Board facts are action-independent, so the caller evaluates this ONCE rather +/// than per action. +fn actor_owns_everything_they_control(state: &GameState, actor: PlayerId) -> bool { + state + .objects + .values() + .all(|o| o.controller != actor || o.owner == actor) +} + +/// Is a landing zone confined — i.e. can the seat NOT act on what arrives there, +/// inside the window the Shorten hands back? +/// +/// This is the single authority for that question, and it exists because the two +/// doors out of a library drifted apart exactly once: the `ChangeZone` arm gated +/// `Zone::Hand` while the `SearchLibrary` split arm gated only `Zone::Battlefield`, +/// so a `SearchDestinationSplit` routing cards to hand stayed classified confined. +/// MEASURED at that point: all twelve split carriers in `card-data.json` route +/// something to hand — nine via `rest_destination` (the Cultivate class: land to +/// the battlefield tapped, the REST to hand) and three via `primary_destination` +/// (Final Parting, Fork in the Road, Jarad's Orders). Two call sites answering one +/// question is how that happens, so there is now one function and no second answer. +/// +/// * `Zone::Battlefield` — CR 110.5b: permanents enter untapped unless something +/// says otherwise, and an untapped permanent taps for mana during the very cast +/// it funds (CR 601.2g). Only a PROVABLY tapped arrival is confined. +/// * `Zone::Hand` — a card in hand is a castable card. After the spell that put it +/// there resolves the active player receives priority (CR 117.3b) and priority +/// then passes in turn order (CR 117.3d), so the responding seat gets it back +/// still inside this window. The AST carries nothing that could prove the card +/// uncastable. +/// * `Zone::Stack` — strictly stronger than the hand case, and the one a `_` arm hid. +/// CR 405.1: a cast spell's card is put on the stack; CR 608.1: the object on top +/// of the stack resolves once all players pass. A card that LANDS there is past +/// casting already and resolves inside this very window, so the seat does not even +/// need priority to get its effect. +/// * `Zone::Command` — CR 903.8: "A player may cast a commander they own from the +/// command zone", and CR 114.1 puts emblems there carrying abilities of their own. +/// It is a zone the seat acts FROM, so it belongs with hand rather than with the +/// need-further-permission group this doc previously filed it under. +/// * `Zone::Library` / `Zone::Graveyard` / `Zone::Exile` — the card lands where the +/// actor needs some FURTHER permission to use it, and that permission would itself +/// be an ability this fold already reads. +/// +/// Callers still apply their own extra conjuncts (the battlefield riders on +/// `ChangeZone`); this answers the destination question only. +fn landing_zone_is_confined(destination: Zone, tapped: EtbTapState) -> bool { + // EXHAUSTIVE ON PURPOSE — no wildcard. `Zone` is a closed seven-variant enum + // (CR 400.1), and a `_` arm here is FAIL-OPEN in a module whose every other + // default is fail-closed: it answers "confined" for any variant nobody thought + // about. That is not hypothetical. This function shipped as + // `Battlefield => …, Hand => false, _ => true`, and the `_` silently absorbed + // `Zone::Stack` — a live `ChangeZone` destination (`game::zones`) — so a node + // landing a card on the stack read as confined, contributing `OwnResourcesOnly` to + // a fold whose whole purpose is to decide whether an Accept is safe. (Whether any + // one node's verdict becomes an Accept depends on the rest of the fold; what is + // certain is that this arm was voting the wrong way.) MEASURED at this candidate's + // projection: 0 `Stack` destinations and 1 `Command` (Hellkite Courser, whose node + // lives in `triggers` and is therefore never folded), so the fix is latent today + // and is rated on what it does when reached, not on how often it is reached. + // The doc above listed "graveyard, library, exile, command" as the remainder and + // never mentioned Stack, which is exactly what a wildcard costs you: the arm list + // stops being a claim the compiler checks. Adding a `Zone` variant must break this + // build. + match destination { + Zone::Battlefield => matches!(tapped, EtbTapState::Tapped), + Zone::Hand | Zone::Stack | Zone::Command => false, + Zone::Library | Zone::Graveyard | Zone::Exile => true, + } +} + +/// Reach of one effect node. Three allowlisted shapes; everything else is +/// interference by default (see the module doc, §2). +fn effect_window_reach(effect: &Effect) -> WindowReach { + match effect { + // Absent `target_player` is an engine parser convention for "the + // actor's own library". It is a true description of engine behaviour + // and it is NOT the safety warrant for this arm, because it is NOT + // reliable: MEASURED over `data/card-data.json`, five abilities whose + // Oracle text explicitly names a FOREIGN library still carry a + // `SearchLibrary` node with `target_player: None` — Head Games[0], + // Jester's Mask[0], Haunting Echoes[0], Jace, Architect of Thought[2], + // and the SECOND search of Sadistic Sacrament[0] (whose first search + // does carry a `Typed` target_player). No aggregate count is claimed + // here: "how many abilities name a foreign library" has no + // scope-independent answer, and an unscoped census is worse than none. + // + // What makes the arm safe is the ABSORBING ability-level fold in + // `ability_window_reach`: a foreign-library ability carries some + // sibling effect or cost that folds to `MayInterfere` regardless of + // what `target_player` says. `Haunting Echoes[0]` is the in-suite + // witness for that fold — see + // `a_foreign_library_search_is_confined_alone_then_absorbed`. Bribery[0] + // and Praetor's Grasp[0] are also `MayInterfere`, but they witness the + // OTHER path: both carry `target_player: Typed`, so this arm catches + // them directly and the fold never has to. + // + // CR 400.1 is cited here only for why a bare `Zone::Library` + // reference is ambiguous in the first place. + // + // `split` IS read, and it is the second door into the battlefield. A + // cultivate-class search moves its own found cards + // (`SearchDestinationSplit { primary_destination, primary_enter_tapped, + // rest_destination }`) WITHOUT any `ChangeZone` sub-ability, so the tap + // gate on that arm cannot see it. Same rule (CR 110.5b), same + // fail-closed direction: a split may reach the battlefield only through + // its `primary_destination` and only when `primary_enter_tapped` proves + // `Tapped`. `rest_destination` carries NO tap state at all, so it can + // never be proven tapped and a battlefield `rest_destination` is reach + // by construction. + Effect::SearchLibrary { + source_zones: _, + filter: _, + count: _, + reveal: _, + target_player, + selection_constraint: _, + split, + } => { + // Destructured `..`-free for the same reason `Effect::ChangeZone` is: a + // new field on `SearchDestinationSplit` (a `rest_enter_tapped`, say) must + // be a COMPILE ERROR here rather than a silently ignored arrival modifier. + // Reading the split by `.field` is exactly how the `Hand` hole survived. + let split_entry_is_confined = split.as_ref().is_none_or( + |SearchDestinationSplit { + primary_destination, + primary_count: _, + primary_enter_tapped, + rest_destination, + }| { + // Both destinations go through the SAME authority as the + // `ChangeZone` arm. `rest_destination` carries no tap state at + // all, so it is asked with `Unspecified` and can never be proven + // tapped — a battlefield `rest_destination` is reach by + // construction, and a hand one is reach because hand always is. + landing_zone_is_confined(*primary_destination, *primary_enter_tapped) + && landing_zone_is_confined(*rest_destination, EtbTapState::Unspecified) + }, + ); + WindowReach::of(target_player.is_none() && split_entry_is_confined) + } + + // CR 400.1: a library is a per-player zone, so shuffling is + // confined exactly when the shuffled player is proven to be the actor. + Effect::Shuffle { target } => WindowReach::of(filter_is_actor_owned(target)), + + // CR 400.1 + CR 400.3 + CR 108.3: a zone change is + // confined when the moved object is proven actor-owned. The second + // disjunct is the anaphoric fetch/ramp shape — `Any` is the serde + // default, i.e. "no filter stated" ("put IT onto the battlefield") — + // which carries no player information of its own. Its player-qualified + // authority is the parent `SearchLibrary`, and the ability-level fold + // is what catches a foreign one: Bribery has the identical + // `Library -> Battlefield / Any` node and still folds to + // `MayInterfere` because its parent search carries + // `target_player: Typed{controller: Opponent}`. + // + // OWNERSHIP IS NOT ENOUGH WHEN THE DESTINATION IS THE BATTLEFIELD. + // CR 110.5b: "Permanents enter the battlefield untapped, unflipped, face + // up, and phased in unless a spell or ability says otherwise." A land + // that arrives UNTAPPED taps for mana in the very window the Shorten + // hands the responder — CR 601.2g runs mana abilities during the cast + // they fund, and CR 302.6's summoning-sickness bar is a CREATURE rule + // that never applies to a land. So an untapped fetch is the + // `Effect::Mana` case below with one extra step, and the reasoning that + // refuses to allowlist mana production refuses this too. MEASURED at + // this base on the live parser: `Terramorphic Expanse[0]` and + // `Evolving Wilds[0]` carry `enter_tapped: Tapped`, while + // `Crop Rotation[0]` and `Nature's Lore[0]` — same anaphoric + // `Library -> Battlefield / Any` node, same absent cost — carry + // `Unspecified` and were allowlisted here until this gate. + // + // Fail-closed on the tap axis: ONLY a provably `Tapped` entry stays + // confined, so `Unspecified` (the serde default, i.e. the AST said + // nothing) and `Untapped` both fall out. A conditionally-untapped entry + // (a shock land's pay-life choice, a land-count gate) cannot be proven + // tapped from the AST and is therefore reach, which is the direction + // this module's §2 default exists to take. + // + // TWO destinations are gated, for the same reason and by different + // evidence. The question this module asks is never "is the moved card + // the actor's own?" — the deleted `Effect::Mana` arm is what happens when + // ownership is mistaken for confinement. It is "can the seat DO something + // with the result inside the window the Shorten hands back?" + // + // * `Battlefield` — an untapped permanent taps for mana during the very + // cast it funds (CR 601.2g), so only a PROVABLY tapped arrival is + // confined. This applies to BOTH disjuncts, not just the anaphoric one: + // proven ownership does not stop an untapped land producing mana, so a + // graveyard-recursion shape reaching the battlefield untapped is the + // same defect through the other door. + // * `Hand` — a card put into hand is a CASTABLE card. When the spell that + // put it there finishes resolving, the active player receives priority + // (CR 117.3b) and priority then passes in turn order (CR 117.3d), so the + // responding seat gets it back still inside this window and can cast it. + // That is the `Effect::Mana` argument with one extra step, exactly as the + // untapped fetch was, and the AST carries nothing that could prove the + // returned card uncastable. MEASURED with the hand gate held off, 327 + // abilities are confined and 176 of them move a card to hand — 166 + // through this arm and 10 through the `SearchLibrary` split, which is + // why both doors now share `landing_zone_is_confined`. The witnesses are + // instant-speed: `Auroral Procession` returns ANY graveyard card, + // including an instant. Gating both doors leaves 151 confined abilities, + // 102 of which reach the battlefield, so the flagship tapped-fetch + // Accept is untouched: this closes a door, it does not vacate the + // feature. + // + // No other destination is gated, and that is a claim about what the seat + // can act with, not an oversight: a move to graveyard, library or exile + // puts the card somewhere the actor cannot cast or activate it from + // inside this window without some FURTHER permission, which would itself + // be an ability this fold already reads. + // + // PROVABLY tapped, not NOMINALLY tapped. Three more of this variant's + // fields decide what actually arrives, and each one is read here rather + // than ignored, because `enter_tapped` alone is not proof: + // + // * `enters_modified_if: Some(filter)` makes the tapped rider + // CONDITIONAL on the moved object's characteristics (CR 614.12: check + // the characteristics of the permanent as it would exist on the + // battlefield; CR 614.12a: any choice it requires is made first), so + // an object that fails the filter enters untapped while the field + // still reads `Tapped`. Unprovable from the AST ⇒ reach. + // * `enters_under` (CR 110.2a: "that object enters the battlefield under + // that player's control unless the effect states otherwise") routes the + // entering permanent to whatever controller it names. Anything but the + // actor puts a permanent on ANOTHER player's board, which is reach by + // definition — tapped or not. + // * `enters_attacking` (CR 508.4) puts the creature into combat against + // a defending player, planeswalker or battle. That is the clearest + // possible touch outside the actor's own resources, and a tapped + // attacker is still an attacker. + // + // A FOURTH battlefield conjunct, on a different axis than the three + // above: `origin == Some(Zone::Library)`. Those three ask what ARRIVES; + // this one asks what the arriving CARD is, and it is the only conjunct + // that can be discharged from this seam's inputs at all. + // + // A permanent entering the battlefield brings its whole printed text + // with it, and `carries_unreadable_rules_content` is this module's + // authority on which of that text the fold cannot classify. For the + // object performing the action, both entry points already run that gate + // (`object_window_reach`, `indexed_ability_window_reach`) — so a + // `target: SelfRef` recursion, where the card that arrives IS the source, + // is already covered by it. For every other target shape the arriving + // card is a DIFFERENT object, and the gate has never been run on it. + // + // `Library` narrows that hole but does NOT close it, and the earlier + // version of this comment claimed it did. It argued that CR 400.2 makes a + // library a hidden zone and the card an unchosen member of it, so "the + // seam is handed a `GameState` and a list of `GameAction`s, and the card + // is the subject of neither, so there is nothing to read". The premise is + // wrong as a fact about this engine. MEASURED on the enrolled 4p dump at + // the exact poll this module decides at: the actor's library is 91 cards + // and ALL 91 resolve in `state.objects` carrying full parsed rules + // content — 5 of them with triggers, and one of those is **Bojuka Bog** + // ("This land enters tapped. / When this land enters, exile target + // player's graveyard. / {T}: Add {B}.", Oracle text verified on + // Scryfall), whose ETB exiles a graveyard the actor does not own. + // CR 701.23a is the rule that makes it reachable: "To search for a card + // in a zone, look at all cards in that zone (EVEN IF IT'S A HIDDEN ZONE) + // and find a card that matches the given description." Hidden means the + // opponents cannot see it; it never meant the searcher cannot choose it. + // + // `board_observer_may_react` genuinely cannot catch this, and for a + // CORRECT reason rather than an omission: CR 113.6, via + // `trigger_definition_functions_in_zone`, answers "does not function" for + // a card sitting in a library. That predicate scans triggers that + // function NOW; the hazard is a trigger that will function once the + // search puts the card onto the battlefield. + // + // So the arriving card is classified where it can be: at the object + // level, by `library_arrivals_are_inert`, which runs + // `carries_unreadable_rules_content` over exactly the cards the actor + // could select. That gate is a board fact, not an AST fact, which is why + // it lives at the two entry points instead of in this arm. + // + // A `Graveyard`, `Hand` or `Exile` origin is a DIFFERENT situation and + // was previously admitted on the same warrant. There the arriving card + // IS in `state.objects`, its rules content is readable, and the fold + // simply never looked — so `OwnResourcesOnly` was asserted over text + // nobody had classified. Helping Hand ("Return target creature card with + // mana value 3 or less from your graveyard to the battlefield tapped.", + // Oracle text verified on Scryfall) read `OwnResourcesOnly` while a + // Fleshbag-Marauder-class creature sat in the actor's graveyard, and + // Accepting it hands back a window in which every player sacrifices. + // + // Gating on `Library` closes that without threading state into an + // AST-only function: it can only move a verdict toward `MayInterfere`, + // per §2 the direction that costs efficacy rather than games, and it is + // the same trade the `Hand` destination gate above already made. + // MEASURED on `data/card-data.json`, over every node in the document + // (not the narrower top-level `abilities` list the fold reads) whose + // shape is a tapped, non-attacking, unconditional, You-controlled + // battlefield entry: 493 total — Library 247, origin ABSENT 140, + // Graveyard 74, Hand 25, Exile 6, Battlefield 1. So the flagship + // library-fetch class keeps half the population outright, and the + // absent-origin bucket falls the same way as the named non-library ones, + // for the stronger version of the same reason: the seam cannot even name + // the zone the card comes from. + Effect::ChangeZone { + origin, + destination, + target, + owner_library: _, + enter_transformed: _, + enters_under, + enter_tapped, + enters_attacking, + up_to: _, + enter_with_counters: _, + conditional_enter_with_counters: _, + face_down_profile: _, + enters_modified_if, + } => { + let object_is_confined = filter_is_actor_owned(target) + || (matches!(target, TargetFilter::Any) + && *origin == Some(Zone::Library) + && *destination == Zone::Battlefield); + let entry_is_confined = landing_zone_is_confined(*destination, *enter_tapped) + && (*destination != Zone::Battlefield + || (*origin == Some(Zone::Library) + && enters_modified_if.is_none() + && !*enters_attacking + && matches!(enters_under, None | Some(ControllerRef::You)))); + WindowReach::of(object_is_confined && entry_is_confined) + } + + // NOT allowlisted — and `Effect::Mana` is the case that has to be NAMED + // here rather than left to the reader, because an earlier revision of + // this module allowlisted it. + // + // CR 106.1: "Mana is the primary resource in the game. Players spend + // mana to pay costs, usually when casting spells and activating + // abilities." CR 106.4 is the rule that earlier arm cited, and it quoted + // only the FIRST sentence — "that mana goes into a player's mana pool". + // The second sentence is the one that refutes the inference drawn from + // it: "From there, it can be used to pay costs immediately". CR 601.2g + // then runs mana abilities during the very cast they fund. + // + // So mana is FUNGIBLE REACH, and the earlier arm's inference — + // "producing it removes nothing from the board" — answers where the mana + // LANDS, which is not what this classifier asks. The question is whether + // the action widens what the seat can do inside the window + // `game::engine`'s `RespondToShortcut(Shorten)` arm hands back. + // + // Whether it does is NOT a function of the AST: it depends on the rest of + // the hand, the board, the colors produced, and on what other permanents + // trigger off mana being added (CR 603.2) or off the source leaving the + // battlefield (CR 701.21a). This function reads ONE ability and no other + // object, so `OwnResourcesOnly` here would be a proof it cannot + // discharge — exactly the unknowable this module's §2 default exists for. + // + // It is also the CONSISTENT answer: stage 1 re-admits a sacrifice-for-mana + // activation precisely BECAUSE the sacrifice is board-changing (see + // `types::mana::ManaSourcePenalty::is_meaningful_priority_activation`), so + // classifying that same action as confined here contradicted the stage + // that handed it over. + _ => WindowReach::MayInterfere, + } +} + +/// Reach of an activation cost. `Sacrifice` is the one cost shape that can name +/// somebody else's permanent, so it reads the same owner axis as the effects. +fn cost_window_reach(cost: &AbilityCost) -> WindowReach { + match cost { + // Tapping the source and spending mana from your own pool (CR 106.4) + // touch nothing outside the actor. + // + // The `Mana` arm here is the SPENDING side and it stays allowlisted; + // only PRODUCING mana is reach. Paying a mana cost consumes a resource + // the actor already holds and hands nothing new to the seat, whereas the + // effect-side `_` arm above explains why adding mana widens what the + // seat can do inside the window. Flipping this arm would classify every + // ability with a mana cost as interference and vacate the feature, so + // its absence from that change is deliberate. + AbilityCost::Tap => WindowReach::OwnResourcesOnly, + AbilityCost::Mana { cost: _ } => WindowReach::OwnResourcesOnly, + AbilityCost::Sacrifice(sacrifice) => { + WindowReach::of(filter_is_actor_owned(&sacrifice.target)) + } + AbilityCost::Composite { costs } => costs + .iter() + .fold(WindowReach::OwnResourcesOnly, |acc, sub| { + acc.or(cost_window_reach(sub)) + }), + _ => WindowReach::MayInterfere, + } +} + +/// Reach of a whole ability, folded over every field that can carry a +/// player-qualified authority. +/// +/// The `..`-free destructure is the structural guard (module doc §2): a new +/// `AbilityDefinition` field is a compile error here until it is classified as +/// walked, conservative-when-present, or reasoned read-free. +fn ability_window_reach(def: &AbilityDefinition) -> WindowReach { + let AbilityDefinition { + // ---- walked ---- + effect, + sub_ability, + else_ability, + mode_abilities, + cost, + player_scope, + // ---- conservative-when-present: each can name a player, an object, or + // a payment outside the actor, and none is walked ---- + activator_filter, + starting_with, + target_chooser, + unless_pay, + distribute, + cost_reduction, + condition, + duration, + multi_target, + target_constraints, + modal, + repeat_for, + announced_x, + repeat_until, + optional_for, + iteration_kind_binding, + // ---- read-free ---- + // Ability class (activated/triggered/static); no player reference. + kind: _, + // Display strings only. + description: _, + target_prompt: _, + // Activation gates: when, from which zone, with which mana, and under + // which keyword this ability may be activated. NOT player-free, and the + // earlier "no player reference" claim here was simply false: an + // `ActivationRestriction::RequiresCondition` carries a `ParsedCondition` + // that can name a player, and several do ("Activate only if an opponent + // lost life this turn"). Read-free anyway, which is the load-bearing + // part: a restriction narrows WHEN an ability may be activated, never + // WHAT it reaches. It contributes no effect, no target and no payment, + // so it can only make a window rarer — never wider. + activation_restrictions: _, + activation_mana_payment_restriction: _, + activation_zone: _, + ability_tag: _, + // Booleans and scalars that gate shape, never a player or an object. + optional_targeting: _, + optional: _, + target_choice_timing: _, + min_x_value: _, + cant_be_copied: _, + forward_result: _, + target_selection_mode: _, + sub_link: _, + sibling_condition: _, + } = def; + + let mut acc = effect_window_reach(effect); + if let Some(sub) = sub_ability { + acc = acc.or(ability_window_reach(sub)); + } + if let Some(other) = else_ability { + acc = acc.or(ability_window_reach(other)); + } + for mode in mode_abilities { + acc = acc.or(ability_window_reach(mode)); + } + if let Some(cost) = cost { + acc = acc.or(cost_window_reach(cost)); + } + // CR 400.1: an untargeted mass effect scoped to anyone but the actor + // reaches another player's resources by construction. + if let Some(scope) = player_scope { + acc = acc.or(WindowReach::of(matches!(scope, PlayerFilter::Controller))); + } + + let conservative_when_present = activator_filter.is_some() + || starting_with.is_some() + || target_chooser.is_some() + || unless_pay.is_some() + || distribute.is_some() + || cost_reduction.is_some() + || condition.is_some() + || duration.is_some() + || multi_target.is_some() + || !target_constraints.is_empty() + || modal.is_some() + || repeat_for.is_some() + || announced_x.is_some() + || repeat_until.is_some() + || optional_for.is_some() + || iteration_kind_binding.is_some(); + acc.or(WindowReach::of(!conservative_when_present)) +} + +/// Does this object carry rules content this module cannot classify? +/// +/// Single authority for that question — both entry points route through it, because +/// the previous shape (each entry point spelling out its own three-field check) is how +/// two answers to one question drift apart, and this module has already paid for that +/// once with `landing_zone_is_confined`. +/// +/// The list is deliberately EVERY rules-bearing field `game::printed_cards` writes and +/// `ability_window_reach` cannot read, not a curated subset of the ones known to be +/// dangerous. A curated subset is an allowlist maintained by whoever remembers to +/// update it; MEASURED, the previous three-field version claimed in its own doc comment +/// to cover what `printed_cards` splits a face into, and was wrong by eight fields. +/// `obj.keywords` alone carries printed **Cascade** (`game::triggers`: printed Cascade +/// lives in `obj.keywords` and never reaches `trigger_definitions`), so a Cascade spell +/// whose printed `abilities` all read confined would have been proven +/// `OwnResourcesOnly` while resolving it casts a free spell of arbitrary reach inside +/// the very window the seat just declined to keep. +/// +/// MEASURED cost of widening from three fields to all of them, on this candidate's own +/// projection: of the 53 cards that survived the three-field gate, 10 now flip (8 on +/// `keywords`, 1 `modal`, 1 `additional_cost`), leaving 43. The class this classifier +/// exists to protect is untouched — Terramorphic Expanse, Evolving Wilds and Rampant +/// Growth carry none of these fields. +/// +/// A presence gate is conservative by construction: it can only move a verdict toward +/// `MayInterfere`, which per §2 is the direction that costs efficacy rather than games. +fn carries_unreadable_rules_content(object: &GameObject) -> bool { + !object.trigger_definitions.is_empty() + || !object.replacement_definitions.is_empty() + || !object.static_definitions.is_empty() + // Keywords are rules text the fold never sees; Cascade is the sharp case. + || !object.keywords.is_empty() + // Casting-time modifiers: each one changes what resolving the object does or + // what the seat may do with it, and none is an `AbilityDefinition`. + || object.modal.is_some() + || object.additional_cost.is_some() + || object.strive_cost.is_some() + || object.cleave_variant.is_some() + || !object.casting_restrictions.is_empty() + || !object.casting_options.is_empty() + || !object.spellbook.is_empty() + // A back face is an entire second face of rules content that this fold never + // descends into. 0 carriers among today's confined set, so gating it is free. + || object.back_face.is_some() + // The four below were found by WRITING the staleness guard below, not by reading + // this list again — which is the argument for the guard existing. All four are + // rules-bearing, all four are written by `printed_cards`, and none was gated. + // MEASURED at this candidate's projection: each is 0 among the cards that survive + // the gate, and real document-wide (solve conditions 15, Class 38, Case 15, + // Attraction 35), so they are latent holes rather than live ones — the same shape + // as the `Zone::Stack` arm, and closed for the same reason. + || object.case_state.is_some() + || object.class_level.is_some() + || object.intensity != 0 + || !object.attraction_lights.is_empty() + // CR 709.5: a shared type line is two static abilities that remove the name, mana + // cost and RULES TEXT of each locked half; CR 709.5c names the unlocked + // designations, and CR 709.5e lets any player unlock a half as a special action at + // any priority. So which halves are unlocked decides what rules text the permanent + // has, `obj.abilities` is a flat list that cannot express that, and an opponent can + // change the answer inside the very window this fold is deciding about. + // Found by the same review round that caught the `card_type` bucket below, and it + // is the fourth subtype-derived field of four — the other three were already here, + // which is what made this one's absence a curated subset rather than a set. + || object.room_unlocks.is_some() + // THE PARSER SAID IT COULD NOT READ THIS CARD. Every other disjunct above gates a + // field whose CONTENTS this fold cannot classify; this one gates the parser's own + // report that some printed clause never became a field at all. That is the exact + // residual the module doc's §2 names as the one thing the fail-closed default + // cannot cover — "a clause the PARSER silently swallows never becomes a shape here, + // so the ability this module is handed looks strictly MORE confined than the + // printed card is" — and a diagnostic is the seat's only in-band evidence that it + // happened. Reading `abilities` and ignoring the note saying `abilities` is + // incomplete is proving confinement from the fraction of the card that parsed. + || !object.parse_warnings.is_empty() +} + +/// Is every card the actor could SELECT out of a hidden zone and put onto the +/// battlefield provably inert? +/// +/// The companion to [`carries_unreadable_rules_content`], on the other object. +/// That gate asks "can this fold read the rules content of the card TAKING the +/// action"; this one asks the same question about the card the action would +/// BRING IN. `effect_window_reach`'s `ChangeZone` arm admits a battlefield +/// arrival only from `Zone::Library`, and its own comment used to discharge the +/// arriving card's text with a hidden-zone argument. CR 701.23a refutes it: "To +/// search for a card in a zone, look at all cards in that zone (even if it's a +/// hidden zone) and find a card that matches the given description." A hidden +/// zone hides the cards from the OPPONENTS; the searcher picks whichever member +/// they like, so the selectable set is readable and this fold has to read it. +/// +/// # Why this is a board fact and not an AST fact +/// +/// It lives here, called from the two entry points beside +/// `carries_unreadable_rules_content`, rather than inside `effect_window_reach`. +/// The reason is not convenience: the answer is not a function of the ability at +/// all. Two players holding the identical printed card get opposite verdicts, +/// because the verdict is about what is in the deck behind it. Threading a +/// `GameState` into an AST classifier would present a board-dependent answer as +/// an AST-dependent one. +/// +/// # The selection set, and the direction each approximation errs +/// +/// * **Which cards are in the pool** — every object the actor OWNS in a pooled +/// zone. `game::effects::search_library` reads `owner.library` and matches with +/// `matches_target_filter_in_owner_zone`, so owner (CR 108.3), not controller, +/// is the axis, and this scans `state.objects` for the same set. The pool is +/// `Zone::Library` plus whatever `Effect::SearchLibrary::source_zones` names — +/// the God-Pharaoh's-Gift class searches graveyard and hand as well, and a card +/// fetched from there reaches the same battlefield. +/// * **Which of them are selectable** — the union of every `SearchLibrary` +/// filter the object carries, plus every library→battlefield `ChangeZone` +/// target that is not `TargetFilter::Any`. A UNION over the object's searches +/// rather than a binding of each search to the node that consumes its result: +/// binding would require threading a "search filter currently in scope" through +/// the whole recursive fold, and a union is a SUPERSET of any single search's +/// result, so it errs toward `MayInterfere`. `TargetFilter::Any` is excluded +/// because it is the serde default on the ANAPHORIC node ("put IT onto the +/// battlefield") — it states no restriction of its own and its authority is the +/// parent search, which is already in the union. An object with a +/// library→battlefield arrival and NO search filter at all leaves the union +/// empty, and an empty union means the WHOLE pool is selectable. +/// * **`selection_constraint` and `game::effects::search_library`'s +/// `library_search_top_limit` are ignored.** Both NARROW what may be chosen, so +/// ignoring them over-counts the pool, which is the conservative direction. +/// +/// # The one place this fails closed rather than answering +/// +/// A non-empty union that matches NOTHING returns `false`. That looks like +/// throwing away the free case — a deck with no basic land for its own fetch — +/// and it is deliberate, because this seam cannot tell that case apart from a +/// filter it is unable to evaluate. `FilterContext::from_source_with_controller` +/// carries the acting object but no ability instance, so a filter that reads the +/// ability's own targets matches nothing here for a reason that has nothing to do +/// with the deck. MEASURED over `data/card-data.json`: 24 cards carry such a +/// `SearchLibrary` filter (23 `SameNameAsParentTarget` — Infernal Tutor, Surgical +/// Extraction, Bifurcate, … — and 2 `CanEnchant { target: ParentTarget }`: +/// Auratouched Mage, Sovereigns of Lost Alara, with Canoptek Wraith carrying +/// both properties). Answering "empty match set, therefore inert" for those would +/// be a false `Accept` produced by an unevaluated filter, so the empty result is +/// treated as the unanswerable it is. +fn library_arrivals_are_inert( + state: &GameState, + object: &GameObject, + abilities: &[AbilityDefinition], +) -> bool { + let mut reaches_battlefield = false; + let mut pool_zones = vec![Zone::Library]; + let mut selection: Vec = Vec::new(); + + // `types::ability_visit` is the engine's single complete `AbilityDefinition` + // walk, and its `Effect` match is wildcard-free — a future variant carrying a + // nested effect is a compile error there rather than a silent miss here. A + // hand-rolled second walk beside `ability_window_reach`'s is exactly the + // "two answers to one question" shape this module has already paid for twice + // (`landing_zone_is_confined`, `carries_unreadable_rules_content`). + for def in abilities { + // The visitor never breaks — the union needs every node — so the + // `ControlFlow` it returns is always `Continue`. + let _ = crate::types::ability_visit::visit_ability_def(def, &mut |effect: &Effect| { + match effect { + // `..`-FREE, exactly like `effect_window_reach`'s two arms and for + // the same reason: a new field that changes what a search may FIND + // or where it may PUT it must be a compile error here, not a + // silently unread modifier. `count`/`reveal`/`selection_constraint` + // are bound and unused deliberately — the first two do not change + // WHICH cards match, and the third only narrows (see the doc). + Effect::SearchLibrary { + source_zones, + filter, + count: _, + reveal: _, + target_player: _, + selection_constraint: _, + split, + } => { + selection.push(filter.clone()); + for zone in source_zones { + if !pool_zones.contains(zone) { + pool_zones.push(*zone); + } + } + // Both split destinations, for the same reason the reach fold + // reads both: `rest_destination` lands cards too. + if split.as_ref().is_some_and(|split| { + split.primary_destination == Zone::Battlefield + || split.rest_destination == Zone::Battlefield + }) { + reaches_battlefield = true; + } + } + // The arrival-shape fields are the REACH fold's business; this arm + // only needs to know that a library card can land on the + // battlefield and which filter picks it. + Effect::ChangeZone { + origin: Some(Zone::Library), + destination: Zone::Battlefield, + target, + owner_library: _, + enter_transformed: _, + enters_under: _, + enter_tapped: _, + enters_attacking: _, + up_to: _, + enter_with_counters: _, + conditional_enter_with_counters: _, + face_down_profile: _, + enters_modified_if: _, + } => { + reaches_battlefield = true; + if !matches!(target, TargetFilter::Any) { + selection.push(target.clone()); + } + } + // A node this walk does not recognize contributes nothing to the + // pool, and that is safe only because it is not the last word: + // `effect_window_reach`'s own `_` arm answers `MayInterfere` for + // every unrecognized shape, so a future effect that could put a + // library card onto the battlefield makes the WHOLE verdict + // interference before this pool is ever consulted. + _ => {} + } + std::ops::ControlFlow::Continue(()) + }); + } + + if !reaches_battlefield { + return true; + } + + let actor = object.controller; + let pool: Vec<&GameObject> = state + .objects + .values() + .filter(|candidate| candidate.owner == actor && pool_zones.contains(&candidate.zone)) + .collect(); + // Nothing can be selected out of an empty pool, so nothing arrives. This is + // the one empty set that IS an answer, and it is the opposite of the empty + // match set below: here the filter never ran. + if pool.is_empty() { + return true; + } + + let context = crate::game::filter::FilterContext::from_source_with_controller(object.id, actor); + let selectable: Vec<&&GameObject> = if selection.is_empty() { + pool.iter().collect() + } else { + pool.iter() + .filter(|candidate| { + selection.iter().any(|filter| { + crate::game::filter::matches_target_filter_in_owner_zone( + state, + candidate.id, + filter, + &context, + ) + }) + }) + .collect() + }; + if selectable.is_empty() { + return false; + } + selectable + .iter() + .all(|candidate| !carries_unreadable_rules_content(candidate)) +} + +/// Fold every ability an object carries. A missing object, or one carrying no +/// abilities at all, is `MayInterfere` — the fail-closed direction, and the one +/// that keeps an empty fold from being proven confined by its identity element. +/// +/// `abilities` is NOT the whole of an object's rules content. `game::printed_cards` +/// spreads a card face across many more rules-bearing fields than this module can +/// classify — `carries_unreadable_rules_content` below is the enumerated authority on +/// which, and the one place to look; a count written here would only drift out of step +/// with it. This module classifies exactly one of them, because `ability_window_reach` +/// destructures an `AbilityDefinition` and the rest carry different types entirely. +/// Folding only `abilities` and returning `OwnResourcesOnly` would therefore prove a +/// card confined from whichever fraction of it happens to be ability-shaped. +/// +/// That is not hypothetical. MEASURED on `data/card-data.json`: `Stunning Reversal` +/// projects `abilities` = one `ChangeZone { destination: Exile, target: SelfRef }` +/// — actor-owned, non-battlefield, hence confined on every conjunct this module +/// reads — while its entire function lives in `replacements[0]`, a `GameLoss` +/// replacement ("The next time you would lose the game this turn, instead draw +/// seven cards and your life total becomes 1"). A seat holding it would have read +/// `OwnResourcesOnly` and Accepted the very shortcut the card exists to survive. +/// Per this module's §2 that is the losing direction, and it is the same defect +/// class as the deleted `Effect::Mana` arm: reasoning from the part of the card the +/// classifier can see instead of from what the seat can do. +/// +/// So a non-empty unreadable field is `MayInterfere` — not because its contents are +/// known to interfere, but because this module cannot prove they do not, which is the +/// only warrant `OwnResourcesOnly` ever has. Reading them properly (classifying +/// `TriggerDefinition` / `ReplacementDefinition` / `StaticDefinition` the way +/// `ability_window_reach` classifies an `AbilityDefinition`) is the named upgrade +/// path; until then the gate is presence, and presence is conservative. +fn object_window_reach(state: &GameState, object_id: ObjectId) -> WindowReach { + let Some(object) = state.objects.get(&object_id) else { + return WindowReach::MayInterfere; + }; + if object.abilities.is_empty() { + return WindowReach::MayInterfere; + } + if carries_unreadable_rules_content(object) { + return WindowReach::MayInterfere; + } + if !library_arrivals_are_inert(state, object, &object.abilities) { + return WindowReach::MayInterfere; + } + object + .abilities + .iter() + .fold(WindowReach::OwnResourcesOnly, |acc, ability| { + acc.or(ability_window_reach(ability)) + }) +} + +/// Reach of one indexed activated ability. An unresolvable object or an +/// out-of-range index is `MayInterfere`. +/// +/// Carries the same unreadable-content gate as `object_window_reach`, and for a +/// reason specific to this path rather than by symmetry: activating an ability is +/// itself a game event, so a trigger on the SAME object can fire off the activation +/// (CR 603.2) or off the cost being paid, and a static ability can change what the +/// activation is allowed to do. This module cannot classify any of that, so an object +/// carrying it is not provably confined no matter how confined the indexed ability +/// reads on its own. +fn indexed_ability_window_reach( + state: &GameState, + source_id: ObjectId, + ability_index: usize, +) -> WindowReach { + let Some(object) = state.objects.get(&source_id) else { + return WindowReach::MayInterfere; + }; + if carries_unreadable_rules_content(object) { + return WindowReach::MayInterfere; + } + let Some(ability) = object.abilities.get(ability_index) else { + return WindowReach::MayInterfere; + }; + // Scoped to the ONE ability being activated, unlike the object-level entry + // point: a sibling ability the seat is not activating fetches nothing. + if !library_arrivals_are_inert(state, object, std::slice::from_ref(ability)) { + return WindowReach::MayInterfere; + } + ability_window_reach(ability) +} + +/// CR 603.2: can a CONFINED action's event stream ever match this trigger's +/// trigger event? Returns `true` iff it PROVABLY cannot. +/// +/// # This predicate is NOT a fact about the trigger +/// +/// It is a fact about the trigger RELATIVE TO the confined-action allowlists this +/// module defines, and it lives here for exactly that reason. Relief is not +/// intrinsic to a `TriggerMode`; every arm below is discharged by reading what +/// [`cost_window_reach`] and [`effect_window_reach`] admit, so the premise has to +/// live where those allowlists live. Moving it to `game::triggers` would present +/// it as a CR-grounded property of a trigger and invite reuse by a caller with a +/// different action set, for which every arm below is unproven. +/// +/// ## The soundness contract, named in full +/// +/// Relief holds only while BOTH allowlists stay exactly as they are today: +/// +/// * [`cost_window_reach`] admits `AbilityCost::Tap`, `AbilityCost::Mana`, +/// an actor-owned `AbilityCost::Sacrifice`, and `AbilityCost::Composite` of +/// those. Everything else is `MayInterfere`. +/// * [`effect_window_reach`] admits `Effect::SearchLibrary`, `Effect::Shuffle` +/// and a gated `Effect::ChangeZone`. Everything else is `MayInterfere`. +/// +/// **WIDEN EITHER ALLOWLIST AND YOU MUST RE-AUDIT EVERY ARM BELOW.** The witness +/// that makes that concrete, and the reason the Life family's relief is +/// CONTINGENT rather than structural: `AbilityCost::PayLife` exists +/// (`types::ability`) and a Polluted-Delta-class fetchland pays it — "{T}, Pay 1 +/// life, Sacrifice this land: Search your library …" — squarely inside this +/// predicate's own event boundary, which counts cost payment. Such a card is +/// `MayInterfere` today ONLY because `PayLife` falls to `cost_window_reach`'s +/// fail-closed `_` arm. Allowlist `PayLife` and a Bloodthirsty Conqueror +/// ("Whenever an opponent loses life, you gain that much life") fires through a +/// mode this function has RELIEVED — which is the precise defect the board scan +/// exists to prevent. +/// +/// ## Boundary +/// +/// The classified action set is `GameAction::CastSpell` and +/// `GameAction::ActivateAbility` only; every other `GameAction` already returns +/// `true` in [`any_action_may_interfere`] without consulting this. Events are +/// counted from announcement through full resolution, INCLUDING cost payment +/// (CR 601.2b–i for a spell, CR 602.2b for an activated ability, which routes to +/// the same process). `GameAction::PassPriority` is excluded by construction (it +/// returns `false` before the fold), so phase advance is out of domain and a +/// beginning-of-phase trigger cannot be reached through this predicate's callers. +/// +/// ## Fail-closed +/// +/// Exhaustive dispatch with a `_ => false` arm, mirroring +/// [`crate::game::triggers::trigger_event_unreachable_in_phase`]: a mode this +/// predicate cannot classify KEEPS its veto. A future mode is swallowed into +/// conservatism, never into relief. +fn trigger_event_unreachable_by_confined_action( + def: &crate::types::ability::TriggerDefinition, +) -> bool { + use crate::types::triggers::TriggerMode; + + match def.mode { + // LIFE — CR 119.3: "If an effect causes a player to gain life or lose + // life, that player's life total is adjusted accordingly." No allowlisted + // effect adjusts a life total, and no allowlisted COST pays life. See the + // `AbilityCost::PayLife` witness above: this family is the contingent one. + // `PayEcho`/`PayCumulativeUpkeep` additionally route to `match_phase` + // (`game::trigger_matchers`), i.e. they key on `GameEvent::PhaseChanged`, + // which the turn-structure argument below covers independently. + TriggerMode::LifeGained + | TriggerMode::LifeLost + | TriggerMode::LifeLostAll + | TriggerMode::LifeChanged + | TriggerMode::PayLife + | TriggerMode::PayCumulativeUpkeep + | TriggerMode::PayEcho => true, + + // DAMAGE — CR 120.1: "Objects can deal damage to battles, creatures, + // planeswalkers, and players." Searching, shuffling and moving a card + // deal no damage, and no allowlisted cost does either. `Fight`/`FightOnce` + // are in this family by CR 701.14a (each fighting creature "deals damage equal to its + // power to the other creature") and are + // additionally keyed on `EffectKind::Fight` in `game::trigger_matchers`, + // which no allowlisted effect produces. + TriggerMode::DamageDone + | TriggerMode::DamageDoneOnce + | TriggerMode::DamageAll + | TriggerMode::DamageDealtOnce + | TriggerMode::DamageDoneOnceByController + | TriggerMode::DamageReceived + | TriggerMode::DamagePreventedOnce + | TriggerMode::ExcessDamage + | TriggerMode::ExcessDamageAll + | TriggerMode::Fight + | TriggerMode::FightOnce => true, + + // COMBAT — CR 508.1 and CR 509.1 both open "this turn-based action doesn't + // use the stack": attackers and blockers are declared by the turn-based + // actions of CR 506.1's declare-attackers and declare-blockers steps, not + // by any spell or ability. MEASURED in `game::trigger_matchers`: every mode + // below keys on `GameEvent::AttackersDeclared` or + // `GameEvent::BlockersDeclared`, neither of which a cast or an activation + // emits. + // + // The one allowlisted effect that could otherwise put a creature into + // combat is `Effect::ChangeZone`'s `enters_attacking` rider (CR 508.4), + // and `effect_window_reach` already requires `!enters_attacking` for a + // battlefield arrival to be confined — so this family depends on that + // conjunct and not merely on the turn-based-action argument. + // + // `TriggerMode::EntersOrAttacks` is DELIBERATELY ABSENT — see the excluded + // list below. + TriggerMode::Attacks + | TriggerMode::AttackersDeclared + | TriggerMode::AttackersDeclaredOneTarget + | TriggerMode::YouAttack + | TriggerMode::YouAttackUnblocked + | TriggerMode::AttackerBlocked + | TriggerMode::AttackerBlockedOnce + | TriggerMode::AttackerBlockedByCreature + | TriggerMode::AttackerUnblocked + | TriggerMode::AttackerUnblockedOnce + | TriggerMode::Blocks + | TriggerMode::BlockersDeclared + | TriggerMode::BecomesBlocked + | TriggerMode::AttacksOrBlocks + | TriggerMode::BlocksOrBecomesBlocked => true, + + // TURN STRUCTURE — CR 500.1: "A turn consists of five phases, in this + // order …". A phase or turn begins by turn-based action, never because a + // player cast a spell or activated an ability. `PassPriority` is the + // action that CAN advance a phase and it never reaches this predicate + // (see Boundary above). + TriggerMode::Phase | TriggerMode::TurnBegin | TriggerMode::NewGame => true, + + // CARD FLOW the allowlist cannot cause. + // + // * `Drawn` — CR 121.1: "A player draws a card by putting the top card of + // their library into their hand." Doubly unreachable: `match_drawn` keys + // on the dedicated `GameEvent::CardDrawn`, AND no confined action can put + // a card into a hand at all, because `landing_zone_is_confined` answers + // `false` for `Zone::Hand` on both doors out of a library. + // * `Discarded`/`DiscardedAll` — CR 701.9a: "To discard a card, move it + // from its owner's hand to that player's graveyard." `match_discarded` + // keys on the dedicated `GameEvent::Discarded`, which + // `game::zone_pipeline` emits only for a recorded discard, not for a + // generic hand-to-graveyard move. + // * `TokenCreated`/`TokenCreatedOnce` — CR 111.1: tokens are put onto the + // battlefield by effects that say so. `Effect::Token` is not allowlisted, + // and `match_token_created` keys on the dedicated + // `GameEvent::TokenCreated`. + // + // `Milled`/`MilledOnce`/`MilledAll` are DELIBERATELY ABSENT — see below. + TriggerMode::Drawn + | TriggerMode::Discarded + | TriggerMode::DiscardedAll + | TriggerMode::TokenCreated + | TriggerMode::TokenCreatedOnce => true, + + // ── NOT RELIEVED. Each keeps its veto, with the source that reaches it. ── + // + // Directly produced by casting or activating: + // `SpellCast` CR 601.2i — announcing the spell IS the event + // `AbilityActivated` CR 602.2b — likewise for an activation + // `Taps` / `TapsForMana` / `ManaAdded` — `AbilityCost::Tap` and the mana + // the actor spends + // `PlayerPerformedAction` — `game::search_library` emits it + // `SearchedLibrary` — `Effect::SearchLibrary` itself + // `Shuffled` — `Effect::Shuffle` itself + // Produced by the allowlisted `ChangeZone` / `Sacrifice`: + // `Exiled`, `Sacrificed`, `Destroyed`, `ChangesZone`, `ChangesZoneAll`, + // `LeavesBattlefield`, `Revealed`, `BecomesTarget`, `CounterAdded` + // (`game::zone_pipeline` puts counters on an entering permanent), + // `ChangesController`, `Attached`, `Unattach`. + // Unknowable here: + // `StateCondition` — CR 603.2 state triggers watch a game STATE, not an + // event, so no event-stream argument can relieve one; + // `Unknown(_)` — an unclassified Forge mode string, by definition. + // + // THREE MEASURED EXCLUSIONS from families that otherwise look relieved. + // Each was found by reading the matcher rather than the mode name, and + // each reads a GENERIC `GameEvent::ZoneChanged` that an allowlisted + // `Effect::ChangeZone` really does emit: + // + // * `Milled` / `MilledOnce` / `MilledAll` — CR 701.17a defines milling as + // library-to-graveyard, and `game::trigger_matchers::match_milled` keys + // on `ZoneChanged { from: Some(Library), to: Graveyard }` rather than on + // any mill-specific event. `Effect::ChangeZone { origin: Some(Library), + // destination: Graveyard, target: }` is confined here + // (graveyard is a confined landing zone, the target proves control) and + // emits exactly that event, so relieving the family would be unsound. + // * `EntersOrAttacks` — `match_enters_or_attacks` reads `ZoneChanged`, so + // the flagship's own tapped fetch fires it. It is a combat mode by name + // only. + // * `EntersOrHauntedCreatureDies` — dispatches to `match_changes_zone` + // outright (`game::trigger_matchers`). + // + // Fail-closed: every mode this predicate cannot classify keeps its veto. + _ => false, + } +} + +/// CR 603.2: could ANY trigger anywhere on the board fire off a confined action +/// the actor takes in this window? +/// +/// [`any_action_may_interfere`]'s per-action fold reads the acting object's own +/// AST and nothing else, so it cannot see an OBSERVER — a permanent belonging to +/// somebody else whose triggered ability watches for the very event the confined +/// action produces. The maintainer witness is Hedron Crab: "Landfall — Whenever a +/// land you control enters, target player mills three cards." A seat that +/// confidently cracks its own fetchland in front of an opponent's Crab has just +/// milled a player, and no amount of reading the fetchland proves otherwise. +/// +/// Three INDEPENDENT SUFFICIENT reliefs, applied as a DISJUNCTION — a trigger is +/// inert iff at least one holds. They are ordered cheapest-first, and the zone +/// gate MUST stay first: MEASURED on the largest board in the enrolled fixture +/// set it removes 103 of 145 candidate definitions before any other work runs. +/// +/// 1. **Zone of function** — CR 113.6 / CR 603.6: +/// [`crate::game::triggers::trigger_definition_functions_in_zone`] is the +/// single authority. Reading `def.trigger_zones.contains(&zone)` directly is +/// FAIL-OPEN and must never be done here: an EMPTY `trigger_zones` means +/// battlefield-only, so a direct `contains` answers "does not function" for +/// every ordinary battlefield trigger on the board. +/// 2. **Mode** — [`trigger_event_unreachable_by_confined_action`]. +/// 3. **The self-reference carve-out** — a trigger that watches only ITS OWN +/// object (CR 109.5: "you"/"your" refer to the object's controller) and whose +/// object the actor does not own cannot be reached by an action confined to +/// the actor's own resources. All three conjuncts are load-bearing: +/// * `zone_change_clauses.is_empty()`, because a non-empty clause list makes +/// the matcher IGNORE the scalar `valid_card` entirely +/// (`types::ability::TriggerDefinition`), so reading `valid_card` under a +/// disjunctive trigger reads a field the engine does not consult; +/// * `valid_card == Some(TargetFilter::SelfRef)` EXACTLY. This deliberately +/// does NOT reuse [`filter_is_actor_owned`], which answers `true` for +/// `Typed { controller: Some(You) }` — precisely Hedron Crab's `valid_card`, +/// so reusing it would carve out the very trigger this scan exists to catch. +/// `valid_card: None` means UNRESTRICTED rather than self +/// (`game::trigger_index`), and `== Some(SelfRef)` rejects it without a +/// dedicated arm; +/// * `obj.owner != actor`, because the actor's OWN self-referential observer +/// is reachable by the actor's own action. +/// +/// NOT a `TriggerDefinition` destructure, unlike [`ability_window_reach`]'s +/// `..`-free discipline, and the asymmetry is deliberate. The 30 fields not read +/// here are inert, effect-side, or NARROWING — a narrowing field can only make a +/// trigger fire less often, so ignoring it is conservative. The two that can +/// broaden a trigger within its class, `origin_zones` and `zone_change_clauses`, +/// belong exclusively to zone-change modes, and no zone-change mode is ever +/// relieved by arm 2 (`ChangesZone`/`ChangesZoneAll` fall to `_ => false`); +/// `zone_change_clauses` is additionally read directly by arm 3. +/// +/// CR 603.10a: this reads live `state.objects` rather than any look-back +/// snapshot, which is correct here because the question is prospective — what +/// could fire if the actor acts — not what did fire. +fn board_observer_may_react(state: &GameState, actor: PlayerId) -> bool { + state.objects.values().any(|obj| { + crate::game::functioning_abilities::active_trigger_definitions(state, obj).any(|active| { + let def = active.definition; + let inert = !crate::game::triggers::trigger_definition_functions_in_zone(def, obj.zone) + || trigger_event_unreachable_by_confined_action(def) + || (def.zone_change_clauses.is_empty() + && def.valid_card == Some(TargetFilter::SelfRef) + && obj.owner != actor); + !inert + }) + }) +} + +/// Stage 2's predicate: does the polled player hold any action that could reach +/// past their own resources? +/// +/// Reads the actions stage 1 counted — no clone, no `find_legal_targets`, no +/// simulation, no graph build. The caller MUST hand it +/// `ai_support::stage_two_action_set`, not the bare flat list: stage 1 counts +/// sacrifice-for-mana activations that only ever live in +/// `legal_actions_by_object`, and an action never handed to this fold reaches no +/// arm at all, so the fail-closed `_ => MayInterfere` default cannot protect it. +/// Missing an action here is an Accept by OMISSION — the direction that loses +/// games. +/// +/// # What this seam's inputs can and cannot describe +/// +/// A fetched permanent that itself enables interference is modelled only through +/// its ARRIVAL STATE, never by walking its abilities. `effect_window_reach` keeps +/// a battlefield entry confined only when the AST proves it arrives tapped +/// (CR 110.5b), so the mana axis — the fetched land that taps for mana inside +/// this same window — is closed: an untapped entry reads `MayInterfere`. +/// +/// What that leaves outside the seam's description, stated as a property of the +/// inputs rather than as a blanket: a permanent that arrives TAPPED and still +/// enables interference through an ability whose cost is not tapping (a +/// sacrifice-for-mana, say). +/// +/// That residual is scoped to ONE origin, and the scoping is enforced in code +/// rather than promised here. `effect_window_reach`'s `ChangeZone` arm admits a +/// battlefield entry only when `origin == Some(Zone::Library)`, and what the +/// arriving card can DO is then classified by [`library_arrivals_are_inert`], +/// which both entry points run. +/// +/// The residual is what THAT gate leaves, and it is narrower than the whole +/// fetch class: `carries_unreadable_rules_content` is a presence check, so a +/// selectable card with no trigger, replacement, static or keyword passes it +/// while still carrying an ACTIVATED ability whose cost is not tapping. Such a +/// card arrives tapped (the arm requires it) and can still be sacrificed for +/// value inside this window. +/// +/// This paragraph previously claimed the residual was total and unreachable, on +/// the argument that CR 400.2 makes the library hidden so "neither input +/// contains it and neither the per-action fold nor the board scan has an object +/// to read". That was measurably false — the enrolled 4p dump resolves all 91 of +/// the actor's library cards in `state.objects` — and CR 701.23a is the rule it +/// mistook: a search looks at all cards in the zone "even if it's a hidden +/// zone". `board_observer_may_react` really does miss them, but for the CR 113.6 +/// reason (`trigger_definition_functions_in_zone` answers "does not function" for +/// a library card), which is why the selection gate is separate from it. +/// +/// A `Graveyard`, `Hand` or `Exile` origin is a different situation again — the +/// arriving card is named by the effect's own target rather than chosen out of a +/// pool — and it is not in this residual because the arm no longer admits it. See +/// that arm's fourth battlefield conjunct for the measurement and the efficacy +/// trade. +/// +/// Its cost, on both axes, for the reader deciding whether that matters: +/// - across windows: a bounded miss — a seat's fetched answer goes unused for +/// THIS shortcut; +/// - within the window: NOT bounded by "one shortcut". On an `UntilLethal` +/// offer the accepted sequence runs to lethal, so the in-window cost of a +/// missed out is elimination. +/// +/// The miss requires the out to be reachable ONLY through the fetched permanent +/// AND that permanent to arrive tapped; a directly-castable answer is already +/// caught by the top-level fold, and CR 732.1b makes shortcut use permissive +/// ("can be used") rather than guaranteed. +/// +/// # Two board-level conjuncts, evaluated ONCE +/// +/// Both run BEFORE the action fold, because each is a fact about the BOARD and +/// not about any action: re-deriving them per action would multiply a whole-board +/// scan by the action count and produce the identical answer every time. +/// +/// * [`actor_owns_everything_they_control`] — the fold's filter proofs are +/// CONTROL proofs (CR 109.5), and CR 701.21a routes a sacrificed permanent to +/// its OWNER's graveyard. +/// * [`board_observer_may_react`] — a confined action is still OBSERVED +/// (CR 603.2); an opponent's Hedron Crab turns the actor's own fetch into a +/// mill targeting somebody else. +pub(crate) fn any_action_may_interfere( + state: &GameState, + actor: PlayerId, + actions: &[GameAction], +) -> bool { + if !actor_owns_everything_they_control(state, actor) { + return true; + } + if board_observer_may_react(state, actor) { + return true; + } + actions.iter().any(|action| match action { + GameAction::PassPriority => false, + GameAction::CastSpell { object_id, .. } => { + object_window_reach(state, *object_id).may_interfere() + } + GameAction::ActivateAbility { + source_id, + ability_index, + } => indexed_ability_window_reach(state, *source_id, *ability_index).may_interfere(), + // Every other priority action (play a land, declare attackers, special + // actions, ...) is unclassified and therefore interference. + _ => true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::zones::create_object; + use crate::parser::oracle::parse_oracle_text; + use crate::types::card::{CardFace, CardMetadata}; + use crate::types::keywords::Keyword; + + /// A card as the pipeline sees it: its REAL printed name plus its verbatim + /// Oracle text. The name is load-bearing, not decoration — `~` + /// normalization resolves a card's self-reference by matching its own name, + /// so parsing "Lightning Bolt deals 3 damage to any target." under a + /// placeholder name yields `Effect::Unimplemented` and the row would then + /// be measuring the fail-closed arm instead of the effect's own semantics + /// (MEASURED: that is exactly what an earlier revision of this table did). + struct Card { + name: &'static str, + oracle: &'static str, + } + + /// Parse verbatim Oracle text the way the card pipeline does and return the + /// ability at `index`. + fn ability(card: &Card, index: usize) -> AbilityDefinition { + let parsed = parse_oracle_text(card.oracle, card.name, &[], &[], &[]); + parsed + .abilities + .get(index) + .unwrap_or_else(|| { + panic!( + "{}[{index}] must exist; parsed {} abilities", + card.name, + parsed.abilities.len() + ) + }) + .clone() + } + + fn reach(card: &Card, index: usize) -> WindowReach { + ability_window_reach(&ability(card, index)) + } + + /// True when this ability's head is the unparsed gap node, i.e. its verdict + /// rides the fail-closed `_` arm rather than the effect's own semantics. + fn head_is_unparsed(card: &Card, index: usize) -> bool { + matches!( + ability(card, index).effect.as_ref(), + Effect::Unimplemented { .. } + ) + } + + const TERRAMORPHIC: Card = Card { + name: "Terramorphic Expanse", + oracle: "{T}, Sacrifice this land: Search your library for a basic land \ + card, put it onto the battlefield tapped, then shuffle.", + }; + /// Helping Hand, verbatim (Oracle text verified on Scryfall, + /// `api.scryfall.com/cards/named?exact=Helping+Hand`). The witness for the + /// origin gate: a tapped, You-controlled, unconditional, non-attacking + /// battlefield entry — confined on every axis the arm reads EXCEPT that the + /// arriving card comes from a graveyard, where it is a real object whose + /// rules content the fold has never classified. + const HELPING_HAND: Card = Card { + name: "Helping Hand", + oracle: "Return target creature card with mana value 3 or less from your \ + graveyard to the battlefield tapped.", + }; + const EVOLVING_WILDS: Card = Card { + name: "Evolving Wilds", + oracle: "{T}, Sacrifice this land: Search your library for a basic land \ + card, put it onto the battlefield tapped, then shuffle.", + }; + const RAMPANT_GROWTH: Card = Card { + name: "Rampant Growth", + oracle: "Search your library for a basic land card, put that card onto the battlefield \ + tapped, then shuffle.", + }; + const DEATHRITE_SHAMAN: Card = Card { + name: "Deathrite Shaman", + oracle: "{T}: Exile target land card from a graveyard. Add one mana of \ + any color.\n{B}, {T}: Exile target instant or sorcery card \ + from a graveyard. Each opponent loses 2 life.\n{G}, {T}: \ + Exile target creature card from a graveyard. You gain 2 life.", + }; + const SOUL_GUIDE_LANTERN: Card = Card { + name: "Soul-Guide Lantern", + oracle: "When this artifact enters, exile target card from a \ + graveyard.\n{T}, Sacrifice this artifact: Exile each \ + opponent's graveyard.\n{1}, {T}, Sacrifice this artifact: \ + Draw a card.", + }; + const RELIC_OF_PROGENITUS: Card = Card { + name: "Relic of Progenitus", + oracle: "{T}: Target player exiles a card from their \ + graveyard.\n{1}, Exile this artifact: Exile all graveyards. \ + Draw a card.", + }; + const SCAVENGING_OOZE: Card = Card { + name: "Scavenging Ooze", + oracle: "{G}: Exile target card from a graveyard. If it was a creature \ + card, put a +1/+1 counter on this creature and you gain 1 life.", + }; + const BRIBERY: Card = Card { + name: "Bribery", + oracle: "Search target opponent's library for a creature card and put that card onto the \ + battlefield under your control. Then that player shuffles.", + }; + const PRAETORS_GRASP: Card = Card { + name: "Praetor's Grasp", + oracle: + "Search target opponent's library for a card and exile it face down. Then that player \ + shuffles. You may play that card for as long as it remains exiled.", + }; + const HAUNTING_ECHOES: Card = Card { + name: "Haunting Echoes", + oracle: "Exile all cards from target player's graveyard other than basic land cards. For \ + each card exiled this way, search that player's library for all cards with the same name \ + as that card and exile them. Then that player shuffles.", + }; + const THOUGHTSEIZE: Card = Card { + name: "Thoughtseize", + oracle: "Target player reveals their hand. You choose a nonland card from \ + it. That player discards that card. You lose 2 life.", + }; + const DURESS: Card = Card { + name: "Duress", + oracle: "Target opponent reveals their hand. You choose a noncreature, nonland \ + card from it. That player discards that card.", + }; + const SURVEYORS_SCOPE: Card = Card { + name: "Surveyor's Scope", + oracle: + "{T}, Exile this artifact: Search your library for up to X basic land cards, where X \ + is the number of players who control at least two more lands than you. Put those \ + cards onto the battlefield, then shuffle.", + }; + const ASSASSINS_TROPHY: Card = Card { + name: "Assassin's Trophy", + oracle: "Destroy target permanent an opponent controls. Its controller \ + may search their library for a basic land card, put it onto \ + the battlefield, then shuffle.", + }; + const LIGHTNING_BOLT: Card = Card { + name: "Lightning Bolt", + oracle: "Lightning Bolt deals 3 damage to any target.", + }; + const WRATH_OF_GOD: Card = Card { + name: "Wrath of God", + oracle: "Destroy all creatures. They can't be regenerated.", + }; + const NATURALIZE: Card = Card { + name: "Naturalize", + oracle: "Destroy target artifact or enchantment.", + }; + const DIVINATION: Card = Card { + name: "Divination", + oracle: "Draw two cards.", + }; + const PATH_TO_EXILE: Card = Card { + name: "Path to Exile", + oracle: "Exile target creature. Its controller may search their library for a basic land \ + card, put that card onto the battlefield tapped, then shuffle.", + }; + /// Nature's Lore, verbatim (MTGJSON `.data["Nature's Lore"][0].text`). The + /// MINIMAL PAIR against `RAMPANT_GROWTH` above: same sentence shape, same + /// absent cost, same anaphoric `Library -> Battlefield / Any` node — and it + /// differs by the ONE printed word (`tapped`) the gate reads. + const NATURES_LORE: Card = Card { + name: "Nature's Lore", + oracle: "Search your library for a Forest card, put that card onto the battlefield, \ + then shuffle.", + }; + /// Crop Rotation, verbatim (MTGJSON, both lines). The reviewer's named + /// residual. MEASURED on the live parser: the additional-cost line is NOT + /// carried (`cost: None`, one ability), so its verdict rides the zone-change + /// node alone — which is exactly why it was allowlisted before this gate. + const CROP_ROTATION: Card = Card { + name: "Crop Rotation", + oracle: "As an additional cost to cast this spell, sacrifice a land.\n\ + Search your library for a land card, put that card onto the battlefield, \ + then shuffle.", + }; + /// Cultivate, verbatim (MTGJSON). The SECOND door onto the battlefield: its + /// search carries a `SearchDestinationSplit` and moves the found cards + /// ITSELF, with no `ChangeZone` sub-ability for the tap gate to read. + /// MEASURED over the corpus: 12 abilities carry a split at all, and every + /// battlefield-primary one prints "tapped" — so this fixture is the class, + /// not an example of it. + const CULTIVATE: Card = Card { + name: "Cultivate", + oracle: "Search your library for up to two basic land cards, reveal those cards, put one \ + onto the battlefield tapped and the other into your hand, then shuffle.", + }; + const SPREADING_SEAS: Card = Card { + name: "Spreading Seas", + oracle: "Enchant land\nWhen this Aura enters, draw a card.\nEnchanted land is an Island.", + }; + const DARK_RITUAL: Card = Card { + name: "Dark Ritual", + oracle: "Add {B}{B}{B}.", + }; + const LOTUS_PETAL: Card = Card { + name: "Lotus Petal", + oracle: "{T}, Sacrifice this artifact: Add one mana of any color.", + }; + + /// Verbatim from the pinned MTGJSON `AtomicCards.json` (`{3}{B}`, Instant), + /// not from memory. The two lines matter in opposite directions: the SECOND + /// is all this module can read, and the FIRST is the entire card. + const STUNNING_REVERSAL: Card = Card { + name: "Stunning Reversal", + oracle: "The next time you would lose the game this turn, instead draw seven cards \ + and your life total becomes 1.\nExile Stunning Reversal.", + }; + + /// V8 — the classifier is correct across the whole class, in BOTH + /// directions. Acceptance (a) must be confined; every acceptance-(b) + /// interaction, the graveyard-hate class the origin-keyed predecessor rule + /// wrongly confined, the foreign-library and foreign-hand analogues, and + /// the hostile pair must all be interference. + /// + /// Revert-probe: deleting any allowlisted arm flips its acceptance-(a) rows + /// to `MayInterfere`. That clause is TIGHTER than it used to be — there are + /// now three allowlisted arms and all three are exercised by the three + /// `confined` rows, whereas the `Effect::Mana` arm this table shipped with + /// had no acceptance-(a) row witnessing it at all. + /// + /// This table no longer witnesses the TRIVIALIZE mutant, and the sentence + /// that said it did has been removed rather than weakened. It claimed that + /// trivializing `filter_is_actor_owned` to `true` flips the + /// `Deathrite Shaman[0]` row to `OwnResourcesOnly`; MEASURED, that stopped + /// being true when `Effect::Mana` left the allowlist. `Deathrite Shaman[0]` + /// carries its "Add one mana of any color" clause as a `sub_ability`, which + /// now folds `MayInterfere` on its own, so the absorbing fold holds the row + /// no matter what the owner axis says. The owner axis keeps its full + /// discriminating power; only this table's claim to be its witness went + /// stale. Its witnesses are the two tests that assert on + /// `filter_is_actor_owned` DIRECTLY — + /// `deathrite_and_terramorphic_are_separated_only_by_the_owner_axis` and + /// `composite_filters_prove_ownership_by_set_logic`, both in this module. Do + /// NOT weaken any assertion here to restore the old sentence. + #[test] + fn window_reach_matches_the_measured_class_table() { + let confined: &[(&Card, usize)] = &[ + (&TERRAMORPHIC, 0), + (&EVOLVING_WILDS, 0), + (&RAMPANT_GROWTH, 0), + // Surveyor's Scope is deliberately NOT here — it is the fetch + // class's fail-closed member and gets its own row below, because + // its verdict rides its `Unimplemented` head and its + // non-allowlisted `Exile` cost rather than the classifier's + // hostile-detection logic. + ]; + let interfering: &[(&Card, usize)] = &[ + // acceptance (b) — real interaction must keep Shortening + (&LIGHTNING_BOLT, 0), + (&WRATH_OF_GOD, 0), + (&NATURALIZE, 0), + (&DIVINATION, 0), + (&PATH_TO_EXILE, 0), + // the B1 defect class: a graveyard is a per-player zone (CR 400.1) + // and none of these filters proves whose + (&DEATHRITE_SHAMAN, 0), + (&DEATHRITE_SHAMAN, 1), + (&SOUL_GUIDE_LANTERN, 0), + (&RELIC_OF_PROGENITUS, 1), + (&SCAVENGING_OOZE, 0), + // Library analogue — caught by `SearchLibrary.target_player` + (&BRIBERY, 0), + (&PRAETORS_GRASP, 0), + // Library analogue caught by the ABSORBING FOLD instead: its + // search node carries `target_player: None` and is confined on its + // own, so neither row above covers this path. The premise that + // makes it a fold witness is pinned separately by + // `a_foreign_library_search_is_confined_alone_then_absorbed`. + (&HAUNTING_ECHOES, 0), + // Hand analogue — these do not ride `ChangeZone` at all; they fall + // to the fail-closed default + (&THOUGHTSEIZE, 0), + (&DURESS, 0), + // hostile — a parsed removal spell that names an opponent's + // permanent explicitly + (&ASSASSINS_TROPHY, 0), + ]; + + for (card, index) in confined { + let name = card.name; + assert!( + !head_is_unparsed(card, *index), + "VACUITY GUARD: {name}[{index}] must PARSE — a confined verdict on an \ + Effect::Unimplemented head would be impossible, so this guard only ever \ + fires on a broken fixture" + ); + assert_eq!( + reach(card, *index), + WindowReach::OwnResourcesOnly, + "{name}[{index}] is a self-contained fetch: it must NOT buy a priority window" + ); + } + for (card, index) in interfering { + let name = card.name; + // The load-bearing anti-vacuity guard. Every row here must reach + // MayInterfere through the classifier's own logic on a PARSED + // effect. Without this, a row whose Oracle text merely failed to + // parse would sit in the table looking like hostile-detection + // coverage while actually exercising only the fail-closed arm — + // and the whole parsed-hostile branch could then be deleted with + // the suite still green. (MEASURED: `Lightning Bolt` did exactly + // this in an earlier revision, because it was parsed under a + // placeholder card name and its self-reference never resolved.) + assert!( + !head_is_unparsed(card, *index), + "VACUITY GUARD: {name}[{index}] rides the Effect::Unimplemented fail-closed \ + arm, so it is NOT a witness for parsed-hostile detection" + ); + assert_eq!( + reach(card, *index), + WindowReach::MayInterfere, + "{name}[{index}] can reach past its controller's own resources" + ); + } + } + + /// The `SearchLibrary` arm's safety warrant is the ABSORBING fold, not the + /// arm itself — and the two library rows in the table above do NOT witness + /// it: `Bribery[0]` and `Praetor's Grasp[0]` each carry + /// `target_player: Typed{controller: Opponent}`, so the arm catches them + /// directly and the fold is never asked to do anything. This test pins the + /// path they miss, on one of the five abilities the arm's own comment names + /// as foreign-library-with-`target_player: None`. + /// + /// MEASURED at this base: `Haunting Echoes[0]` searches "that player's + /// library" — FOREIGN — yet its `sub_ability` heads + /// `SearchLibrary { target_player: None }`, so that node ALONE classifies + /// `OwnResourcesOnly`. What makes the ability interference is its sibling + /// head, `ChangeZoneAll` (graveyard exile, not an allowlisted effect). + /// + /// The two assertions name the fail-open node and its absorber; the + /// composed verdict is the `HAUNTING_ECHOES` row in the interfering table + /// above, so no assertion here duplicates one there. + /// + /// Read the split correctly: MEASURED, that table row's verdict is carried + /// ENTIRELY by the `ChangeZoneAll` head — the ability has no cost, no + /// `player_scope` and no conservative-when-present field, so dropping the + /// fold's `sub_ability` leg leaves it `MayInterfere` unchanged. The reason is + /// specific to THIS fixture and does NOT generalize: in + /// `ability_window_reach` the head is the only term that is not optional, so + /// when the absorber IS the head, there is no leg the fold could lose that + /// would carry it away. Do NOT read that as "an absorbed verdict can never + /// flip" — when the absorbers sit on OPTIONAL legs, the verdict flips as soon + /// as those legs go. MEASURED at this base by fresh `parse_oracle_text` on + /// `Jace, Architect of Thought[2]`, another of the five abilities the + /// `SearchLibrary` arm's own comment names: its head is + /// `SearchLibrary { target_player: None }` — `OwnResourcesOnly` — and all + /// THREE of its absorbers are optional legs: `cost: Loyalty(-8)` (the + /// `cost_window_reach` wildcard), a `sub_ability` heading + /// `ChangeZone { Library -> Exile, target: Any }`, and `player_scope: All`. + /// Dropping the `sub_ability` and `cost` legs together still measures + /// `MayInterfere`, because `player_scope` alone still absorbs; dropping all + /// three measures `OwnResourcesOnly`. That card is NOT a fixture in this + /// suite, so nothing here pins it — it is cited to bound the claim above, not + /// as coverage. What remains lockable on Haunting Echoes is therefore the + /// premise, and the first assertion is what locks it. MEASURED: closing the + /// `SearchLibrary` arm (`WindowReach::of(target_player.is_none())` to a bare + /// `MayInterfere`) flips that assertion. It cannot flip `Bribery[0]` or + /// `Praetor's Grasp[0]` — DERIVED, not measured: closing the arm can only + /// move a verdict toward `MayInterfere`, and both are already there. + #[test] + fn a_foreign_library_search_is_confined_alone_then_absorbed() { + let echoes = ability(&HAUNTING_ECHOES, 0); + let search = echoes + .sub_ability + .as_ref() + .expect("PREMISE: Haunting Echoes[0] carries the library search as its sub_ability"); + assert_eq!( + effect_window_reach(&search.effect), + WindowReach::OwnResourcesOnly, + "PREMISE: the foreign-library search must still look CONFINED on its own — that is \ + the fail-open shape the fold exists to absorb. If this flips, the parser now carries \ + the foreign `target_player` and this row has silently become a second Bribery" + ); + assert_eq!( + effect_window_reach(&echoes.effect), + WindowReach::MayInterfere, + "the absorber: the sibling graveyard-exile head is what the fold ORs in" + ); + } + + /// The fetch class's ONE fail-open candidate, pinned with its mechanism + /// visible instead of hidden among the parsed rows. + /// + /// Surveyor's Scope is shaped like acceptance (a) — it searches the actor's + /// own library for the actor's own basics and shuffles the actor's own + /// library — but at this base the parser does not carry its whole sentence + /// ("where X is the number of players who control at least two more lands + /// than you"), so `abilities[0]` heads an `Effect::Unimplemented`. + /// + /// The verdict is JOINTLY determined, and the two assertions below are not + /// equally load-bearing. MEASURED at this base: the head reaches + /// `effect_window_reach`'s fail-closed `_` arm, AND the cost is + /// `Composite[Tap, Exile{filter: SelfRef}]` — `AbilityCost::Exile` is not + /// allowlisted, so `cost_window_reach` returns `MayInterfere` on its own. + /// Therefore: + /// + /// * the head assertion is the one that carries weight. If it ever flips + /// (the parser learns the sentence), this row fails LOUDLY rather than + /// silently changing which arm it exercises — the failure is the signal + /// to re-verify the verdict on the parsed AST and, if it is then + /// confined, move it into the confined table above. Do NOT delete it as + /// "redundant with the verdict": that leaves a vacuous row; + /// * the verdict assertion is DOMINATED by the cost leg — it would still + /// hold if the head parsed to a fully confined fetch, so it is NOT + /// evidence that the `_` effect arm fired. It is kept because it is the + /// class-level statement "the fetch class has no fail-open member". + /// + /// Kept OUT of the two tables deliberately: an `Unimplemented` row placed + /// among the interfering rows would look like hostile-detection coverage + /// while exercising only the fail-closed arm, which is the exact vacuity + /// the tables' guards exist to forbid. + #[test] + fn surveyors_scope_is_the_fetch_classs_fail_closed_member() { + assert!( + head_is_unparsed(&SURVEYORS_SCOPE, 0), + "PREMISE: this row exists because the head is UNPARSED at this base. If the parser \ + has learned the sentence, re-measure the parsed AST and reclassify the row \ + deliberately — do not delete this assertion to make the suite green" + ); + assert_eq!( + reach(&SURVEYORS_SCOPE, 0), + WindowReach::MayInterfere, + "the fetch class has no fail-open member: an ability the parser cannot carry must \ + never be proven confined" + ); + } + + /// V1c's premise, at AST granularity: the ONLY thing separating + /// `Deathrite Shaman[0]` from `Terramorphic Expanse[0]` is + /// `filter_is_actor_owned`. Trivializing it merges them — which is exactly + /// the defect the integration row pins on a real board. + #[test] + fn deathrite_and_terramorphic_are_separated_only_by_the_owner_axis() { + let drs = ability(&DEATHRITE_SHAMAN, 0); + let Effect::ChangeZone { target, origin, .. } = drs.effect.as_ref() else { + panic!( + "Deathrite Shaman[0] must head a ChangeZone, got {:?}", + drs.effect + ); + }; + assert_eq!( + *origin, + Some(Zone::Graveyard), + "the graveyard origin is what the refuted origin-keyed rule keyed on" + ); + assert!( + !filter_is_actor_owned(target), + "a bare graveyard-card filter names no owner (CR 400.1), so ownership is UNPROVEN — \ + this is the whole of the B1 fix" + ); + + let terramorphic = ability(&TERRAMORPHIC, 0); + let Some(AbilityCost::Composite { costs }) = terramorphic.cost.as_ref() else { + panic!("Terramorphic Expanse[0] must carry a composite cost"); + }; + assert!( + costs.iter().any( + |c| matches!(c, AbilityCost::Sacrifice(s) if filter_is_actor_owned(&s.target)) + ), + "Terramorphic sacrifices ITSELF — proven actor-owned, which is why the fix is \ + surgical rather than a blanket flip to MayInterfere" + ); + } + + /// V8c — the row that discharges B2. An unrecognized nesting container is + /// interference regardless of what it nests: every branch below is + /// individually confined, and the container still folds to `MayInterfere`. + /// + /// Revert-probe: changing `effect_window_reach`'s `_` arm to + /// `OwnResourcesOnly` flips this assertion. + #[test] + fn an_unrecognized_container_with_confined_branches_is_still_interference() { + let confined_branch = ability(&RAMPANT_GROWTH, 0); + assert_eq!( + ability_window_reach(&confined_branch), + WindowReach::OwnResourcesOnly, + "reach-guard: the branch really is confined, so the container's verdict below is \ + attributable to the container and not to its contents" + ); + + let container = Effect::ChooseOneOf { + chooser: PlayerFilter::Controller, + branches: vec![confined_branch.clone(), confined_branch], + }; + assert_eq!( + effect_window_reach(&container), + WindowReach::MayInterfere, + "an unallowlisted container is interference regardless of what it nests" + ); + + assert_eq!( + effect_window_reach(&Effect::unimplemented("test", "unparsed fragment")), + WindowReach::MayInterfere, + "an unparsed effect is the Surveyor's Scope path — never confined" + ); + } + + /// An object is not the same thing as its ability list, and proving the + /// ability list confined proves nothing about the object. + /// + /// `game::printed_cards` splits one card face across four collections. This + /// module can classify exactly one of them. Before the gate in + /// `object_window_reach`, a card whose ability list was the confined fraction + /// and whose real content sat in another collection read `OwnResourcesOnly`. + /// + /// The witness is real, not constructed: `Stunning Reversal` parses to one + /// ability — `Exile ~`, i.e. `ChangeZone { destination: Exile, target: SelfRef + /// }`, actor-owned and off the battlefield, so confined on every conjunct this + /// module reads — plus one `GameLoss` replacement carrying the whole card. A + /// seat holding it would have Accepted the shortcut the card exists to + /// survive, which is the direction §2 calls the one that loses games. + /// + /// Discrimination is measured, not asserted, and by construction rather than + /// by a second fixture: the two objects below differ in exactly one field. + /// The CONTROL clears `replacement_definitions` and nothing else, and it must + /// return `OwnResourcesOnly` — so the guarded verdict cannot be a constant, + /// and it is attributable to that field alone. + /// + /// Revert-probe: delete the `carries_unreadable_rules_content` call from + /// `object_window_reach` and this row's first assertion reds while the control + /// stays green. Cutting a single disjunct out of that function instead reds only + /// the row for that disjunct, which is how each one is shown to carry its own + /// weight rather than riding the first. + #[test] + fn an_object_whose_rules_content_this_module_cannot_read_is_never_confined() { + let parsed = parse_oracle_text( + STUNNING_REVERSAL.oracle, + STUNNING_REVERSAL.name, + &[], + &[], + &[], + ); + assert_eq!( + parsed.abilities.len(), + 1, + "PREMISE: the card projects exactly one ability; got {:?}", + parsed.abilities + ); + assert_eq!( + parsed.replacements.len(), + 1, + "PREMISE: and exactly one replacement — the half this module cannot read; got {:?}", + parsed.replacements + ); + assert_eq!( + ability_window_reach(&parsed.abilities[0]), + WindowReach::OwnResourcesOnly, + "PREMISE: the ability half really is confined on its own, so the verdict below is \ + produced by the GATE and not by the ability list" + ); + + // `game::zones::create_object` is the shared primitive for this: it allocates the id, + // builds the object AND registers it in its zone. Hand-rolling the insert here would + // duplicate it and, worse, would leave the object absent from the hand's own id list. + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(1), + crate::types::player::PlayerId(0), + STUNNING_REVERSAL.name.to_string(), + Zone::Hand, + ); + let object = state.objects.get_mut(&id).expect("just created"); + object.abilities = std::sync::Arc::new(parsed.abilities.clone()); + object.replacement_definitions = parsed.replacements.clone().into(); + + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "a GameLoss replacement this module cannot classify is not proof of confinement" + ); + assert_eq!( + indexed_ability_window_reach(&state, id, 0), + WindowReach::MayInterfere, + "the activation path takes the same gate: a trigger or replacement on the SAME object \ + can fire off the activation (CR 603.2)" + ); + + // CONTROL — one field cleared IN PLACE, nothing else touched. + state + .objects + .get_mut(&id) + .expect("still present") + .replacement_definitions = Vec::new().into(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "REACH-GUARD: with the unreadable collection gone the SAME object is confined, so the \ + two verdicts above are attributable to that one field and are not a constant" + ); + + // The TRIGGER disjunct, exercised through the real materialization path rather than + // asserted from reading it. The three collections are not wired alike: `printed_cards` + // assigns `replacement_definitions` and `static_definitions` directly, but routes triggers + // through `base_trigger_definitions` + `materialize_base_trigger_definitions()`. A gate that + // reads the materialized field while the pipeline only ever fills the printed one would be + // silently blind to every trigger, so the wiring is the thing under test here, not the + // `is_empty()` call. + let triggers = parse_oracle_text( + SOUL_GUIDE_LANTERN.oracle, + SOUL_GUIDE_LANTERN.name, + &[], + &[], + &[], + ) + .triggers; + assert_eq!( + triggers.len(), + 1, + "PREMISE: Soul-Guide Lantern's ETB is a real parsed trigger; got {triggers:?}" + ); + let object = state.objects.get_mut(&id).expect("still present"); + object.base_trigger_definitions = std::sync::Arc::new(triggers); + object.materialize_base_trigger_definitions(); + assert!( + !object.trigger_definitions.is_empty(), + "PREMISE: materializing the printed triggers must populate the field the gate reads — \ + if this ever fails the gate is blind to triggers no matter what it returns" + ); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "a trigger this module cannot classify is not proof of confinement either" + ); + + // The STATIC disjunct — the third of three, and the one a review round + // correctly noted had neither a test nor a probe. 7 of the 19 printed cards + // this gate flips at today's pool are static-only carriers, so an untested + // disjunct here would leave better than a THIRD of the gate's own measured + // effect unexercised. + let object = state.objects.get_mut(&id).expect("still present"); + object.base_trigger_definitions = std::sync::Arc::new(Vec::new()); + object.materialize_base_trigger_definitions(); + assert!( + object.trigger_definitions.is_empty(), + "PREMISE: triggers cleared, so the next verdict cannot be the trigger clause again" + ); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "CONTROL: with every unreadable collection empty the SAME object is confined again" + ); + let parsed_statics = + parse_oracle_text(SPREADING_SEAS.oracle, SPREADING_SEAS.name, &[], &[], &[]).statics; + assert!( + !parsed_statics.is_empty(), + "PREMISE: Spreading Seas carries a real parsed static ability; got {parsed_statics:?}" + ); + state + .objects + .get_mut(&id) + .expect("still present") + .static_definitions = parsed_statics.into(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "nor is a static ability this module cannot classify proof of confinement" + ); + + // The KEYWORD disjunct. This is the one a review round caught: the gate used to + // read three collections while `printed_cards` writes far more, and printed + // Cascade lives in `obj.keywords` and never reaches `trigger_definitions` — so a + // Cascade spell whose printed abilities all read confined was provably + // `OwnResourcesOnly` while resolving it casts a free spell of arbitrary reach. + let object = state.objects.get_mut(&id).expect("still present"); + object.static_definitions = Vec::new().into(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "CONTROL: statics cleared, so the keyword verdict below is the keyword's alone" + ); + state + .objects + .get_mut(&id) + .expect("still present") + .keywords + .push(Keyword::Cascade); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "a keyword is rules text this fold never reads; Cascade resolves a free spell of \ + arbitrary reach inside the window the seat would have declined to keep" + ); + assert_eq!( + indexed_ability_window_reach(&state, id, 0), + WindowReach::MayInterfere, + "and the activation path takes the same widened gate, not the old three-field one" + ); + + // A CASTING-TIME MODIFIER, to show the widening is not keyword-specific. + let object = state.objects.get_mut(&id).expect("still present"); + object.keywords.clear(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "CONTROL: keyword cleared and the SAME object is confined again, so neither verdict \ + above is a constant" + ); + state + .objects + .get_mut(&id) + .expect("still present") + .spellbook + .push("Lightning Bolt".to_string()); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "a spellbook names cards this object can reach for, and it is not an \ + AbilityDefinition either — the gate is over unreadable CONTENT, not over one field" + ); + + // The four fields the STALENESS GUARD surfaced. They are inert at today's pool + // (0 carriers among the cards that survive the gate), which is exactly why they + // need a witness here: an inert disjunct with no test is indistinguishable from a + // disjunct that does nothing, and the next person to tidy this function would have + // no way to tell. Each is set on the SAME object with every other unreadable field + // cleared first, so each verdict is attributable to that one field. + // Named rather than written inline: `clippy::type_complexity` rejects the tuple-of-fn-ptr + // array form, and the alias is the lint's own suggested remedy. + type FieldSetter = fn(&mut GameObject); + let setters: [(&str, FieldSetter); 4] = [ + ("case_state", |o| { + o.case_state = Some(crate::game::game_object::CaseState { + is_solved: false, + solve_condition: crate::types::ability::SolveCondition::Text { + description: "probe".to_string(), + }, + }) + }), + ("class_level", |o| o.class_level = Some(1)), + ("intensity", |o| o.intensity = 1), + ("attraction_lights", |o| o.attraction_lights = vec![1]), + ]; + for (field, set) in setters { + let object = state.objects.get_mut(&id).expect("still present"); + object.spellbook.clear(); + object.case_state = None; + object.class_level = None; + object.intensity = 0; + object.attraction_lights.clear(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "CONTROL before {field}: with every unreadable field cleared the SAME object \ + is confined again, so the verdict below is attributable to {field} alone" + ); + set(state.objects.get_mut(&id).expect("still present")); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "{field} is rules content this fold cannot read, so it is not proof of confinement" + ); + } + } + + /// STALENESS GUARD. Every printed rules field is either folded or gated. + /// + /// The defect this exists to prevent has now happened twice on this one gate: it + /// shipped reading three collections while `printed_cards` wrote eleven fields, and a + /// review round found `keywords` (printed Cascade) that way. Writing THIS test then + /// found four more — `case_state`, `class_level`, `intensity`, `attraction_lights` — + /// which no amount of re-reading the gate by hand had surfaced. An enumerated list is + /// only ever correct on the day it is written; a `..`-free destructure is correct + /// until the compiler says otherwise. + /// + /// **Why `CardFace` and not `GameObject`.** `GameObject` has 149 fields, most of them + /// runtime state (zone, damage, counters, attachments) with no bearing on what a card + /// can do. Destructuring it here would be a churn magnet that every unrelated field + /// addition breaks, and it would be blanket-`..`'d back within a round. `CardFace` has + /// 33 and is the actual source `printed_cards` reads to populate object rules content, + /// so it guards the defect class that occurred rather than the largest surface + /// available. + /// + /// **Honest scope limit:** this guards fields that reach an object THROUGH + /// `printed_cards`. A `GameObject` field written by some other path is not covered — + /// `game::stickers` is the live example, and it writes only the three definition + /// collections, which are gated. + #[test] + fn every_printed_rules_field_is_either_folded_or_gated() { + // `..`-free ON PURPOSE. A new `CardFace` field is a COMPILE ERROR here until + // someone sorts it into one of the three buckets. The sort IS the assertion: + // there is nothing to run, and that is the point — this fires at build time, + // when it can still be cheap, rather than at review time. + let CardFace { + // ---- FOLDED: this module classifies these itself. + abilities: _, + + // ---- GATED: unreadable rules content. `carries_unreadable_rules_content` + // returns true on the corresponding `GameObject` field. + keywords: _, + triggers: _, + static_abilities: _, + replacements: _, + cleave_variant: _, + modal: _, + additional_cost: _, + casting_restrictions: _, + casting_options: _, + strive_cost: _, + solve_condition: _, // lands as `obj.case_state` + attraction_lights: _, + // GATED as `obj.parse_warnings`. It was in the NOT-RULES-BEARING bucket + // below, reasoned "parser diagnostics, never consulted at runtime". That + // reason was TRUE about the field and WRONG about the conclusion: a + // diagnostic is not rules text, it is the parser's report that some printed + // rules text is MISSING from `abilities`, which is the one thing that makes + // an otherwise-confined fold unsound (module doc §2's named residual). + parse_warnings: _, + // `metadata` is MIXED, so it is DESTRUCTURED rather than bound whole. Binding + // it was a hole in the guard that the guard's own comment declared and did not + // close: `spellbook` is exactly a rules-bearing field that arrived inside this + // struct, so "mixed" is the reason to open it, not the reason to wave it past. + metadata: + CardMetadata { + // GATED as `obj.spellbook`. + spellbook: _, + // Parser-provenance counters: how many abilities came from Forge + // scripts rather than the Oracle parser. The abilities themselves are + // in the collections above; these are counts of them. + forge_abilities: _, + forge_triggers: _, + forge_statics: _, + forge_replacements: _, + // Names tokens this card can make; MAKING one runs through + // `Effect::Token` in `abilities`, which this fold already reads. + related_token_ids: _, + // Image/catalog identifiers. + source_printing_ids: _, + // CR 202.3d + CR 709.4b: a split card's combined off-stack mana value, + // read by deck-construction checks. A cost, not what resolving does. + off_stack_mana_value_override: _, + }, + + // ---- NOT RULES-BEARING. One reason each, because an unjustified entry here + // is exactly where the next lazy re-bucket lands. + name: _, // identity, not behaviour + mana_cost: _, // cost to cast, not what resolving does + // NOT inert, and the earlier reason here ("types/subtypes gate other rules, + // carry none alone") was measurably WRONG. `printed_cards` derives four + // object-level rules fields from `subtypes` ALONE, with no CardFace field + // behind them: `Class` => `class_level` (:206), `Case` => `case_state` (:238), + // `Room` => `room_unlocks` (:246), `Attraction` => `attraction_lights` (:250). + // The face's own field is therefore safe to skip only BECAUSE all four + // derived fields are gated — and `room_unlocks` was not, until the review + // round that read this line. A wrong reason in this bucket is worse than no + // reason: it certifies that no gate is needed. + card_type: _, + power: _, // combat statistic + toughness: _, // combat statistic + loyalty: _, // resource counter, abilities that spend it are in `abilities` + defense: _, // battle counter, same argument as loyalty + oracle_text: _, // the SOURCE the parser reads; the parse is the rules content + non_ability_text: _, // by definition not an ability + flavor_name: _, // cosmetic + color_override: _, // colour is a characteristic, not an action + color_identity: _, // deck construction (CR 903.4), not in-game behaviour + scryfall_oracle_id: _, // external identifier + brawl_commander: _, // format eligibility + is_commander: _, // format eligibility + is_oathbreaker: _, // format eligibility + deck_copy_limit: _, // deck construction + rarities: _, // printing metadata + } = CardFace::default(); + } + + /// Every GATED bucket entry above must actually REACH the gate, through the pipeline + /// that really populates it. + /// + /// This exists because a review round measured that seven of the gate's disjuncts had + /// no test and no revert-probe — `modal`, `additional_cost`, `strive_cost`, + /// `cleave_variant`, `casting_restrictions`, `casting_options`, `back_face` — each + /// occurring exactly once in the whole file, in the gate itself. Deleting any of them + /// left the entire suite green. The sting: `modal` and `additional_cost` are 2 of the + /// 10 cards the widening actually flips, so the disjuncts with no coverage were the + /// LIVE ones while the four that had witnesses were the inert ones. The guard above is + /// a compile-time claim that each field is classified; this is the runtime claim that + /// the classification is TRUE. + /// + /// It also turns the guard's `CardFace` → `GameObject` mapping from a comment into an + /// assertion. Each case mutates a **`CardFace`** and runs the real + /// `apply_card_face_to_object`, so a rename or a dropped copy in `printed_cards` reds + /// here instead of silently un-gating the field — which is the failure the `spellbook` + /// and `keywords` holes both took to get in. + #[test] + fn every_gated_card_face_field_reaches_the_gate_through_printed_cards() { + use crate::game::printed_cards::apply_card_face_to_object; + + // `clippy::type_complexity` rejects the inline tuple-of-fn-ptr array; the alias is + // the lint's own suggested remedy. + type FaceSetter = fn(&mut CardFace); + let cases: [(&str, FaceSetter); 13] = [ + // --- carried on the face itself. + ("keywords", |f| f.keywords = vec![Keyword::Cascade]), + ("cleave_variant", |f| { + f.cleave_variant = Some(crate::types::card::CleaveVariant::default()) + }), + ("modal", |f| { + f.modal = Some(crate::types::ability::ModalChoice::default()) + }), + ("additional_cost", |f| { + f.additional_cost = Some(crate::types::ability::AdditionalCost::Kicker { + costs: vec![], + repeatability: crate::types::ability::AdditionalCostRepeatability::Once, + }) + }), + ("casting_restrictions", |f| { + f.casting_restrictions = vec![crate::types::ability::CastingRestriction::AsSorcery] + }), + ("casting_options", |f| { + f.casting_options = vec![crate::types::ability::SpellCastingOption::free_cast()] + }), + ("strive_cost", |f| { + f.strive_cost = Some(crate::types::mana::ManaCost::default()) + }), + ("metadata.spellbook", |f| { + f.metadata.spellbook = vec!["Lightning Bolt".to_string()] + }), + // The parser's own report that some printed clause is NOT in `abilities`. + // `IgnoredRemainder` is the cheapest variant to build and the gate is a + // presence check, so the variant is not load-bearing — that the field + // SURVIVES `apply_card_face_to_object` is. + ("parse_warnings", |f| { + f.parse_warnings = vec![ + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: "and you gain 2 life".to_string(), + parser: "probe".to_string(), + line_index: 0, + }, + ] + }), + // --- DERIVED FROM `card_type.subtypes` ALONE, with no face field behind them. + // These four are why the guard's `card_type` bucket reason had to be rewritten: + // the face's own `card_type` is safe to skip only because all four landing + // fields are gated, and `room_unlocks` was not until this round. + ("card_type: Class → class_level", |f| { + f.card_type.subtypes = vec!["Class".to_string()] + }), + ("card_type: Case → case_state", |f| { + f.card_type.subtypes = vec!["Case".to_string()]; + f.solve_condition = Some(crate::types::ability::SolveCondition::Text { + description: "probe".to_string(), + }); + }), + ("card_type: Room → room_unlocks", |f| { + f.card_type.subtypes = vec!["Room".to_string()] + }), + ("card_type: Attraction → attraction_lights", |f| { + f.card_type.subtypes = vec!["Attraction".to_string()] + }), + ]; + + for (field, set) in cases { + // CONTROL and WITNESS are SEPARATE freshly-created objects rather than one + // object applied twice. `printed_cards` seeds `class_level` only when + // `base_characteristics_initialized` is still false (CR 716.2b), so re-applying + // to the same object would silently skip the very field the Class case tests — + // and the case would pass for the wrong reason on the three cases beside it. + let mut face = CardFace { + name: "Probe".to_string(), + ..CardFace::default() + }; + let mut state = GameState::default(); + + let control = create_object( + &mut state, + crate::types::identifiers::CardId(1), + crate::types::player::PlayerId(0), + face.name.clone(), + Zone::Hand, + ); + apply_card_face_to_object( + state.objects.get_mut(&control).expect("just created"), + &face, + ); + assert!( + !carries_unreadable_rules_content( + state.objects.get(&control).expect("just created") + ), + "CONTROL for {field}: a default face lands no unreadable content, so the \ + verdict below is produced by {field} and not by the face it rides on" + ); + + set(&mut face); + let witness = create_object( + &mut state, + crate::types::identifiers::CardId(2), + crate::types::player::PlayerId(0), + face.name.clone(), + Zone::Hand, + ); + apply_card_face_to_object( + state.objects.get_mut(&witness).expect("just created"), + &face, + ); + assert!( + carries_unreadable_rules_content( + state.objects.get(&witness).expect("just created") + ), + "CardFace {field} is rules content this fold cannot read, so `printed_cards` \ + must land it somewhere the gate looks — either the gate lost a disjunct or \ + the copy into `GameObject` was renamed out from under it" + ); + } + + // `back_face` is the one gated field with no `apply_card_face_to_object` route: + // `printed_cards::apply_card_face_to_back_face` fills a `BackFaceData` on the + // transform path instead. Asserted directly, and reusing `game::specialize`'s + // existing empty constructor rather than hand-rolling a 22-field literal that would + // go stale the moment `BackFaceData` gains a field. + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(3), + crate::types::player::PlayerId(0), + "Probe".to_string(), + Zone::Hand, + ); + assert!( + !carries_unreadable_rules_content(state.objects.get(&id).expect("just created")), + "CONTROL for back_face: a bare object carries nothing the gate reads" + ); + state.objects.get_mut(&id).expect("just created").back_face = + Some(crate::game::specialize::empty_back_face()); + assert!( + carries_unreadable_rules_content(state.objects.get(&id).expect("just created")), + "a back face is an entire second face of rules content this fold never descends \ + into, so its presence is not proof of confinement" + ); + } + + /// The destination axis, on a REAL parsed node, one field mutated. + /// + /// `Stunning Reversal`'s ability is `ChangeZone { destination: Exile, target: + /// SelfRef }`, so `object_is_confined` holds by ownership alone and does not + /// depend on `destination` — which makes `destination` the only thing the + /// verdicts below can be measuring. (The anaphoric fetch node cannot serve + /// here: its `object_is_confined` disjunct itself requires + /// `destination == Battlefield`, so mutating the field would move two things + /// at once and the row would prove nothing.) + /// + /// `Hand` is gated for the same reason a `Battlefield`-untapped arrival is: a + /// card put into hand is a CASTABLE card. The spell that put it there + /// resolves, the active player receives priority (CR 117.3b) and priority then + /// passes in turn order (CR 117.3d), so the responding seat gets it back still + /// inside this window and can cast it. Graveyard, library and exile are not gated + /// because the actor cannot cast or activate from them without some further + /// permission — which would itself be an ability this fold already reads. + /// + /// Both directions are present, so neither verdict can be a constant. + #[test] + fn a_destination_is_confined_only_when_the_seat_cannot_act_on_what_lands_there() { + let parsed = parse_oracle_text( + STUNNING_REVERSAL.oracle, + STUNNING_REVERSAL.name, + &[], + &[], + &[], + ); + let node = parsed.abilities[0].effect.as_ref().clone(); + let Effect::ChangeZone { target, .. } = &node else { + panic!("PREMISE: the node must head a ChangeZone; got {node:?}"); + }; + assert!( + filter_is_actor_owned(target), + "PREMISE: the target is actor-owned, so `object_is_confined` holds independently of \ + `destination` and every verdict below is attributable to the destination alone" + ); + + // Every one of `Zone`'s seven variants (CR 400.1) appears here. The previous + // table listed five and the production match closed the gap with `_ => true`, + // so `Zone::Stack` was classified confined by a wildcard and no row noticed. + let table = [ + (Zone::Exile, WindowReach::OwnResourcesOnly), + (Zone::Graveyard, WindowReach::OwnResourcesOnly), + (Zone::Library, WindowReach::OwnResourcesOnly), + (Zone::Hand, WindowReach::MayInterfere), + // Already past casting (CR 405.1) and resolves inside the window + // (CR 608.1) — strictly stronger reach than the hand row above. + (Zone::Stack, WindowReach::MayInterfere), + // CR 903.8: a commander may be cast from here. + (Zone::Command, WindowReach::MayInterfere), + // `enter_tapped` is `Unspecified` on this node, so the battlefield + // arm is reach for the TAP reason, not the destination reason. + (Zone::Battlefield, WindowReach::MayInterfere), + ]; + + // COMPLETENESS GUARD, compile-time half: this match is exhaustive over `Zone`, + // so adding a variant breaks THIS test's build and forces a row decision here + // as well as in the production match. The runtime half below then catches a + // variant that compiles but was left out of `table`. Deliberately NOT a mirror + // of the production match — it asserts coverage only, never a verdict, so it + // cannot pass by agreeing with a wrong implementation. + for zone in [ + Zone::Library, + Zone::Hand, + Zone::Battlefield, + Zone::Graveyard, + Zone::Stack, + Zone::Exile, + Zone::Command, + ] { + match zone { + Zone::Library + | Zone::Hand + | Zone::Battlefield + | Zone::Graveyard + | Zone::Stack + | Zone::Exile + | Zone::Command => {} + } + assert!( + table.iter().any(|(z, _)| *z == zone), + "Zone::{zone:?} has no row in the destination table — every zone must be \ + classified explicitly, because the failure this test exists for is a \ + destination nobody wrote a row for" + ); + } + + for (zone, want) in table { + let mut mutated = node.clone(); + let Effect::ChangeZone { destination, .. } = &mut mutated else { + unreachable!("just matched above") + }; + *destination = zone; + assert_eq!( + effect_window_reach(&mutated), + want, + "destination {zone:?} must classify {want:?}" + ); + } + } + + /// V8's hostile edges on the action fold: nothing resolvable, nothing to + /// resolve, and an index past the end are all interference. + #[test] + fn unresolvable_action_subjects_are_interference() { + let state = GameState::default(); + let missing = ObjectId(9_999_999); + assert_eq!( + object_window_reach(&state, missing), + WindowReach::MayInterfere, + "an object that is not on the board cannot be proven confined" + ); + assert_eq!( + indexed_ability_window_reach(&state, missing, 0), + WindowReach::MayInterfere, + "neither can an ability index into an object that is not on the board" + ); + assert!( + any_action_may_interfere( + &state, + PlayerId(0), + &[GameAction::ActivateAbility { + source_id: missing, + ability_index: 7, + }] + ), + "an out-of-range ability index is interference, not a confined no-op" + ); + assert!( + !any_action_may_interfere(&state, PlayerId(0), &[GameAction::PassPriority]), + "reach-guard: the fold CAN return false, so the trues above are attributable" + ); + } + + /// The `Or`/`And` legs are set logic, not a coin flip, and an empty leg set + /// is never proven. + #[test] + fn composite_filters_prove_ownership_by_set_logic() { + let owned = TargetFilter::Controller; + let unowned = TargetFilter::Any; + + assert!( + !filter_is_actor_owned(&TargetFilter::Or { + filters: vec![owned.clone(), unowned.clone()], + }), + "an Or is proven only when EVERY leg is proven" + ); + assert!( + filter_is_actor_owned(&TargetFilter::Or { + filters: vec![owned.clone(), owned.clone()], + }), + "reach-guard: an all-proven Or IS proven, so the negative above is not vacuous" + ); + assert!( + filter_is_actor_owned(&TargetFilter::And { + filters: vec![owned, unowned], + }), + "an And narrows, so one proven leg suffices" + ); + assert!( + !filter_is_actor_owned(&TargetFilter::Or { filters: vec![] }), + "a degenerate empty Or must not be proven by a vacuous all()" + ); + } + + /// Mana is FUNGIBLE REACH (CR 106.1 / CR 106.4 / CR 601.2g), so neither a + /// cast ritual nor an actor-owned sacrifice-for-mana is confined. + /// + /// The Lotus Petal half is the load-bearing one, and its ATTRIBUTION is the + /// second assertion: the parser emits "Sacrifice this artifact" as a + /// `TargetFilter::SelfRef`, which `filter_is_actor_owned` returns true for, + /// so `cost_window_reach` returns `OwnResourcesOnly` and the ONLY thing that + /// can carry the verdict is the mana effect. + /// + /// What that predicate does and does NOT establish — CR 701.21a, quoted from + /// its FIRST sentence: "To sacrifice a permanent, its controller moves it + /// from the battlefield directly to its owner's graveyard. A player can't + /// sacrifice something that isn't a permanent, or something that's a + /// permanent they don't control." Sentence two bounds the actor to + /// permanents they CONTROL — that much is grounded. Sentence one is the half + /// that bounds the conclusion: the card goes to its OWNER's graveyard, so a + /// controlled-but-not-owned Petal (Control Magic) puts a card into another + /// player's graveyard while this leg still answers `OwnResourcesOnly`. + /// `SelfRef` therefore proves control, never ownership, and "confined" is + /// narrower than the predicate's name reads. + /// + /// Not repairable at this seam: `filter_is_actor_owned` is a pure AST + /// predicate and the AST carries no ownership. Nor does it move this row, + /// whose verdict the mana head decides on its own. It is named so the limit + /// is not later widened on the strength of a half-quoted rule — which is the + /// exact species of error (CR 106.4, above) this whole change repairs. + /// + /// A confined cost leg plus a verdict carried by the mana head ALONE is what + /// separates this row from `v9b`'s Ironworks, which reaches `MayInterfere` + /// through an UNPROVEN sacrifice filter and therefore never exercises this + /// arm. + /// + /// Revert-probe, EXECUTED: restoring + /// `Effect::Mana {..} => WindowReach::OwnResourcesOnly` as this match's first + /// arm reds this row at the Dark Ritual verdict, which is where the run + /// aborts. The Lotus Petal verdict flips under the same mutation and that is + /// MEASURED rather than derived — the integration row `v10b` reads exactly + /// this ability through `indexed_ability_window_reach` and flips to `Accept` + /// under the same mutation, which it can only do if this fold returned + /// `OwnResourcesOnly`. + #[test] + fn mana_production_is_reach_not_a_confined_own_resource() { + for (card, index) in [(&DARK_RITUAL, 0usize), (&LOTUS_PETAL, 0usize)] { + assert!( + !head_is_unparsed(card, index), + "VACUITY GUARD: {} must PARSE, or the row measures the fail-closed arm instead \ + of the mana effect's own semantics", + card.name + ); + } + + // PREMISE (card-data is gitignored; re-measured here on the live parser). + let ritual = ability(&DARK_RITUAL, 0); + assert!( + matches!(ritual.effect.as_ref(), Effect::Mana { .. }), + "PREMISE: Dark Ritual heads Effect::Mana; got {:?}", + ritual.effect + ); + assert!( + ritual.cost.is_none(), + "PREMISE: Dark Ritual carries no cost, so NOTHING but the head can carry the verdict" + ); + assert_eq!( + reach(&DARK_RITUAL, 0), + WindowReach::MayInterfere, + "a cast ritual funds an otherwise-unaffordable response" + ); + + let petal = ability(&LOTUS_PETAL, 0); + let cost = petal + .cost + .as_ref() + .expect("PREMISE: Lotus Petal carries an activation cost"); + assert_eq!( + cost_window_reach(cost), + WindowReach::OwnResourcesOnly, + "ATTRIBUTION: the SelfRef sacrifice is proven CONTROLLED (CR 701.21a bounds the actor \ + to permanents they control; the card still leaves for its OWNER's graveyard — see \ + the doc comment), so the cost leg reads confined and cannot be what produces the \ + verdict below" + ); + assert_eq!( + ability_window_reach(&petal), + WindowReach::MayInterfere, + "…so the verdict is carried by the mana effect ALONE — this is the row v9b cannot be" + ); + + // Reach-guard: the classifier can still return OwnResourcesOnly, so the + // two MayInterfere verdicts above are attributable and not a constant. + assert_eq!(reach(&RAMPANT_GROWTH, 0), WindowReach::OwnResourcesOnly); + } + + /// The tap-state gate (CR 110.5b), on the MINIMAL PAIR. `Rampant Growth` and + /// `Nature's Lore` differ by one printed word, and every other axis this + /// classifier reads is identical: no cost, a `SearchLibrary` head with + /// `target_player: None`, and one anaphoric + /// `ChangeZone { Library -> Battlefield, target: Any }` sub-ability. So the + /// verdicts below are attributable to `enter_tapped` and to nothing else — + /// the first two assertions MEASURE that premise rather than assert it. + /// + /// Why the untapped one is reach: the fetched land arrives ready (CR 302.6's + /// summoning-sickness bar is a creature rule), taps for mana inside the + /// window the Shorten hands back, and CR 601.2g runs that mana ability during + /// the cast it funds. `v10c` in `tests/integration/shorten_efficacy.rs` + /// measures that whole chain on the real 4p board; this row pins the AST + /// premise it rests on. + /// + /// Revert-probe: in `effect_window_reach`'s `ChangeZone` arm, replace + /// `object_is_confined && entry_is_confined` with `object_is_confined` alone + /// (the pre-fix expression) — BOTH untapped rows flip to `OwnResourcesOnly`. + #[test] + fn an_untapped_fetch_is_reach_and_a_tapped_one_stays_confined() { + for card in [&NATURES_LORE, &CROP_ROTATION] { + let name = card.name; + assert!( + !head_is_unparsed(card, 0), + "VACUITY GUARD: {name}[0] must PARSE, or the row measures the fail-closed arm \ + instead of the zone-change node's own semantics" + ); + let def = ability(card, 0); + assert!( + def.cost.is_none(), + "PREMISE: {name}[0] carries no cost, so no cost leg can carry the verdict; got \ + {:?}", + def.cost + ); + assert_eq!( + effect_window_reach(&def.effect), + WindowReach::OwnResourcesOnly, + "PREMISE: {name}[0]'s SearchLibrary head is confined ON ITS OWN, so the verdict \ + below is produced by the zone-change sub-ability and by nothing else" + ); + assert_eq!( + reach(card, 0), + WindowReach::MayInterfere, + "{name}[0] puts a land onto the battlefield UNTAPPED (CR 110.5b), which taps for \ + mana inside the window the Shorten hands back — the Effect::Mana case with one \ + extra step" + ); + } + + // The tapped half of the pair, and the reach-guard that keeps the two + // MayInterfere verdicts above from being a constant. + assert_eq!( + reach(&RAMPANT_GROWTH, 0), + WindowReach::OwnResourcesOnly, + "the gate is on the TAP STATE, not on the fetch shape: the same sentence with \ + 'tapped' printed in it stays confined" + ); + + // Fail-closed direction, on all THREE `EtbTapState` variants, measured on + // the REAL parsed node rather than a hand-built one. Only a provably + // `Tapped` entry is allowlisted; `Unspecified` (the AST said nothing) and + // `Untapped` are both reach — which is what makes a conditionally-untapped + // entry (a shock land's pay-life choice, a land-count gate) safe by + // default instead of a shape this predicate would have to model. + let mut node = ability(&NATURES_LORE, 0) + .sub_ability + .as_ref() + .expect("PREMISE: Nature's Lore[0] carries the zone change as its sub_ability") + .effect + .as_ref() + .clone(); + for (state, expected) in [ + (EtbTapState::Tapped, WindowReach::OwnResourcesOnly), + (EtbTapState::Unspecified, WindowReach::MayInterfere), + (EtbTapState::Untapped, WindowReach::MayInterfere), + ] { + let Effect::ChangeZone { enter_tapped, .. } = &mut node else { + panic!("PREMISE: the sub-ability must head a ChangeZone; got {node:?}"); + }; + *enter_tapped = state; + assert_eq!( + effect_window_reach(&node), + expected, + "fail-closed on the tap axis: {state:?} must classify {expected:?}" + ); + } + } + + /// **The origin gate** — a battlefield entry is confined only when the card + /// that arrives comes from a LIBRARY. + /// + /// The three riders in the row below ask what ARRIVES. This one asks what the + /// arriving CARD is, and it is the axis on which `OwnResourcesOnly` was + /// previously asserted over rules text nobody had classified: a graveyard, + /// hand or exile card is a real object in `state.objects`, and neither + /// `object_window_reach`'s `carries_unreadable_rules_content` gate (which + /// runs on the SOURCE) nor `board_observer_may_react` (CR 113.6 — an ETB does + /// not function in a graveyard) ever reads it. + /// + /// **Attribution: exactly one field varies.** Helping Hand's REAL parsed node + /// is measured `MayInterfere`, then the SAME node with `origin` rewritten to + /// `Library` and nothing else touched is measured `OwnResourcesOnly`. Every + /// other axis the arm reads — target filter, tap state, the three riders — is + /// held byte-identical across the pair, so the flip is the origin field's and + /// cannot be the fixture's. + /// + /// The `None` row is the stronger case of the same argument: an absent origin + /// does not even name the zone the card comes from. + #[test] + fn a_battlefield_entry_is_confined_only_from_a_library() { + assert!( + !head_is_unparsed(&HELPING_HAND, 0), + "VACUITY GUARD: Helping Hand[0] must PARSE, or this row measures the fail-closed arm \ + instead of the origin axis" + ); + let node = ability(&HELPING_HAND, 0).effect.as_ref().clone(); + let Effect::ChangeZone { + origin, + destination, + enter_tapped, + target, + .. + } = &node + else { + panic!("PREMISE: Helping Hand[0] must head a ChangeZone; got {node:?}"); + }; + assert_eq!( + (*origin, *destination, *enter_tapped), + ( + Some(Zone::Graveyard), + Zone::Battlefield, + EtbTapState::Tapped + ), + "PREMISE: the real card prints a TAPPED graveyard-to-battlefield return, which is what \ + makes the origin the only axis left to decide the verdict" + ); + assert!( + filter_is_actor_owned(target), + "PREMISE: the target is proven actor-controlled, so `object_is_confined` holds and the \ + verdict below is decided by `entry_is_confined` alone" + ); + + assert_eq!( + effect_window_reach(&node), + WindowReach::MayInterfere, + "a graveyard card entering the battlefield IS a readable object this fold never \ + classified — Accepting here hands back a window in which a Fleshbag-Marauder-class \ + ETB makes every player sacrifice" + ); + + for (label, rewritten) in [ + ( + "Library: the card is an unchosen member of a hidden zone (CR 400.2), so there is \ + nothing to read and nothing is skipped", + Some(Zone::Library), + ), + ("Hand: readable, and never classified", Some(Zone::Hand)), + ("Exile: readable, and never classified", Some(Zone::Exile)), + ( + "None: the seam cannot even name the zone the card comes from", + None, + ), + ] { + let mut mutated = node.clone(); + let Effect::ChangeZone { origin, .. } = &mut mutated else { + unreachable!("just destructured above"); + }; + *origin = rewritten; + let expected = if rewritten == Some(Zone::Library) { + WindowReach::OwnResourcesOnly + } else { + WindowReach::MayInterfere + }; + assert_eq!( + effect_window_reach(&mutated), + expected, + "ONE FIELD VARIES from the node asserted above — {label}" + ); + } + } + + /// PROVABLY tapped, not NOMINALLY tapped — the three riders that decide what + /// actually arrives, plus the split that reaches the battlefield without a + /// `ChangeZone` node at all. + /// + /// Every row mutates ONE field of a REAL parsed node and re-measures, so the + /// unmutated verdict is each row's own positive control: the base node is + /// asserted `OwnResourcesOnly` first, which is what makes each flip + /// attributable to the field and not to the fixture. + /// + /// The corpus is why these are fixtures rather than hypotheticals: MEASURED + /// over every card in the pinned MTGJSON projection, 12 abilities carry a + /// `SearchDestinationSplit`, and NO ability that classifies + /// `OwnResourcesOnly` carries `enters_under`, `enters_attacking` or + /// `enters_modified_if` today. So these guards change no card's verdict at + /// this base — they close the door before a parser improvement or a new card + /// walks through it, in the one direction (toward `MayInterfere`) that can + /// never produce a false Accept. + #[test] + fn a_battlefield_entry_must_be_provably_tapped_actor_controlled_and_not_attacking() { + // ── the three ChangeZone riders, on Terramorphic's real fetch node ── + let base_node = ability(&TERRAMORPHIC, 0) + .sub_ability + .as_ref() + .expect("PREMISE: Terramorphic Expanse[0] carries the zone change as its sub_ability") + .effect + .as_ref() + .clone(); + assert_eq!( + effect_window_reach(&base_node), + WindowReach::OwnResourcesOnly, + "POSITIVE CONTROL: the unmutated tapped fetch node IS confined, so every flip below is \ + attributable to the single field it mutates" + ); + + let mut attacking = base_node.clone(); + let mut foreign = base_node.clone(); + let mut conditional = base_node.clone(); + let Effect::ChangeZone { + enters_attacking, .. + } = &mut attacking + else { + panic!("PREMISE: the fetch node must head a ChangeZone"); + }; + *enters_attacking = true; + let Effect::ChangeZone { enters_under, .. } = &mut foreign else { + panic!("PREMISE: the fetch node must head a ChangeZone"); + }; + *enters_under = Some(ControllerRef::Opponent); + let Effect::ChangeZone { + enters_modified_if, .. + } = &mut conditional + else { + panic!("PREMISE: the fetch node must head a ChangeZone"); + }; + *enters_modified_if = Some(TargetFilter::Any); + + for (label, node) in [ + ( + "enters_attacking (CR 508.4): a tapped attacker is still an attacker", + &attacking, + ), + ( + "enters_under (CR 110.2a): the permanent lands on ANOTHER player's board", + &foreign, + ), + ( + "enters_modified_if (CR 614.12 + CR 614.12a): the tapped rider is CONDITIONAL, so \ + enter_tapped == Tapped is not proof of a tapped entry", + &conditional, + ), + ] { + assert_eq!( + effect_window_reach(node), + WindowReach::MayInterfere, + "{label}" + ); + } + + // ── the split door, on Cultivate's real search node ── + assert!( + !head_is_unparsed(&CULTIVATE, 0), + "VACUITY GUARD: Cultivate[0] must PARSE, or this half measures the fail-closed arm" + ); + let mut search = ability(&CULTIVATE, 0).effect.as_ref().clone(); + let Effect::SearchLibrary { split, .. } = &search else { + panic!("PREMISE: Cultivate[0] must head a SearchLibrary; got {search:?}"); + }; + let split = split.as_ref().expect( + "PREMISE: Cultivate[0] carries a SearchDestinationSplit — this is the shape \ + that reaches the battlefield with no ChangeZone node for the tap gate to read", + ); + assert_eq!( + split.primary_destination, + Zone::Battlefield, + "PREMISE: the split's PRIMARY destination is the battlefield" + ); + assert_eq!( + split.primary_enter_tapped, + EtbTapState::Tapped, + "PREMISE: and the real card prints 'tapped', which is the only reason it stays confined" + ); + assert_eq!( + split.rest_destination, + Zone::Hand, + "PREMISE: and the REST of the search goes to hand — the door this arm used to leave open" + ); + assert_eq!( + effect_window_reach(&search), + WindowReach::MayInterfere, + "the split arm takes the SAME landing-zone authority as the ChangeZone arm: a rest \ + destination of hand is a castable card inside the window, so Cultivate is NOT confined \ + however tapped its primary arrival is" + ); + + // From here the rest destination is moved OFF hand, which is what makes a + // confined split expressible at all: MEASURED on this candidate's own + // projection, all TWELVE `SearchDestinationSplit` carriers route something to + // hand (nine via `rest_destination`, three via `primary_destination`), so no + // real card can serve as the positive control for the tap axis. Single-field + // mutation off a real parsed node is the honest way to get one, and saying so + // is the point — an inert branch presented as covered is the error this row + // exists to avoid. + let Effect::SearchLibrary { + split: Some(split), .. + } = &mut search + else { + panic!("PREMISE: the mutated node must keep its split"); + }; + split.rest_destination = Zone::Graveyard; + assert_eq!( + effect_window_reach(&search), + WindowReach::OwnResourcesOnly, + "CONTROL: one field moved and nothing else, so the verdict above is attributable to \ + `rest_destination` alone and is not a constant" + ); + + for (state, expected) in [ + (EtbTapState::Tapped, WindowReach::OwnResourcesOnly), + (EtbTapState::Unspecified, WindowReach::MayInterfere), + (EtbTapState::Untapped, WindowReach::MayInterfere), + ] { + let Effect::SearchLibrary { + split: Some(split), .. + } = &mut search + else { + panic!("PREMISE: the mutated node must keep its split"); + }; + split.primary_enter_tapped = state; + assert_eq!( + effect_window_reach(&search), + expected, + "the split reaches the battlefield WITHOUT a ChangeZone node, so it takes the same \ + fail-closed tap gate: {state:?} must classify {expected:?}" + ); + } + + let Effect::SearchLibrary { + split: Some(split), .. + } = &mut search + else { + panic!("PREMISE: the mutated node must keep its split"); + }; + split.primary_enter_tapped = EtbTapState::Tapped; + split.rest_destination = Zone::Battlefield; + assert_eq!( + effect_window_reach(&search), + WindowReach::MayInterfere, + "`rest_destination` carries NO tap state of its own, so a battlefield rest can never be \ + proven tapped and is reach by construction" + ); + } + + // ======================================================================= + // PR #7101 — the predicates the integration rows exercise through the real + // board, asserted here at the level the board cannot reach (all three are + // private, and widening them to `pub` for a test would be the wrong trade). + // ======================================================================= + + use crate::types::ability::TriggerDefinition; + use crate::types::triggers::TriggerMode; + + fn trigger(mode: TriggerMode) -> TriggerDefinition { + TriggerDefinition::new(mode) + } + + /// Put one object carrying one trigger on the board and ask the scan. + fn scan_with( + owner: crate::types::player::PlayerId, + zone: Zone, + def: TriggerDefinition, + actor: crate::types::player::PlayerId, + ) -> bool { + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(1), + owner, + "Observer".to_string(), + zone, + ); + state + .objects + .get_mut(&id) + .expect("just created") + .install_trigger_base_definitions(std::sync::Arc::new(vec![def])) + .expect("staging one printed trigger"); + board_observer_may_react(&state, actor) + } + + const ACTOR: crate::types::player::PlayerId = crate::types::player::PlayerId(0); + const OPPONENT: crate::types::player::PlayerId = crate::types::player::PlayerId(1); + + /// **T2.3 — the B4a NEGATIVE CONTROL, and the most load-bearing row here.** + /// + /// The carve-out and [`filter_is_actor_owned`] answer two DIFFERENT questions + /// on the identical input, and the whole [HIGH] fix depends on that gap. + /// `Typed { controller: Some(You) }` is Hedron Crab's `valid_card`; if the + /// carve-out had reused `filter_is_actor_owned` — the obvious reuse, and the + /// one this repo's building-block discipline actively invites — the crab + /// would have been carved out and the finding would have survived the fix. + /// + /// Both directions are asserted, so neither can be a constant: the helper + /// says `true` and the shipped carve-out says "not relieved" on ONE input. + #[test] + fn t2_3_the_carve_out_rejects_the_typed_you_shape_that_filter_is_actor_owned_accepts() { + let hedron_crab_shape = TargetFilter::Typed(crate::types::ability::TypedFilter { + controller: Some(ControllerRef::You), + ..Default::default() + }); + + assert!( + filter_is_actor_owned(&hedron_crab_shape), + "PREMISE: the helper really does accept this shape. If this ever goes false the \ + asymmetry below stops being a hazard and this row stops being a control" + ); + + let mut def = trigger(TriggerMode::ChangesZone); + def.valid_card = Some(hedron_crab_shape); + assert!( + scan_with(OPPONENT, Zone::Battlefield, def, ACTOR), + "THE [HIGH] FIX: an opponent-owned `Typed{{controller: You}}` observer must KEEP the \ + veto. Reusing `filter_is_actor_owned` as the carve-out predicate makes this pass \ + through as relieved — that is the exact defect, and this assertion is what forbids \ + the refactor that reintroduces it" + ); + + let mut self_ref = trigger(TriggerMode::ChangesZone); + self_ref.valid_card = Some(TargetFilter::SelfRef); + assert!( + !scan_with(OPPONENT, Zone::Battlefield, self_ref, ACTOR), + "…while the EXACT `SelfRef` shape is relieved. Without this half the row above would \ + pass under a carve-out that never fires at all" + ); + } + + /// **T2.5 — the relieved-mode table**, one row per family. + /// + /// The integration-level witness for this table is the flagship board itself: + /// Dina, Soul Steeper (`LifeGained`) and Bloodthirsty Conqueror (`LifeLost`) + /// are two of its three zone-gate survivors, and they are relieved by the Life + /// family alone. + /// + /// REVERT-PROBE (executed): delete the Life arm ⇒ those two survive the scan, + /// `board_observer_may_react` returns true for P2, and + /// `v1_live_path_fetchland_seat_accepts_on_the_real_4p_board` flips to + /// `Shorten`. + #[test] + fn t2_5_each_relieved_family_is_relieved_and_its_siblings_are_not() { + let relieved = [ + // CR 119.3 — life. + TriggerMode::LifeGained, + TriggerMode::LifeLost, + TriggerMode::LifeLostAll, + TriggerMode::LifeChanged, + TriggerMode::PayLife, + TriggerMode::PayCumulativeUpkeep, + TriggerMode::PayEcho, + // CR 120.1 — damage. + TriggerMode::DamageDone, + TriggerMode::DamageReceived, + TriggerMode::ExcessDamage, + TriggerMode::Fight, + // CR 508.1 / CR 509.1 — combat turn-based actions. + TriggerMode::Attacks, + TriggerMode::Blocks, + TriggerMode::AttackersDeclared, + TriggerMode::BlockersDeclared, + // CR 500.1 — turn structure. + TriggerMode::Phase, + TriggerMode::TurnBegin, + TriggerMode::NewGame, + // Card flow the allowlist cannot cause. + TriggerMode::Drawn, + TriggerMode::Discarded, + TriggerMode::TokenCreated, + ]; + for mode in relieved { + let def = trigger(mode.clone()); + assert!( + trigger_event_unreachable_by_confined_action(&def), + "{mode:?} must be relieved: no confined action can produce its trigger event" + ); + assert!( + !scan_with(OPPONENT, Zone::Battlefield, trigger(mode.clone()), ACTOR), + "{mode:?} must also be relieved END TO END, through the scan the caller runs" + ); + } + + // The other direction, on modes a confined action really does produce. + for mode in [ + TriggerMode::SpellCast, + TriggerMode::AbilityActivated, + TriggerMode::Taps, + TriggerMode::TapsForMana, + TriggerMode::ManaAdded, + TriggerMode::Shuffled, + TriggerMode::SearchedLibrary, + TriggerMode::PlayerPerformedAction, + TriggerMode::Sacrificed, + TriggerMode::Destroyed, + TriggerMode::Exiled, + TriggerMode::ChangesZone, + TriggerMode::BecomesTarget, + TriggerMode::CounterAdded, + TriggerMode::Revealed, + ] { + assert!( + !trigger_event_unreachable_by_confined_action(&trigger(mode.clone())), + "{mode:?} IS producible by a confined cast or activation, so it must keep its veto" + ); + } + } + + /// **T2.6 — an unclassifiable mode KEEPS its veto.** + /// + /// The fail-closed direction, and the one an earlier draft of this work had + /// backwards. `StateCondition` watches a game STATE rather than an event + /// (CR 603.2), so no event-stream argument can ever relieve it, and + /// `Unknown(_)` is an unclassified Forge mode string by construction. + /// + /// REVERT-PROBE (executed): change the predicate's `_ => false` arm to + /// `_ => true` ⇒ this row fails. + #[test] + fn t2_6_a_mode_the_predicate_cannot_classify_keeps_its_veto() { + for mode in [ + TriggerMode::StateCondition, + TriggerMode::Unknown("SomeFutureForgeMode".to_string()), + // The three MEASURED exclusions from families that read a GENERIC + // `GameEvent::ZoneChanged` an allowlisted `Effect::ChangeZone` emits. + // `match_milled` keys on `ZoneChanged { from: Library, to: Graveyard }` + // (CR 701.17a), which a confined actor-owned self-mill produces. + TriggerMode::Milled, + TriggerMode::MilledAll, + TriggerMode::EntersOrAttacks, + TriggerMode::EntersOrHauntedCreatureDies, + ] { + assert!( + !trigger_event_unreachable_by_confined_action(&trigger(mode.clone())), + "{mode:?} must NOT be relieved — either it is unclassifiable, or its matcher reads \ + a generic zone-change event the confined allowlist really does emit" + ); + } + } + + /// **T2.7 — a disjunctive zone-change trigger is not carved out.** + /// + /// Scrap Trawler's shape: "Whenever this creature dies OR another artifact you + /// control is put into a graveyard from the battlefield, …". When + /// `zone_change_clauses` is non-empty the matcher IGNORES the scalar + /// `valid_card` entirely (`types::ability::TriggerDefinition`), so a `SelfRef` + /// sitting in that field describes only the FIRST clause and says nothing + /// about the second. Carving out on it would relieve a trigger that fires on + /// other objects. + /// + /// Matched pair on the clause list alone. + #[test] + fn t2_7_a_disjunctive_zone_change_trigger_is_not_relieved_by_its_scalar_valid_card() { + let mut scalar = trigger(TriggerMode::ChangesZone); + scalar.valid_card = Some(TargetFilter::SelfRef); + assert!( + !scan_with(OPPONENT, Zone::Battlefield, scalar, ACTOR), + "control: with NO clauses the scalar `valid_card` is what the matcher reads, so the \ + carve-out applies" + ); + + let mut disjunctive = trigger(TriggerMode::ChangesZone); + disjunctive.valid_card = Some(TargetFilter::SelfRef); + disjunctive.zone_change_clauses = vec![crate::types::ability::ZoneChangeClause { + origin: crate::types::ability::OriginConstraint::Equals(Zone::Battlefield), + destination: Some(Zone::Graveyard), + destination_constraint: crate::types::ability::OriginConstraint::any_default(), + valid_card: None, + }]; + assert!( + scan_with(OPPONENT, Zone::Battlefield, disjunctive, ACTOR), + "witness: one clause added and nothing else. The engine now ignores `valid_card`, so \ + reading it would be reading a field that no longer decides anything" + ); + } + + /// **T2.10 — SHAPE PIN.** The relieved set is asserted against `TriggerMode`'s + /// own variant list, so a family that silently widens reds here. + /// + /// The list below is the COMPLETE set of modes this module relieves. A new + /// `TriggerMode` variant defaults to `_ => false` (not relieved) and does not + /// break this row — that is the fail-closed direction and it is correct. What + /// this catches is the dangerous edit: somebody adding a variant to a relieved + /// arm without re-deriving the soundness argument in that arm's comment. + #[test] + fn t2_10_the_relieved_mode_set_is_exactly_this_list() { + let expected: Vec = vec![ + TriggerMode::LifeGained, + TriggerMode::LifeLost, + TriggerMode::LifeLostAll, + TriggerMode::LifeChanged, + TriggerMode::PayLife, + TriggerMode::PayCumulativeUpkeep, + TriggerMode::PayEcho, + TriggerMode::DamageDone, + TriggerMode::DamageDoneOnce, + TriggerMode::DamageAll, + TriggerMode::DamageDealtOnce, + TriggerMode::DamageDoneOnceByController, + TriggerMode::DamageReceived, + TriggerMode::DamagePreventedOnce, + TriggerMode::ExcessDamage, + TriggerMode::ExcessDamageAll, + TriggerMode::Fight, + TriggerMode::FightOnce, + TriggerMode::Attacks, + TriggerMode::AttackersDeclared, + TriggerMode::AttackersDeclaredOneTarget, + TriggerMode::YouAttack, + TriggerMode::YouAttackUnblocked, + TriggerMode::AttackerBlocked, + TriggerMode::AttackerBlockedOnce, + TriggerMode::AttackerBlockedByCreature, + TriggerMode::AttackerUnblocked, + TriggerMode::AttackerUnblockedOnce, + TriggerMode::Blocks, + TriggerMode::BlockersDeclared, + TriggerMode::BecomesBlocked, + TriggerMode::AttacksOrBlocks, + TriggerMode::BlocksOrBecomesBlocked, + TriggerMode::Phase, + TriggerMode::TurnBegin, + TriggerMode::NewGame, + TriggerMode::Drawn, + TriggerMode::Discarded, + TriggerMode::DiscardedAll, + TriggerMode::TokenCreated, + TriggerMode::TokenCreatedOnce, + ]; + + for mode in &expected { + assert!( + trigger_event_unreachable_by_confined_action(&trigger(mode.clone())), + "{mode:?} is in the pinned relieved list but the predicate does not relieve it" + ); + } + + // REVERSE CONTAINMENT, over the enum's own declaration read from source. + // `TriggerMode` derives no iteration trait and there is no name table, so + // the only total enumeration available is the declaration itself; a + // hand-copied list here would be the very drift this row exists to catch. + // Each scanned name goes back through `FromStr`, which is the decoder the + // card pipeline uses. + let source = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/types/triggers.rs" + )) + .expect("the enum's own source file is readable"); + let body = source + .split_once("pub enum TriggerMode {") + .expect("the enum declaration must be findable") + .1; + let body = body.split_once("\n}").expect("…and terminated").0; + + // The names this enumeration cannot construct, PINNED so the gap cannot + // grow silently. Three carry payloads and have no bare-name spelling for + // `FromStr`; the other three are PRE-EXISTING gaps in + // `types::triggers`'s own decoder — they are declared on the enum but have + // no `FromStr` arm, so `from_str` degrades them to `Unknown`. That gap is + // not this module's to fix (and `types/triggers.rs` is outside this + // change), but it IS this row's to disclose: none of the six is in the + // relieved list above, so the reverse containment below covers 165 of the + // enum's 171 variants and this constant names the remaining six. + // Sorted, and compared as a SET: this row's subject is which variants are + // undecodable, not where they sit in the declaration. Pinning declaration + // order would red this `ai_support` row on a no-op reordering of + // `TriggerMode` — a failure that says nothing about either module. + const UNCONSTRUCTIBLE: [&str; 6] = [ + "Copied", // no `FromStr` arm + "Explored", // no `FromStr` arm + "HauntedCreatureDies", // no `FromStr` arm + "KeywordAbilityActivated", // payload + "Planeswalked", // payload + "Unknown", // payload + ]; + let mut undecodable = Vec::new(); + + let mut scanned = 0usize; + let mut unexpected = Vec::new(); + for line in body.lines() { + let line = line.trim(); + // Payload variants FIRST: `Foo(Bar),` would otherwise strip only the + // comma and then fail the identifier filter below, silently dropping + // the variant from the enumeration entirely. + let Some(name) = line + .split_once('(') + .map(|(head, _)| head) + .or_else(|| line.strip_suffix(" {")) + .or_else(|| line.strip_suffix(',')) + else { + continue; + }; + let name = name.trim(); + if name.is_empty() + || !name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) + || !name.chars().all(|c| c.is_ascii_alphanumeric()) + { + continue; + } + let mode: TriggerMode = name.parse().expect("FromStr for TriggerMode is Infallible"); + if matches!(mode, TriggerMode::Unknown(_)) { + undecodable.push(name.to_string()); + continue; + } + scanned += 1; + if trigger_event_unreachable_by_confined_action(&trigger(mode.clone())) + && !expected.contains(&mode) + { + unexpected.push(mode); + } + } + + undecodable.sort_unstable(); + assert_eq!( + undecodable, UNCONSTRUCTIBLE, + "the set of `TriggerMode` variants this enumeration cannot construct changed. If a \ + variant was ADDED to it, the reverse containment below silently stopped covering \ + that variant — restore its `FromStr` arm rather than widening this constant" + ); + assert!( + scanned >= 165, + "the source scan must actually reach the variant list — a parsing change that made it \ + match nothing, or match only a prefix, would turn this whole row vacuous; scanned \ + {scanned}" + ); + assert!( + unexpected.is_empty(), + "these modes are relieved but are NOT in the pinned list — a family widened without \ + its soundness comment being re-derived: {unexpected:?}" + ); + } + + /// **T2.4b — an EMPTY `trigger_zones` means battlefield, not nowhere.** + /// + /// CR 113.6: the zone-of-function question has one authority, + /// `game::triggers::trigger_definition_functions_in_zone`, and the reason the + /// scan must call it rather than read the field is this exact shape. An empty + /// list is the engine's spelling of "battlefield only"; a direct + /// `def.trigger_zones.contains(&zone)` answers `false` for it and would + /// relieve every ordinary battlefield observer on the board — the FAIL-OPEN + /// direction, which produces a false `Accept`. + /// + /// Built here rather than on the real board because it cannot be built there: + /// MEASURED, every active trigger definition on the flagship carries an + /// explicit `trigger_zones: [Battlefield]`, so the direct read and the + /// authority agree across the whole fixture corpus and no integration row can + /// tell them apart. Latent, not live — and closed for the same reason this + /// module closed its `Zone::Stack` arm. + /// + /// REVERT-PROBE (executed): swap the authority call for + /// `def.trigger_zones.contains(&obj.zone)` ⇒ this row fails. + #[test] + fn t2_4b_an_empty_trigger_zones_list_means_battlefield_not_nowhere() { + let mut def = trigger(TriggerMode::ChangesZone); + def.valid_card = Some(TargetFilter::Typed(crate::types::ability::TypedFilter { + controller: Some(ControllerRef::You), + ..Default::default() + })); + assert!( + def.trigger_zones.is_empty(), + "PREMISE: a fresh `TriggerDefinition` carries no zone list, which is the shape under \ + test" + ); + + assert!( + scan_with(OPPONENT, Zone::Battlefield, def.clone(), ACTOR), + "CR 113.6: an empty `trigger_zones` FUNCTIONS on the battlefield, so this observer \ + keeps its veto. A direct `contains` read answers `false` here and relieves it" + ); + assert!( + !scan_with(OPPONENT, Zone::Hand, def, ACTOR), + "…and the same definition does NOT function from hand, so the gate is a distinction \ + and not a constant" + ); + } + + /// **T3.1 — an unread parse warning is not proof of confinement.** + /// + /// RAMPANT_GROWTH is the clean single-variable discriminator: it carries no + /// keywords, no modal, no additional cost, no triggers, no statics and no + /// replacements, so every other disjunct of the gate is already false and the + /// verdict below can only be produced by `parse_warnings`. (Crop Rotation + /// cannot serve — it is over-determined by `additional_cost`.) + /// + /// Both directions on ONE object, one field mutated. + #[test] + fn t3_1_a_parse_diagnostic_gates_an_otherwise_confined_object() { + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(1), + crate::types::player::PlayerId(0), + RAMPANT_GROWTH.name.to_string(), + Zone::Hand, + ); + let parsed = parse_oracle_text(RAMPANT_GROWTH.oracle, RAMPANT_GROWTH.name, &[], &[], &[]); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.abilities = std::sync::Arc::new(parsed.abilities); + + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "PREMISE: with a clean parse this card is the module's flagship confined shape. If \ + this direction ever fails the row below stops measuring the diagnostic" + ); + + state + .objects + .get_mut(&id) + .expect("just created") + .parse_warnings = vec![ + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: "and each opponent loses 2 life".to_string(), + parser: "probe".to_string(), + line_index: 0, + }, + ]; + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "the parser reported that some printed clause never became an `AbilityDefinition`. \ + Proving confinement from the abilities that DID parse is proving it from the fraction \ + of the card the classifier can see — the module doc's §2 residual, now in band" + ); + } + + /// Urza's Cave, verbatim, BOTH lines (Oracle text verified on Scryfall, + /// `api.scryfall.com/cards/named?exact=Urza's+Cave`). Terramorphic Expanse's + /// UNRESTRICTED sibling on the ACTIVATED path: same `{T}` + sacrifice-this + /// cost shape, same tapped library arrival, and the search filter is + /// `Typed[Land]` where Terramorphic's is `Typed[Land] + HasSupertype(Basic)`. + /// Ability `[0]` is its mana ability and `[1]` is the fetch, which is what + /// makes it the right card for the INDEXED entry point: folding the whole + /// object would be over-determined by `Effect::Mana`. + const URZAS_CAVE: Card = Card { + name: "Urza's Cave", + oracle: "{T}: Add {C}.\n{3}, {T}, Sacrifice this land: Search your library for a land \ + card, put it onto the battlefield tapped, then shuffle.", + }; + + /// Bojuka Bog, verbatim (Oracle text verified on Scryfall, + /// `api.scryfall.com/cards/named?exact=Bojuka+Bog`). A LAND whose ETB reaches + /// a graveyard belonging to somebody else — the card that makes "an + /// unrestricted land search is confined" false. + const BOJUKA_BOG: Card = Card { + name: "Bojuka Bog", + oracle: "This land enters tapped.\nWhen this land enters, exile target player's \ + graveyard.\n{T}: Add {B}.", + }; + + /// Snow-Covered Swamp, verbatim — its entire printed rules text is one mana + /// ability. The inert control, and a REAL basic rather than a hand-built + /// vanilla stand-in. + const SNOW_COVERED_SWAMP: Card = Card { + name: "Snow-Covered Swamp", + oracle: "({T}: Add {B}.)", + }; + + /// Put a real parsed card into `player`'s library through the production face + /// path, so its trigger/replacement/static/keyword content is whatever the + /// pipeline actually produces rather than whatever a fixture remembered to + /// set. + fn give_library_card( + state: &mut GameState, + player: PlayerId, + card: &Card, + supertypes: Vec, + ) -> ObjectId { + let parsed = parse_oracle_text(card.oracle, card.name, &[], &[], &[]); + let face = CardFace { + name: card.name.to_string(), + oracle_text: Some(card.oracle.to_string()), + card_type: crate::types::card_type::CardType { + supertypes, + core_types: vec![crate::types::card_type::CoreType::Land], + subtypes: vec![], + }, + abilities: parsed.abilities, + triggers: parsed.triggers, + static_abilities: parsed.statics, + replacements: parsed.replacements, + keywords: parsed.extracted_keywords, + parse_warnings: parsed.parse_warnings, + ..CardFace::default() + }; + let id = create_object( + state, + crate::types::identifiers::CardId(state.next_object_id), + player, + card.name.to_string(), + Zone::Library, + ); + crate::game::printed_cards::apply_card_face_to_object( + state.objects.get_mut(&id).expect("just created"), + &face, + ); + id + } + + /// **T3.3 — a tapped library fetch is confined only while every card it could + /// SELECT is inert, at BOTH entry points.** + /// + /// `effect_window_reach`'s `ChangeZone` arm admits a tapped battlefield + /// arrival whose origin is a library, and it used to discharge the arriving + /// card's rules content with a hidden-zone argument: CR 400.2 makes a library + /// hidden, so "the card is the subject of neither input". CR 701.23a says + /// otherwise — "To search for a card in a zone, look at all cards in that zone + /// (even if it's a hidden zone) and find a card that matches the given + /// description" — and the library cards really are in `state.objects`. + /// + /// TWO AXES, varied one at a time against the same board: + /// * **the deck** — Urza's Cave / Reshape the Earth search for ANY land, so a + /// Bojuka Bog in the library is selectable and a Snow-Covered Swamp is not + /// enough to save it; + /// * **the printed filter** — Terramorphic Expanse searches for a BASIC land + /// on the identical library, and the Bog is not basic, so it stays confined. + /// This is the row that separates the implemented gate from a + /// "Basic means safe" heuristic: the heuristic passes the filter axis and + /// fails the deck axis. + /// + /// Both entry points are exercised because they gate independently: + /// `object_window_reach` folds every ability (Reshape the Earth, a spell), and + /// `indexed_ability_window_reach` folds exactly one (Urza's Cave `[1]`, an + /// activation). Dropping the conjunct from either one alone leaves the other + /// row green. + /// + /// REVERT-PROBES (both executed): delete the `library_arrivals_are_inert` + /// conjunct from `object_window_reach` ⇒ the Reshape row's `MayInterfere` + /// flips; delete it from `indexed_ability_window_reach` ⇒ the Urza's Cave + /// row's flips. + #[test] + fn t3_3_a_library_fetch_is_confined_only_while_every_selectable_card_is_inert() { + const RESHAPE_THE_EARTH: Card = Card { + name: "Reshape the Earth", + oracle: "Search your library for up to ten land cards, put them onto the battlefield \ + tapped, then shuffle.", + }; + let actor = crate::types::player::PlayerId(0); + + // One board, built once, then two sources staged on it. + let mut state = GameState::default(); + state.players.push(crate::types::player::Player { + id: actor, + ..Default::default() + }); + let swamp = give_library_card( + &mut state, + actor, + &SNOW_COVERED_SWAMP, + vec![ + crate::types::card_type::Supertype::Basic, + crate::types::card_type::Supertype::Snow, + ], + ); + let bog = give_library_card(&mut state, actor, &BOJUKA_BOG, vec![]); + for id in [swamp, bog] { + state + .players + .iter_mut() + .find(|p| p.id == actor) + .expect("seated") + .library + .push_back(id); + } + assert!( + !carries_unreadable_rules_content(&state.objects[&swamp]), + "PREMISE: a basic Snow-Covered Swamp is inert, so it can never be what produces a \ + `MayInterfere` below" + ); + assert!( + carries_unreadable_rules_content(&state.objects[&bog]), + "PREMISE: Bojuka Bog carries rules content this fold cannot read — its ETB. Without \ + this the rows below have no hazard to detect" + ); + + // ── source A: a SPELL, folded through `object_window_reach` ── + let reshape = create_object( + &mut state, + crate::types::identifiers::CardId(90), + actor, + RESHAPE_THE_EARTH.name.to_string(), + Zone::Hand, + ); + state + .objects + .get_mut(&reshape) + .expect("just created") + .abilities = std::sync::Arc::new(vec![ability(&RESHAPE_THE_EARTH, 0)]); + assert_eq!( + object_window_reach(&state, reshape), + WindowReach::MayInterfere, + "an unrestricted land search LOOKS AT ALL CARDS (CR 701.23a) and this library holds \ + Bojuka Bog, whose ETB exiles a graveyard the actor does not own" + ); + + // ── source B: an ACTIVATION, folded through `indexed_ability_window_reach` ── + let cave = create_object( + &mut state, + crate::types::identifiers::CardId(91), + actor, + URZAS_CAVE.name.to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&cave) + .expect("just created") + .abilities = + std::sync::Arc::new(vec![ability(&URZAS_CAVE, 0), ability(&URZAS_CAVE, 1)]); + assert_eq!( + indexed_ability_window_reach(&state, cave, 1), + WindowReach::MayInterfere, + "the same hazard reached through the ACTIVATED entry point, which gates separately" + ); + + // ── AXIS 1: vary the DECK, hold the cards fixed ── + let inert_library = { + let mut inert = state.clone(); + inert.objects.remove(&bog); + inert + .players + .iter_mut() + .find(|p| p.id == actor) + .expect("seated") + .library + .retain(|id| *id != bog); + inert + }; + assert_eq!( + object_window_reach(&inert_library, reshape), + WindowReach::OwnResourcesOnly, + "AXIS 1 (deck varies): the identical unrestricted search is confined once every land \ + it could select is inert. Without this direction the gate could be a constant" + ); + assert_eq!( + indexed_ability_window_reach(&inert_library, cave, 1), + WindowReach::OwnResourcesOnly, + "AXIS 1, activated path" + ); + + // ── AXIS 2: vary the printed FILTER, hold the deck fixed ── + let terramorphic = create_object( + &mut state, + crate::types::identifiers::CardId(92), + actor, + TERRAMORPHIC.name.to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&terramorphic) + .expect("just created") + .abilities = std::sync::Arc::new(vec![ability(&TERRAMORPHIC, 0)]); + assert_eq!( + object_window_reach(&state, terramorphic), + WindowReach::OwnResourcesOnly, + "AXIS 2 (filter varies, deck held): a BASIC land search on the very library that \ + defeats the unrestricted one stays confined — the Bog is not basic. A gate that read \ + 'is there a fetch' rather than 'what can it select' fails here" + ); + } + + /// **T3.4 — a non-empty selection filter that matches nothing fails closed.** + /// + /// The one place [`library_arrivals_are_inert`] refuses to answer instead of + /// answering. A library with cards in it but none matching the search could be + /// a deck that genuinely cannot fetch, or it could be a filter this seam + /// cannot evaluate — `FilterContext::from_source_with_controller` carries the + /// acting object but no ability instance, and MEASURED over + /// `data/card-data.json` 24 cards carry a `SearchLibrary` filter that reads + /// the ability's own target (`SameNameAsParentTarget`, `CanEnchant { + /// ParentTarget }`). Those two cases are indistinguishable here, and one of + /// them is a false `Accept`, so neither gets one. + /// + /// The row is the pair: an EMPTY library is confined (nothing can be + /// selected — the filter never ran), a NON-EMPTY library with no match is not. + /// + /// REVERT-PROBE (executed): change the `selectable.is_empty()` arm to return + /// `true` ⇒ the second assertion flips to `OwnResourcesOnly`. + #[test] + fn t3_4_a_search_filter_that_matches_nothing_is_unanswerable_not_confined() { + const RAMPANT_GROWTH: Card = Card { + name: "Rampant Growth", + oracle: "Search your library for a basic land card, put that card onto the \ + battlefield tapped, then shuffle.", + }; + let actor = crate::types::player::PlayerId(0); + let mut state = GameState::default(); + state.players.push(crate::types::player::Player { + id: actor, + ..Default::default() + }); + let growth = create_object( + &mut state, + crate::types::identifiers::CardId(1), + actor, + RAMPANT_GROWTH.name.to_string(), + Zone::Hand, + ); + state + .objects + .get_mut(&growth) + .expect("just created") + .abilities = std::sync::Arc::new(vec![ability(&RAMPANT_GROWTH, 0)]); + + assert_eq!( + object_window_reach(&state, growth), + WindowReach::OwnResourcesOnly, + "an EMPTY library selects nothing, so nothing arrives. This empty set IS an answer" + ); + + // One NON-basic land in the library: the pool is non-empty and the + // basic-land filter matches none of it. + let bog = give_library_card(&mut state, actor, &BOJUKA_BOG, vec![]); + state + .players + .iter_mut() + .find(|p| p.id == actor) + .expect("seated") + .library + .push_back(bog); + assert_eq!( + object_window_reach(&state, growth), + WindowReach::MayInterfere, + "a non-empty pool that the filter matched NOTHING in is the case this seam cannot \ + tell apart from a filter it is unable to evaluate, so it does not claim confinement" + ); + } + + /// **T3.2 — a transform carries the DISPLAYED face's parse diagnostics, both + /// ways.** + /// + /// `obj.parse_warnings` documents itself as "diagnostics for the displayed + /// face", and before `BackFaceData` carried the field that sentence was false + /// the moment a permanent transformed. `printed_cards`' face application + /// copies field by field, so the FRONT face's diagnostics stayed on the object + /// while the BACK face's rules text was displayed. Both directions are wrong + /// and they are wrong in opposite ways, which is why both are asserted here: + /// a front-clean/back-dirty card looked clean while showing text the parser + /// could not read, and a front-dirty/back-clean one kept a diagnostic that + /// described nothing on the object any more. + /// + /// Driven through the REAL `game::transform::transform_permanent`, not through + /// the two face helpers it calls — a test that called `snapshot_object_face` + /// and `apply_back_face_to_object` itself would go green even if the transform + /// stopped using them. + /// + /// **What this row does NOT claim.** It is not a `carries_unreadable_rules_content` + /// row, and asserting that gate here would be vacuous: the gate has a + /// `back_face.is_some()` disjunct, so EVERY double-faced object trips it + /// whatever its diagnostics say. The claim is about the state the gate (and + /// `game::visibility`'s two redactions) read — that after a transform the + /// field describes the face now on top. + /// + /// REVERT-PROBES (both executed): + /// * drop `obj.parse_warnings = back_face.parse_warnings` from + /// `printed_cards::apply_back_face_to_object` ⇒ the post-transform assertion + /// fails in BOTH cases; + /// * drop `parse_warnings: obj.parse_warnings.clone()` from + /// `printed_cards::snapshot_object_face` ⇒ the transform-back assertion + /// fails in the front-dirty case (the outgoing face's diagnostics are lost + /// rather than stashed). + #[test] + fn t3_2_a_transform_carries_the_displayed_faces_parse_diagnostics() { + use crate::game::printed_cards::{apply_card_face_to_back_face, apply_card_face_to_object}; + + fn diagnostic(text: &str) -> crate::parser::oracle_ir::diagnostic::OracleDiagnostic { + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: text.to_string(), + parser: "probe".to_string(), + line_index: 0, + } + } + + // Both directions, as a table: neither verdict can be a constant. + for (label, front_warnings, back_warnings) in [ + ("front-clean / back-dirty", vec![], vec![diagnostic("back")]), + ( + "front-dirty / back-clean", + vec![diagnostic("front")], + vec![], + ), + ] { + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(1), + crate::types::player::PlayerId(0), + "Probe Front".to_string(), + Zone::Battlefield, + ); + + let front = CardFace { + name: "Probe Front".to_string(), + parse_warnings: front_warnings.clone(), + ..CardFace::default() + }; + let back = CardFace { + name: "Probe Back".to_string(), + parse_warnings: back_warnings.clone(), + ..CardFace::default() + }; + apply_card_face_to_object(state.objects.get_mut(&id).expect("just created"), &front); + let mut stored_back = crate::game::specialize::empty_back_face(); + apply_card_face_to_back_face(&mut stored_back, &back); + assert_eq!( + stored_back.parse_warnings, back_warnings, + "PREMISE ({label}): `apply_card_face_to_back_face` must carry the face's own \ + diagnostics, or the transform below has nothing to install" + ); + state.objects.get_mut(&id).expect("just created").back_face = Some(stored_back); + + assert_eq!( + state.objects[&id].parse_warnings, front_warnings, + "PREMISE ({label}): the object starts on its FRONT face" + ); + + let mut events = Vec::new(); + crate::game::transform::transform_permanent(&mut state, id, &mut events) + .expect("CR 701.27a: a battlefield permanent with a back face transforms"); + assert!( + state.objects[&id].transformed, + "PREMISE ({label}): the transform must actually have happened, or the assertion \ + below is comparing the front face to itself" + ); + assert_eq!( + state.objects[&id].parse_warnings, back_warnings, + "({label}) the displayed face is now the BACK face, so its diagnostics are the \ + object's. Reporting the front face's here is reporting evidence about text the \ + permanent no longer has" + ); + + crate::game::transform::transform_permanent(&mut state, id, &mut events) + .expect("CR 701.27a: and it transforms back"); + assert!( + !state.objects[&id].transformed, + "PREMISE ({label}): the round trip must return to the front face" + ); + assert_eq!( + state.objects[&id].parse_warnings, front_warnings, + "({label}) the return trip restores the FRONT face's diagnostics exactly — a \ + stale back-face warning would outlive the text it described" + ); + + // THE THIRD SNAPSHOT HELPER. `game::effects::turn_face_down` stashes + // `snapshot_object_base_face` and the face-up path installs it with + // the same `apply_back_face_to_object` the transform above used, so + // that restore now READS a field the base snapshot has to write. + // MEASURED: `morph::apply_face_down_creature_characteristics` blanks + // name, types, abilities, keywords and every definition list but never + // touches `parse_warnings`, so a base snapshot that dropped the field + // would make turning face UP clear diagnostics that turning face DOWN + // had left alone — a regression this change would have introduced. + // + // Helper-level on purpose and stated as such: the production entry is + // an effect resolver needing a `ResolvedAbility` and a legal morph + // cost, and those would become the variables the row measures. + // REVERT-PROBE (executed): replace `snapshot_object_base_face`'s + // `parse_warnings: obj.parse_warnings.clone()` with `Vec::new()` ⇒ + // this assertion fails in the front-dirty case. + let stashed = + crate::game::printed_cards::snapshot_object_base_face(&state.objects[&id]); + crate::game::printed_cards::apply_back_face_to_object( + state.objects.get_mut(&id).expect("just created"), + stashed, + ); + assert_eq!( + state.objects[&id].parse_warnings, front_warnings, + "({label}) the face-down stash/restore pair must round-trip the displayed face's \ + diagnostics too" + ); + } + } +} diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 2b3a34402a..b8ce7bc4bc 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -10574,6 +10574,7 @@ fn hearth_elemental_self_cost_reduction_counts_adventures() { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Adventure), + parse_warnings: vec![], }); } } @@ -26689,6 +26690,7 @@ fn create_adventure_in_hand(state: &mut GameState, player: PlayerId) -> ObjectId casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], }); obj_id @@ -26781,6 +26783,7 @@ fn create_enchantment_adventure_in_hand(state: &mut GameState, player: PlayerId) casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Adventure), + parse_warnings: vec![], }); obj_id @@ -26866,6 +26869,7 @@ fn create_omen_in_hand(state: &mut GameState, player: PlayerId) -> ObjectId { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Omen), + parse_warnings: vec![], }); obj_id @@ -30680,6 +30684,7 @@ fn add_disturb_creature_to_graveyard( casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Transform), + parse_warnings: vec![], }); obj_id } @@ -36967,6 +36972,7 @@ mod mtmte_cast_flow { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Transform), + parse_warnings: vec![], } } @@ -50977,6 +50983,7 @@ fn exact_resolution_offer_does_not_inherit_sibling_cast_transformed() { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Transform), + parse_warnings: vec![], }); obj.casting_permissions .push(CastingPermission::ExileWithAltCost { diff --git a/crates/engine/src/game/day_night.rs b/crates/engine/src/game/day_night.rs index 985ab5d63b..e03a649772 100644 --- a/crates/engine/src/game/day_night.rs +++ b/crates/engine/src/game/day_night.rs @@ -178,6 +178,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); id } diff --git a/crates/engine/src/game/effects/become_copy.rs b/crates/engine/src/game/effects/become_copy.rs index d6448b5634..8d2a44385b 100644 --- a/crates/engine/src/game/effects/become_copy.rs +++ b/crates/engine/src/game/effects/become_copy.rs @@ -1167,6 +1167,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); } diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 31143ec135..081ad1e1ef 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -9559,6 +9559,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); } diff --git a/crates/engine/src/game/effects/flip_coin.rs b/crates/engine/src/game/effects/flip_coin.rs index 45a9badb58..e20ccac43d 100644 --- a/crates/engine/src/game/effects/flip_coin.rs +++ b/crates/engine/src/game/effects/flip_coin.rs @@ -934,6 +934,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); id } diff --git a/crates/engine/src/game/effects/flip_permanent.rs b/crates/engine/src/game/effects/flip_permanent.rs index 11241f497a..1c3ecf3270 100644 --- a/crates/engine/src/game/effects/flip_permanent.rs +++ b/crates/engine/src/game/effects/flip_permanent.rs @@ -148,6 +148,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Flip), + parse_warnings: vec![], }); id } diff --git a/crates/engine/src/game/effects/prepare.rs b/crates/engine/src/game/effects/prepare.rs index 2f720163d1..cb17a3aa3a 100644 --- a/crates/engine/src/game/effects/prepare.rs +++ b/crates/engine/src/game/effects/prepare.rs @@ -1260,6 +1260,7 @@ mod tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Prepare), + parse_warnings: vec![], } } } diff --git a/crates/engine/src/game/effects/set_room_door_lock.rs b/crates/engine/src/game/effects/set_room_door_lock.rs index debbc1d22a..fe6728e5ad 100644 --- a/crates/engine/src/game/effects/set_room_door_lock.rs +++ b/crates/engine/src/game/effects/set_room_door_lock.rs @@ -139,6 +139,7 @@ mod tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(crate::types::card::LayoutKind::Split), + parse_warnings: vec![], } } diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 7fb6e53adb..bd691211e0 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -3295,6 +3295,9 @@ fn incubator_phyrexian_back_face() -> BackFaceData { strive_cost: None, casting_restrictions: vec![], casting_options: vec![], + // Built in code from CR 111.10i, not parsed from printed text, so there is + // no parse to have gone wrong. + parse_warnings: vec![], layout_kind: None, } } diff --git a/crates/engine/src/game/effects/transform_effect.rs b/crates/engine/src/game/effects/transform_effect.rs index 6a5572cacf..efb2804a0c 100644 --- a/crates/engine/src/game/effects/transform_effect.rs +++ b/crates/engine/src/game/effects/transform_effect.rs @@ -222,6 +222,7 @@ mod tests { // face so `is_double_faced_permanent` recognizes it (the mass-transform // resolver pre-filters on that authority). layout_kind: Some(crate::types::card::LayoutKind::Transform), + parse_warnings: vec![], }); id } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index b30586e2bf..598b66d572 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -14230,6 +14230,7 @@ mod priority_principal_tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], }); for _ in 0..mana_count { runner.state_mut().players[0].mana_pool.add(ManaUnit::new( diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index db293d883c..a44e187059 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -1551,6 +1551,7 @@ mod tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Prepare), + parse_warnings: vec![], } } diff --git a/crates/engine/src/game/engine_mdfc_land_tests.rs b/crates/engine/src/game/engine_mdfc_land_tests.rs index b2637d7df5..c82568085c 100644 --- a/crates/engine/src/game/engine_mdfc_land_tests.rs +++ b/crates/engine/src/game/engine_mdfc_land_tests.rs @@ -62,6 +62,7 @@ fn make_back_face( casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind, + parse_warnings: vec![], } } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index fa08fddddc..2b930cff13 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -10225,8 +10225,8 @@ mod tests { /// than a stricter sentence. /// /// POPULATION IS DISCOVERED, NOT LISTED. The guard walks the crate and enforces on every file - /// carrying the opt-in marker comment, so a seventh file joining the class is covered the - /// moment it opts in, and a hardcoded list cannot drift out of sync with the class it names. + /// carrying the opt-in marker comment, so a newly enrolling file is covered the moment it opts + /// in, and a hardcoded list cannot drift out of sync with the class it names. /// A whole-crate sweep was measured and rejected as out of scope, not as unnecessary: /// `crates/engine/src` carries 216 such anchors across 61 files (`game/engine.rs` alone 29), /// ~20x this change. Regenerate that census with: @@ -10253,7 +10253,7 @@ mod tests { const TEST_MOD: &str = "#[cfg(test)]\nmod tests {"; // Enrolment floor. Not a list — a non-vacuity guard, so a broken walk or a renamed marker // reds instead of passing on an empty population. Raise it when a file joins. - const ENROLLED_FLOOR: usize = 6; + const ENROLLED_FLOOR: usize = 8; // `CR 732.2a` has no colon; `std::vec` no digit; `field:1` and `{"Life":1}` have a name or // a quote before the colon. A bare back-reference is recognized only after whitespace, a diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index ab045257ab..c665caea18 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -1656,6 +1656,7 @@ fn room_back_face(name: &str) -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(crate::types::card::LayoutKind::Split), + parse_warnings: vec![], } } diff --git a/crates/engine/src/game/flip.rs b/crates/engine/src/game/flip.rs index ff53e9eea8..e5db14ecc4 100644 --- a/crates/engine/src/game/flip.rs +++ b/crates/engine/src/game/flip.rs @@ -456,6 +456,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Flip), + parse_warnings: vec![], }); id diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 2e87a31108..d95807ec68 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -239,6 +239,25 @@ pub struct BackFaceData { pub strive_cost: Option, pub casting_restrictions: Vec, pub casting_options: Vec, + /// Parser diagnostics for THIS face — the `BackFaceData` half of + /// [`GameObject::parse_warnings`], and per-face for the same reason `abilities` + /// is: the two faces are parsed independently, so a card whose front reads + /// cleanly and whose back does not is the normal case rather than a corner one. + /// + /// Without this field the diagnostic was not a per-face fact at all. Face + /// application copies field by field, so a transform kept the FRONT face's + /// diagnostics on the object while displaying the back face's rules text: a + /// front-clean card looked clean after transforming into a back face the parser + /// could not fully read, and a front-dirty one kept a diagnostic that no longer + /// described anything. `ai_support::shortcut_efficacy` reads the object field as + /// evidence that the printed rules text is fully modelled, so the stale answer + /// was load-bearing in both directions. + /// + /// `serde(default)` keeps every persisted dump loadable — an older + /// `BackFaceData` simply carries no diagnostics — and `skip_serializing_if` + /// keeps a clean parse byte-identical on the way out. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parse_warnings: Vec, /// Source layout kind — distinguishes Modal DFCs from Transform DFCs /// so the engine can offer face-choice for MDFCs (CR 712.12). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -549,6 +568,38 @@ pub struct GameObject { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub spellbook: Vec, + /// Parser diagnostics for the DISPLAYED face, copied verbatim from + /// `CardFace::parse_warnings` by `game::printed_cards`. A transform swaps this + /// along with the rest of the face, through [`BackFaceData::parse_warnings`] — + /// the two faces parse independently, so a card can be clean on one and not the + /// other, and reporting the wrong face's diagnostics is worse than reporting + /// none. + /// + /// NOT a rules field, and it does not change how anything resolves. It is + /// carried onto the object because a diagnostic is EVIDENCE ABOUT the rules + /// content: it records that the parser saw printed text it could not turn + /// into an `AbilityDefinition`. `game::coverage` already reads the same list + /// off the face to decide whether a card is supported. Any consumer that + /// wants to prove an object's printed rules text is fully modelled has to be + /// able to see that the parse was lossy, and before this field existed that + /// evidence stopped at the card database. + /// + /// `skip_serializing_if` keeps every existing dump byte-identical: the field + /// is empty for a clean parse, which is the overwhelming majority of objects. + /// + /// DELIBERATELY ABSENT FROM `CopiableValues`. CR 707.2 gives a copy the + /// copiable values of the original's CHARACTERISTICS, and CR 707.2a says why + /// the abilities come along: "those values are derived from its rules text". + /// A parse diagnostic is not derived from the rules text — it is a statement + /// about this engine's READING of it — so it is not a characteristic and a + /// copy does not acquire it. The consequence, stated rather than left to be + /// discovered: a token copy carries the source's `abilities` but its own + /// (empty) diagnostics. That is exactly the coverage a copy had before this + /// field existed; the field narrows the gap for printed objects and leaves + /// the copy case where it already was. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parse_warnings: Vec, + // Back face data for double-faced cards (DFCs) pub back_face: Option, @@ -1270,6 +1321,17 @@ fn _gameobject_partition_is_total(o: &GameObject) { token_image_ref: _, source_related_token_ids: _, spellbook: _, + // OMITTED, SAFE BY WRITE SITE. Every write is a FACE INSTALL: + // `printed_cards::apply_card_face_to_object` (front) and + // `apply_back_face_to_object` (the face a transform swaps in), each a verbatim + // clone of that face's own diagnostics, plus the two `game::visibility` + // redactions which act on a projected copy and never on stored state. So the + // field is a function of WHICH FACE IS DISPLAYED, and `transformed` — compared + // above — is that same function's discriminator: two states this comparator + // calls equal are showing the same face of the same card, and therefore agree + // here. Nothing accumulates in it, so it cannot become the per-iteration drift + // the §5.2c ADD set exists to catch. + parse_warnings: _, back_face: _, specialize_faces: _, specialized_color: _, @@ -2144,6 +2206,7 @@ impl GameObject { token_image_ref: None, source_related_token_ids: Vec::new(), spellbook: Vec::new(), + parse_warnings: Vec::new(), back_face: None, specialize_faces: None, specialized_color: None, diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index b156e6c9c0..de0021ad5f 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -184,6 +184,10 @@ pub fn apply_card_face_to_object(obj: &mut GameObject, card_face: &CardFace) { obj.base_printed_ref = obj.printed_ref.clone(); obj.source_related_token_ids = card_face.metadata.related_token_ids.clone(); obj.spellbook = card_face.metadata.spellbook.clone(); + // Evidence that this face's printed text did not parse cleanly. Carried onto + // the object so a consumer can tell "this card has no such ability" apart from + // "the parser could not read that clause". + obj.parse_warnings = card_face.parse_warnings.clone(); obj.modal = card_face.modal.clone(); obj.additional_cost = card_face.additional_cost.clone(); obj.strive_cost = card_face.strive_cost.clone(); @@ -292,6 +296,9 @@ pub fn apply_card_face_to_back_face(back_face: &mut BackFaceData, card_face: &Ca back_face.strive_cost = card_face.strive_cost.clone(); back_face.casting_restrictions = card_face.casting_restrictions.clone(); back_face.casting_options = card_face.casting_options.clone(); + // Same copy, same reason, as `apply_card_face_to_object`: evidence that THIS + // face's printed text did not parse cleanly travels with the face. + back_face.parse_warnings = card_face.parse_warnings.clone(); } pub fn apply_back_face_to_object(obj: &mut GameObject, back_face: BackFaceData) { @@ -341,6 +348,10 @@ pub fn apply_back_face_to_object(obj: &mut GameObject, back_face: BackFaceData) obj.strive_cost = back_face.strive_cost; obj.casting_restrictions = back_face.casting_restrictions; obj.casting_options = back_face.casting_options; + // The displayed face's diagnostics replace the outgoing face's. Both + // directions matter and both are this one line: a back face the parser could + // not fully read starts gating here, and transforming back off it stops. + obj.parse_warnings = back_face.parse_warnings; } /// CR 306.5b + CR 310.4b + CR 614.1c: Seed the intrinsic "enters with N @@ -743,6 +754,9 @@ pub fn snapshot_object_face(obj: &GameObject) -> BackFaceData { strive_cost: obj.strive_cost.clone(), casting_restrictions: obj.casting_restrictions.clone(), casting_options: obj.casting_options.clone(), + // The outgoing face's diagnostics ride out with it, so the return trip + // restores them rather than inheriting whatever the other face had. + parse_warnings: obj.parse_warnings.clone(), layout_kind: None, } } @@ -788,6 +802,10 @@ pub fn snapshot_object_base_face(obj: &GameObject) -> BackFaceData { strive_cost: obj.strive_cost.clone(), casting_restrictions: obj.casting_restrictions.clone(), casting_options: obj.casting_options.clone(), + // Face-derived, with no base/live split to choose between: nothing writes + // `parse_warnings` except a face install, so the live field IS the printed + // face's diagnostics and the layer system never touches it. + parse_warnings: obj.parse_warnings.clone(), layout_kind: None, } } @@ -1040,6 +1058,8 @@ fn back_face_for_card_face_with_printed_ref( strive_cost: None, casting_restrictions: Vec::new(), casting_options: Vec::new(), + // Empty seed; `apply_card_face_to_back_face` below fills it from the face. + parse_warnings: Vec::new(), layout_kind: None, }; apply_card_face_to_back_face(&mut back, face); @@ -1902,6 +1922,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); rehydrate_game_from_card_db(&mut state, &db); diff --git a/crates/engine/src/game/specialize.rs b/crates/engine/src/game/specialize.rs index 95d1332ff0..67263dcac8 100644 --- a/crates/engine/src/game/specialize.rs +++ b/crates/engine/src/game/specialize.rs @@ -73,7 +73,10 @@ pub fn eligible_specialize_colors( .collect() } -fn empty_back_face() -> BackFaceData { +/// An all-empty `BackFaceData`. `pub(crate)` rather than private because it is the only +/// constructor of the type that does not hand-roll the full field list, and a second copy +/// of that literal would go stale the moment `BackFaceData` gains a field. +pub(crate) fn empty_back_face() -> BackFaceData { BackFaceData { name: String::new(), power: None, @@ -95,6 +98,7 @@ fn empty_back_face() -> BackFaceData { strive_cost: None, casting_restrictions: vec![], casting_options: vec![], + parse_warnings: vec![], layout_kind: None, } } diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index d18ce4d81e..2af1ebe66b 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -4806,6 +4806,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/src/game/transform.rs b/crates/engine/src/game/transform.rs index 71c6a4b43c..4ef44b7893 100644 --- a/crates/engine/src/game/transform.rs +++ b/crates/engine/src/game/transform.rs @@ -255,6 +255,7 @@ mod tests { use super::*; use crate::game::game_object::BackFaceData; use crate::game::zones::create_object; + use crate::parser::oracle_ir::diagnostic::OracleDiagnostic; use crate::types::card_type::{CardType, CoreType}; use crate::types::identifiers::CardId; use crate::types::keywords::Keyword; @@ -294,6 +295,11 @@ mod tests { obj.base_abilities = Arc::clone(&obj.abilities); obj.color = vec![ManaColor::Green]; obj.base_color = vec![ManaColor::Green]; + obj.parse_warnings = vec![OracleDiagnostic::IgnoredRemainder { + text: "front diagnostic".to_string(), + parser: "transform_test".to_string(), + line_index: 0, + }]; obj.back_face = Some(BackFaceData { name: "Werewolf Back".to_string(), @@ -327,6 +333,11 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![OracleDiagnostic::IgnoredRemainder { + text: "back diagnostic".to_string(), + parser: "transform_test".to_string(), + line_index: 0, + }], }); id @@ -351,6 +362,10 @@ mod tests { "BackAbility" ); assert_eq!(obj.color, vec![ManaColor::Green, ManaColor::Red]); + assert!(matches!( + obj.parse_warnings.as_slice(), + [OracleDiagnostic::IgnoredRemainder { text, .. }] if text == "back diagnostic" + )); assert!(state.layers_dirty.is_dirty()); assert_eq!(events.len(), 1); assert_eq!(events[0], GameEvent::Transformed { object_id: id }); @@ -368,6 +383,10 @@ mod tests { let obj = &state.objects[&id]; assert!(!obj.transformed); assert_eq!(obj.name, "Werewolf Front"); + assert!(matches!( + obj.parse_warnings.as_slice(), + [OracleDiagnostic::IgnoredRemainder { text, .. }] if text == "front diagnostic" + )); assert_eq!(events.len(), 2); } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 02df951016..d7495c5730 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -24719,6 +24719,7 @@ pub mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); } diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 3b371f680e..d4cbf49626 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -1807,6 +1807,14 @@ fn hide_card(state: &mut GameState, obj_id: ObjectId) { obj.back_face = None; obj.token_image_ref = None; obj.source_related_token_ids.clear(); + // CR 400.2: library and hand are hidden zones — a viewer without + // look-permission may not see the card's face. `parse_warnings` is + // parser evidence derived from that face's printed text (a + // `SwallowedClause` carries the rules text verbatim), and only 891 of + // 35,657 card faces carry a non-empty vector, so its presence and + // contents both fingerprint the card. Redact it with the rest of the + // printed identity. + obj.parse_warnings.clear(); obj.foretold = false; } } @@ -1836,6 +1844,13 @@ fn redact_face_down_identity_from_observer(obj: &mut crate::game::game_object::G obj.printed_ref = None; obj.base_printed_ref = None; obj.back_face = None; + // CR 708.5 + CR 708.2: a face-down permanent has no name and no abilities, + // and no player but its controller may look at the card underneath. + // `parse_warnings` survives the face-down transformation on the + // authoritative object (measured: a manifested card keeps the printed + // face's warnings), so it must be redacted here for the same reason + // `back_face` is — it is evidence about the hidden printed text. + obj.parse_warnings.clear(); } /// CR 603.3b + CR 400.2: A pending trigger awaiting its @@ -2751,6 +2766,116 @@ mod tests { assert!(hidden.back_face.is_none()); } + /// CR 400.2: a hand is a hidden zone. `parse_warnings` is derived from the + /// hidden face's printed text — an `IgnoredRemainder`/`SwallowedClause` + /// payload quotes that text verbatim, and `skip_serializing_if` makes the + /// field's mere presence a fingerprint (891 of 35,657 faces carry one). + /// Matched pair: the owner keeps the diagnostic, the opponent gets nothing. + /// The owner arm is the reach-guard — without it an empty-opponent + /// assertion would pass even if the field were never populated. + #[test] + fn hidden_hand_card_redacts_parse_warnings_from_opponent() { + let mut state = GameState::new_two_player(42); + let card_id = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Warned Card".to_string(), + Zone::Hand, + ); + state.objects.get_mut(&card_id).unwrap().parse_warnings = vec![ + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: "and each opponent loses 2 life".to_string(), + parser: "effect_chain".to_string(), + line_index: 0, + }, + ]; + + let owner_view = filter_state_for_viewer(&state, PlayerId(1)); + let owned = owner_view.objects.get(&card_id).unwrap(); + assert_eq!(owned.name, "Warned Card"); + assert_eq!( + owned.parse_warnings.len(), + 1, + "reach guard: the owner must still see the diagnostic, otherwise \ + the opponent assertion below is vacuous" + ); + + let opponent_view = filter_state_for_viewer(&state, PlayerId(0)); + let hidden = opponent_view.objects.get(&card_id).unwrap(); + assert_eq!(hidden.name, "Hidden Card"); + assert!( + hidden.parse_warnings.is_empty(), + "opponent must not receive parse diagnostics for a hidden-zone card" + ); + // The wire payload is the actual leak vector: assert on the serialized + // bytes, not just the in-memory field. + assert!( + !serde_json::to_string(hidden) + .unwrap() + .contains("each opponent loses 2 life"), + "hidden card's serialized payload must not quote its printed text" + ); + } + + /// CR 708.5 + CR 708.2: a face-down permanent has no name and no abilities, + /// and only its controller may look at the card underneath. `manifest` does + /// not reset `parse_warnings`, so the printed face's diagnostics survive on + /// the authoritative object and must be redacted for every other viewer — + /// the same reason `back_face` is redacted on this path. + #[test] + fn face_down_permanent_redacts_parse_warnings_from_observer() { + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let controller = PlayerId(0); + let secret = create_object( + &mut state, + CardId(7), + controller, + "Secret Manifest".to_string(), + Zone::Library, + ); + { + let obj = state.objects.get_mut(&secret).unwrap(); + obj.card_types = CardType { + supertypes: vec![], + core_types: vec![CoreType::Creature], + subtypes: vec![], + }; + obj.parse_warnings = vec![ + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: "and each opponent loses 2 life".to_string(), + parser: "effect_chain".to_string(), + line_index: 0, + }, + ]; + } + + let mut events = Vec::new(); + manifest(&mut state, controller, &mut events).unwrap(); + // Reach guard: the field genuinely survives the face-down transform, so + // the observer assertion below is not vacuous. + assert_eq!( + state.objects[&secret].parse_warnings.len(), + 1, + "manifest must leave the printed face's diagnostics on the object" + ); + + let controller_view = filter_state_for_viewer(&state, controller); + assert_eq!( + controller_view.objects[&secret].parse_warnings.len(), + 1, + "the controller may look at their own face-down permanent" + ); + + let observer_view = filter_state_for_viewer(&state, PlayerId(1)); + let observed = observer_view.objects.get(&secret).unwrap(); + assert_eq!(observed.name, "Hidden Card"); + assert!( + observed.parse_warnings.is_empty(), + "observer must not receive parse diagnostics for a face-down permanent" + ); + } + #[test] fn search_choice_is_visible_to_turn_controller() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 4e094ea099..bf2112a9a5 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -4023,6 +4023,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Modal), + parse_warnings: vec![], }); } @@ -4188,6 +4189,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Modal), + parse_warnings: vec![], }); } // Apply back face (simulating ChooseModalFace on stack). diff --git a/crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz b/crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz new file mode 100644 index 0000000000..169ab59d3d Binary files /dev/null and b/crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz differ diff --git a/crates/engine/tests/integration/azors_gateway_transform_condition.rs b/crates/engine/tests/integration/azors_gateway_transform_condition.rs index bc19a636bb..6ff8f8ed04 100644 --- a/crates/engine/tests/integration/azors_gateway_transform_condition.rs +++ b/crates/engine/tests/integration/azors_gateway_transform_condition.rs @@ -68,6 +68,7 @@ fn sanctum_of_the_sun_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/copied_ability_transform_generation.rs b/crates/engine/tests/integration/copied_ability_transform_generation.rs index 250d1ff995..bf3af9c4c4 100644 --- a/crates/engine/tests/integration/copied_ability_transform_generation.rs +++ b/crates/engine/tests/integration/copied_ability_transform_generation.rs @@ -83,6 +83,7 @@ fn stonewing_antagonizer_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/craft_tithing_blade_transform.rs b/crates/engine/tests/integration/craft_tithing_blade_transform.rs index 78190502e1..81f9dbe73f 100644 --- a/crates/engine/tests/integration/craft_tithing_blade_transform.rs +++ b/crates/engine/tests/integration/craft_tithing_blade_transform.rs @@ -391,6 +391,7 @@ fn consuming_sepulcher_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/deterministic_game_state_serde.rs b/crates/engine/tests/integration/deterministic_game_state_serde.rs index eed8182619..f97aba069d 100644 --- a/crates/engine/tests/integration/deterministic_game_state_serde.rs +++ b/crates/engine/tests/integration/deterministic_game_state_serde.rs @@ -1191,6 +1191,7 @@ fn back_face(name: &str) -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/integration_adventure.rs b/crates/engine/tests/integration/integration_adventure.rs index 70458bf2bc..d25b69a7ab 100644 --- a/crates/engine/tests/integration/integration_adventure.rs +++ b/crates/engine/tests/integration/integration_adventure.rs @@ -93,6 +93,7 @@ fn stomp_back_face() -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs b/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs index fc0dcb6324..e532f31595 100644 --- a/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs +++ b/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs @@ -50,6 +50,7 @@ fn reflection_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } @@ -80,6 +81,7 @@ fn etching_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs b/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs index 86ca2b2121..8b16553292 100644 --- a/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs +++ b/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs @@ -63,6 +63,7 @@ fn blow_off_steam_back_face() -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs b/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs index 0c66d980fb..6aba669b6a 100644 --- a/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs +++ b/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs @@ -81,6 +81,7 @@ fn attach_aang_back_face(runner: &mut engine::game::scenario::GameRunner, aang: casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); } diff --git a/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs b/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs index 7c7083ccfb..6cdcd03dfe 100644 --- a/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs +++ b/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs @@ -48,6 +48,7 @@ fn attach_transform_back_face(runner: &mut GameRunner, object_id: ObjectId) { // CR 712.16: this is a transforming DFC, so the mass resolver may // transform it rather than applying CR 701.27c's no-op. layout_kind: Some(LayoutKind::Transform), + parse_warnings: vec![], }); } diff --git a/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs b/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs index 64894f8a23..544d68b4a6 100644 --- a/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs +++ b/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs @@ -49,6 +49,7 @@ fn true_scriptures_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/kamigawa_flip_cards.rs b/crates/engine/tests/integration/kamigawa_flip_cards.rs index 00c3340b18..ef16674be3 100644 --- a/crates/engine/tests/integration/kamigawa_flip_cards.rs +++ b/crates/engine/tests/integration/kamigawa_flip_cards.rs @@ -70,6 +70,7 @@ fn kenzo_alternative_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(LayoutKind::Flip), + parse_warnings: vec![], } } @@ -129,6 +130,7 @@ fn tok_tok_alternative_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(LayoutKind::Flip), + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 92deedaf71..c384626bba 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -902,6 +902,7 @@ mod sensei_golden_tail_5950; mod sentinel_sliver_vigilance_grant; mod serpent_society_ward_poison_cost; mod serras_emissary_chosen_card_type_protection; +mod shorten_efficacy; mod sin_spiras_punishment_repeat; mod skullwinder_chosen_opponent; mod slaughter_the_strong_total_power_4380; diff --git a/crates/engine/tests/integration/room_door_lock_unlock.rs b/crates/engine/tests/integration/room_door_lock_unlock.rs index a818e1ea34..35d0328289 100644 --- a/crates/engine/tests/integration/room_door_lock_unlock.rs +++ b/crates/engine/tests/integration/room_door_lock_unlock.rs @@ -55,6 +55,7 @@ fn room_back_face() -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Split), + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/rules/battle.rs b/crates/engine/tests/integration/rules/battle.rs index 1cb418f067..dbbf599964 100644 --- a/crates/engine/tests/integration/rules/battle.rs +++ b/crates/engine/tests/integration/rules/battle.rs @@ -104,6 +104,7 @@ fn siege_victory_cast_during_resolution_enters_transformed() { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], }); } diff --git a/crates/engine/tests/integration/shorten_efficacy.rs b/crates/engine/tests/integration/shorten_efficacy.rs new file mode 100644 index 0000000000..005a18a9de --- /dev/null +++ b/crates/engine/tests/integration/shorten_efficacy.rs @@ -0,0 +1,3186 @@ +// engine-citation-gate: symbol anchors only +//! CR 732.2b/c stage 2 — EFFICACY of a polled seat's loop-shortcut response. +//! +//! CITATION FORM: rule NUMBER only. The number is itself the greppable heading — +//! `grep '^732.2c' docs/MagicCompRules.txt` resolves any citation below. Line +//! anchors are forbidden here (this file is enrolled in +//! `subsystem_citations_are_symbol_anchored`) because `docs/MagicCompRules.txt` +//! is gitignored and re-fetched per checkout, so a line anchor is pinned to +//! whichever rules revision the author happened to hold — the anchors this file +//! originally shipped already resolved to the wrong lines against the revision +//! fetched into the neighbouring checkout. +//! +//! `ai_support::smart_shortcut_response` shipped with a POSSIBILITY predicate +//! only: any meaningful priority action bought a `Shorten`, i.e. a real priority +//! window. That is right for a seat holding a Bolt and wrong for a seat holding +//! a fetchland — activating Terramorphic Expanse satisfies CR 732.2c's "must +//! make a different game choice" while changing nothing about the loop, so the +//! window is spent achieving nothing. +//! +//! Stage 2 is AI POLICY, not a rule: CR 732.2b grants an +//! unconditioned accept-or-shorten option and states no efficacy criterion. The +//! rows below pin the policy's two arms and, more importantly, pin the ONE +//! thing an over-broad version would destroy — that a seat holding real +//! interaction still gets its window. +//! +//! # Mutant discipline +//! +//! Two mutants are named per row, and every row states which one flips it: +//! * **DROP** — delete stage 2 from `smart_shortcut_response` (both arms), i.e. +//! restore the shipped one-stage predicate. +//! * **TRIVIALIZE** — make the stage-2 predicate constant. For arm (B) that is +//! `shortcut_efficacy::filter_is_actor_owned ≡ true` (everything looks +//! confined); for arm (A) it is deleting the `crowned_winner` guard. +//! +//! A row whose expected value equals the SHIPPED value cannot be flipped by +//! DROP — its discriminating power is entirely in TRIVIALIZE, and that is +//! stated on the row rather than papered over. + +use engine::analysis::decision_template::IterationCount; +use engine::analysis::loop_check::ShortcutResponse; +use engine::game::engine::apply; +use engine::game::scenario::{GameRunner, GameScenario}; +use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, QuantityExpr, TargetFilter}; +use engine::types::actions::{GameAction, PrecastCopyShortcutResponse}; +use engine::types::card_type::{CoreType, Supertype}; +use engine::types::game_state::{GameState, LayersDirty, LoopDetectionMode, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); +const P2: PlayerId = PlayerId(2); +const P3: PlayerId = PlayerId(3); + +// --- Oracle-text constants, and what each one's provenance ACTUALLY is --- +// +// Two provenances ship here and they are deliberately not conflated, because +// "verbatim Oracle text" is a claim about a printing and only some of these +// make it. +// +// (1) SIBLING-FIXTURE PROVENANCE — the four loop-shape constants below are +// byte-identical copies of the shipped constants in +// `tests/integration/loop_shortcut.rs`, which does not present them as any +// card's Oracle text either. They exist to reproduce that file's mutual-drain +// loop, and copying them verbatim is what keeps the two files' loop shape +// identical. MEASURED against MTGJSON `AtomicCards.json` (`.data[*][0].text`): +// * `DRAIN_CLERIC` IS one printing's complete Oracle text (Epicure of Blood, +// Marauding Blight-Priest — 2 exact matches); +// * `BLOOD_SIPPER` matches NO card, not even as a substring; +// * `KICKOFF` / `TARGETED_KICKOFF` match no card's complete text; they are +// single-clause fragments (substrings of 53 and 3 cards respectively). +// So do NOT cite this block as card-derived: only `DRAIN_CLERIC` would +// survive that claim, and it is not why any of the four is here. +// +// (2) CARD PROVENANCE — `TERRAMORPHIC` and `DEATHRITE_SHAMAN` (below) ARE their +// named printing's complete Oracle text, verified byte-for-byte against +// MTGJSON. That matters for those two specifically: they are the rows' +// subject matter, and a paraphrase can take a different parser branch and go +// green while the real card stays broken. + +const DRAIN_CLERIC: &str = "Whenever you gain life, each opponent loses 1 life."; +const BLOOD_SIPPER: &str = "Whenever an opponent loses life, you gain 1 life."; +const KICKOFF: &str = "You gain 1 life."; +const TARGETED_KICKOFF: &str = "Target player gains 1 life."; + +/// Terramorphic Expanse, verbatim. Acceptance (a): a fetchland is the canonical +/// action that is legal, meaningful to stage 1, and totally confined. +const TERRAMORPHIC: &str = "{T}, Sacrifice this land: Search your library for a basic land card, \ + put it onto the battlefield tapped, then shuffle."; + +/// Deathrite Shaman, verbatim, all three abilities. Ability `[0]`'s cost is +/// `{T}` ALONE — no mana component — which is what lets the V1c fixture deny +/// `{B}`/`{G}` and still leave `[0]` legal. Its target is a land card in *a* +/// graveyard: the AST names no player, so ownership is UNPROVEN (CR 400.1 — +/// "Each player has their own library, hand, and graveyard"), which is exactly +/// why an `origin`-keyed confinement rule would wrongly call it self-contained. +const DEATHRITE_SHAMAN: &str = "{T}: Exile target land card from a graveyard. Add one mana of any \ + color.\n{B}, {T}: Exile target instant or sorcery card from a \ + graveyard. Each opponent loses 2 life.\n{G}, {T}: Exile target \ + creature card from a graveyard. You gain 2 life."; + +// --------------------------------------------------------------------------- +// Shared drive helpers. Deliberately local: `loop_shortcut.rs`'s equivalents +// are private to that module and it is not in this change's scope. +// --------------------------------------------------------------------------- + +/// Pass/answer beats until the state leaves `Priority`/`OrderTriggers`. +fn drive_collect(runner: &mut GameRunner, cap: usize) -> WaitingFor { + for _ in 0..cap { + match runner.state().waiting_for.clone() { + WaitingFor::Priority { .. } => { + if runner.act(GameAction::PassPriority).is_err() { + break; + } + } + WaitingFor::OrderTriggers { triggers, .. } => { + let order: Vec = (0..triggers.len()).collect(); + if runner + .act(GameAction::OrderTriggers { order }) + .or_else(|_| runner.act(GameAction::OrderTriggers { order: vec![] })) + .is_err() + { + break; + } + } + _ => break, + } + } + runner.state().waiting_for.clone() +} + +/// The exact action list `smart_shortcut_response` folds over — obtained by +/// CALLING production's recipe (`ai_support::shortcut_probe`), not by copying it. +/// A local copy would drift the moment production's recipe changed, and every +/// reach-guard in this file reads this list, so the guards would then be +/// measuring a different action set than the code under test. +fn probe_actions(state: &GameState, player: PlayerId) -> Vec { + engine::ai_support::shortcut_probe(state, player).1 +} + +/// Stage 1's verdict, evaluated on the PROBE state — which is the state +/// production evaluates it on. Evaluating it on the caller's +/// `RespondToShortcut` state instead silently drops +/// `has_meaningful_priority_action`'s sacrifice-for-mana rung, which is gated on +/// `waiting_for` being `Priority`. +fn stage_one_meaningful(state: &GameState, player: PlayerId) -> bool { + let (probe, actions) = engine::ai_support::shortcut_probe(state, player); + engine::ai_support::has_meaningful_priority_action(probe.state(), &actions) +} + +/// Which ability indices of `source` are actually enumerated at this window. +/// V1c's two reach-guards read this. +fn legal_ability_indices(state: &GameState, player: PlayerId, source: ObjectId) -> Vec { + let mut indices: Vec = probe_actions(state, player) + .iter() + .filter_map(|a| match a { + GameAction::ActivateAbility { + source_id, + ability_index, + } if *source_id == source => Some(*ability_index), + _ => None, + }) + .collect(); + indices.sort_unstable(); + indices +} + +/// The shipped `setup_3p_optional_cascade` shape (`loop_shortcut.rs`): P0 runs +/// a self-refilling mutual drain, P1's Mountain + Bolt make the loop OPTIONAL +/// so an offer is raised at all. `decorate` stages the seat under test. +fn optional_cascade(decorate: impl FnOnce(&mut GameScenario)) -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new_n_player(3, 7); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + scenario.with_life(P1, 20); + scenario.with_life(P2, 20); + scenario.add_creature_from_oracle(P0, "Test Drain Cleric", 2, 2, DRAIN_CLERIC); + scenario.add_creature_from_oracle(P0, "Test Blood Sipper", 2, 2, BLOOD_SIPPER); + scenario.add_basic_land(P1, ManaColor::Red); + scenario.add_bolt_to_hand(P1); + decorate(&mut scenario); + let kickoff = scenario + .add_spell_to_hand_from_oracle(P0, "Test Lifegain Kickoff", false, KICKOFF) + .id(); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + (runner, kickoff) +} + +/// Cast the kick-off, drive to the offer, have P0 declare, then walk the APNAP +/// queue to `seat` by submitting manual Accepts for everyone ahead of it (never +/// the AI's answer — that would stop the queue). +fn respond_window_at(runner: &mut GameRunner, kickoff: ObjectId, seat: PlayerId) { + let _ = runner.cast(kickoff).resolve(); + let wf = drive_collect(runner, 500); + assert!( + matches!(wf, WaitingFor::LoopShortcut { .. }), + "reach-guard: the optional cascade must OFFER a shortcut, got {wf:?}" + ); + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: None, + }) + .expect("the proposer declares"); + for _ in 0..8 { + match runner.state().waiting_for { + WaitingFor::RespondToShortcut { player, .. } if player == seat => return, + WaitingFor::RespondToShortcut { .. } => { + runner + .act(GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }) + .expect("manual Accept advances the APNAP queue"); + } + _ => break, + } + } + panic!( + "reach-guard: {seat:?} must be polled; stopped at {:?}", + runner.state().waiting_for + ); +} + +// =========================================================================== +// V1b — ACCEPTANCE (a), Path A class: a fetchland no longer buys a window. +// =========================================================================== + +/// The shipped optional-cascade fixture plus exactly ONE card: a Terramorphic +/// Expanse on the polled seat's battlefield. Stage 1 still says "you have a +/// meaningful action" — a non-mana activated ability always does — and stage 2 +/// answers the question stage 1 cannot: the action reaches nothing but its own +/// controller's library, so the window would change nothing. +/// +/// MUTANTS — both flip the `Accept` assertion: +/// * DROP ⇒ `Shorten { at_iteration: 0 }` (this IS the shipped behaviour, which +/// is the defect). +/// * TRIVIALIZE arm (B) (`any_action_may_interfere ≡ true`) ⇒ `Shorten`. +/// +/// REACH-GUARDS (both are assertions): the fetchland really is enumerated, and +/// stage 1 really does return `true`. Without them an `Accept` here would be +/// indistinguishable from the empty-board stage-1 path — the vacuity that a +/// naive version of this row would ship. +#[test] +fn v1b_a_confined_fetchland_accepts_instead_of_buying_a_vacuous_window() { + let mut terramorphic = ObjectId(0); + let (mut runner, kickoff) = optional_cascade(|s| { + terramorphic = s + .add_land_from_oracle(P2, "Terramorphic Expanse", TERRAMORPHIC) + .id(); + }); + respond_window_at(&mut runner, kickoff, P2); + + let actions = probe_actions(runner.state(), P2); + assert!( + actions.contains(&GameAction::ActivateAbility { + source_id: terramorphic, + ability_index: 0, + }), + "REACH-GUARD 1: the fetchland's ability must be enumerated, otherwise this row \ + degenerates to the empty-board stage-1 path; got {actions:?}" + ); + assert!( + stage_one_meaningful(runner.state(), P2), + "REACH-GUARD 2: stage 1 (POSSIBILITY, untouched by this change) must still return \ + true — an Accept produced by stage 1 would prove nothing about stage 2" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(runner.state(), P2), + ShortcutResponse::Accept, + "a seat whose ONLY action is a self-contained fetch has no efficacious response; \ + spending a real priority window on it changes nothing (CR 732.2c is satisfied by \ + any different choice, which is precisely why it grants no efficacy)" + ); +} + +// =========================================================================== +// V1c — B1 REGRESSION LOCK. The graveyard-hate seat still Shortens. +// =========================================================================== + +/// The one row whose sole job is pinning the owner axis. A graveyard is a +/// PER-PLAYER zone (CR 400.1) and `Zone` carries no +/// player field, so a rule keyed on `ChangeZone.origin` cannot tell "exile a +/// land card from MY graveyard" from "…from YOURS". Deathrite Shaman `[0]` +/// exiles a land card from P0's graveyard — a real interaction with another +/// player's resources — and must keep its window. +/// +/// This row's expected value (`Shorten`) IS the shipped value, so **DROP cannot +/// flip it**. Its whole discriminating power is the TRIVIALIZE arm: +/// `filter_is_actor_owned ≡ true` makes DRS `[0]` fold to `OwnResourcesOnly` +/// and the response becomes `Accept` — the row FLIPS. +/// +/// That flip only exists if the fixture leaves ability `[0]` and ONLY `[0]` +/// legal, so both constraints ship as assertions: +/// * REACH-GUARD 1 — without a land card in a graveyard, `[0]` is not +/// enumerated at all and the action list collapses to `["PassPriority"]`; +/// stage 1 returns false and the row would pass through the wrong path. +/// * REACH-GUARD 2 — with `{B}`/`{G}` available and a matching graveyard card, +/// `[1]`/`[2]` become legal. Their `LoseLife`/`GainLife` sub-effects classify +/// `MayInterfere` even under the mutant, so `any_action_may_interfere`'s +/// `.any()` absorbs the mutation and the row passes VACUOUSLY. +/// +/// The fixture denies `{B}`/`{G}` by construction (P2 controls no lands) and +/// stages no instant/sorcery/creature card in any graveyard, so `[1]` and `[2]` +/// are each blocked on two independent axes. +#[test] +fn v1c_graveyard_hate_across_a_per_player_zone_keeps_its_window() { + let mut shaman = ObjectId(0); + let (mut runner, kickoff) = optional_cascade(|s| { + // CR 302.6 (the summoning-sickness rule): `add_creature_from_oracle` + // stages a pre-existing battlefield creature, so the `{T}` cost is + // payable. + shaman = s + .add_creature_from_oracle(P2, "Deathrite Shaman", 1, 2, DEATHRITE_SHAMAN) + .id(); + // The land card sits in P0's graveyard — the ability reaches ACROSS a + // per-player zone that the AST does not player-qualify. That crossing + // is the whole of the defect this row locks. + s.add_land_to_graveyard(P0, "Test Graveyard Land"); + }); + respond_window_at(&mut runner, kickoff, P2); + + let indices = legal_ability_indices(runner.state(), P2, shaman); + assert!( + indices.contains(&0), + "REACH-GUARD 1: without a land card in a graveyard the Shaman's [0] is not enumerated \ + and this row degenerates to the stage-1 empty-action path; got {indices:?}" + ); + assert_eq!( + indices, + vec![0], + "REACH-GUARD 2: [1]/[2] must stay illegal. They classify MayInterfere even under the \ + TRIVIALIZE mutant, so leaving one legal lets .any() absorb the mutation and this row \ + passes vacuously; got {indices:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(runner.state(), P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "exiling a land card out of ANOTHER player's graveyard is real interaction — the \ + confinement predicate must require PROVEN actor ownership, not merely a zone name" + ); +} + +// =========================================================================== +// V2 / V3 — ACCEPTANCE (b) and the matched pair. +// =========================================================================== + +/// V3, both arms in one row, because neither arm alone is the discriminator. +/// The two boards are identical but for P2's holdings: +/// * `{}` ⇒ Accept, reached through stage 1 (nothing to do); +/// * `{Mountain, Lightning Bolt}` ⇒ Shorten, reached through stage 2. +/// +/// The pass ⇒ grant / respond ⇒ no-grant pair is what proves stage 2 did not +/// over-generalize into "always Accept". Sibling coverage for Wrath of God, +/// Naturalize, Divination and Path to Exile is at classifier granularity in +/// `ai_support::shortcut_efficacy`'s unit table (they are sorceries/instants +/// with no legal target on this board, so a runtime row would assert on +/// castability rather than on efficacy). +/// +/// MUTANTS: TRIVIALIZE arm (B) (`any_action_may_interfere ≡ false`, or +/// `filter_is_actor_owned ≡ true` — Bolt's `DealDamage` reaches neither, so it +/// is the whole-predicate constant that bites) flips the Bolt arm to `Accept`. +/// DROP leaves both arms at their shipped values and flips neither; that is +/// stated rather than claimed otherwise. +#[test] +fn v3_matched_pair_empty_seat_accepts_and_bolt_seat_still_shortens() { + // Arm 1 — nothing at all. + let (mut bare, bare_kickoff) = optional_cascade(|_| {}); + respond_window_at(&mut bare, bare_kickoff, P2); + let bare_actions = probe_actions(bare.state(), P2); + assert!( + !stage_one_meaningful(bare.state(), P2), + "reach-guard: this arm must resolve at STAGE 1, so it stays a control for the stage-2 \ + arm below; got {bare_actions:?}" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(bare.state(), P2), + ShortcutResponse::Accept, + "no meaningful action ⇒ Accept, unchanged from the shipped predicate" + ); + + // Arm 2 — the SAME board plus a Mountain and a Bolt. + let (mut armed, armed_kickoff) = optional_cascade(|s| { + s.add_basic_land(P2, ManaColor::Red); + s.add_bolt_to_hand(P2); + }); + respond_window_at(&mut armed, armed_kickoff, P2); + let armed_actions = probe_actions(armed.state(), P2); + assert!( + armed_actions + .iter() + .any(|a| matches!(a, GameAction::CastSpell { .. })), + "reach-guard: the Bolt must actually be castable, otherwise this arm tests the empty \ + board twice; got {armed_actions:?}" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(armed.state(), P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "ACCEPTANCE (b): a seat holding real interaction must still get its priority window — \ + this is the assertion any over-broad confinement rule destroys first" + ); +} + +// =========================================================================== +// V4 / V6 / V7 — arm (A): the crowned seat, keyed on `predicted_winner`. +// =========================================================================== + +/// CR 732.2a lets the player with priority propose a +/// shortcut whose predictable result crowns SOMEONE ELSE. This fixture is the +/// shipped `interactive_offer_separates_priority_proposer_from_predicted_winner` +/// shape: P1 proposes, P0 is the measured winner, and P1 (the proposer) is +/// excluded from the response queue, so P0 is polled. +/// +/// P0 also holds a Mountain and a Bolt — the reach-guard the measurement proved +/// load-bearing. WITHOUT them P0 has no meaningful action and Accepts via +/// stage 1, making the row vacuous; WITH them the shipped predicate returns +/// `Shorten`, i.e. the crowned player shortens its own guaranteed win. +/// +/// Three claims ride this one board: +/// * **V4** — arm (A) fires: the crowned seat Accepts. +/// * **V6** — it is keyed on `predicted_winner`, never `proposer`. The row +/// asserts `proposer != predicted_winner` and `polled == predicted_winner`, +/// so a `proposer`-keyed implementation (which passes every other row) fails +/// exactly here. +/// * **V7** — read order. Arm (A) reads the proposal off the ORIGINAL state; +/// `smart_shortcut_response` overwrites its probe clone's `waiting_for` with +/// `Priority` before enumerating. Moving that read after the clone makes +/// `crowned_winner` unconditionally `None` and this row FAILS — it is the +/// only row that can detect the mis-ordering. +/// +/// MUTANTS — both flip the `Accept` assertion: DROP ⇒ `Shorten`; TRIVIALIZE +/// arm (A) (delete the `crowned_winner` guard) ⇒ `Shorten` via arm (B), because +/// the Bolt is genuine interference. +#[test] +fn v4_the_crowned_seat_accepts_its_own_predicted_win() { + 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 Drain Cleric", 2, 2, DRAIN_CLERIC); + scenario.add_creature_from_oracle(P0, "Test Blood Sipper", 2, 2, BLOOD_SIPPER); + scenario.add_basic_land(P1, ManaColor::Red); + scenario.add_bolt_to_hand(P1); + // The reach-guard: P0 must hold a meaningful action or stage 1 answers first. + scenario.add_basic_land(P0, ManaColor::Red); + scenario.add_bolt_to_hand(P0); + let kickoff = scenario + .add_spell_to_hand_from_oracle(P1, "P0 Lifegain Kickoff", false, TARGETED_KICKOFF) + .id(); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + runner.state_mut().active_player = P1; + runner.state_mut().priority_player = P1; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P1 }; + + let _ = runner.cast(kickoff).target_player(P0).resolve(); + let wf = drive_collect(&mut runner, 500); + let WaitingFor::LoopShortcut { + proposer, + predicted_winner, + .. + } = wf + else { + panic!("reach-guard: P1's priority window must receive an offer, got {wf:?}"); + }; + assert_eq!( + proposer, P1, + "CR 732.2a routes the offer to the priority holder" + ); + assert_eq!( + predicted_winner, + Some(P0), + "reach-guard: the two authorities must actually DIFFER, or the winner-keyed and \ + proposer-keyed implementations are indistinguishable here" + ); + + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: None, + }) + .expect("P1 declares"); + let WaitingFor::RespondToShortcut { + player, + ref proposal, + .. + } = runner.state().waiting_for + else { + panic!( + "reach-guard: a response window must open, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!( + player, P0, + "the proposer is excluded from its own response queue, so the crowned seat is polled" + ); + assert_ne!( + proposal.proposer, + proposal + .predicted_winner + .expect("this offer names a winner"), + "V6: the multi-authority premise — a proposer-keyed rule would read P1 here" + ); + + let actions = probe_actions(runner.state(), P0); + assert!( + stage_one_meaningful(runner.state(), P0), + "REACH-GUARD: without a meaningful action P0 would Accept via stage 1 and this row \ + would be vacuous; got {actions:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(runner.state(), P0), + ShortcutResponse::Accept, + "arm (A): the offer's predicted result already crowns this seat. CR 732.2c grants a \ + shortening player nothing but the obligation to choose differently, so shortening \ + here moves the game away from a win it already holds" + ); +} + +// =========================================================================== +// V1 / V5 — REAL 4-player board, loaded through the production restore +// chokepoint and driven through the public `apply()` boundary. +// =========================================================================== + +/// Inflate a committed dump fixture. +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 +} + +/// Decode AS `PersistedGameState` — the production chokepoint the server's +/// `from_persisted` and WASM's `decode_restored_game_state` both funnel +/// through — rather than decoding a bare `GameState`. +fn restore_dump(json: &str) -> GameState { + let envelope: serde_json::Value = + serde_json::from_str(json).expect("dump envelope parses as JSON"); + serde_json::from_value::( + envelope["gameState"].clone(), + ) + .expect("gameState deserializes through the production decoder") + .into_game_state() +} + +/// The LIVE-PATH board: the real 4-player Dina / Bloodthirsty Conqueror drain +/// on which the defect actually occurs, because seat P2 controls a Terramorphic +/// Expanse. +/// +/// Derived from the read-only pristine archive, and the derivation is the +/// artifact's provenance rather than a claim about it: +/// `unzip -p combofb-dumps-pristine/dina-conqueror-offers-no-ff.zip | +/// jq -c '{gameState}' | gzip -9 -n` +/// → 844846 bytes, sha256 +/// `9843d5165cbbf7dd7bca4171c7888c190b7eba7e52a2ed095b44ff76fadd7886`. +/// `gzip -n` is a no-op from a pipe but load-bearing from a file (it strips the +/// stored name and mtime), so KEEP it: a re-derivation that stages the 21 MB +/// dump through an intermediate file — the natural thing to do at that size — +/// otherwise misses the digest and presents as a corrupt artifact rather than +/// as convention drift. +fn live_path_board() -> GameState { + restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_noff_turn5_4p.json.gz" + ))) +} + +/// The QUIET board: the same matchup captured at a beat where NO seat holds any +/// meaningful priority action. Retained only as a negative control — see +/// `v1_control_*` for why it has no discriminating power for acceptance (a). +fn quiet_board() -> GameState { + restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_conqueror_4p.json.gz" + ))) +} + +fn dump_driver_forbids(a: &GameAction) -> bool { + matches!(a, GameAction::Concede { .. } | GameAction::Debug(_)) +} + +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-drive policy: at `Priority` ALWAYS pass (the mandatory +/// triggers re-trigger — that IS the loop), answer every other prompt. +fn dump_drive_one_beat(state: &mut GameState) -> 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 { + 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(|_| ()) + .map_err(|e| format!("apply err ({action:?}): {e:?}")) +} + +/// Drive real beats until the board mints a bounded offer. +fn drive_to_offer(state: &mut GameState, cap: usize) -> Option { + for beat in 0..cap { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + return Some(beat); + } + if dump_drive_one_beat(state).is_err() { + return None; + } + } + None +} + +// NOTE: no `give_fetchland` staging helper. The live-path test drives the seat's +// OWN Terramorphic Expanse out of the restored dump (`ObjectId(203)`), so there is +// nothing to inject; a staging helper here would have made the live test synthetic +// again. `give_bolt` below survives because the positive control needs an +// interactive card the recorded board does not contain. + +/// Stage a castable Lightning Bolt in `player`'s hand, mirroring +/// `GameScenario::add_bolt_to_hand` (same `Effect::DealDamage` ability, same +/// absence of a printed mana cost) so the positive control below is the same +/// interaction the shipped fixtures use. +fn give_bolt(state: &mut GameState, player: PlayerId) -> ObjectId { + give_bolt_with_cost(state, player, ManaCost::zero()) +} + +/// `give_bolt` with a PRINTED cost, so a row can stage an interaction the seat +/// cannot yet afford. `GameObject::mana_cost` is the field the castability probe +/// reads, and `ManaCost`'s `Default` is `zero()` (`GameObject::new` seeds both +/// cost fields from it), so the free-Bolt caller above is byte-unchanged. +/// +/// BOTH fields are assigned, but the LIVE one is what carries this helper — +/// `base_mana_cost` is NOT load-bearing for the objects staged here, and saying +/// otherwise would be a justification the next reader trusts. +/// +/// READ FROM SOURCE (three call sites, not a runtime probe — the evidence grade +/// is stated because overclaiming it is the very habit this comment replaces). +/// `game::layers`' base→live reseed does run +/// `mana_cost = base_mana_cost.clone()` (`seed_live_characteristics_from_base`), +/// and every consumer here does reach the object through +/// `ai_support::shortcut_probe`, which flushes layers — but the full pass +/// applies that reseed (via `reset_recipient_to_base`) only over +/// `battlefield_phased_in_ids()`, and the hand branch of the same pass resets +/// `keywords` alone. `layers::layer_pass_materializes_keywords`' doc is the +/// in-repo authority for that split ("Battlefield — resets the full +/// characteristic set" vs "Hand — keywords-only reset"); the incremental arm +/// resets only battlefield entrants and their hosts, so it cannot reach a hand +/// object either. This object is staged to `Zone::Hand`, so no pass reseeds its +/// `mana_cost`. `GameObject`'s +/// `sync_missing_base_characteristics` — which the hand branch DOES call — +/// would in fact back-fill `base_mana_cost` from the live field, the opposite +/// direction. +/// +/// `base_mana_cost` is set for symmetry: it keeps the two fields from +/// disagreeing on a freshly minted object, and it keeps the helper correct if +/// the hardcoded `Zone::Hand` below ever becomes the battlefield, where the +/// reseed WOULD restore the default (free) cost over the printed one and +/// silently leave an "otherwise-unaffordable" premise measuring nothing. +fn give_bolt_with_cost(state: &mut GameState, player: PlayerId, cost: ManaCost) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = engine::game::zones::create_object( + state, + card_id, + player, + "Lightning Bolt".to_string(), + Zone::Hand, + ); + let ability = AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 3 }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types + .core_types + .push(engine::types::card_type::CoreType::Instant); + obj.base_card_types = obj.card_types.clone(); + obj.base_mana_cost = cost.clone(); + obj.mana_cost = cost; + obj.abilities = std::sync::Arc::new(vec![ability.clone()]); + obj.base_abilities = std::sync::Arc::new(vec![ability]); + state.layers_dirty = LayersDirty::full(); + id +} + +/// Declare the offer this board minted, then poll `seat` by walking the APNAP +/// queue with MANUAL Accepts (never the AI's answer, which would stop the +/// queue). Returns the state parked at `seat`'s response window. +fn declare_and_poll(state: &GameState, seat: PlayerId) -> GameState { + let WaitingFor::LoopShortcut { + proposer, + ref schema, + .. + } = state.waiting_for + else { + panic!( + "declare_and_poll expects a LoopShortcut window, got {:?}", + state.waiting_for + ); + }; + let mut s = state.clone(); + apply( + &mut s, + proposer, + GameAction::DeclareShortcut { + count: schema.iteration_count.clone(), + template: None, + }, + ) + .expect("the proposer declares its own offer"); + for _ in 0..8 { + match s.waiting_for { + WaitingFor::RespondToShortcut { player, .. } if player == seat => return s, + WaitingFor::RespondToShortcut { player, .. } => { + apply( + &mut s, + player, + GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }, + ) + .expect("manual Accept advances the APNAP queue"); + } + _ => break, + } + } + panic!( + "reach-guard: {seat:?} must be polled; stopped at {:?}", + s.waiting_for + ); +} + +/// The non-`PassPriority` actions available to `seat`, rendered with the source +/// object's name / zone / controller so a reach-guard failure names the board +/// rather than an opaque id. +fn non_pass_actions(state: &GameState, seat: PlayerId) -> Vec { + probe_actions(state, seat) + .iter() + .filter(|a| !matches!(a, GameAction::PassPriority)) + .map(|a| match a { + GameAction::ActivateAbility { + source_id, + ability_index, + } => { + let o = state.objects.get(source_id); + format!( + "ActivateAbility({source_id:?} {:?} #{ability_index} zone={:?} controller={:?})", + o.map(|o| o.name.clone()), + o.map(|o| o.zone), + o.map(|o| o.controller) + ) + } + other => format!("{other:?}"), + }) + .collect() +} + +// =========================================================================== +// V1 / V5 — ACCEPTANCE (a) on the REAL 4-player board, LIVE PATH. +// =========================================================================== + +/// **The acceptance row.** A real 4-player Dina / Bloodthirsty Conqueror drain, +/// restored through the production chokepoint +/// (`PersistedGameState::into_game_state()`, the same path the server's +/// `from_persisted` and WASM's `decode_restored_game_state` funnel through) and +/// driven beat by beat through the public `apply()` boundary. No `GameScenario` +/// anywhere on this row: a synthetic board going green while the live 4p case +/// failed is a documented failure mode in this lane. +/// +/// The defect, on this exact board: seat P2 controls **`ObjectId(203)`, +/// "Terramorphic Expanse"**, on the battlefield. A non-mana activated ability is +/// unconditionally "meaningful" to stage 1, so the shipped one-stage predicate +/// answered `Shorten { at_iteration: 0 }` — handing P2 a real priority window +/// whose only content is cracking its own fetchland, which cannot touch the +/// drain. Stage 2 answers `Accept`. +/// +/// **V5 — bounded offers are NOT exempt.** MEASURED on this board: the offer +/// mints at beat 21 carrying `predicted_winner: None` and +/// `IterationCount::Fixed(25)`. It is the BOUNDED class, not the `UntilLethal` +/// class the synthetic rows use, and `predicted_winner: None` additionally +/// proves arm (A) cannot be what produces the `Accept` below — only arm (B) +/// can. Re-introducing an `UntilLethal`-only gate makes this row return +/// `Shorten` and fail. +/// +/// **Why the flip set is exactly {P2}, asserted rather than asserted-about.** +/// P1 and P3 are polled on the same board and hold nothing, so they answer at +/// stage 1 and are unaffected. That is the sibling control: the change is +/// surgical, not a blanket flip to `Accept`. +/// +/// MUTANTS — the `Accept` assertion flips under both: +/// * **DROP** (delete stage 2) ⇒ `Shorten { at_iteration: 0 }`, which is the +/// shipped behaviour and therefore the defect itself; +/// * **TRIVIALIZE** (`any_action_may_interfere ≡ true`) ⇒ `Shorten`. +/// +/// The opposite direction is `v1_positive_control_*` below, on this same board. +#[test] +fn v1_live_path_fetchland_seat_accepts_on_the_real_4p_board() { + let mut board = live_path_board(); + assert!( + !matches!(board.waiting_for, WaitingFor::LoopShortcut { .. }), + "reach-guard: the dump must not ship AT an offer — the offer is this drive's product, \ + not its input; got {:?}", + board.waiting_for + ); + let beat = drive_to_offer(&mut board, 400).expect( + "CR 732.2a: the offer must FIRE on this real 4p drain. A failure here is the offer \ + never being raised, not a fixture accident", + ); + let WaitingFor::LoopShortcut { + predicted_winner, + ref schema, + .. + } = board.waiting_for + else { + unreachable!("drive_to_offer only returns at a LoopShortcut window"); + }; + + // ── V5's premise, read off the live offer rather than assumed ── + assert_eq!( + predicted_winner, None, + "V5: the BOUNDED class mints no crown — so arm (A) is structurally unable to produce \ + the Accept below, and only arm (B) can (offer beat {beat})" + ); + assert_eq!( + schema.iteration_count, + IterationCount::Fixed(25), + "V5: a FINITE count is the point — stage 2 takes the identical rule for it and for \ + the UntilLethal class" + ); + + // ── the row: P2, whose only action is its own fetchland ── + let polled = declare_and_poll(&board, P2); + let non_pass = non_pass_actions(&polled, P2); + + assert_eq!( + non_pass.len(), + 1, + "REACH-GUARD: P2 must hold EXACTLY ONE non-pass action. Two would let the fold's \ + .any() reach Shorten through the other one and this row would pass for the wrong \ + reason; zero would make it the stage-1 path; got {non_pass:?}" + ); + assert!( + non_pass[0].contains("Terramorphic Expanse") + && non_pass[0].contains("zone=Some(Battlefield)") + && non_pass[0].contains("controller=Some(PlayerId(2))"), + "REACH-GUARD: that one action must be P2's OWN battlefield fetchland — the object the \ + diagnosis pinned; got {non_pass:?}" + ); + + // NON-VACUITY PIN. The guards above read the FLAT list, which cannot contain + // a BATTLEFIELD mana activation: `candidates.rs` excludes it at generation + // (`!is_mana_ability(&ability_def)`), and a land's `TapLandForMana` is + // additionally dropped by `flat_priority_actions_with_probe`'s + // `GameAction::is_mana_ability` filter. (It CAN contain a hand- or + // graveyard-zone mana activation, which has its own candidate loop and is a + // `GameAction::ActivateAbility` — so the filter never sees it. That class is + // not on this board, and what would catch it is the `non_pass.len() == 1` + // REACH-GUARD above, NOT the assertion below: such an activation is already + // IN the flat list, so it shows up as a second non-pass action, while + // `stage_two_action_set` only APPENDS `meaningful_sacrifice_mana_actions` — + // a non-sacrifice one adds nothing and `stage_two == flat` still holds.) + // The set stage 2 actually folds over is WIDER still — `stage_two_action_set` + // re-admits sacrifice-for-mana activations — so without this the flagship is + // blind to exactly the class that would vacuate the feature: a seat that + // acquired a Lotus-Petal-shaped permanent during the drive would silently + // start Shortening and this row would flip. + let (probe, flat) = engine::ai_support::shortcut_probe(&polled, P2); + let stage_two = engine::ai_support::stage_two_action_set(probe.state(), &flat); + assert_eq!( + stage_two, flat, + "NON-VACUITY: P2 must own NO mana-producing action on this board, so its Accept is \ + produced by the fetchland's confinement and NOT by the absence of a widening. If this \ + fails, the flagship Accept is no longer measuring what it claims — re-derive the row, \ + do NOT relax the assertion" + ); + + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD: stage 1 (POSSIBILITY, untouched here) must still return true. An Accept \ + produced by stage 1 would prove nothing about stage 2 — this is the assertion that \ + makes the row non-vacuous" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Accept, + "ACCEPTANCE (a), LIVE PATH: cracking its own fetchland cannot touch the drain, so P2 \ + must not buy a priority window with it. CR 732.2c is satisfied by ANY different \ + choice, which is exactly why satisfying it carries no efficacy" + ); + + // ── sibling control: the flip set is exactly {P2} ── + for seat in [P1, P3] { + let other = declare_and_poll(&board, seat); + assert!( + !stage_one_meaningful(&other, seat), + "sibling control: {seat:?} holds nothing on this board, so it answers at stage 1 \ + and stage 2 never runs for it; got {:?}", + non_pass_actions(&other, seat) + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&other, seat), + ShortcutResponse::Accept, + "sibling control: {seat:?} is unchanged by this fix — the flip set is exactly {{P2}}" + ); + } +} + +/// The positive control for the row above, on the SAME real board: give P2 a +/// castable Lightning Bolt and it must Shorten. +/// +/// This is what makes `v1_live_path_*`'s `Accept` attributable. The same +/// instrument, on the same restored 4p board, at the same offer, returns BOTH +/// values — so the `Accept` is caused by the fetchland's confinement and not by +/// anything about the board, the beat, or the offer class. Without this row a +/// classifier that answered `Accept` unconditionally would pass. +/// +/// MUTANT: `any_action_may_interfere ≡ false` ⇒ `Accept` — this row flips. +/// +/// This row's expected value (`Shorten`) IS the shipped value, so **DROP cannot +/// flip it**. Its whole discriminating power is the TRIVIALIZE arm named above. +#[test] +fn v1_positive_control_interactive_seat_still_shortens_on_the_real_4p_board() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + let bolt = give_bolt(&mut board, P2); + + let polled = declare_and_poll(&board, P2); + let actions = probe_actions(&polled, P2); + assert!( + actions + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == bolt)), + "REACH-GUARD: the Bolt must actually be castable here, or this control cannot fire; \ + got {:?}", + non_pass_actions(&polled, P2) + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "ACCEPTANCE (b), LIVE PATH: a seat holding real interaction must still get its window. \ + This is the assertion any over-broad confinement rule destroys first" + ); +} + +/// NEGATIVE CONTROL — and its limits are the point. +/// +/// The previously-tracked `dina_conqueror_4p` board is the same matchup at a +/// beat where NO seat holds any meaningful priority action. MEASURED: the offer +/// mints at beat 19 with `predicted_winner: None`, `IterationCount::Fixed(30)`, +/// and all three polled seats (P1, P2, P3) enumerate exactly +/// `["PassPriority"]` — length one, NOT empty; it is +/// `has_meaningful_priority_action` returning false that produces the Accept, +/// not an empty action vector. +/// +/// **This board therefore has ZERO discriminating power for acceptance (a), and +/// it is retained only for what it CAN show.** The defect needs a seat holding +/// a meaningful-but-vacuous action; a board with no such seat cannot exhibit it, +/// so this row passes identically with and without stage 2. Do not promote it +/// to an acceptance row, and do not read its green as evidence about the fix: +/// what it pins is the one-way property that stage 2 must not make a quiet +/// board start Shortening. +#[test] +fn v1_control_quiet_board_is_unchanged_and_cannot_discriminate() { + let mut board = quiet_board(); + let beat = drive_to_offer(&mut board, 400).expect("the quiet board still mints an offer"); + let WaitingFor::LoopShortcut { + predicted_winner, + ref schema, + .. + } = board.waiting_for + else { + unreachable!() + }; + assert_eq!(predicted_winner, None, "bounded class (offer beat {beat})"); + assert_eq!(schema.iteration_count, IterationCount::Fixed(30)); + + for seat in [P1, P2, P3] { + let polled = declare_and_poll(&board, seat); + let actions = probe_actions(&polled, seat); + assert_eq!( + actions, + vec![GameAction::PassPriority], + "the premise of this control: {seat:?} enumerates exactly one action, and it is a \ + pass. If this ever fails the board is no longer quiet and the row's `cannot \ + discriminate` claim needs re-deriving" + ); + assert!( + !stage_one_meaningful(&polled, seat), + "and it is stage 1, not an empty action list, that answers" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, seat), + ShortcutResponse::Accept, + "no-regress: stage 2 must not make a quiet seat start Shortening" + ); + } +} + +// =========================================================================== +// V8 — the SECOND window this authority answers: the pre-cast copy route. +// =========================================================================== + +const PRECAST_EPOCH: u64 = 7; +const PRECAST_BREAKPOINT: u64 = 99; + +/// Re-park an already-polled state at the PRE-CAST responder window, board +/// untouched. +/// +/// Hand-built from the engine's own constructor shape +/// (`game::precast_copy_shortcut::responder_wait`), exactly as the shipped +/// `precast_copy_shortcut.rs` fixture `precast_shortcut_response_state` does. +/// Sound HERE specifically: `smart_shortcut_response` reads `waiting_for` for +/// one thing only (the crown — and this variant carries no crown to read) and +/// then re-parks its own probe clone at `Priority`, so the efficacy answer is a +/// function of the BOARD. Driving a genuine pre-cast copy route would supply a +/// different board, which is the one variable this row must hold fixed against +/// `v1b`/`v3` above. +fn as_precast_window(state: &GameState, seat: PlayerId) -> GameState { + let mut s = state.clone(); + s.waiting_for = WaitingFor::RespondToPrecastCopyShortcut { + player: seat, + epoch: PRECAST_EPOCH, + breakpoint_ids: vec![PRECAST_BREAKPOINT], + remaining_players: Vec::new(), + }; + s +} + +/// The pre-cast reply the PRODUCTION candidate builder emits for this state. +/// Goes through `ai_support::candidate_actions`, i.e. the real consumer at +/// `candidates::candidate_actions_broad_with_probe`, so the +/// `ShortcutResponse` → `PrecastCopyShortcutResponse` mapping is measured too. +fn precast_candidate_response(state: &GameState) -> PrecastCopyShortcutResponse { + let replies: Vec = engine::ai_support::candidate_actions(state) + .iter() + .filter_map(|candidate| match &candidate.action { + GameAction::PrecastCopyShortcut { epoch, response } if *epoch == PRECAST_EPOCH => { + Some(response.clone()) + } + _ => None, + }) + .collect(); + assert_eq!( + replies.len(), + 1, + "reach-guard: the pre-cast responder window must offer exactly one reply candidate, \ + otherwise this row is reading something else; got {replies:?}" + ); + replies[0].clone() +} + +/// `smart_shortcut_response` is the single authority for TWO accept-or-shorten +/// windows, not one: `candidates::candidate_actions_broad_with_probe` routes +/// `WaitingFor::RespondToPrecastCopyShortcut` through it as well and maps the +/// answer onto `PrecastCopyShortcutResponse`. Stage 2 therefore changed behaviour +/// at that window too, and this row measures it instead of assuming it. +/// +/// Uniform treatment is the deliberate choice: both windows ask the identical +/// question — is a real priority window worth taking here — so a seat whose only +/// action cannot touch the loop should decline both. Arm (A) is separately +/// INAPPLICABLE here rather than merely skipped: `RespondToPrecastCopyShortcut` +/// carries no proposal summary and hence no `predicted_winner` field, so there is +/// no crown to read. Stage 1 and arm (B) both apply and both run. +/// +/// NON-VACUITY, and it is arm 2 that supplies it: `candidates.rs` maps a +/// `Shorten` with an EMPTY `breakpoint_ids` back to `Accept`, so on a +/// breakpoint-less prompt both answers would collapse to `Accept` and arm 1 +/// would pass for free. Arm 2 returns `Shorten { breakpoint_id }` off the same +/// staged breakpoint list, which proves the mapping is live and arm 1's `Accept` +/// is the efficacy verdict rather than the collapse. +/// +/// MUTANTS — both RUN, not reasoned about: +/// * `any_action_may_interfere ≡ true` ⇒ arm 1's production-path assertion fails +/// with `left: Shorten { breakpoint_id: 99 }, right: Accept`. This is also the +/// direct measurement of the non-vacuity claim above: the mapping's `Shorten` +/// branch really is reachable on this prompt. +/// * `any_action_may_interfere ≡ false` ⇒ arm 2 fails with +/// `left: Accept, right: Shorten { breakpoint_id: 99 }`. +/// +/// DROP (delete stage 2 entirely) flips arm 1 the same way and leaves arm 2 at +/// its shipped value; the first mutant covers that direction. +#[test] +fn v8_precast_window_takes_the_same_efficacy_answer() { + // Arm 1 — the confined fetchland seat. + let (mut runner, kickoff) = optional_cascade(|s| { + s.add_land_from_oracle(P2, "Terramorphic Expanse", TERRAMORPHIC); + }); + respond_window_at(&mut runner, kickoff, P2); + let fetch_precast = as_precast_window(runner.state(), P2); + + assert!( + stage_one_meaningful(&fetch_precast, P2), + "REACH-GUARD: stage 1 must still say `meaningful` at the PRE-CAST window, or this arm \ + measures the stage-1 path and says nothing about stage 2; got {:?}", + non_pass_actions(&fetch_precast, P2) + ); + // The PRODUCTION-PATH assertion comes first deliberately: it is the one that + // has to discriminate, and an authority-level assertion ahead of it would + // absorb every mutant before the candidate builder was ever exercised. + assert_eq!( + precast_candidate_response(&fetch_precast), + PrecastCopyShortcutResponse::Accept, + "the pre-cast candidate builder must carry stage 2's answer through: a window whose only \ + content is cracking one's own fetchland is worth no more on the pre-cast route than on \ + the generic one" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&fetch_precast, P2), + ShortcutResponse::Accept, + "and the authority itself answers identically at both windows" + ); + + // Arm 2 — the same board plus real interaction. The window is still granted. + let (mut armed, armed_kickoff) = optional_cascade(|s| { + s.add_land_from_oracle(P2, "Terramorphic Expanse", TERRAMORPHIC); + s.add_basic_land(P2, ManaColor::Red); + s.add_bolt_to_hand(P2); + }); + respond_window_at(&mut armed, armed_kickoff, P2); + let armed_precast = as_precast_window(armed.state(), P2); + + assert!( + probe_actions(&armed_precast, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { .. })), + "reach-guard: the Bolt must be castable at the pre-cast window too, or this arm repeats \ + arm 1; got {:?}", + non_pass_actions(&armed_precast, P2) + ); + assert_eq!( + precast_candidate_response(&armed_precast), + PrecastCopyShortcutResponse::Shorten { + breakpoint_id: PRECAST_BREAKPOINT + }, + "ACCEPTANCE (b) on the pre-cast route: a seat holding real interaction keeps its window, \ + named at the breakpoint the engine issued to it. This is also arm 1's non-vacuity proof \ + — the Shorten branch of the mapping is reachable on this exact prompt" + ); +} + +// =========================================================================== +// V9 — COVERAGE INVARIANT: stage 2 classifies everything stage 1 counted. +// =========================================================================== + +/// Krark-Clan Ironworks, verbatim, verified byte-for-byte against MTGJSON +/// `AtomicCards.json` (`.data["Krark-Clan Ironworks"][0].text`; `.types` is +/// `["Artifact"]`). CARD PROVENANCE, in the sense the header block above defines +/// — it is the shape under test, so a paraphrase could take a different parser +/// branch and go green while the real card stayed broken. +/// +/// Why THIS card: its activation is the issue #544 shape — a sacrifice-for-mana +/// ability that `legal_actions` structurally omits while +/// `has_meaningful_priority_action`'s second rung still counts it off `state`. +/// That gap between the two stages' inputs is the whole subject of this section. +const IRONWORKS: &str = "Sacrifice an artifact: Add {C}{C}."; + +/// Stage the Ironworks on `player`'s battlefield, ability taken from the REAL +/// parser (see `give_parsed_card`, which this is now one call into). +/// +/// It was an inlined copy of that helper until the two were diffed field by +/// field and found byte-equivalent — same parse call, same assertion text once +/// `name` is substituted, same `create_object` arguments, same core-type push, +/// same `base_card_types`/`abilities`/`base_abilities` assignment, same +/// `layers_dirty`. Delegating is behaviour-identical BY CONSTRUCTION, and the +/// divergence it prevents already fired once inside this same change: +/// `base_mana_cost` reached one construction path and not the other. +fn give_ironworks(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Krark-Clan Ironworks", + IRONWORKS, + CoreType::Artifact, + Zone::Battlefield, + ) +} + +/// A vanilla artifact for the Ironworks to eat. No abilities and no card claim: +/// it exists so the sacrifice cost is payable, and it must contribute no action +/// of its own or it would give the fold a second way to reach `Shorten`. +fn give_artifact_fodder(state: &mut GameState, player: PlayerId) -> ObjectId { + let id = engine::game::zones::create_object( + state, + CardId(state.next_object_id), + player, + "Test Artifact Fodder".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types + .core_types + .push(engine::types::card_type::CoreType::Artifact); + obj.base_card_types = obj.card_types.clone(); + obj.summoning_sick = false; + state.layers_dirty = LayersDirty::full(); + id +} + +/// The optional-cascade board with an Ironworks + one artifact staged on P2 +/// AFTER the response window opens, so the addition cannot perturb the drive to +/// the offer. +fn ironworks_seat_polled() -> (GameRunner, ObjectId) { + let (mut runner, kickoff) = optional_cascade(|_| {}); + respond_window_at(&mut runner, kickoff, P2); + let ironworks = give_ironworks(runner.state_mut(), P2); + let _fodder = give_artifact_fodder(runner.state_mut(), P2); + (runner, ironworks) +} + +/// The invariant, asserted directly on the set rather than inferred from a +/// verdict: **stage 2 folds over every action stage 1 counted as meaningful.** +/// +/// This is the row that survives a future reclassification. `v9b` below reads +/// the Ironworks' *verdict*, which a later, more precise `filter_is_actor_owned` +/// could legitimately flip (CR 701.21a: "A player can't sacrifice something that +/// isn't a permanent, or something that's a permanent they don't control" — so +/// "Sacrifice an artifact" IS actor-owned in fact, merely not PROVEN so by this +/// AST). This row does not depend on the verdict at all — it +/// pins that the action is *handed to the classifier*, which is what keeps a +/// newly added stage-1 rung from silently reintroducing Accept-by-omission. +/// +/// The three assertions are the defect's three premises, in order: +/// 1. the activation is ABSENT from the flat list (issue #544 grouping), so +/// 2. stage 1 nonetheless counts it — via the `state`-reading second rung — and +/// 3. `stage_two_action_set` therefore has to put it back, or the two stages +/// read different inputs. +/// +/// Revert-probe (EXECUTED, see the report): defining `stage_two_action_set` as +/// `flat_actions.to_vec()` fails assertion 3. +#[test] +fn v9a_stage_two_folds_over_every_action_stage_one_counted() { + let (runner, ironworks) = ironworks_seat_polled(); + let activation = GameAction::ActivateAbility { + source_id: ironworks, + ability_index: 0, + }; + + let (probe, flat) = engine::ai_support::shortcut_probe(runner.state(), P2); + assert!( + !flat.contains(&activation), + "PREMISE 1: sacrifice-for-mana stays out of the flat priority list (issue #544) — if it \ + were present, the two stages would already agree and this row would be vacuous; got \ + {flat:?}" + ); + assert!( + stage_one_meaningful(runner.state(), P2), + "PREMISE 2: stage 1 counts it anyway, off `state` rather than off that list — this is the \ + asymmetry the invariant exists to close; got {flat:?}" + ); + + let stage_two = engine::ai_support::stage_two_action_set(probe.state(), &flat); + assert!( + stage_two.contains(&activation), + "THE INVARIANT: every action stage 1 counted as meaningful must be handed to stage 2. An \ + action the classifier never sees reaches no arm, so the fail-closed default cannot save \ + it and the seat Accepts BY OMISSION; got {stage_two:?}" + ); +} + +/// The response-level discriminator: with the invariant restored, this seat +/// Shortens; without it, it Accepts. +/// +/// It discriminates because the Ironworks activation classifies `MayInterfere`, +/// and since V10 it does so through TWO independent legs. Its `Sacrifice` cost +/// filter (`Typed{Artifact}`) names no controller, so `filter_is_actor_owned` +/// cannot PROVE actor ownership and `cost_window_reach` takes the fail-closed +/// direction; and its `Effect::Mana` head is no longer allowlisted either, so +/// the head alone would carry the verdict. +/// +/// The counterfactual this doc used to carry — "a sacrifice ability whose filter +/// *were* proven actor-owned would classify `OwnResourcesOnly` … i.e. would not +/// discriminate" — is FALSE since `Effect::Mana` left the allowlist, and +/// `v10b` is the row that refutes it on exactly that shape (Lotus Petal's +/// `SelfRef` sacrifice IS proven actor-owned, and the seat still Shortens). What +/// survives is the sentence's purpose: the unproven filter is still what makes +/// THIS row's own MUTANT discriminate, because that mutant deletes the widening +/// rather than touching the classifier. +/// +/// REACH-GUARD: the flat list is asserted to be EXACTLY `[PassPriority]`. That +/// is what makes the verdict attributable: `PassPriority` is the classifier's +/// one `false` arm, so the flat half cannot reach `Shorten` on its own and the +/// only action that can is the one the widening added. +/// +/// MUTANT (EXECUTED, see the report): `stage_two_action_set ≡ flat_actions +/// .to_vec()` — i.e. delete the widening — flips this row to `Accept`. +/// +/// This row's verdict is now OVER-DETERMINED, which changes what a future +/// refinement does to it. The doc used to predict that a `filter_is_actor_owned` +/// which learns to prove "Sacrifice an artifact" actor-owned (CR 701.21a bounds +/// the actor to permanents they CONTROL, which is the fact such a refinement +/// would be reading) would red this row; it will not, because the unallowlisted +/// `Effect::Mana` head carries `MayInterfere` unconditionally. The guidance the +/// prediction carried still stands and is the part to keep: if this row ever +/// does red, re-derive the fixture on an ability whose reach is genuinely +/// unproven — NOT delete the row, and NOT weaken the classifier. Its +/// discriminating power comes from its MUTANT rather than from the cost filter's +/// imprecision, and `v9a` above holds the invariant meanwhile. +#[test] +fn v9b_a_sacrifice_for_mana_seat_still_gets_its_window() { + let (runner, ironworks) = ironworks_seat_polled(); + let activation = GameAction::ActivateAbility { + source_id: ironworks, + ability_index: 0, + }; + + let flat = probe_actions(runner.state(), P2); + assert_eq!( + flat, + vec![GameAction::PassPriority], + "REACH-GUARD: the flat half must be exactly the one action the classifier answers `false` \ + on, or a `Shorten` here is not attributable to the widening; got {flat:?}" + ); + assert!( + stage_one_meaningful(runner.state(), P2), + "reach-guard: stage 1 must return true, or the seat resolves at stage 1 and never reaches \ + the fold under test" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(runner.state(), P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "a seat whose only meaningful action is {activation:?} must keep its window: the AST does \ + not prove the sacrificed artifact is this seat's own, and stage 2 must not Accept a \ + reach it cannot rule out" + ); +} + +// =========================================================================== +// V10 — MANA IS FUNGIBLE REACH, not a confined own resource. +// +// CR 106.4's first sentence ("that mana goes into a player's mana pool") is the +// half an earlier `Effect::Mana` arm quoted; the rest of the same rule says the +// mana "can be used to pay costs immediately", CR 106.1 says paying costs is +// mana's whole function, and CR 601.2g runs mana abilities during the very cast +// they fund. So producing mana widens what the polled seat can do inside the +// window `game::engine`'s `RespondToShortcut(Shorten)` arm hands back, and the +// classifier — which reads ONE ability's AST and no other object — cannot prove +// otherwise. Both rows below ride the REAL 4p board through the same production +// chokepoint the flagship uses. +// =========================================================================== + +/// Dark Ritual, verbatim. CARD PROVENANCE in the sense the header block defines. +/// The `Effect::Mana` head with `cost: None` is the point — nothing but the head +/// can carry this object's verdict. +const DARK_RITUAL: &str = "Add {B}{B}{B}."; + +/// Lotus Petal, verbatim. CARD PROVENANCE. This is the maintainer's named +/// "actor-owned sacrifice-for-mana" class: the parser emits "Sacrifice this +/// artifact" as a `TargetFilter::SelfRef`, which `filter_is_actor_owned` +/// returns true for, so the cost leg reads confined and the mana head carries +/// the verdict ALONE. CR 701.21a bounds the actor to permanents they CONTROL, +/// which is the most that predicate can be grounded in — its first sentence +/// sends the sacrificed card to its OWNER's graveyard, so control is not +/// ownership and "confined" is narrower than the predicate's name suggests. +/// `shortcut_efficacy`'s `mana_production_is_reach_not_a_confined_own_resource` +/// quotes the rule in full and names the limit; nothing here rests on it, +/// because the mana head decides this verdict either way. That is also what +/// makes this row not a second `v9b`, +/// whose Ironworks reaches the same verdict through an UNPROVEN cost filter. +const LOTUS_PETAL: &str = "{T}, Sacrifice this artifact: Add one mana of any color."; + +/// Sol Ring, verbatim. CARD PROVENANCE. The ORDINARY mana source: no sacrifice +/// leg, so `mana_ability_penalty` is `None` rather than `Sacrifices` and the +/// stage-2 widening must not re-admit it. +const SOL_RING: &str = "{T}: Add {C}{C}."; + +/// Crop Rotation, verbatim, BOTH lines. CARD PROVENANCE (MTGJSON +/// `.data["Crop Rotation"][0].text`). The UNTAPPED fetch — the residual the +/// `enter_tapped` gate closes, and the class `Terramorphic Expanse` is NOT. +/// +/// MEASURED on the live parser: this text yields exactly ONE ability with +/// `cost: None` — the additional-cost line is not carried — heading +/// `SearchLibrary { filter: Typed[Land], target_player: None }` over a +/// `ChangeZone { Library -> Battlefield, target: Any, enter_tapped: Unspecified }` +/// over `Shuffle { Controller }`. So every leg but the tap state reads confined, +/// which is precisely why this card was allowlisted before the gate. Its search +/// filter is ANY land card, which is what lets the funding lemma below find a +/// real basic Swamp in P2's own recorded library instead of staging one. +const CROP_ROTATION: &str = "As an additional cost to cast this spell, sacrifice a land.\n\ + Search your library for a land card, put that card onto the \ + battlefield, then shuffle."; + +/// Rampant Growth, verbatim. CARD PROVENANCE. The TAPPED sibling and the whole +/// point of the matched pair: one printed word apart from Crop Rotation on every +/// axis this classifier reads, and the fetched land arrives unable to pay for +/// anything. +const RAMPANT_GROWTH: &str = "Search your library for a basic land card, put that card onto \ + the battlefield tapped, then shuffle."; + +/// Reshape the Earth, verbatim. CARD PROVENANCE — Oracle text verified on +/// Scryfall (`/cards/named?exact=Reshape+the+Earth`), a Sorcery whose whole +/// printed text is this one sentence. +/// +/// Rampant Growth's UNRESTRICTED sibling, and the second one-word-apart pair in +/// this file: identical AST on every axis this classifier reads — no cost, one +/// ability, `SearchLibrary` over `ChangeZone { Library -> Battlefield, target: +/// Any, enter_tapped: Tapped }` over `Shuffle { Controller }`, no triggers, no +/// statics, no replacements, no keywords — and the search filter is +/// `Typed[Land]` where Rampant Growth's is `Typed[Land] + HasSupertype(Basic)`. +/// So the only thing that can separate their verdicts is WHICH CARDS THE ACTOR +/// COULD SELECT. +/// +/// Elvish Reclaimer is the card the review named for this row and it CANNOT +/// serve, which is worth stating rather than leaving as an unexplained +/// substitution. Its ability costs "Sacrifice a land" — parsed +/// `AbilityCost::Sacrifice(Typed[Land])` with `controller: null` — and +/// `filter_is_actor_owned` proves nothing about an unqualified `Typed`, so +/// `cost_window_reach` already answers `MayInterfere` for it. It reads +/// `Shorten` with the selection gate and `Shorten` without it: an +/// over-determined row that would go green while measuring nothing. +const RESHAPE_THE_EARTH: &str = "Search your library for up to ten land cards, put them onto \ + the battlefield tapped, then shuffle."; + +/// Stage a real card with its abilities taken from the REAL parser, not +/// hand-built: every verdict below is a function of the AST, so a hand-written +/// `AbilityDefinition` would let this section pass against a shape the pipeline +/// never produces. Parameterized over the three axes the V10 rows vary, and the +/// SINGLE staging path for parsed cards in this file — `give_ironworks` above +/// delegates here rather than keeping the byte-equivalent copy it used to be. +fn give_parsed_card( + state: &mut GameState, + player: PlayerId, + name: &str, + oracle: &str, + core_type: CoreType, + zone: Zone, +) -> ObjectId { + let parsed = engine::parser::oracle::parse_oracle_text(oracle, name, &[], &[], &[]); + assert_eq!( + parsed.abilities.len(), + 1, + "PREMISE: {name} parses to exactly one ability; got {:?}", + parsed.abilities + ); + let id = engine::game::zones::create_object( + state, + CardId(state.next_object_id), + player, + name.to_string(), + zone, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(core_type); + obj.base_card_types = obj.card_types.clone(); + // No `summoning_sick = false` here: it would be a no-op that reads as + // load-bearing. `zones::create_object` documents that it deliberately does + // NOT set the flag (only the real ETB pipeline's + // `reset_for_battlefield_entry` does), `add_to_zone` never touches it, and + // `GameObject::new` already defaults it to `false`. + obj.abilities = std::sync::Arc::new(parsed.abilities.clone()); + obj.base_abilities = std::sync::Arc::new(parsed.abilities); + state.layers_dirty = LayersDirty::full(); + id +} + +/// The Ritual is staged as an INSTANT and with no printed mana cost, and both +/// are deliberate. Without a core type the sorcery-timing gate refuses the cast +/// at this window and the funder never enters the action set — the row would go +/// vacuous silently. And Dark Ritual's real printed cost is `{B}` while P2 +/// controls no mana source, so a printed-cost Ritual would itself be uncastable +/// and the row would measure an empty board twice. What the row measures is the +/// CLASSIFIER, which reads the AST and never the mana cost. +fn give_dark_ritual(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Dark Ritual", + DARK_RITUAL, + CoreType::Instant, + Zone::Hand, + ) +} + +/// THE SIZING SITE. This source contributes **exactly 1** to +/// `game::mana_sources`' `feasible_mana_capacity`: its `AnyOneColor` production +/// carries `count: Fixed { value: 1 }`, and that arm of +/// `game::effects::mana`'s `resolve_mana_types_for_ability` returns +/// `vec![mana_type; amount]` — length `amount`, NOT `color_options.len()`. +/// +/// MEASURED on this fixture, the only mana-gated action P2 owns at this window +/// is Angel of the Ruins' hand-zone plainscycling (object 210), whose cost is +/// `Composite[Mana{generic 2}, Discard{self_ref}]` — `{2}` generic. **The margin +/// is exactly 1 mana.** ANY staged P2 source contributing 2 or more unlocks that +/// cycling, puts an `ActivateAbility` in P2's flat list, and destroys `v10b`'s +/// attribution — its `non_pass` assertion is what fails, loudly, if that +/// happens. A future edit that raises this source's capacity silently destroys +/// the discrimination, so do NOT "strengthen" it. +fn give_lotus_petal(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Lotus Petal", + LOTUS_PETAL, + CoreType::Artifact, + Zone::Battlefield, + ) +} + +/// Both fetches are staged as INSTANTS with no printed mana cost, for +/// `give_dark_ritual`'s reasons: without a core type the sorcery-timing gate +/// refuses the cast at this window and the funder never enters the action set, +/// and P2 controls no mana source so a printed cost would make the funder itself +/// uncastable and the row would measure an empty board twice. +/// +/// The type is card-faithful for Crop Rotation (a real instant) and is NOT for +/// Rampant Growth (a real sorcery). That deviation is deliberate and stated: the +/// control's subject is the TAP STATE of the fetched land, and putting the two +/// fetches on different timing rails would confound exactly that axis. +fn give_crop_rotation(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Crop Rotation", + CROP_ROTATION, + CoreType::Instant, + Zone::Hand, + ) +} + +/// The tapped half of `v10c`'s pair. See `give_crop_rotation` for the staging. +fn give_rampant_growth(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Rampant Growth", + RAMPANT_GROWTH, + CoreType::Instant, + Zone::Hand, + ) +} + +/// Cast `fetch` at P2's OWN probe priority and drive real `apply()` beats until +/// it resolves, selecting a basic **Swamp** at the search prompt. Returns the +/// resulting state and the fetched land. +/// +/// The Swamp is chosen by NAME rather than taken as the prompt's first offer: +/// several of the lands in P2's recorded library carry their own "enters tapped" +/// clause (Path of Ancestry, Goldmire Bridge, Temple of Silence, ...), and one of +/// those would make the funding lemma measure the FETCHED CARD's printed text +/// instead of the fetch effect's `enter_tapped` rider — the very axis under test. +fn resolve_fetch_choosing_a_swamp(arm: &GameState, fetch: ObjectId) -> (GameState, ObjectId) { + let (probe, list) = engine::ai_support::shortcut_probe(arm, P2); + let cast = list + .iter() + .find(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == fetch)) + .cloned() + .expect("reach-guard: the fetch must be castable at P2's own probe priority"); + let mut state = probe.state().clone(); + // This drive is the first thing in this file to reach a library SHUFFLE, and + // a shuffle is the one operation that reads the live ChaCha20 stream. + // `restore_dump` above decodes through `into_game_state()`, which does NOT + // reseed the `#[serde(skip)]` `rng` — production's restore does that in a + // SECOND step (`engine-wasm`'s `restore_game_state` calls + // `GameState::rehydrate_rng` right after decoding, issue #5466). Without it + // the live stream sits at offset 0 while this dump's `rng_word_pos` is 313, + // and `game::library`'s `capture_rng_word_pos` fails the entropy-high-water + // invariant. Doing it here rather than in `restore_dump` keeps every other + // row in this file byte-identical: the reseed lands only on this quarantined + // funding clone, which no verdict is ever taken on. + state.rehydrate_rng(); + apply(&mut state, P2, cast).expect("the fetch casts at P2's own probe priority"); + + let mut chosen: Option = None; + for _ in 0..60 { + let prompt = match &state.waiting_for { + WaitingFor::SearchChoice { player, cards, .. } => Some((*player, cards.clone())), + _ => None, + }; + if let Some((player, cards)) = prompt { + let swamp = cards + .iter() + .copied() + .find(|id| state.objects.get(id).is_some_and(|o| o.name == "Swamp")) + .expect("P2's recorded library must offer a basic Swamp to this search"); + apply( + &mut state, + player, + GameAction::SelectCards { cards: vec![swamp] }, + ) + .expect("the searcher selects the Swamp"); + chosen = Some(swamp); + continue; + } + if chosen.is_some() && !state.stack.iter().any(|entry| entry.id == fetch) { + break; + } + dump_drive_one_beat(&mut state).expect("passing priority resolves the top of the stack"); + } + ( + state, + chosen.expect("reach-guard: the fetch must have PROMPTED a library search"), + ) +} + +/// Capacity **2** (`Colorless { count: Fixed 2 }`), which is why the control it +/// serves asserts re-admission ONLY and never a verdict — see `v10b`. +fn give_sol_ring(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Sol Ring", + SOL_RING, + CoreType::Artifact, + Zone::Battlefield, + ) +} + +/// V10a — a CAST mana spell funds an otherwise-unaffordable answer, so the seat +/// must keep its window. +/// +/// The pair varies exactly one object: a Dark Ritual in P2's hand. Both arms +/// hold the same `{B}{B}{B}` Bolt, and assertion 1 is the operational definition +/// of "otherwise-unaffordable" — `feasible_mana_capacity` is battlefield-scoped, +/// so a Ritual sitting in HAND contributes 0 and the castability gate +/// structurally cannot see "cast a ritual first, then the Bolt". That two-step +/// is exactly what the priority window buys, and CR 601.2g / CR 117.1d are the +/// rules that make the mana available to pay a cost the moment it is produced. +/// +/// BOTH halves of "otherwise-unaffordable" are measured, as in `v10b`. The +/// negative half is assertion 1 above, in both arms. The positive half is the +/// QUARANTINED funding lemma at the end of the row: the Ritual is driven through +/// the stack on the `apply()` boundary and the SAME Bolt is re-probed, so the +/// arithmetic (`{B}{B}{B}` added against `{B}{B}{B}` printed) is read off the +/// engine's castability gate rather than off the verbatim texts. +/// +/// MUTANT: restoring `Effect::Mana {..} => WindowReach::OwnResourcesOnly` as +/// `effect_window_reach`'s first arm flips the SHORTEN arm to `Accept`. Under it +/// the Ritual's single ability folds `OwnResourcesOnly` (head `Effect::Mana`, +/// `cost: None`), and P2's remaining actions are `PassPriority` — the +/// classifier's one `false` arm — and the fetchland, whose verdict rides +/// untouched arms. The ACCEPT arm is unaffected by the mutation by construction: +/// its action set carries no `Effect::Mana` node at all. +/// +/// Every `GameAction::CastSpell` matcher below binds `{ object_id, .. }` and +/// must NOT name `payment_mode`: a Petal- or Ritual-funded cast can be offered +/// as `CastPaymentMode::AutoExceptSacrificialMana`, and a mode-specific matcher +/// would fail with a message claiming the funding does not work. +#[test] +fn v10a_a_cast_mana_spell_that_funds_an_unaffordable_answer_keeps_its_window() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + // ONE drive, ONE poll, shared by both arms — so staging cannot perturb the + // drive, the offer schema, or the APNAP walk. + let polled = declare_and_poll(&board, P2); + + let mut base = polled.clone(); + let bolt = give_bolt_with_cost( + &mut base, + P2, + // `{B}{B}{B}` — sized to exactly what one Dark Ritual adds. + ManaCost::Cost { + shards: vec![ManaCostShard::Black; 3], + generic: 0, + }, + ); + + // ── arm ACCEPT: the interaction alone ── + let accept_arm = base.clone(); + // ── arm SHORTEN: same board, same Bolt, PLUS the funding piece ── + let mut shorten_arm = base.clone(); + let ritual = give_dark_ritual(&mut shorten_arm, P2); + + for (label, arm) in [("ACCEPT", &accept_arm), ("SHORTEN", &shorten_arm)] { + assert!( + !probe_actions(arm, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == bolt) + ), + "PREMISE ({label} arm): the Bolt must be OTHERWISE-UNAFFORDABLE at poll time — a \ + hand-zone Ritual is invisible to the battlefield-scoped capacity scan, so the \ + castability gate cannot see the two-step the window buys; got {:?}", + non_pass_actions(arm, P2) + ); + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label} arm): stage 1 must return true, or the seat answers at stage 1 \ + and the fold under test never runs" + ); + } + + assert!( + probe_actions(&shorten_arm, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == ritual)), + "reach-guard: the FUNDER must really be castable, or the SHORTEN arm is the ACCEPT arm \ + with extra steps; got {:?}", + non_pass_actions(&shorten_arm, P2) + ); + + let shorten_non_pass = non_pass_actions(&shorten_arm, P2); + assert_eq!( + shorten_non_pass.len(), + 2, + "ATTRIBUTION + THRESHOLD SENTINEL, mirroring `v10b`'s: the SHORTEN arm must be the \ + ACCEPT arm's single fetchland PLUS the Ritual cast, and nothing else. The Ritual sits \ + in `Zone::Hand`, which the battlefield-scoped `feasible_mana_capacity` cannot see, so \ + it unlocks no third action — but that is DERIVED, and a fixture or capacity change \ + that added one `MayInterfere` action here would over-determine this row silently \ + instead of reddening. MEASURED at 2 on the committed fixture; got {shorten_non_pass:?}" + ); + + let accept_non_pass = non_pass_actions(&accept_arm, P2); + assert_eq!( + accept_non_pass.len(), + 1, + "ATTRIBUTION: the ACCEPT arm's action set must be the flagship's exactly, so its Accept \ + is the already-shipped verdict and the pair's ONLY variable is the Ritual; got \ + {accept_non_pass:?}" + ); + assert!( + accept_non_pass[0].contains("Terramorphic Expanse") + && accept_non_pass[0].contains("zone=Some(Battlefield)") + && accept_non_pass[0].contains("controller=Some(PlayerId(2))"), + "ATTRIBUTION: that one action must be P2's OWN battlefield fetchland; got \ + {accept_non_pass:?}" + ); + + // IDENTITY, not just cardinality. The sentinel above bounds the SHORTEN + // arm's COUNT; this bounds its MEMBERSHIP, which is what that sentinel's + // prose actually claims ("the ACCEPT arm's single fetchland PLUS the Ritual + // cast, and nothing else"). Without this pair, a fixture or capacity change + // that DROPPED the fetchland and added some unrelated `MayInterfere` action + // still satisfies `len() == 2` AND the ritual reach-guard, and the row + // silently measures the wrong pair. + // + // The fetchland is the same fixture object in both arms — both are clones + // of one `base`, and only the Ritual is staged on top — so + // `accept_non_pass[0]` is reusable verbatim instead of re-typing the three + // `contains` substrings. MEASURED: the two formatted strings are byte-equal + // (`ObjectId(203)` in both arms). + // + // The Ritual leg is matched on `object_id` ONLY. It must NOT name + // `payment_mode`, for the reason this test's doc comment gives. + let (_ritual_leg, other_legs): (Vec<&String>, Vec<&String>) = shorten_non_pass + .iter() + .partition(|a| a.starts_with(&format!("CastSpell {{ object_id: {ritual:?},"))); + assert_eq!( + other_legs, + vec![&accept_non_pass[0]], + "ATTRIBUTION: the SHORTEN arm's set MINUS the Ritual must be the ACCEPT arm's set \ + EXACTLY — same fetchland object, same zone, same controller. Anything else means the \ + pair varies more than the single object it claims to vary, and the Shorten verdict below \ + is no longer attributable to the Ritual. This one equality also pins the Ritual leg: if \ + the partition matched nothing, `other_legs` carries both members and reddens; if it \ + matched both, `other_legs` is empty and reddens; got {shorten_non_pass:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_arm, P2), + ShortcutResponse::Accept, + "the pair's negative arm: an unaffordable answer and a confined fetchland buy nothing" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "the pair's positive arm: producing mana is REACH (CR 106.1 / CR 106.4 / CR 601.2g). \ + The ritual is the one object that differs, and accepting here surrenders a live out" + ); + + // ── FUNDING LEMMA (CR 117.1d + CR 601.2g), deliberately QUARANTINED ── + // + // The negative half is already asserted above: the `{B}{B}{B}` Bolt is NOT + // castable in EITHER arm at poll time. This is the positive half — the half + // this row's title claims ("funds an unaffordable answer") — measured on the + // production instrument rather than left to the verbatim texts' arithmetic. + // + // QUARANTINE, mirroring `v10b`'s lemma: this clone NEVER reaches + // `smart_shortcut_response`. Both verdicts above are already taken; resolving + // the Ritual inside an arm would change the very action set they were taken + // on, and the funded board additionally unlocks the Angel's `{2}` cycling + // (`give_lotus_petal`'s capacity note), which carries `MayInterfere` on a + // route this pair does not model. + let (probe, probe_list) = engine::ai_support::shortcut_probe(&shorten_arm, P2); + let cast_ritual = probe_list + .iter() + .find(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == ritual)) + .cloned() + .expect("the funder is castable — the reach-guard above asserts exactly this"); + let mut funded = probe.state().clone(); + apply(&mut funded, P2, cast_ritual).expect("the funder casts at P2's own probe priority"); + // Pass beats until the Ritual leaves the stack: it is on top, so the first + // full pass round resolves it (MEASURED: 4 beats on this 4p board). + for _ in 0..40 { + if !funded.stack.iter().any(|entry| entry.id == ritual) { + break; + } + dump_drive_one_beat(&mut funded).expect("passing priority resolves the top of the stack"); + } + assert_eq!( + funded.objects.get(&ritual).map(|o| o.zone), + Some(Zone::Graveyard), + "reach-guard: the Ritual must have RESOLVED, not merely been cast — CR 608.2n puts a \ + resolved instant into its owner's graveyard, so this is the observable that separates \ + 'it resolved' from 'the drive capped out' and would otherwise red the funding \ + assertion below for the wrong reason" + ); + assert!( + probe_actions(&funded, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == bolt)), + "FUNDING: with the Ritual resolved, the engine's own castability gate says the SAME \ + `{{B}}{{B}}{{B}}` Bolt that is unaffordable in BOTH arms above IS payable. That is the \ + two-step the priority window buys (CR 117.1d / CR 601.2g), measured rather than \ + inferred from the printed texts; got {:?}", + non_pass_actions(&funded, P2) + ); +} + +/// V10b — an ACTOR-OWNED sacrifice-for-mana seat keeps its window. This is the +/// maintainer's named class, and it is the row `v9b` structurally cannot be. +/// +/// The pair varies exactly ONE object: a Lotus Petal on P2's battlefield. No +/// Bolt, no Swamp, no second permanent. +/// +/// **CAPACITY THRESHOLD — the sizing this row lives or dies on.** The staged +/// source contributes exactly **1** to `feasible_mana_capacity` (see +/// `give_lotus_petal`), and the only mana-gated action P2 owns at this window is +/// Angel of the Ruins' hand-zone plainscycling at `{2}` generic. **The margin is +/// exactly 1 mana.** ANY staged P2 source contributing 2 or more unlocks that +/// cycling, puts an `ActivateAbility` in P2's flat list, and destroys this row's +/// attribution — the `non_pass` assertion below is what fails, loudly, if that +/// happens. A row that reds that way is a fixture-threshold artifact, not a +/// defect in the classifier: check whether `non_pass_actions` names object 210 +/// first, shrink the staged source, and do NOT respond by relaxing an +/// `Effect::Mana` classification. +/// +/// **P2's full reachable surface at this window, enumerated so the threshold is +/// not a claim about the hand alone.** MEASURED from the committed fixture: +/// battlefield 1 (Terramorphic Expanse, capacity 0); hand 7, of which six are +/// sorcery-speed (`Victimize`, `Plains`, `Arcane Signet`, `Commander's Sphere`, +/// `Compleated Huntmaster`, `Night's Whisper`) and only the Angel offers a +/// mana-gated instant-speed action; command zone 1 — `Brimaz, Blight of +/// Oreskos`, `{2}{W}{B}`, a CREATURE, and `format_config.command_zone` is true, +/// so `casting::spell_objects_available_to_cast`'s `Zone::Command` clause does +/// put it inside the candidate loop. The fixture is `active_player` 0 in +/// `CombatDamage` with priority at P2, so it is neither P2's turn nor a main +/// phase and sorcery-speed timing blocks Brimaz — and no amount of mana lifts a +/// timing gate. Library-zone activations are gated `is_active && stack_empty` +/// and P2 is not active. So `{2}` really is the whole threshold, over the whole +/// surface rather than over the hand. +/// +/// MUTANT: restoring `Effect::Mana {..} => WindowReach::OwnResourcesOnly` flips +/// the SHORTEN arm to `Accept`. The Petal's cost leg is ALREADY +/// `OwnResourcesOnly` — `Composite[Tap, Sacrifice{SelfRef}]`, and +/// `filter_is_actor_owned` proves `SelfRef` at its first match arm — so with the +/// mana arm restored the whole ability folds `OwnResourcesOnly`. That is exactly +/// why this row is not a second `v9b`, whose verdict rides its UNPROVEN cost +/// filter and is untouched by the mutation. +#[test] +fn v10b_an_actor_owned_sacrifice_for_mana_seat_keeps_its_window() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + let polled = declare_and_poll(&board, P2); + + // ── arm ACCEPT: the polled board, untouched ── + let accept_arm = polled.clone(); + // ── arm SHORTEN: the polled board plus ONE object ── + let mut shorten_arm = polled.clone(); + let petal = give_lotus_petal(&mut shorten_arm, P2); + let activation = GameAction::ActivateAbility { + source_id: petal, + ability_index: 0, + }; + + assert!( + !probe_actions(&shorten_arm, P2).contains(&activation), + "PREMISE: the Petal's ability IS a mana ability (CR 605.1a), so candidate generation \ + excludes it from the flat list outright. This is the issue-#544 asymmetry the whole \ + V9/V10 section exists for — if it were present, stage 2 would already see it and the \ + widening below would be vacuous" + ); + + let non_pass = non_pass_actions(&shorten_arm, P2); + assert_eq!( + non_pass.len(), + 1, + "ATTRIBUTION + THRESHOLD SENTINEL: if the Petal's mana made ANY P2 action affordable — a \ + hand card, or the Angel's {{2}} cycling — it would appear here and could carry \ + MayInterfere independently of the arm under test. See this row's capacity-threshold \ + note before touching the staged source; got {non_pass:?}" + ); + assert!( + non_pass[0].contains("Terramorphic Expanse") + && non_pass[0].contains("zone=Some(Battlefield)") + && non_pass[0].contains("controller=Some(PlayerId(2))"), + "ATTRIBUTION: that one action must still be P2's OWN battlefield fetchland; got \ + {non_pass:?}" + ); + + let (probe, flat) = engine::ai_support::shortcut_probe(&shorten_arm, P2); + let mut expected = flat.clone(); + expected.push(activation); + assert_eq!( + engine::ai_support::stage_two_action_set(probe.state(), &flat), + expected, + "the widening added EXACTLY the Petal. Order is determinate — `stage_two_action_set` is \ + the flat list chained with the meaningful sacrifice-mana actions — and the penalty is \ + `Sacrifices` because `mana_ability_penalty`'s FIRST clause is `cost_includes_sacrifice`, \ + which inspects `Composite` legs" + ); + + let (accept_probe, accept_flat) = engine::ai_support::shortcut_probe(&accept_arm, P2); + assert_eq!( + engine::ai_support::stage_two_action_set(accept_probe.state(), &accept_flat), + accept_flat, + "the negative half of the widening, on the same instrument: without the Petal there is \ + nothing to re-admit" + ); + + for (label, arm) in [("ACCEPT", &accept_arm), ("SHORTEN", &shorten_arm)] { + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label} arm): stage 1 must return true, or the seat answers at stage 1 \ + and the fold under test never runs" + ); + } + + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_arm, P2), + ShortcutResponse::Accept, + "the pair's negative arm — and it is the flagship's own verdict on the flagship's own \ + action set" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "the pair's positive arm: an actor-owned sacrifice-for-mana activation is two board \ + events, not a confined own resource. Stage 1 re-admits it PRECISELY because the \ + sacrifice is board-changing, so classifying it as confined here contradicted the stage \ + that handed it over" + ); + + // ── FUNDING LEMMA (CR 117.1d + CR 601.2g), deliberately QUARANTINED ── + // + // These two clones NEVER reach `smart_shortcut_response`. Staging the probe + // spell into an arm above would put a `CastSpell` in the flat list whose own + // `Effect::DealDamage` reaches the fail-closed arm — the pair would then + // Shorten with the fix reverted and would stop measuring anything. The + // separation IS the design; do not merge them. + // + // `{1}` generic on purpose: a generic residual is decided by comparing the + // summed capacity against it, so both halves are decided on one read path + // with zero slack. Without the Petal, P2's battlefield is one Terramorphic + // Expanse, whose ability heads `SearchLibrary` and contributes 0, so the sum + // is 0 < 1; with the Petal it is exactly 1 >= 1. + let mut funded = shorten_arm.clone(); + let probe_spell = give_bolt_with_cost(&mut funded, P2, ManaCost::generic(1)); + assert!( + probe_actions(&funded, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == probe_spell) + ), + "FUNDING: with the Petal on the battlefield the engine's own castability gate says a \ + {{1}} interaction IS payable — by activating a mana ability during cost payment, which \ + is exactly what happens inside the window this row buys (CR 117.1d / CR 601.2g); got \ + {:?}", + non_pass_actions(&funded, P2) + ); + + let mut unfunded = accept_arm.clone(); + let unfunded_spell = give_bolt_with_cost(&mut unfunded, P2, ManaCost::generic(1)); + assert!( + !probe_actions(&unfunded, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == unfunded_spell) + ), + "FUNDING, negative half: the SAME interaction on the SAME board minus the Petal is NOT \ + payable. Together with the assertion above, the Petal's mana is what makes it reachable \ + — which is the whole content of 'otherwise-unaffordable'; got {:?}", + non_pass_actions(&unfunded, P2) + ); + + // ── the ORDINARY-mana-source control: plain mana never leaks into stage 2 ── + let mut ordinary = polled.clone(); + let sol_ring = give_sol_ring(&mut ordinary, P2); + let (ordinary_probe, ordinary_flat) = engine::ai_support::shortcut_probe(&ordinary, P2); + // REACH-GUARD — the assertion the two negatives below rest on. `stage_two_action_set` + // filters `activatable_object_mana_actions`, and on a probe state parked at + // `Priority { player }` that IS + // `mana_sources::activatable_mana_actions_for_player(state, player)` — the same call, + // routed through `mana_action_player`'s `Priority` arm. Asserting the Sol Ring is IN + // that sweep is what makes "absent from the stage-2 set" attributable to the penalty + // filter; without it, the two negatives below cannot tell `ManaSourcePenalty::None` + // apart from "this object was never swept as a mana source at all". + assert!( + engine::game::mana_sources::activatable_mana_actions_for_player(ordinary_probe.state(), P2) + .contains(&GameAction::ActivateAbility { + source_id: sol_ring, + ability_index: 0, + }), + "REACH-GUARD: the Sol Ring must reach the sweep stage 2 filters, or the negatives \ + below pass because nothing was ever there to re-admit — which is not the \ + `None`-vs-`Sacrifices` penalty distinction this control claims to measure" + ); + assert!( + !ordinary_flat.iter().any( + |a| matches!(a, GameAction::ActivateAbility { source_id, .. } if *source_id == sol_ring) + ), + "candidate generation excludes a mana ability outright (CR 605.1a); got {ordinary_flat:?}" + ); + assert_eq!( + engine::ai_support::stage_two_action_set(ordinary_probe.state(), &ordinary_flat), + ordinary_flat, + "NON-VACUITY: an ordinary mana source has penalty `None`, not `Sacrifices`, so the \ + stage-2 widening does NOT re-admit it. The Lotus Petal DOES enter this same set in this \ + same test, so this emptiness is a measurement and not a stuck instrument" + ); + // NO verdict assertion here, deliberately. Sol Ring contributes 2 to + // `feasible_mana_capacity`, which is enough to pay Angel of the Ruins' + // hand-zone plainscycling already sitting in P2's hand on this fixture. That + // activation enters the FLAT list — MEASURED, because the withheld verdict + // rests on it and a stale fixture could silently make it false: + let ordinary_non_pass = non_pass_actions(&ordinary, P2); + assert!( + ordinary_non_pass.iter().any(|a| { + a.contains("Angel of the Ruins") + && a.contains("#0 zone=Some(Hand)") + && a.contains("controller=Some(PlayerId(2))") + }), + "the Sol Ring's 2 mana must unlock the Angel's {{2}} plainscycling — this is the fact \ + that makes withholding a verdict assertion correct rather than evasive. Ability index 0 \ + is MEASURED off this fixture, never assumed: the Angel (object 210) carries EXACTLY ONE \ + parsed ability and it is the plainscycling one — `ability_tag: Cycling`, \ + `activation_zone: Hand`, cost `Composite[Mana{{generic 2}}, Discard{{self_ref}}]`, \ + effect `SearchLibrary` filtered to `Subtype(Plains)`. A bare name match would also be \ + satisfied by some OTHER Angel activated ability, or by an Angel in a zone whose \ + activation this Sol Ring does not pay for, neither of which supports the withheld \ + verdict. If this reddens, first check whether the INDEX shifted (a newly parsed second \ + ability) before concluding the semantics changed; got {ordinary_non_pass:?}" + ); + // It is `MayInterfere` on two independent legs (`AbilityCost::Discard` is not + // allowlisted; its `sub_ability` moves a card to a HAND, which the anaphoric + // fetch disjunct does not cover), so this seat answers Shorten — through + // candidate affordability, a route this control does not model and claims + // nothing about. Asserting Accept here would assert a false fact about the + // board. +} + +/// V10c — an UNTAPPED fetch is the `Effect::Mana` case with one extra step, so +/// the seat must keep its window; the TAPPED sibling still Accepts. +/// +/// `v10a`/`v10b` closed mana production. This row closes the residual one arm +/// over: `effect_window_reach`'s `ChangeZone` arm allowlisted ANY +/// `Library -> Battlefield` move, and a land that arrives untapped (CR 110.5b — +/// permanents enter untapped "unless a spell or ability says otherwise") taps for +/// mana inside the window the Shorten hands back. CR 302.6's summoning-sickness +/// bar is a CREATURE rule and never reaches a land, and CR 601.2g runs the mana +/// ability during the cast it funds. +/// +/// **The pair varies exactly one object** on the flagship board: a Crop Rotation +/// in P2's hand. Its four-legged AST is confined on every axis but the tap state +/// (MEASURED — see `CROP_ROTATION`), so the Shorten below is attributable to the +/// gate and to nothing else. +/// +/// **Otherwise-unaffordable, both halves, on the production instrument.** The +/// negative half is asserted in BOTH arms at poll time: the `{1}` answer is not +/// castable, because `feasible_mana_capacity` is battlefield-scoped and P2's +/// battlefield is one Terramorphic Expanse (capacity 0). The positive half is the +/// QUARANTINED funding lemma: the Rotation is driven through the stack on the +/// `apply()` boundary, a real basic Swamp from P2's own recorded library enters +/// UNTAPPED, and the SAME answer is re-probed against the engine's own +/// castability gate. +/// +/// **The tapped control is the load-bearing half of the pair.** The same drive +/// with Rampant Growth puts a land on the battlefield TAPPED, the answer stays +/// uncastable, and the seat still Accepts. That is what makes this gate a +/// distinction rather than a blanket flip — and it is the measurement that keeps +/// `v1`/`v1b`'s Terramorphic Accept correct rather than merely surviving. +/// +/// MUTANT: in `effect_window_reach`'s `ChangeZone` arm, replace +/// `object_is_confined && entry_is_confined` with `object_is_confined` alone (the +/// pre-fix expression) — the SHORTEN arm flips to `Accept` (the shipped defect +/// this row exists to catch). The ACCEPT arm and +/// the tapped control are unaffected by that mutation by construction — neither +/// carries an untapped battlefield entry at all — so the row cannot pass by +/// trivializing in either direction. +/// +/// Every `GameAction::CastSpell` matcher binds `{ object_id, .. }` and must NOT +/// name `payment_mode`, for `v10a`'s reason. +#[test] +fn v10c_an_untapped_fetch_that_funds_an_unaffordable_answer_keeps_its_window() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + let polled = declare_and_poll(&board, P2); + + let mut base = polled.clone(); + // `{1}` generic, sized as in `v10b`: a generic residual is decided by + // comparing summed battlefield capacity against it, so ONE untapped Swamp is + // exactly enough and one tapped Swamp is exactly not — zero slack on both + // halves, decided on one read path. + let answer = give_bolt_with_cost(&mut base, P2, ManaCost::generic(1)); + + // ── arm ACCEPT: the answer alone ── + let accept_arm = base.clone(); + // ── arm SHORTEN: same board, same answer, PLUS the untapped fetch ── + let mut shorten_arm = base.clone(); + let rotation = give_crop_rotation(&mut shorten_arm, P2); + + for (label, arm) in [("ACCEPT", &accept_arm), ("SHORTEN", &shorten_arm)] { + assert!( + !probe_actions(arm, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == answer) + ), + "PREMISE ({label} arm): the answer must be OTHERWISE-UNAFFORDABLE at poll time — P2's \ + battlefield is one Terramorphic Expanse, which contributes 0 to the \ + battlefield-scoped capacity scan, and the scan cannot see the fetch-then-tap two-step \ + the window buys; got {:?}", + non_pass_actions(arm, P2) + ); + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label} arm): stage 1 must return true, or the seat answers at stage 1 \ + and the fold under test never runs" + ); + } + + assert!( + probe_actions(&shorten_arm, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == rotation) + ), + "reach-guard: the FUNDER must really be castable, or the SHORTEN arm is the ACCEPT arm \ + with extra steps; got {:?}", + non_pass_actions(&shorten_arm, P2) + ); + + let shorten_non_pass = non_pass_actions(&shorten_arm, P2); + assert_eq!( + shorten_non_pass.len(), + 2, + "ATTRIBUTION + THRESHOLD SENTINEL, mirroring `v10a`'s: the SHORTEN arm must be the ACCEPT \ + arm's single fetchland PLUS the Rotation cast, and nothing else. A fixture or capacity \ + change that added a third `MayInterfere` action would over-determine this row silently \ + instead of reddening; got {shorten_non_pass:?}" + ); + + let accept_non_pass = non_pass_actions(&accept_arm, P2); + assert_eq!( + accept_non_pass.len(), + 1, + "ATTRIBUTION: the ACCEPT arm's action set must be the flagship's exactly, so its Accept is \ + the already-shipped verdict and the pair's ONLY variable is the Rotation; got \ + {accept_non_pass:?}" + ); + assert!( + accept_non_pass[0].contains("Terramorphic Expanse") + && accept_non_pass[0].contains("zone=Some(Battlefield)") + && accept_non_pass[0].contains("controller=Some(PlayerId(2))"), + "ATTRIBUTION: that one action must be P2's OWN battlefield fetchland; got \ + {accept_non_pass:?}" + ); + + // MEMBERSHIP, not just cardinality — `v10a`'s partition, for its reasons. + let (_rotation_leg, other_legs): (Vec<&String>, Vec<&String>) = shorten_non_pass + .iter() + .partition(|a| a.starts_with(&format!("CastSpell {{ object_id: {rotation:?},"))); + assert_eq!( + other_legs, + vec![&accept_non_pass[0]], + "ATTRIBUTION: the SHORTEN arm's set MINUS the Rotation must be the ACCEPT arm's set \ + EXACTLY — same fetchland object, same zone, same controller; got {shorten_non_pass:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_arm, P2), + ShortcutResponse::Accept, + "the pair's negative arm: an unaffordable answer and a confined TAPPED fetchland buy \ + nothing" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "the pair's positive arm: a fetch that puts a land onto the battlefield UNTAPPED \ + (CR 110.5b) hands the seat mana inside its own window, so accepting here surrenders a \ + live out. The Rotation is the one object that differs" + ); + + // ── FUNDING LEMMA (CR 110.5b + CR 117.1d + CR 601.2g), QUARANTINED ── + // + // Both verdicts above are already taken; resolving the fetch inside an arm + // would change the very action set they were taken on, and the funded board + // additionally unlocks the Angel's `{2}` cycling (`give_lotus_petal`'s + // capacity note), which carries `MayInterfere` on a route this pair does not + // model. Same quarantine as `v10a`/`v10b`. + let (funded, fetched) = resolve_fetch_choosing_a_swamp(&shorten_arm, rotation); + let land = funded + .objects + .get(&fetched) + .expect("the fetched Swamp is a real object on this board"); + assert_eq!( + land.zone, + Zone::Battlefield, + "reach-guard: the Rotation must have RESOLVED and MOVED the land, not merely been cast — \ + otherwise the funding assertion below would red for the wrong reason" + ); + assert!( + !land.tapped, + "CR 110.5b: the fetched land enters UNTAPPED because this fetch says nothing otherwise. \ + This is the game-level fact the AST's `enter_tapped` stands for, measured on the object \ + rather than inferred from the printed text" + ); + assert!( + probe_actions(&funded, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == answer)), + "FUNDING: with the fetched Swamp untapped, the engine's own castability gate says the SAME \ + `{{1}}` answer that is unaffordable in BOTH arms above IS payable. That is the two-step \ + the priority window buys (CR 117.1d / CR 601.2g), measured rather than inferred; got {:?}", + non_pass_actions(&funded, P2) + ); + + // ── THE TAPPED CONTROL — the half that makes this a distinction ── + // + // Same board, same answer, same drive, same chosen Swamp: only the fetch's + // printed tap rider differs. The land arrives tapped, funds nothing, and the + // seat still Accepts — which is `v1`/`v1b`'s Terramorphic verdict, measured + // here on the funding mechanism itself instead of assumed to survive. + let mut tapped_arm = base.clone(); + let growth = give_rampant_growth(&mut tapped_arm, P2); + assert_eq!( + non_pass_actions(&tapped_arm, P2).len(), + 2, + "reach-guard: the tapped fetch must be castable too, or its Accept below is produced by an \ + absent action rather than by a confined one; got {:?}", + non_pass_actions(&tapped_arm, P2) + ); + + let (tapped_funded, tapped_fetched) = resolve_fetch_choosing_a_swamp(&tapped_arm, growth); + let tapped_land = tapped_funded + .objects + .get(&tapped_fetched) + .expect("the fetched Swamp is a real object on this board"); + assert_eq!( + tapped_land.zone, + Zone::Battlefield, + "reach-guard: the tapped fetch must have resolved and moved the land as well" + ); + assert!( + tapped_land.tapped, + "CR 110.5b: 'unless a spell or ability says otherwise' — this one says otherwise, and this \ + assertion is what separates the pair" + ); + assert!( + !probe_actions(&tapped_funded, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == answer)), + "FUNDING, negative half: a TAPPED land pays for nothing, so the identical answer stays \ + uncastable after the identical drive. Together with the assertion above, the tap state — \ + not the fetch shape — is what funds the out; got {:?}", + non_pass_actions(&tapped_funded, P2) + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&tapped_arm, P2), + ShortcutResponse::Accept, + "…and the classifier agrees with the board: a tapped fetch buys the seat nothing, so the \ + gate is a DISTINCTION on the tap axis and not a blanket flip of the fetch class. This is \ + the assertion an over-broad version of this fix destroys first" + ); +} + +// =========================================================================== +// PR #7101 — the two maintainer findings, both on the REAL 4p board. +// +// SHARED PREMISE, MEASURED once here rather than re-asserted per row. At the +// offer beat the flagship board carries 171 active trigger definitions; the +// CR 113.6 zone-of-function gate removes 168, leaving exactly three: +// +// Dina, Soul Steeper LifeGained valid_card=None owner=P0 +// Bloodthirsty Conqueror LifeLost valid_card=None owner=P0 +// Abundant Growth ChangesZone valid_card=SelfRef owner=P1 +// +// Dina and the Conqueror are relieved by the MODE gate (CR 119.3: no confined +// action adjusts a life total). Abundant Growth is relieved for P2 by the +// SELF-REFERENCE carve-out and by nothing else — `obj.owner (P1) != actor (P2)` +// is the whole of it. That is why `v1_live_path_*` still Accepts, and it is why +// polling P1 on this same board would find a live observer: for P1 the carve-out +// conjunct is false. P1 is nevertheless safe, and NOT because "no board fires" — +// it is because `smart_shortcut_response` returns `Accept` from STAGE 1, before +// `any_action_may_interfere` ever runs, which `v1_live_path_*`'s own sibling +// control pins via `!stage_one_meaningful(&other, seat)`. +// =========================================================================== + +const HEDRON_CRAB: &str = "Landfall — Whenever a land you control enters, target player mills \ + three cards. (They put the top three cards of their library into their \ + graveyard.)"; +const BLOODGHAST_LANDFALL: &str = "Landfall — Whenever a land you control enters, you may return \ + this card from your graveyard to the battlefield."; + +/// Stage a real parsed OBSERVER — a card whose rules content is a printed +/// TRIGGER rather than an activated/spell ability — owned and controlled by +/// `player`. +/// +/// Distinct from `give_parsed_card` above on purpose: that helper stages the +/// ACTING object and asserts exactly one parsed *ability*, which is the premise +/// the V10 rows need and the exact opposite of what an observer carries. Verbatim +/// Oracle text under the card's REAL name — a paraphrase can take a different +/// parser branch and green a row while the printed card stays broken. +fn give_parsed_observer( + state: &mut GameState, + player: PlayerId, + zone: Zone, + name: &str, + oracle: &str, +) -> ObjectId { + let parsed = engine::parser::oracle::parse_oracle_text(oracle, name, &[], &[], &[]); + assert_eq!( + parsed.triggers.len(), + 1, + "PREMISE: {name} must parse to exactly one printed trigger, or the row below cannot \ + attribute its verdict to that trigger; got {:?}", + parsed.triggers + ); + let id = engine::game::zones::create_object( + state, + CardId(state.next_object_id), + player, + name.to_string(), + zone, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.install_trigger_base_definitions(std::sync::Arc::new(parsed.triggers)) + .expect("staging one printed trigger set on a fresh object"); + state.layers_dirty = LayersDirty::full(); + id +} + +/// Every active trigger definition on `object`, rendered for assertions. +fn trigger_shapes(state: &GameState, object: ObjectId) -> Vec { + let obj = state.objects.get(&object).expect("staged object exists"); + engine::game::functioning_abilities::active_trigger_definitions(state, obj) + .map(|a| { + format!( + "mode={:?} valid_card={:?} trigger_zones={:?} zcc={}", + a.definition.mode, + a.definition.valid_card, + a.definition.trigger_zones, + a.definition.zone_change_clauses.len() + ) + }) + .collect() +} + +/// **T1.2 — the [MED] finding.** `SelfRef` / `Controller` prove CONTROL, never +/// OWNERSHIP. +/// +/// CR 110.2 makes owner and controller independent. CR 701.21a: "To sacrifice a +/// permanent, its controller moves it from the battlefield directly to its +/// OWNER'S graveyard." So when P2 cracks a Terramorphic Expanse it controls but +/// does NOT own, the land lands in somebody else's graveyard — a per-player zone +/// (CR 400.1) — and the action was never confined at all. The fold's +/// `filter_is_actor_owned(sacrifice.target)` says `true` regardless, because +/// `TargetFilter::SelfRef` resolves through control (CR 109.5). +/// +/// ONE VARIABLE against `v1_live_path_fetchland_seat_accepts_on_the_real_4p_board`: +/// the same board, the same beat, the same offer, the same single action — only +/// `ObjectId(203).owner` differs. MEASURED: all four seats own everything they +/// control on this dump naturally, so the violating shape has to be constructed; +/// it is constructed on the flagship's OWN fetchland rather than on a spare +/// object so that the mis-owned permanent IS the one being sacrificed. +/// +/// REVERT-PROBE (executed): trivialize `actor_owns_everything_they_control` to +/// `true` ⇒ this row returns `Accept` and fails. +#[test] +fn t1_2_a_controlled_but_unowned_fetchland_is_not_confined() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + let mut polled = declare_and_poll(&board, P2); + + // REACH-GUARD, before the mutation: an `Accept` produced by an empty seat + // would be indistinguishable from an `Accept` produced by the predicate. + let non_pass = non_pass_actions(&polled, P2); + assert!( + !non_pass.is_empty(), + "REACH-GUARD: P2 must hold a non-pass action, or stage 1 answers and stage 2 never runs" + ); + assert!( + non_pass + .iter() + .any(|a| a.contains("Terramorphic Expanse") && a.contains("zone=Some(Battlefield)")), + "REACH-GUARD: the action under test must be the fetchland activation the finding names; \ + got {non_pass:?}" + ); + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD: stage 1 must still pass P2 through to stage 2" + ); + + let fetch = *polled + .objects + .values() + .find(|o| o.name == "Terramorphic Expanse" && o.controller == P2) + .map(|o| &o.id) + .expect("P2's fetchland is on this board"); + + // CONTROL half — untouched board, unchanged answer. + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Accept, + "matched control (T1.3): with ownership intact the seat still Accepts, so the flip below \ + is attributable to the owner field and to nothing else about this board" + ); + + // WITNESS half — one field. + polled.objects.get_mut(&fetch).expect("just read").owner = P1; + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 701.21a: sacrificing a permanent P2 controls but P1 OWNS puts a card into P1's \ + graveyard. `filter_is_actor_owned` cannot see that — it proves control (CR 109.5) — so \ + the ownership fact has to be proven at board level or this Accept is unsound" + ); +} + +/// **T2.1 — the [HIGH] finding.** A confined action is still OBSERVED. +/// +/// CR 603.2: "Whenever a game event or game state matches a triggered ability's +/// trigger event, that ability automatically triggers." Reading the ACTING +/// object's AST proves what that object does; it proves nothing about what the +/// rest of the board is watching for. Hedron Crab watches for exactly the event +/// the flagship's confined fetch produces, and its effect targets a PLAYER. +/// +/// The crab is OPPONENT-owned on purpose. Actor-owned, both the shipped +/// carve-out and the `filter_is_actor_owned` shape rejected in T2.3 would relieve +/// it (the `obj.owner != actor` conjunct rescues it), so an actor-owned crab +/// cannot tell the two apart. Only the opponent-owned one discriminates. +/// +/// REVERT-PROBE (executed): delete the `board_observer_may_react` conjunct from +/// `any_action_may_interfere` ⇒ this row returns `Accept` and fails. +#[test] +fn t2_1_an_opponent_owned_landfall_observer_defeats_a_confined_fetch() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + + // MATCHED CONTROL (T2.2) — same board, same beat, no crab. + let clean = declare_and_poll(&board, P2); + assert!( + stage_one_meaningful(&clean, P2), + "REACH-GUARD: stage 1 passes P2 through, so both halves below measure stage 2" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&clean, P2), + ShortcutResponse::Accept, + "T2.2 matched control: without an observer the confined fetch still Accepts" + ); + + // WITNESS — one object added, owned and controlled by an opponent. + let mut with_crab = board.clone(); + let crab = give_parsed_observer( + &mut with_crab, + P1, + Zone::Battlefield, + "Hedron Crab", + HEDRON_CRAB, + ); + + // PARSE PIN: if the parser ever stops producing this shape the row must go + // RED rather than silently green on a different (or absent) trigger. + let shapes = trigger_shapes(&with_crab, crab); + assert_eq!( + shapes.len(), + 1, + "PREMISE: the crab must carry exactly one active trigger; got {shapes:?}" + ); + assert!( + shapes[0].contains("mode=ChangesZone"), + "PREMISE: landfall is a CR 603.6a zone-change trigger; got {shapes:?}" + ); + assert!( + shapes[0].contains("Land") && shapes[0].contains("You"), + "PREMISE: `valid_card` must be the TYPED land-you-control filter, NOT `SelfRef`. This is \ + the exact shape T2.3 proves `filter_is_actor_owned` accepts — reusing that helper as the \ + carve-out would relieve this trigger and the [HIGH] finding would survive; got {shapes:?}" + ); + assert!( + shapes[0].contains("trigger_zones=[Battlefield]"), + "PREMISE: the crab must FUNCTION where it is standing, or the zone gate — not the \ + carve-out — would be what produces the verdict below; got {shapes:?}" + ); + + let polled = declare_and_poll(&with_crab, P2); + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD: the crab must not have changed which stage answers" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 603.2: P2's 'confined' fetch makes a land enter, the opponent's Crab triggers on it, \ + and its effect mills a TARGET PLAYER. The action reached outside P2's own resources \ + without P2's own AST containing anything that says so" + ); +} + +/// **T2.4 — the zone-of-function gate, on one axis.** Same card, same trigger, +/// two zones. +/// +/// CR 113.6 / CR 603.6: a Bloodghast-shaped landfall trigger functions from the +/// GRAVEYARD (that is where the ability returns the card from), so a copy sitting +/// in HAND observes nothing. The pair moves the zone and nothing else, so the +/// `Accept` cannot be a property of the card and the `Shorten` cannot be a +/// property of the board. +/// +/// REVERT-PROBE (executed): delete the `trigger_definition_functions_in_zone` +/// conjunct from `board_observer_may_react` ⇒ the hand half returns `Shorten` +/// and this row fails. +/// +/// SCOPE, measured rather than assumed. The OTHER way this gate can go wrong — +/// reading `def.trigger_zones.contains(&obj.zone)` directly, which answers +/// "functions nowhere" for the empty list that means battlefield-only — is NOT +/// discriminated by this row, and it cannot be discriminated on this board: +/// MEASURED, every active trigger definition on the flagship carries an explicit +/// `trigger_zones: [Battlefield]`, so the direct read and the authority agree on +/// the whole corpus. That fail-open direction is a LATENT hole here, closed for +/// the same reason as this module's `Zone::Stack` arm, and it is pinned by +/// `t2_4b_an_empty_trigger_zones_list_means_battlefield_not_nowhere` at unit +/// level where the shape can actually be built. +#[test] +fn t2_4_a_landfall_observer_in_hand_does_not_veto_but_in_its_own_zone_it_does() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + + for (zone, expected) in [ + (Zone::Hand, ShortcutResponse::Accept), + ( + Zone::Graveyard, + ShortcutResponse::Shorten { at_iteration: 0 }, + ), + ] { + let mut staged = board.clone(); + let ghast = give_parsed_observer(&mut staged, P1, zone, "Bloodghast", BLOODGHAST_LANDFALL); + let shapes = trigger_shapes(&staged, ghast); + assert!( + shapes.iter().any(|s| s.contains("mode=ChangesZone")), + "PREMISE at {zone:?}: the landfall trigger must survive parsing; got {shapes:?}" + ); + + let polled = declare_and_poll(&staged, P2); + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD at {zone:?}: stage 2 must be the stage that answers" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + expected, + "CR 113.6: the SAME trigger on the SAME card must veto from the zone it functions in \ + and not from the zone it does not; failed at {zone:?}" + ); + } +} + +/// **T2.8 — the scan is not battlefield-only.** A command-zone emblem is scanned. +/// +/// CR 114.1 puts emblems in the command zone and CR 114.4 is exact — "Abilities +/// of emblems function in the command zone" — so an observer that never touches +/// the battlefield +/// still observes. `active_trigger_definitions` applies the CR 114.4 emblem gate +/// itself, which is why the scan delegates to it rather than filtering zones. +/// +/// REVERT-PROBE (executed): restrict `board_observer_may_react` to +/// `obj.zone == Zone::Battlefield` ⇒ this row returns `Accept` and fails. +#[test] +fn t2_8_a_command_zone_emblem_observer_is_scanned() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + + let emblem = give_parsed_observer(&mut board, P1, Zone::Command, "Hedron Crab", HEDRON_CRAB); + { + let obj = board.objects.get_mut(&emblem).expect("just staged"); + obj.is_emblem = true; + // CR 114.1: an emblem has no characteristics other than its abilities. + obj.card_types.core_types.clear(); + obj.base_card_types = obj.card_types.clone(); + // CR 114.4: "Abilities of emblems function in the command zone." The + // printed crab trigger parses as battlefield-only, so the zone list is + // retargeted — that retarget is the whole staging, and the assertion + // below proves it survived into the live set. + let mut defs = (*obj.base_trigger_definitions).clone(); + for d in &mut defs { + d.trigger_zones = vec![Zone::Command]; + } + obj.install_trigger_base_definitions(std::sync::Arc::new(defs)) + .expect("re-staging the emblem's trigger set"); + } + let shapes = trigger_shapes(&board, emblem); + assert_eq!( + shapes.len(), + 1, + "PREMISE: the emblem must expose exactly one active trigger from the command zone — \ + `active_trigger_definitions` drops non-emblem command-zone triggers, so an empty list \ + here would make the row vacuous; got {shapes:?}" + ); + + let polled = declare_and_poll(&board, P2); + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD: stage 2 must be the stage that answers" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 114.1 + CR 603.2: an observer in the command zone observes. A battlefield-only scan \ + would miss every emblem and every command-zone trigger" + ); +} + +/// **T2.9 — the actor's OWN self-referential observer is NOT relieved**, on the +/// real board's own survivor. +/// +/// Abundant Growth (`ChangesZone`, `valid_card: SelfRef`) is one of the three +/// definitions that survive the zone gate on the flagship board, and for P2 it is +/// relieved by the carve-out's `obj.owner != actor` conjunct alone. Retag it to +/// P2 — owner AND controller together, so the T1.2 ownership conjunct stays +/// satisfied and cannot be what moves the verdict — and the same trigger must +/// stop being relieved. +/// +/// One variable: the object's seat. This is the row that pins the carve-out to +/// "somebody ELSE'S self-reference", which is the only version of it that is +/// sound: an actor's own self-referential trigger is reachable by the actor's own +/// action. +/// +/// REVERT-PROBE (executed): drop the `obj.owner != actor` conjunct ⇒ this row +/// returns `Accept` and fails. +#[test] +fn t2_9_the_actors_own_self_referential_observer_keeps_the_veto() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + let mut polled = declare_and_poll(&board, P2); + + let growth = *polled + .objects + .values() + .find(|o| o.name == "Abundant Growth") + .map(|o| &o.id) + .expect("MEASURED: Abundant Growth is on this board and survives the zone gate"); + let shapes = trigger_shapes(&polled, growth); + assert!( + shapes + .iter() + .any(|s| s.contains("valid_card=Some(SelfRef)")), + "PREMISE: this row needs the SelfRef shape the carve-out keys on; got {shapes:?}" + ); + assert_ne!( + polled.objects[&growth].owner, P2, + "PREMISE: it starts on another seat, which is why the flagship Accepts" + ); + + // CONTROL — somebody else's self-reference, relieved. + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Accept, + "control: an opponent's SelfRef observer cannot be reached by an action confined to P2's \ + own resources" + ); + + // WITNESS — the same trigger, now the actor's own. + { + let obj = polled.objects.get_mut(&growth).expect("just read"); + obj.owner = P2; + obj.base_controller = Some(P2); + obj.controller = P2; + } + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 109.5: 'you' on the observer means ITS controller. Once that is the actor, the actor's \ + own confined action can reach it, so the carve-out must not apply" + ); +} + +/// **T3.5 — the new `GameObject::parse_warnings` field changes no committed byte.** +/// +/// `skip_serializing_if = "Vec::is_empty"` is the whole mechanism, and this is +/// the assertion that it is actually wired: re-serialize every object of a +/// committed fixture through the production decoder and compare against the +/// bytes the fixture shipped with. +/// +/// REVERT-PROBE (executed): drop `skip_serializing_if` from the field ⇒ every +/// object gains `"parse_warnings":[]` and this row fails. +#[test] +fn t3_5_the_new_parse_warnings_field_keeps_dumps_byte_identical() { + let json = gunzip_dump(include_bytes!("../fixtures/dina_noff_turn5_4p.json.gz")); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + let before = envelope["gameState"]["objects"].clone(); + assert!( + before.as_object().is_some_and(|m| !m.is_empty()), + "PREMISE: the fixture must carry objects, or byte-equality below is vacuous" + ); + assert!( + !before.to_string().contains("parse_warnings"), + "PREMISE: the committed OBJECTS predate the field, so any occurrence below is new. \ + (Scoped to `objects` on purpose: the envelope's card-database subtree carries the \ + face-level `parse_warnings` this field is copied FROM, and has since before this change.)" + ); + + let state = restore_dump(&json); + let after = + serde_json::to_value(state.objects.values().collect::>()).expect("objects reencode"); + assert!( + !after.to_string().contains("parse_warnings"), + "an EMPTY diagnostics list must not serialize. Without `skip_serializing_if` every object \ + in every committed dump gains a `\"parse_warnings\":[]` key and every stored game grows" + ); +} + +/// Helping Hand, verbatim (Oracle text verified on Scryfall, +/// `api.scryfall.com/cards/named?exact=Helping+Hand`). Confined on every axis +/// `effect_window_reach`'s `ChangeZone` arm reads EXCEPT the origin: the card it +/// returns is a real object in the actor's graveyard whose rules content the fold +/// never classified. +const HELPING_HAND: &str = "Return target creature card with mana value 3 or less from your \ + graveyard to the battlefield tapped."; + +/// Stage a vanilla creature card in `player`'s graveyard. VANILLA on purpose: +/// with no abilities of its own it cannot itself move any verdict — not through +/// `board_observer_may_react` (nothing to observe with), not through the fold +/// (it is not the subject of any action). It exists only so the Helping Hand +/// below has a legal target, and it is staged in BOTH arms so it is held +/// constant rather than varied. +fn give_graveyard_creature(state: &mut GameState, player: PlayerId) -> ObjectId { + let id = engine::game::zones::create_object( + state, + CardId(state.next_object_id), + player, + "Grizzly Bears".to_string(), + Zone::Graveyard, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.power = Some(2); + obj.toughness = Some(2); + state.layers_dirty = LayersDirty::full(); + id +} + +/// V10d — a TAPPED battlefield entry from a GRAVEYARD keeps the seat's window; +/// the tapped LIBRARY sibling still Accepts. +/// +/// `v10c` closed the tap axis on a library fetch. This row closes the ORIGIN +/// axis: `effect_window_reach`'s `ChangeZone` arm allowlisted a tapped +/// battlefield entry from ANY origin, so a graveyard recursion read as confined +/// while the card it returns IS in `state.objects` with readable +/// trigger/replacement/static definitions that nothing reads. +/// `object_window_reach`'s `carries_unreadable_rules_content` gate runs on the +/// SOURCE (the Helping Hand), never on the card it returns, and +/// `board_observer_may_react` correctly answers "does not function" for a +/// graveyard ETB (CR 113.6). So a Fleshbag-Marauder-class creature in the actor's +/// own graveyard — "When this creature enters, each player sacrifices a creature +/// of their choice" — arrives inside the very window the seat declined to keep. +/// +/// The LIBRARY sibling below is not confined because its origin excuses it from +/// being read — v10e is the row that shows a library fetch reading `Shorten` on +/// this same board — but because `library_arrivals_are_inert` reads the cards +/// Rampant Growth could actually select and finds them vanilla. +/// +/// **The pair varies exactly one object** on the flagship board, and both halves +/// of that object are the SAME SHAPE on every axis but origin: two spells staged +/// identically (`give_parsed_card`, instant, no printed cost), each heading a +/// `ChangeZone` to the battlefield with `enter_tapped: Tapped`, no +/// `enters_attacking`, no `enters_modified_if`, and a You-controlled target. The +/// graveyard creature is present in BOTH arms. So the Shorten is attributable to +/// the origin field and to nothing else. +/// +/// **The control is the load-bearing half.** Rampant Growth's tapped LIBRARY +/// fetch must still Accept — that is what keeps this gate a distinction rather +/// than a blanket flip, and it is the same measurement that keeps `v1`/`v1b`'s +/// Terramorphic Accept correct. +/// +/// **Conservative by construction, and the cost is stated.** Grizzly Bears is +/// vanilla, so this seat surrenders its window over a recursion that could not +/// have interfered. That is the gate's shape: it reads the ORIGIN, not the +/// returned card, so it is uniform over what the graveyard happens to hold. Per +/// the module's §2 that is the direction that costs efficacy rather than games, +/// and it is the same trade the `Hand` destination gate already made. MEASURED on +/// `data/card-data.json`, of the 493 document-wide tapped/non-attacking/ +/// unconditional/You-controlled battlefield entries, 247 come from a library and +/// keep their classification; 74 graveyard, 25 hand, 6 exile and 140 +/// absent-origin nodes lose it. +/// +/// MUTANT: in `effect_window_reach`'s `ChangeZone` arm, delete the +/// `*origin == Some(Zone::Library)` conjunct from `entry_is_confined` (the +/// pre-fix expression) — the SHORTEN arm flips to `Accept`. The ACCEPT arm is +/// unaffected by that mutation by construction: its origin already IS +/// `Library`, so the conjunct it deletes was true there anyway. +/// +/// Every `GameAction::CastSpell` matcher binds `{ object_id, .. }` and must NOT +/// name `payment_mode`, for `v10a`'s reason. +#[test] +fn v10d_a_tapped_graveyard_return_keeps_its_window_and_the_library_sibling_accepts() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + let polled = declare_and_poll(&board, P2); + + let mut base = polled.clone(); + give_graveyard_creature(&mut base, P2); + + // ── arm ACCEPT: the tapped LIBRARY fetch ── + let mut accept_arm = base.clone(); + let growth = give_rampant_growth(&mut accept_arm, P2); + // ── arm SHORTEN: same board, same graveyard, the tapped GRAVEYARD return ── + let mut shorten_arm = base.clone(); + let helping_hand = give_parsed_card( + &mut shorten_arm, + P2, + "Helping Hand", + HELPING_HAND, + CoreType::Instant, + Zone::Hand, + ); + + for (label, arm, staged) in [ + ("ACCEPT", &accept_arm, growth), + ("SHORTEN", &shorten_arm, helping_hand), + ] { + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label} arm): stage 1 must return true, or the seat answers at stage 1 \ + and the fold under test never runs" + ); + assert!( + probe_actions(arm, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == staged) + ), + "reach-guard ({label} arm): the staged spell must really be castable at this window, \ + or the arm is the base board with extra steps and the pair measures nothing; got {:?}", + non_pass_actions(arm, P2) + ); + } + + // ATTRIBUTION: both arms must be the flagship's action set PLUS exactly the + // one staged cast. A third `MayInterfere` action in either arm would + // over-determine the row silently instead of reddening. + let accept_non_pass = non_pass_actions(&accept_arm, P2); + let shorten_non_pass = non_pass_actions(&shorten_arm, P2); + assert_eq!( + accept_non_pass.len(), + 2, + "ATTRIBUTION: the ACCEPT arm must be the flagship fetchland PLUS the Rampant Growth cast, \ + and nothing else; got {accept_non_pass:?}" + ); + assert_eq!( + shorten_non_pass.len(), + 2, + "ATTRIBUTION: the SHORTEN arm must be the flagship fetchland PLUS the Helping Hand cast, \ + and nothing else; got {shorten_non_pass:?}" + ); + + // MEMBERSHIP, not just cardinality: the two arms MINUS their staged cast must + // be the same single flagship action, so the pair really does vary one object. + let accept_rest: Vec<&String> = accept_non_pass + .iter() + .filter(|a| !a.starts_with(&format!("CastSpell {{ object_id: {growth:?},"))) + .collect(); + let shorten_rest: Vec<&String> = shorten_non_pass + .iter() + .filter(|a| !a.starts_with(&format!("CastSpell {{ object_id: {helping_hand:?},"))) + .collect(); + assert_eq!( + accept_rest, shorten_rest, + "ATTRIBUTION: each arm's set MINUS its staged cast must be the SAME flagship action — same \ + object, same zone, same controller; got {accept_non_pass:?} vs {shorten_non_pass:?}" + ); + assert!( + accept_rest.len() == 1 + && accept_rest[0].contains("Terramorphic Expanse") + && accept_rest[0].contains("zone=Some(Battlefield)") + && accept_rest[0].contains("controller=Some(PlayerId(2))"), + "ATTRIBUTION: that shared action must be P2's OWN battlefield fetchland; got \ + {accept_rest:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_arm, P2), + ShortcutResponse::Accept, + "the pair's negative arm: a tapped LIBRARY fetch stays confined — Rampant Growth searches \ + for a BASIC land and every basic in P2's recorded library is vanilla, so \ + `library_arrivals_are_inert` really did read the selectable set rather than skip it \ + (v10e is the row that varies that set)" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "the pair's positive arm: the returned card is a REAL object in the actor's graveyard \ + whose rules content this fold never classified, so `OwnResourcesOnly` would be a proof it \ + cannot discharge. The Helping Hand is the one object that differs" + ); +} + +/// V10e — a tapped LIBRARY fetch whose search can SELECT an interfering +/// permanent is not confined, and the two things that could be producing that +/// verdict are separated by two orthogonal one-variable controls. +/// +/// The hole this closes was argued, not overlooked. Every earlier revision of +/// `effect_window_reach`'s `ChangeZone` arm discharged the arriving card with a +/// hidden-zone argument: CR 400.2 makes a library hidden, the card is an unchosen +/// member of it, so "the seam is handed a `GameState` and a list of +/// `GameAction`s, and the card is the subject of neither". CR 701.23a is the rule +/// that refutes it — "To search for a card in a zone, look at all cards in that +/// zone (even if it's a hidden zone) and find a card that matches the given +/// description" — and MEASURED on THIS fixture at THIS poll, all 91 cards of P2's +/// library resolve in `state.objects` with full parsed rules content. Hidden +/// means the opponents cannot see it. The searcher picks whichever member they +/// like. +/// +/// **`board_observer_may_react` cannot cover this**, and correctly so: CR 113.6, +/// through `trigger_definition_functions_in_zone`, answers "does not function" +/// for a card in a library. That scan asks which triggers function NOW; the +/// hazard is a trigger that functions once the search puts the card onto the +/// battlefield. +/// +/// **The witness is on the recorded board, not staged.** P2's own library holds +/// **Bojuka Bog** — "This land enters tapped. / When this land enters, exile +/// target player's graveyard. / {T}: Add {B}." (Oracle text verified on Scryfall) +/// — a LAND whose ETB exiles a graveyard belonging to somebody else. A seat that +/// Accepts while holding an unrestricted land fetch has declined a window in +/// which it could have made a live cross-player choice. +/// +/// **Three arms, two orthogonal controls, one variable each.** +/// * `SHORTEN` — Reshape the Earth (`Typed[Land]`) on the recorded library. +/// * `ACCEPT_BY_FILTER` — Rampant Growth (`Typed[Land] + Basic`) on the SAME +/// library. Holds the board fixed and varies the printed search filter, so the +/// gate is shown to read the filter and not merely "some land fetch exists". +/// * `ACCEPT_BY_LIBRARY` — Reshape the Earth again, with the non-inert LANDS +/// removed from P2's library. Holds the card fixed and varies the deck, which +/// is the axis the gate actually claims to read. This is the arm that +/// distinguishes the implemented gate from a filter-text heuristic: a +/// `Basic`-means-safe shortcut passes `ACCEPT_BY_FILTER` and fails here. +/// +/// MUTANT (executed): delete the `library_arrivals_are_inert` conjunct from +/// `object_window_reach` ⇒ the `SHORTEN` arm flips to `Accept`. The two ACCEPT +/// arms are unaffected by that mutation by construction — their selectable sets +/// are already inert, so the deleted conjunct was true in both. +#[test] +fn v10e_an_unrestricted_land_fetch_that_can_select_bojuka_bog_keeps_its_window() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + let polled = declare_and_poll(&board, P2); + + // ── PREMISE, read off the recorded library rather than assumed ── + let library: Vec = polled + .players + .iter() + .find(|p| p.id == P2) + .expect("P2 is seated on this dump") + .library + .iter() + .copied() + .collect(); + assert!( + library.len() > 20, + "PREMISE: P2 must have a real recorded library for the selection set to mean anything; \ + got {}", + library.len() + ); + let resolved = library + .iter() + .filter(|id| polled.objects.contains_key(id)) + .count(); + assert_eq!( + resolved, + library.len(), + "PREMISE, and the refutation of the hidden-zone argument this row exists to retire: every \ + library card must resolve in `state.objects` at the decision point. If this ever fails, \ + the gate has nothing to read and the row measures nothing" + ); + + let named = |state: &GameState, id: &ObjectId| { + state + .objects + .get(id) + .map(|o| o.name.clone()) + .unwrap_or_default() + }; + // A land the gate must reject, and the basics it must accept — both real + // members of this deck. + let bog = library + .iter() + .copied() + .find(|id| named(&polled, id) == "Bojuka Bog") + .expect("PREMISE: the recorded library must contain Bojuka Bog — it IS the hazard"); + let bog_obj = polled.objects.get(&bog).expect("just found"); + assert!( + !bog_obj.trigger_definitions.is_empty(), + "PREMISE: Bojuka Bog must carry its ETB trigger, or it is not the card this row names" + ); + assert!( + bog_obj.card_types.core_types.contains(&CoreType::Land), + "PREMISE: it must be a LAND, or an unrestricted land search cannot select it" + ); + assert!( + !bog_obj.card_types.supertypes.contains(&Supertype::Basic), + "PREMISE: it must NOT be basic, or the ACCEPT_BY_FILTER control below cannot be a control" + ); + let basics: Vec = library + .iter() + .copied() + .filter(|id| { + polled.objects[id] + .card_types + .supertypes + .contains(&Supertype::Basic) + }) + .collect(); + assert!( + !basics.is_empty(), + "PREMISE: ACCEPT_BY_FILTER needs a NON-EMPTY basic-land match set — an empty match set is \ + the one case `library_arrivals_are_inert` deliberately fails closed on, so it would \ + produce the wrong verdict for the wrong reason" + ); + for id in &basics { + let o = &polled.objects[id]; + assert!( + o.trigger_definitions.is_empty() + && o.replacement_definitions.is_empty() + && o.static_definitions.is_empty() + && o.keywords.is_empty(), + "PREMISE: every basic in this deck must be vanilla, or ACCEPT_BY_FILTER would be \ + asserting the wrong direction; {} is not", + o.name + ); + } + + // ── the three arms ── + let mut shorten_arm = polled.clone(); + let reshape_shorten = give_parsed_card( + &mut shorten_arm, + P2, + "Reshape the Earth", + RESHAPE_THE_EARTH, + CoreType::Instant, + Zone::Hand, + ); + + let mut accept_by_filter = polled.clone(); + let growth = give_rampant_growth(&mut accept_by_filter, P2); + + // Vary the DECK, not the card: strip every library land the gate would + // reject, leaving a pool whose land members are all vanilla. + let mut accept_by_library = polled.clone(); + let stripped = strip_non_inert_library_lands(&mut accept_by_library, P2); + assert!( + stripped.contains(&bog), + "the ACCEPT_BY_LIBRARY arm must actually remove the hazard, or it is the SHORTEN arm with \ + extra steps; removed {stripped:?}" + ); + let reshape_accept = give_parsed_card( + &mut accept_by_library, + P2, + "Reshape the Earth", + RESHAPE_THE_EARTH, + CoreType::Instant, + Zone::Hand, + ); + + // ── reach-guards: each arm's staged spell must really be castable here, and + // each arm must be the flagship action set PLUS exactly that one cast ── + for (label, arm, staged) in [ + ("SHORTEN", &shorten_arm, reshape_shorten), + ("ACCEPT_BY_FILTER", &accept_by_filter, growth), + ("ACCEPT_BY_LIBRARY", &accept_by_library, reshape_accept), + ] { + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label}): stage 1 must return true, or the seat answers at stage 1 and \ + the fold under test never runs" + ); + let non_pass = non_pass_actions(arm, P2); + assert_eq!( + non_pass.len(), + 2, + "ATTRIBUTION ({label}): the arm must be the flagship fetchland PLUS the one staged \ + cast, and nothing else; got {non_pass:?}" + ); + assert!( + non_pass + .iter() + .any(|a| a.starts_with(&format!("CastSpell {{ object_id: {staged:?},"))), + "reach-guard ({label}): the staged spell must be castable at this window; got \ + {non_pass:?}" + ); + assert!( + non_pass.iter().any(|a| a.contains("Terramorphic Expanse") + && a.contains("zone=Some(Battlefield)") + && a.contains("controller=Some(PlayerId(2))")), + "ATTRIBUTION ({label}): the other action must be P2's OWN battlefield fetchland, so \ + all three arms share it; got {non_pass:?}" + ); + } + + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 701.23a: an unrestricted land search LOOKS AT ALL CARDS in the library, and this one \ + contains Bojuka Bog, whose ETB exiles a graveyard P2 does not own. Accepting here \ + declines a window holding a live cross-player choice" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_by_filter, P2), + ShortcutResponse::Accept, + "CONTROL 1 (filter varies, deck held): a basic-land search on this same library can select \ + only vanilla Plains and Swamps, so the gate is a distinction and not a blanket flip" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_by_library, P2), + ShortcutResponse::Accept, + "CONTROL 2 (deck varies, card held): the SAME unrestricted search is confined once every \ + land it could select is vanilla. This is what proves the gate reads the library rather \ + than the printed word 'basic'" + ); +} + +/// Remove from `player`'s library every LAND that carries rules content +/// `shortcut_efficacy`'s gate cannot classify, and return what was removed. +/// +/// Lands only, deliberately: the search under test selects lands, so stripping +/// the rest would vary more than the arm claims to. The predicate mirrors the +/// public half of `carries_unreadable_rules_content` — if the two ever disagree +/// the ACCEPT arm reds rather than passing quietly, because a survivor the gate +/// still rejects keeps the verdict at `Shorten`. +fn strip_non_inert_library_lands(state: &mut GameState, player: PlayerId) -> Vec { + let doomed: Vec = state + .players + .iter() + .find(|p| p.id == player) + .expect("player is seated") + .library + .iter() + .copied() + .filter(|id| { + state.objects.get(id).is_some_and(|o| { + o.card_types.core_types.contains(&CoreType::Land) + && (!o.trigger_definitions.is_empty() + || !o.replacement_definitions.is_empty() + || !o.static_definitions.is_empty() + || !o.keywords.is_empty()) + }) + }) + .collect(); + for id in &doomed { + state.objects.remove(id); + } + let library = &mut state + .players + .iter_mut() + .find(|p| p.id == player) + .expect("player is seated") + .library; + library.retain(|id| !doomed.contains(id)); + doomed +} diff --git a/crates/engine/tests/integration/specialize_runtime.rs b/crates/engine/tests/integration/specialize_runtime.rs index a2151b0f8a..52e88f5efb 100644 --- a/crates/engine/tests/integration/specialize_runtime.rs +++ b/crates/engine/tests/integration/specialize_runtime.rs @@ -41,6 +41,7 @@ fn specialize_back(name: &str, color: ManaColor, shard: ManaCostShard) -> BackFa casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/std_s07_batch5b.rs b/crates/engine/tests/integration/std_s07_batch5b.rs index 8546922e4f..c49871ebf4 100644 --- a/crates/engine/tests/integration/std_s07_batch5b.rs +++ b/crates/engine/tests/integration/std_s07_batch5b.rs @@ -119,6 +119,7 @@ fn add_aang_dfc(state: &mut GameState) -> ObjectId { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); id } diff --git a/crates/engine/tests/integration/stolen_goodies_zero_targets.rs b/crates/engine/tests/integration/stolen_goodies_zero_targets.rs index 38d4c24e0e..c00eff8605 100644 --- a/crates/engine/tests/integration/stolen_goodies_zero_targets.rs +++ b/crates/engine/tests/integration/stolen_goodies_zero_targets.rs @@ -80,6 +80,7 @@ fn stolen_goodies_can_be_cast_with_no_targets() { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); let pool = &mut runner.state_mut().players[P0.0 as usize].mana_pool; diff --git a/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs b/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs index a0d4f4be7d..898a6cb4ab 100644 --- a/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs +++ b/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs @@ -113,6 +113,7 @@ fn tamiyo_third_draw_returns_transformed_not_stranded_in_exile() { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); // Precondition: Tamiyo is on the battlefield, front-face (not transformed). diff --git a/crates/engine/tests/integration/wedding_announcement_transform.rs b/crates/engine/tests/integration/wedding_announcement_transform.rs index ecc56c4735..70fc1b1efe 100644 --- a/crates/engine/tests/integration/wedding_announcement_transform.rs +++ b/crates/engine/tests/integration/wedding_announcement_transform.rs @@ -97,6 +97,7 @@ fn wedding_announcement_human_branch_transforms_and_applies_festivity_anthem() { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); wedding };