diff --git a/crates/engine/src/database/synthesis.rs b/crates/engine/src/database/synthesis.rs index 6381b74c95..5a3d2bbcea 100644 --- a/crates/engine/src/database/synthesis.rs +++ b/crates/engine/src/database/synthesis.rs @@ -18597,6 +18597,33 @@ mod sorcery_speed_invariant_tests { )), "materials-exile sub-cost present (CR 702.167a/b)" ); + + // CR 702.167a + CR 113.6m: Craft's cost EXILES THE PERMANENT FROM THE + // BATTLEFIELD, so CR 113.6m's `unless` clause ("a previous part of its + // cost … specifies that the object is put into that zone") exempts it, + // and CR 113.6j makes the battlefield the only zone the cost is payable + // from. Craft is synthesized here, never through + // `parse_activated_ability_ir`, so the CR 113.6m effect-side derivation + // cannot reach it — this pins that. + assert_eq!( + def.activation_zone, None, + "craft functions from the battlefield" + ); + // Second, independent line of defense: even on a hypothetical parser + // path, `activation_zone_from_self_cost` matches this cost component + // FIRST and yields Battlefield, so the effect side is never consulted. + assert!( + costs.iter().any(|c| matches!( + c, + AbilityCost::Exile { + zone: Some(Zone::Battlefield), + filter: Some(TargetFilter::SelfRef), + .. + } + )), + "the self-exile cost names the battlefield, so the cost-side \ + derivation wins before the effect side is consulted" + ); } /// CR 702.87a: Level Up synthesis must carry AsSorcery. diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index fb851f9ac6..96e6f30279 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use std::{borrow::Cow, ops::ControlFlow}; use crate::parser::oracle_nom::error::{OracleError, OracleResult}; use nom::branch::alt; @@ -19,6 +19,7 @@ use crate::types::ability::{ ReplacementDefinition, SolveCondition, SpellCastingOption, StaticCondition, StaticDefinition, TapStateChange, TargetFilter, TriggerCondition, TriggerDefinition, TypedFilter, }; +use crate::types::ability_visit::{visit_ability_def_scoped, ResolutionScope}; use crate::types::format::DeckCopyLimit; use crate::types::keywords::{EscapeCost, FlashbackCost, Keyword, KeywordKind}; use crate::types::mana::ManaCost; @@ -6754,6 +6755,9 @@ fn activation_zone_from_self_cost(cost: &AbilityCost) -> Option { zone: Some(zone), .. } => Some(*zone), + AbilityCost::Sacrifice(sacrifice) if sacrifice.target == TargetFilter::SelfRef => { + Some(Zone::Battlefield) + } AbilityCost::Composite { costs } => costs.iter().find_map(activation_zone_from_self_cost), _ => None, } @@ -6762,27 +6766,76 @@ fn activation_zone_from_self_cost(cost: &AbilityCost) -> Option { /// Effect-side companion to `activation_zone_from_self_cost`. /// /// CR 113.6m + CR 602.1: an activated ability whose *effect* moves the object -/// it's printed on out of a particular non-battlefield zone (e.g. "Put this -/// card from your hand onto the battlefield") functions only from that zone. -/// The cost-based derivation cannot see this because the zone lives in the -/// effect, not the cost. This walks the parsed effect chain for a self- -/// `ChangeZone` whose `origin` is a non-battlefield zone and `destination` is -/// the battlefield, returning that origin as the activation zone. +/// it's printed on out of a particular non-battlefield zone functions only from +/// that zone. The cost-based derivation cannot see this because the zone lives +/// in the effect, not the cost. This walks the parsed effect chain for a self- +/// `ChangeZone` with a non-battlefield `origin`, returning that origin as the +/// activation zone. +/// +/// **The rule quantifies over the ORIGIN zone only.** CR 113.6m reads "an +/// ability whose cost or effect specifies that it moves the object it's on +/// **out of a particular zone** functions only in that zone" — the destination +/// appears nowhere in it. Both destinations are live in the corpus and both +/// derive the same way: `→ Battlefield` (Reassembling Skeleton / +/// Bloodsoaked Champion, CR 113.6m's own printed example) and `→ Hand` +/// (Gutterbones / Bestial Bloodline, "Return this card from your graveyard to +/// your hand"). Do not re-add a `destination` field to the pattern. +/// +/// `origin != Zone::Battlefield` is the CR 113.6 default guard, **not** part of +/// CR 113.6m: an ability whose effect moves its own source *off* the +/// battlefield already functions there by default, so there is nothing to +/// derive. Keep it — it is the correct default and costs nothing — but do not +/// mistake it for load-bearing: **its class is empty at this corpus vintage.** +/// 0 of the 22,794 parsed abilities carry a self-`ChangeZone` with +/// `origin: Some(Zone::Battlefield)`, so no card and no test reaches this line. +/// The shape that would reach it is an effect lowering to +/// `ChangeZone { origin: Some(Zone::Battlefield), target: TargetFilter::SelfRef, .. }` +/// — a self-move whose text names the battlefield as the zone it moves out of. +/// No printed self-move does today: they leave the origin unstated +/// (`origin: None`) or lower to a different variant. The two Auras that look +/// like this class are rejected by *earlier* parts of the pattern and never +/// arrive here — Cooped Up (`{2}{W}: Exile enchanted creature.`) by +/// `target: TargetFilter::SelfRef`, because it moves the enchanted creature and +/// not its own source, and Cage of Hands (`{1}{W}: Return this Aura to its +/// owner's hand.`) by the `Effect::ChangeZone` variant match, because it lowers +/// to `Effect::Bounce`. +/// +/// The canonical own-resolution traversal is **kind-agnostic** and walks direct +/// sub-, otherwise-, and modal branches. Lochmere Serpent depends on exactly +/// that: its `Graveyard → Hand` self-move sits on a sub-ability whose kind is +/// `Spell`, not `Activated`. Three parts of CR 113.6m are deliberately **not** implemented because +/// each governs a measurably empty class at this corpus vintage; each has its +/// extension point named here: +/// - the `unless` clause's effect half ("a previous part of its … effect +/// specifies that the object is put into that zone") — 0 operative cards; +/// extension point: skip a later self-move whose zone an earlier part filled. +/// - the Aura half of the `unless` clause (satisfiable by a cost, an effect +/// **or** a trigger condition specifying that the enchanted object leaves the +/// battlefield) — none of the Auras in the class qualifies; extension point: +/// a cost-chain inspection in this function. +/// - CR 113.6m sentence 2 (an effect that creates a delayed triggered ability +/// which moves the object out of a zone, CR 603.7) — 0 operative cards (the +/// abilities carrying that shape are synthesized Unearth, CR 702.84, whose +/// delayed move is `Battlefield → Exile`, i.e. the CR 113.6 default); +/// extension point: an `Effect::CreateDelayedTrigger` arm here that recurses +/// into the carried `AbilityDefinition`. fn activation_zone_from_self_effect(def: &AbilityDefinition) -> Option { - if let Effect::ChangeZone { - origin: Some(origin), - destination: Zone::Battlefield, - target: TargetFilter::SelfRef, - .. - } = *def.effect - { - if origin != Zone::Battlefield { - return Some(origin); + let mut activation_zone = None; + let _ = visit_ability_def_scoped(def, ResolutionScope::OwnResolutionOnly, &mut |effect| { + if let Effect::ChangeZone { + origin: Some(origin), + target: TargetFilter::SelfRef, + .. + } = effect + { + if *origin != Zone::Battlefield { + activation_zone = Some(*origin); + return ControlFlow::Break(()); + } } - } - def.sub_ability - .as_deref() - .and_then(activation_zone_from_self_effect) + ControlFlow::Continue(()) + }); + activation_zone } /// CR 608.2k: Source zone of a non-self `AbilityCost::Exile` component @@ -6843,13 +6896,45 @@ fn parse_activated_ability_ir( ctx.current_ability_exile_cost_zone = prev_exile_zone; ctx.current_ability_index = prev_ability_index; let lowered_for_activation_zone = lower_ability_ir(&ir); - // CR 113.6m: fall back to the effect-side derivation — an ability whose - // effect moves the source out of a non-battlefield zone functions only - // from that zone. Cost-based derivation keeps priority. - ir.shell.activation_zone = lowered_for_activation_zone - .activation_zone - .or_else(|| activation_zone_from_self_cost(&cost)) - .or_else(|| activation_zone_from_self_effect(&lowered_for_activation_zone)); + // Three-authority precedence for the activation zone. The ORDER IS A RULES + // BOUNDARY, not a style choice — see Kogla and Yidaro below. + // + // 1. CR 113.6b: "An ability that states which zones it functions in + // functions only from those zones." When the card states the zone there + // is nothing to derive, and "only from those zones" is exclusive. Today + // this link is reachable only from the whole-line dispatch sites that + // stamp the shell directly (Channel, CR 207.2c; Forecast, CR 702.57a) and + // from the `database/` synthesis writers — never from inside this + // function, whose `ir` is built fresh from the post-colon effect text. + // It is a deliberate forward guard for the day an explicit-zone grammar + // routes through here, NOT dead code to be tidied away. + // 2. CR 113.6j + CR 118.3: a cost-derived source zone takes priority over + // a conflicting effect origin. Battlefield remains the implicit default + // representation unless that priority is needed. + // 3. CR 113.6m: an ability whose effect moves the source out of a + // non-battlefield zone functions only from that zone. + // + // 2 ≻ 3 is discriminating on **Kogla and Yidaro**: "{2}{R}{G}, Discard this + // card: … Shuffle this card into your library from your graveyard, …". + // The cost yields `Hand` and the effect yields `Graveyard`; `Hand` is + // correct, because discarding is what put the card into the graveyard, so + // CR 113.6m's `unless` clause ("a previous part of its cost … specifies + // that the object is put into that zone") makes the effect side + // inapplicable by rule, and CR 118.3 makes a graveyard activation + // unpayable rather than merely suboptimal. Reversing this precedence + // regresses that card. + let cost_activation_zone = activation_zone_from_self_cost(&cost); + let effect_activation_zone = activation_zone_from_self_effect(&lowered_for_activation_zone); + ir.shell.activation_zone = lowered_for_activation_zone.activation_zone.or({ + match (cost_activation_zone, effect_activation_zone) { + // A self-sacrifice is paid from the battlefield, but Battlefield is + // the default activation zone. Preserve `None` until it must defeat + // a derived non-battlefield effect origin. + (Some(Zone::Battlefield), None) => None, + (Some(cost_zone), _) => Some(cost_zone), + (None, effect_zone) => effect_zone, + } + }); ir.shell.cost = Some(cost); ir.shell.description = Some(description.to_string()); if !constraints.restrictions.is_empty() { diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index b8dba929b3..9020f5b513 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -9907,6 +9907,15 @@ fn parses_activate_only_timing_and_only_if_condition() { &["Creature"], &[], ); + // CR 113.6m: the effect moves the source out of the graveyard, so the + // ability functions only from the graveyard. This is the assertion that was + // missing — the restrictions below were checked from day one, the zone was + // not, which is how the `Graveyard → Hand` gap survived. + assert_eq!( + r.abilities[0].activation_zone, + Some(Zone::Graveyard), + "CR 113.6m: a graveyard self-return functions only from the graveyard" + ); let restrictions = &r.abilities[0].activation_restrictions; assert!(restrictions.contains(&ActivationRestriction::DuringYourTurn)); assert!(restrictions.iter().any(|restriction| matches!( @@ -13489,9 +13498,9 @@ fn put_self_from_graveyard_onto_battlefield_activates_from_graveyard() { #[test] fn battlefield_self_changezone_leaves_activation_zone_unset() { - // Negative control: a normal battlefield-activated ability whose effect - // does NOT move the source out of a non-battlefield zone must keep - // activation_zone == None (→ defaults to Battlefield at runtime). + // Negative control, VARIANT level: a normal battlefield-activated ability + // whose effect does NOT move the source out of a non-battlefield zone must + // keep activation_zone == None (→ defaults to Battlefield at runtime). let r = parse( "{1}{U}: Return Test Bounce Creature to its owner's hand.", "Test Bounce Creature", @@ -13502,12 +13511,548 @@ fn battlefield_self_changezone_leaves_activation_zone_unset() { assert_eq!(r.abilities.len(), 1); let ability = &r.abilities[0]; assert_eq!(ability.kind, AbilityKind::Activated); + // Reach-guard — without it this control is VACUOUS. A self-bounce lowers to + // `Effect::Bounce`, not `Effect::ChangeZone`, so it never reaches the + // `ChangeZone` arm of `activation_zone_from_self_effect` and would pass + // identically with or without the CR 113.6m derivation. Pinning the variant + // makes that explicit: if a future parser change routes self-bounce through + // `ChangeZone`, this fails loudly instead of going quietly vacuous. The + // FIELD-level control (a real `ChangeZone` that IS entered and then + // rejected on its fields) is + // `enchanted_creature_exile_leaves_activation_zone_unset` below. + assert!( + matches!( + *ability.effect, + Effect::Bounce { + target: TargetFilter::SelfRef, + .. + } + ), + "reach-guard: a self-bounce must lower to Effect::Bounce, so this \ + control never enters the ChangeZone arm; got {:?}", + ability.effect + ); assert_eq!( ability.activation_zone, None, "a self-bounce (battlefield → hand) must not derive an activation zone" ); } +/// V1 — CR 113.6m: `Graveyard → Hand` is the reported bug. Bestial Bloodline's +/// `{4}{G}: Return this card from your graveyard to your hand.` must derive +/// `activation_zone: Graveyard`; before the destination gate was removed from +/// `activation_zone_from_self_effect` it stayed `None`, so the Aura offered the +/// ability on the battlefield and withheld it in the graveyard — both wrong. +#[test] +fn return_self_from_graveyard_to_hand_activates_from_graveyard() { + let r = parse( + "Enchant creature\nEnchanted creature gets +2/+2.\n{4}{G}: Return this \ + card from your graveyard to your hand.", + "Bestial Bloodline", + &[], + &["Enchantment"], + &["Aura"], + ); + assert_eq!(r.abilities.len(), 1); + let ability = &r.abilities[0]; + assert_eq!(ability.kind, AbilityKind::Activated); + // Reach-guard: the ability parsed and carries the shape the derivation reads. + assert!( + matches!( + *ability.effect, + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Hand, + target: TargetFilter::SelfRef, + .. + } + ), + "reach-guard: expected a Graveyard → Hand self-ChangeZone, got {:?}", + ability.effect + ); + assert_eq!( + ability.activation_zone, + Some(Zone::Graveyard), + "CR 113.6m: the effect moves the card out of the graveyard, so the \ + ability functions only from the graveyard" + ); +} + +/// V2 — the derivation is DESTINATION-AGNOSTIC. CR 113.6m quantifies over the +/// zone the object is moved *out of*; the destination appears nowhere in the +/// rule. This is the building-block property, not a `→ Hand` special case. +#[test] +fn self_changezone_activation_zone_is_destination_agnostic() { + let rows: [(&str, Zone); 4] = [ + ( + "{1}: Return this card from your graveyard to your hand.", + Zone::Hand, + ), + ( + "{1}: Put this card from your graveyard onto the battlefield.", + Zone::Battlefield, + ), + ("{1}: Exile this card from your graveyard.", Zone::Exile), + ( + "{1}: Shuffle this card into your library from your graveyard.", + Zone::Library, + ), + ]; + for (text, destination) in rows { + let r = parse( + text, + "Test Destination Axis", + &[], + &["Creature"], + &["Zombie"], + ); + assert_eq!(r.abilities.len(), 1, "one activated ability for {text:?}"); + let ability = &r.abilities[0]; + assert_eq!(ability.kind, AbilityKind::Activated); + // Reach-guard FIRST: a parser shortfall must fail loudly here rather + // than make the activation-zone assertion below vacuous. + assert!( + matches!( + *ability.effect, + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: parsed, + target: TargetFilter::SelfRef, + .. + } if parsed == destination + ), + "reach-guard: {text:?} must lower to a Graveyard → {destination:?} \ + self-ChangeZone, got {:?}", + ability.effect + ); + assert_eq!( + ability.activation_zone, + Some(Zone::Graveyard), + "CR 113.6m: the origin decides the activation zone; destination \ + {destination:?} must not change the answer" + ); + } +} + +/// V3 — the derivation is PER ABILITY, not per card. On a card carrying both an +/// affected and an unaffected activated ability, only the affected one may flip. +/// Lochmere Serpent additionally proves (a) the `sub_ability` recursion is +/// walked and is KIND-AGNOSTIC (its self-move sits on a `Spell`-kind +/// sub-ability) and (b) a non-self `ChangeZone` at the top level neither +/// short-circuits the walk nor mis-derives. Abzan Devotee proves the derivation +/// does not leak sideways onto a mana sibling on the same card. +#[test] +fn activation_zone_is_derived_per_ability_not_per_card() { + /// Proof that the ability at this index is the one the row means — an index + /// shift or a parser shape change fails loudly instead of silently moving + /// the expectation onto a different ability. + type AbilityGuard = fn(&AbilityDefinition) -> bool; + + // The keyword column mirrors MTGJSON's `keywords` array, which production + // passes alongside the printed text; a printed keyword-only line ("Flash") + // needs that hint to be recognised as a keyword line rather than falling + // through to an ability slot. + #[allow(clippy::type_complexity)] + let rows: [( + &str, + &str, + &[Keyword], + &[&str], + &[&str], + &[(Option, AbilityGuard)], + ); 3] = [ + ( + "Tymaret, the Murder King", + "{1}{R}, Sacrifice another creature: Tymaret deals 2 damage to \ + target player or planeswalker.\n{1}{B}, Sacrifice a creature: \ + Return this card from your graveyard to your hand.", + &[], + &["Creature"], + &["Zombie", "Warrior"], + &[ + (None, |a| matches!(*a.effect, Effect::DealDamage { .. })), + (Some(Zone::Graveyard), |a| { + matches!( + *a.effect, + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Hand, + target: TargetFilter::SelfRef, + .. + } + ) + }), + ], + ), + ( + "Lochmere Serpent", + "Flash\n{U}, Sacrifice an Island: This creature can't be blocked \ + this turn.\n{B}, Sacrifice a Swamp: You gain 1 life and draw a \ + card.\n{U}{B}: Exile five target cards from an opponent's \ + graveyard. Return this card from your graveyard to your hand. \ + Activate only as a sorcery.", + &[Keyword::Flash], + &["Creature"], + &["Serpent"], + &[ + (None, |a| matches!(*a.effect, Effect::GenericEffect { .. })), + (None, |a| matches!(*a.effect, Effect::GainLife { .. })), + (Some(Zone::Graveyard), |a| { + // Top-level move is a NON-self exile of opponent cards; the + // self-move lives on a `Spell`-kind sub-ability, which only + // the kind-agnostic recursion can reach. + matches!( + *a.effect, + Effect::ChangeZone { + origin: None, + destination: Zone::Exile, + target: TargetFilter::Typed { .. }, + .. + } + ) && a.sub_ability.as_deref().is_some_and(|sub| { + sub.kind == AbilityKind::Spell + && matches!( + *sub.effect, + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Hand, + target: TargetFilter::SelfRef, + .. + } + ) + }) + }), + ], + ), + ( + "Abzan Devotee", + "{1}: Add {W}, {B}, or {G}. Activate only once each turn.\n{2}{B}: \ + Return this card from your graveyard to your hand.", + &[], + &["Creature"], + &["Dog", "Cleric"], + &[ + // A mana ability: the derivation must not leak sideways onto it. + (None, |a| matches!(*a.effect, Effect::Mana { .. })), + (Some(Zone::Graveyard), |a| { + matches!( + *a.effect, + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Hand, + target: TargetFilter::SelfRef, + .. + } + ) + }), + ], + ), + ]; + + for (name, text, keywords, types, subtypes, expected) in rows { + let r = parse(text, name, keywords, types, subtypes); + assert_eq!( + r.abilities.len(), + expected.len(), + "{name}: activated-ability count" + ); + for (index, (expected_zone, guard)) in expected.iter().enumerate() { + let ability = &r.abilities[index]; + assert!( + guard(ability), + "{name}: ability {index} is not the shape this row expects; \ + got {:?}", + ability.effect + ); + assert_eq!( + ability.activation_zone, *expected_zone, + "{name}: ability {index} activation_zone (CR 113.6m applies per \ + ability, not per card)" + ); + } + } +} + +/// V4 — NEGATIVE, FIELD level. Cooped Up's `{2}{W}: Exile enchanted creature.` +/// produces a real `ChangeZone` that DOES reach the derivation and is rejected +/// on both `origin: Some(_)` and `target: SelfRef`. Distinct from the +/// variant-level control above, which never enters the `ChangeZone` arm at all. +#[test] +fn enchanted_creature_exile_leaves_activation_zone_unset() { + let r = parse( + "Enchant creature\nEnchanted creature can't attack or block.\n{2}{W}: \ + Exile enchanted creature.", + "Cooped Up", + &[], + &["Enchantment"], + &["Aura"], + ); + assert_eq!(r.abilities.len(), 1); + let ability = &r.abilities[0]; + assert_eq!(ability.kind, AbilityKind::Activated); + // Paired positive reach-guard: the effect IS a ChangeZone, so the `if let` + // arm is entered; it is rejected on the fields, not on the variant. + assert!( + matches!( + *ability.effect, + Effect::ChangeZone { + origin: None, + destination: Zone::Exile, + target: TargetFilter::Typed { .. }, + .. + } + ), + "reach-guard: the derivation must be entered with a real ChangeZone \ + and rejected on its fields; got {:?}", + ability.effect + ); + assert_eq!( + ability.activation_zone, None, + "moving another object out of a zone says nothing about where THIS \ + ability functions (CR 113.6m is about the object the ability is on)" + ); +} + +/// V5 — NEGATIVE, variant level, on a real printed card. Cage of Hands' +/// `{1}{W}: Return this Aura to its owner's hand.` is a self-bounce from the +/// battlefield: CR 113.6's default already covers it, and CR 113.6m adds +/// nothing because the move is not *out of* a non-battlefield zone. +#[test] +fn cage_of_hands_self_bounce_leaves_activation_zone_unset() { + let r = parse( + "Enchant creature\nEnchanted creature can't attack or block.\n{1}{W}: \ + Return this Aura to its owner's hand.", + "Cage of Hands", + &[], + &["Enchantment"], + &["Aura"], + ); + assert_eq!(r.abilities.len(), 1); + let ability = &r.abilities[0]; + assert_eq!(ability.kind, AbilityKind::Activated); + assert!( + matches!( + *ability.effect, + Effect::Bounce { + target: TargetFilter::SelfRef, + .. + } + ), + "reach-guard: Cage of Hands' self-bounce lowers to Effect::Bounce; if \ + it ever lowers to ChangeZone this control must be re-derived, got {:?}", + ability.effect + ); + assert_eq!(ability.activation_zone, None); +} + +/// V6 — MULTI-AUTHORITY. Kogla and Yidaro is the only parsed ability in the +/// corpus where the cost-side and effect-side derivations disagree: the cost +/// ("Discard this card") says `Hand`, the effect ("Shuffle this card into your +/// library from your graveyard") says `Graveyard`. `Hand` is correct: CR 113.6j +/// and CR 118.3 together make a graveyard activation unpayable, and CR 113.6m's +/// `unless` clause exempts the effect side because the discard is what put the +/// card in the graveyard. This pins the `.or_else()` ORDER, which became +/// load-bearing only once the destination gate was removed (before that, +/// `→ Library` silenced the effect side by accident). +#[test] +fn discard_self_cost_beats_graveyard_effect_origin() { + let r = parse( + "When Kogla and Yidaro enters, choose one —\n• It gains trample and \ + haste until end of turn.\n• It fights target creature you don't \ + control.\n{2}{R}{G}, Discard this card: Destroy up to one target \ + artifact or enchantment. Shuffle this card into your library from your \ + graveyard, then draw a card.", + "Kogla and Yidaro", + &[], + &["Creature"], + &["Ape", "Dinosaur", "Turtle"], + ); + assert_eq!(r.abilities.len(), 1); + let ability = &r.abilities[0]; + assert_eq!(ability.kind, AbilityKind::Activated); + + // Both authorities must genuinely fire — `Hand` is a RESOLVED CONFLICT, not + // a single-source result. `oracle_tests.rs` is a child module of `oracle`, + // so the two private derivations can be called directly. + let cost = ability.cost.as_ref().expect("the ability has a cost"); + assert_eq!( + activation_zone_from_self_cost(cost), + Some(Zone::Hand), + "CR 113.6j + CR 118.3: 'Discard this card' is payable only from hand" + ); + assert_eq!( + activation_zone_from_self_effect(ability), + Some(Zone::Graveyard), + "CR 113.6m read alone would say Graveyard — this is the losing authority" + ); + assert_eq!( + ability.activation_zone, + Some(Zone::Hand), + "cost-side derivation keeps priority; swapping the .or_else() links \ + regresses this card to Graveyard" + ); +} + +/// CR 113.6j + CR 113.6m: a self-sacrifice cost puts the source into its +/// graveyard, so it keeps battlefield activation authority over a later +/// graveyard self-return effect. +#[test] +fn sacrifice_self_cost_beats_graveyard_effect_origin() { + let r = parse( + "{B}, Sacrifice this creature: Return this card from your graveyard to your hand.", + "Test Sacrificial Return", + &[], + &["Creature"], + &[], + ); + assert_eq!(r.abilities.len(), 1); + let ability = &r.abilities[0]; + let cost = ability.cost.as_ref().expect("the ability has a cost"); + assert!( + matches!( + cost, + AbilityCost::Composite { costs } + if costs.iter().any(|cost| matches!( + cost, + AbilityCost::Sacrifice(sacrifice) + if sacrifice.target == TargetFilter::SelfRef + )) + ), + "reach-guard: expected a self-sacrifice cost, got {cost:?}" + ); + assert_eq!( + activation_zone_from_self_cost(cost), + Some(Zone::Battlefield), + "a self-sacrifice is payable only while the source is on the battlefield" + ); + assert_eq!( + activation_zone_from_self_effect(ability), + Some(Zone::Graveyard), + "the later self-return alone would derive Graveyard" + ); + assert_eq!( + ability.activation_zone, + Some(Zone::Battlefield), + "cost-side source-zone authority must beat the later effect-side origin" + ); +} + +/// A self-sacrifice cost is battlefield-only, but Battlefield is already the +/// runtime default. Do not serialize it unless it must override an effect-side +/// non-battlefield origin. +#[test] +fn self_sacrifice_without_effect_origin_keeps_default_activation_zone() { + let r = parse( + "{R}, Sacrifice this artifact: Draw a card.", + "Test Sacrificial Draw", + &[], + &["Artifact"], + &[], + ); + assert_eq!(r.abilities.len(), 1); + let ability = &r.abilities[0]; + let cost = ability.cost.as_ref().expect("the ability has a cost"); + assert_eq!( + activation_zone_from_self_cost(cost), + Some(Zone::Battlefield), + "reach-guard: the cost must derive battlefield authority" + ); + assert_eq!( + activation_zone_from_self_effect(ability), + None, + "reach-guard: drawing does not derive an effect-side source zone" + ); + assert_eq!( + ability.activation_zone, None, + "the default battlefield representation must not create a parse delta" + ); +} + +/// The canonical own-resolution visitor must cover every direct ability branch +/// that can carry a self-zone move. +#[test] +fn self_changezone_derivation_visits_sub_else_and_mode_branches() { + let root = parse( + "{1}: Draw a card.", + "Test Branch Root", + &[], + &["Artifact"], + &[], + ) + .abilities + .remove(0); + let self_return = parse( + "{1}: Return this card from your graveyard to your hand.", + "Test Branch Return", + &[], + &["Artifact"], + &[], + ) + .abilities + .remove(0); + + let mut sub = root.clone(); + sub.sub_ability = Some(Box::new(self_return.clone())); + let mut otherwise = root.clone(); + otherwise.else_ability = Some(Box::new(self_return.clone())); + let mut modal = root; + modal.mode_abilities.push(self_return); + + for (branch, ability) in [("sub", sub), ("else", otherwise), ("mode", modal)] { + assert_eq!( + activation_zone_from_self_effect(&ability), + Some(Zone::Graveyard), + "the {branch} branch's self-return must contribute its origin" + ); + } +} + +/// V7 — cost-shape NEAR-MISSES must not short-circuit to the wrong zone. +/// Phantasmagorian's cost discards *other* cards (`self_scope` is not +/// `SourceCard`), and Salvage Titan's cost exiles *other* artifact cards from +/// the graveyard rather than itself. In both the cost side must decline and the +/// effect side must win with `Graveyard`. +#[test] +fn non_self_discard_and_non_self_exile_costs_defer_to_effect_origin() { + let rows: [(&str, &str, &[&str], &[&str]); 2] = [ + ( + "Phantasmagorian", + "When you cast this spell, any player may discard three cards. If a \ + player does, counter Phantasmagorian.\nDiscard three cards: Return \ + this card from your graveyard to your hand.", + &["Creature"], + &["Horror"], + ), + ( + "Salvage Titan", + "You may sacrifice three artifacts rather than pay this spell's \ + mana cost.\nExile three artifact cards from your graveyard: Return \ + this card from your graveyard to your hand.", + &["Artifact", "Creature"], + &["Golem"], + ), + ]; + for (name, text, types, subtypes) in rows { + let r = parse(text, name, &[], types, subtypes); + assert_eq!(r.abilities.len(), 1, "{name}: one activated ability"); + let ability = &r.abilities[0]; + let cost = ability.cost.as_ref().expect("the ability has a cost"); + // Reach-guard: the cost side is REACHED and DECLINES, so the effect + // side's win below is real rather than an artifact of an absent cost. + assert_eq!( + activation_zone_from_self_cost(cost), + None, + "{name}: the cost does not move the source out of any zone, so the \ + cost-side derivation must decline; cost = {cost:?}" + ); + assert_eq!( + ability.activation_zone, + Some(Zone::Graveyard), + "{name}: CR 113.6m — the effect's origin decides" + ); + } +} + // ----------------------------------------------------------------------- // Boast (CR 702.142 — keyword ability) // ----------------------------------------------------------------------- diff --git a/crates/engine/tests/integration/graveyard_to_hand_activation_zone.rs b/crates/engine/tests/integration/graveyard_to_hand_activation_zone.rs new file mode 100644 index 0000000000..44728a162e --- /dev/null +++ b/crates/engine/tests/integration/graveyard_to_hand_activation_zone.rs @@ -0,0 +1,563 @@ +//! CR 113.6m — an activation zone is derived from a self-`ChangeZone`'s ORIGIN, +//! never gated on its destination. +//! +//! CR 113.6m: "An ability whose cost or effect specifies that it moves the +//! object it's on out of a particular zone functions only in that zone, unless +//! its trigger condition or a previous part of its cost or effect specifies +//! that the object is put into that zone or, if the object is an Aura, that the +//! object it enchants leaves the battlefield." +//! +//! The rule quantifies over the zone the object is moved *out of*. The +//! destination appears nowhere in it. `activation_zone_from_self_effect` +//! (`parser/oracle.rs`) was introduced for the `Hand → Battlefield` case (issue +//! #425, Talon Gates of Madara) and pinned the pattern to +//! `destination: Zone::Battlefield`, so the 55 abilities of the form "{cost}: +//! Return this card from your graveyard to your hand." derived no activation +//! zone at all. The runtime gate (`casting.rs`, `unwrap_or(Zone::Battlefield)`) +//! therefore offered them **on the battlefield** and withheld them **in the +//! graveyard** — both halves wrong. +//! +//! These tests drive the real `legal_actions` / `apply()` pipeline. The three +//! negatives each carry a positive reach-guard proving the ability could +//! otherwise have been offered — including that its **activation restrictions +//! are absent**, because `can_activate_ability_now` checks the zone *before* the +//! restrictions, so a restricted fixture would be rejected with and without the +//! fix and prove nothing. + +use engine::ai_support::legal_actions; +use engine::game::casting::can_pay_cost_after_auto_tap; +use engine::game::game_object::AttachTarget; +use engine::game::layers::evaluate_layers; +use engine::game::sba::check_state_based_actions; +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::{AbilityCost, ActivationRestriction, Effect, TargetFilter}; +use engine::types::actions::GameAction; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +/// Sanitarium Skeleton's entire printed Oracle text. +const SANITARIUM_SKELETON_TEXT: &str = "{2}{B}: Return this card from your graveyard to your hand."; + +/// A self-sacrifice puts the source into the graveyard while paying the cost; +/// it must therefore remain a battlefield activation even when its later effect +/// returns that source from the graveyard. +const SELF_SACRIFICE_RETURN_TEXT: &str = + "{B}, Sacrifice this creature: Return this card from your graveyard to your hand."; + +/// Bestial Bloodline's printed Oracle text (the reported card). +const BESTIAL_BLOODLINE_TEXT: &str = "Enchant creature\nEnchanted creature gets +2/+2.\n{4}{G}: Return this card from your graveyard to your hand."; + +/// Slumbering Keepguard's printed Oracle text — the over-restriction canary. +const SLUMBERING_KEEPGUARD_TEXT: &str = "Whenever an enchantment you control enters, scry 1.\n{2}{W}: This creature gets +1/+1 until end of turn for each enchantment you control."; + +/// Braided Net's printed Oracle text — the Craft hard constraint. +const BRAIDED_NET_TEXT: &str = "This artifact enters with three net counters on it.\n{T}, Remove a net counter from this artifact: Tap another target nonland permanent. Its activated abilities can't be activated for as long as it remains tapped.\nCraft with artifact {1}{U}"; + +fn floating(mana: &[ManaType]) -> Vec { + mana.iter() + .map(|t| ManaUnit::new(*t, ObjectId(0), false, vec![])) + .collect() +} + +/// Is `object`'s ability offered as an `ActivateAbility` action right now? +fn offers_activation(state: &engine::types::game_state::GameState, object: ObjectId) -> bool { + legal_actions(state).iter().any(|action| { + matches!(action, GameAction::ActivateAbility { source_id, .. } if *source_id == object) + }) +} + +/// V10 — the bug, primary: a `Graveyard → Hand` self-return must NOT be offered +/// while the card sits on the battlefield, and submitting it must be rejected. +/// +/// Sanitarium Skeleton is deliberately a plain `Creature — Skeleton` with a +/// single mana-cost ability and **no activation restrictions**: no Aura subtype +/// (so no CR 704.5m interaction) and nothing upstream of the zone gate that +/// could make this negative pass for the wrong reason. +#[test] +fn sanitarium_skeleton_not_activatable_from_battlefield() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let skeleton = scenario + .add_creature(P0, "Sanitarium Skeleton", 1, 2) + .with_subtypes(vec!["Skeleton"]) + .from_oracle_text(SANITARIUM_SKELETON_TEXT) + .id(); + scenario.with_mana_pool( + P0, + floating(&[ManaType::Colorless, ManaType::Colorless, ManaType::Black]), + ); + let mut runner = scenario.build(); + + // ---- positive reach-guards: everything except the zone gate says yes ---- + let object = &runner.state().objects[&skeleton]; + assert_eq!(object.abilities.len(), 1, "one activated ability"); + let ability = &object.abilities[0]; + assert!( + matches!( + *ability.effect, + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Hand, + target: TargetFilter::SelfRef, + .. + } + ), + "reach-guard: expected a Graveyard → Hand self-ChangeZone, got {:?}", + ability.effect + ); + assert_eq!( + ability.activation_zone, + Some(Zone::Graveyard), + "CR 113.6m: the parser must derive Graveyard from the effect's origin" + ); + assert!( + ability.activation_restrictions.is_empty(), + "reach-guard: no activation restriction may explain the negative — \ + `can_activate_ability_now` checks the zone BEFORE the restrictions, so \ + a restricted fixture would be rejected with and without the fix" + ); + assert_eq!( + object.zone, + Zone::Battlefield, + "reach-guard: the card is on the battlefield" + ); + assert_eq!( + runner.state().players[0].mana_pool.mana.len(), + 3, + "reach-guard: {{2}}{{B}} is floating, so affordability cannot be the \ + reason the ability is withheld" + ); + + // ---- the assertions that fail if the fix is reverted ---- + assert!( + !offers_activation(runner.state(), skeleton), + "CR 113.6m: a graveyard self-return must not be offered from the \ + battlefield; legal_actions returned {:?}", + legal_actions(runner.state()) + ); + assert!( + runner + .act(GameAction::ActivateAbility { + source_id: skeleton, + ability_index: 0, + }) + .is_err(), + "the submit path must reject the activation too (casting.rs zone gate)" + ); +} + +/// V11 — the reported bug, verbatim: an attached Bestial Bloodline Aura on the +/// battlefield must not offer its graveyard-return ability. This mirrors the +/// user's saved game, where two attached Bestial Bloodline Auras were offering +/// the ability from the battlefield. +/// +/// The Aura is attached with the repo's printed-Aura idiom (set `attached_to`, +/// push to the host's `attachments`, then `evaluate_layers`) — NOT +/// `attach_as_bestowed_aura`, which stamps `bestow_form` and would make the +/// fixture survive CR 704.5m for the wrong reason. +#[test] +fn attached_bestial_bloodline_aura_not_activatable_from_battlefield() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let host = scenario + .add_creature(P0, "Savior of the Sleeping", 2, 3) + .with_subtypes(vec!["Human", "Knight"]) + .id(); + // Re-parse after the Aura subtype is set and with MTGJSON's printed + // `keywords: ["Enchant"]` hint, exactly as the card-data pipeline does — + // `Keyword::Enchant(filter)` comes from that hint, and CR 704.5m's + // `is_valid_attachment_target` keys on it. + let aura = scenario + .add_enchantment_from_oracle(P0, "Bestial Bloodline", BESTIAL_BLOODLINE_TEXT) + .with_subtypes(vec!["Aura"]) + .from_oracle_text_with_keywords(&["Enchant"], BESTIAL_BLOODLINE_TEXT) + .id(); + scenario.with_mana_pool( + P0, + floating(&[ + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Green, + ]), + ); + let mut runner = scenario.build(); + + // Attach the Aura the way 29 existing integration tests do. + runner + .state_mut() + .objects + .get_mut(&aura) + .expect("the Aura exists") + .attached_to = Some(AttachTarget::Object(host)); + runner + .state_mut() + .objects + .get_mut(&host) + .expect("the host exists") + .attachments + .push(aura); + evaluate_layers(runner.state_mut()); + + // ---- fixture-stability guard: CR 704.5m must not sweep the Aura away ---- + assert!( + runner.state().objects[&aura] + .keywords + .iter() + .any(|kw| matches!(kw, Keyword::Enchant(_))), + "reach-guard: `is_valid_attachment_target` keys on Keyword::Enchant" + ); + let mut events = Vec::new(); + check_state_based_actions(runner.state_mut(), &mut events); + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Battlefield, + "CR 704.5m: the fixture Aura must survive the unattached-Aura SBA" + ); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(host)), + "CR 704.5m: the fixture Aura must still be attached after SBAs" + ); + + // ---- positive reach-guards ---- + let ability = &runner.state().objects[&aura].abilities[0]; + assert_eq!(ability.activation_zone, Some(Zone::Graveyard)); + assert!( + ability.activation_restrictions.is_empty() && ability.condition.is_none(), + "reach-guard: nothing downstream of the zone gate can explain the \ + negative (measured: both null on the printed card)" + ); + + // ---- the assertions that fail if the fix is reverted ---- + assert!( + !offers_activation(runner.state(), aura), + "CR 113.6m: the Aura's graveyard-return must not be offered while the \ + Aura is on the battlefield; legal_actions returned {:?}", + legal_actions(runner.state()) + ); + assert!( + runner + .act(GameAction::ActivateAbility { + source_id: aura, + ability_index: 0, + }) + .is_err(), + "the submit path must reject the activation too" + ); +} + +/// V12 — the other half: the same Aura sitting in the GRAVEYARD must be offered +/// the ability, and it must actually resolve the card into its owner's hand. +/// Pre-fix the activation was not offered at all. +/// +/// No attachment is needed: `check_unattached_auras` iterates a **battlefield** +/// snapshot, so a graveyard card is never examined. +#[test] +fn bestial_bloodline_activatable_from_graveyard_returns_to_hand() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let aura = scenario + .add_creature_to_graveyard(P0, "Bestial Bloodline", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(BESTIAL_BLOODLINE_TEXT) + .id(); + scenario.with_mana_pool( + P0, + floating(&[ + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Green, + ]), + ); + let mut runner = scenario.build(); + + // ---- preconditions ---- + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Graveyard, + "precondition: the Aura starts in the graveyard" + ); + let ability = &runner.state().objects[&aura].abilities[0]; + assert!( + ability.activation_restrictions.is_empty(), + "precondition: no restriction can make this positive fail for a \ + non-zone reason" + ); + + // ---- the assertions that fail if the fix is reverted ---- + // NOTE ordering: the `activation_zone` assertion deliberately sits BELOW + // the runtime assertion, for the same reason it is absent from the Aura + // block in `sibling_battlefield_pump_ability_still_offered`. That value + // flips when the production line is reverted, so asserting it first would + // abort this test on a parser-level mismatch and the runtime claim — that + // the offer actually appears in `legal_actions` — would never be exercised + // on revert. Keep the runtime assertion first so this row discriminates on + // the seam it names. Do not "tidy" it back up with the preconditions. + assert!( + offers_activation(runner.state(), aura), + "CR 113.6m: the ability must be offered from the graveyard; \ + legal_actions returned {:?}", + legal_actions(runner.state()) + ); + assert_eq!( + runner.state().objects[&aura].abilities[0].activation_zone, + Some(Zone::Graveyard), + "CR 113.6m: the parser must have derived the graveyard as the \ + activation zone" + ); + let outcome = runner.activate(aura, 0).resolve(); + outcome.assert_zone(&[aura], Zone::Hand); +} + +/// Cost-side authority is exercised through the full activation pipeline: the +/// source starts on the battlefield, pays its self-sacrifice cost, and then the +/// effect returns it from the graveyard to hand. +#[test] +fn self_sacrifice_return_remains_battlefield_activated() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, floating(&[ManaType::Black])); + let source = scenario + .add_creature(P0, "Test Sacrificial Return", 1, 1) + .from_oracle_text(SELF_SACRIFICE_RETURN_TEXT) + .id(); + let mut runner = scenario.build(); + + let ability = &runner.state().objects[&source].abilities[0]; + assert_eq!( + ability.activation_zone, + Some(Zone::Battlefield), + "the self-sacrifice cost is payable only from the battlefield" + ); + assert!( + offers_activation(runner.state(), source), + "the battlefield source must offer its activation" + ); + + let outcome = runner.activate(source, 0).resolve(); + outcome.assert_zone(&[source], Zone::Hand); +} + +/// V13 — over-restriction canary. A battlefield ability with +/// `activation_zone: None` whose effect moves nothing must stay offered. This is +/// entry 25 of the user's saved game ("6 and 28 gone, 25 remains"), in the same +/// scenario shape as V11. It passes both before and after the fix by design. +#[test] +fn sibling_battlefield_pump_ability_still_offered() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let host = scenario + .add_creature(P0, "Savior of the Sleeping", 2, 3) + .with_subtypes(vec!["Human", "Knight"]) + .id(); + // Re-parse after the Aura subtype is set and with MTGJSON's printed + // `keywords: ["Enchant"]` hint, exactly as the card-data pipeline does — + // `Keyword::Enchant(filter)` comes from that hint, and CR 704.5m's + // `is_valid_attachment_target` keys on it. + let aura = scenario + .add_enchantment_from_oracle(P0, "Bestial Bloodline", BESTIAL_BLOODLINE_TEXT) + .with_subtypes(vec!["Aura"]) + .from_oracle_text_with_keywords(&["Enchant"], BESTIAL_BLOODLINE_TEXT) + .id(); + let keepguard = scenario + .add_creature(P0, "Slumbering Keepguard", 1, 1) + .with_subtypes(vec!["Human", "Knight"]) + .from_oracle_text(SLUMBERING_KEEPGUARD_TEXT) + .id(); + scenario.with_mana_pool( + P0, + floating(&[ + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Colorless, + ManaType::Green, + ManaType::Colorless, + ManaType::Colorless, + ManaType::White, + ]), + ); + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&aura) + .expect("the Aura exists") + .attached_to = Some(AttachTarget::Object(host)); + runner + .state_mut() + .objects + .get_mut(&host) + .expect("the host exists") + .attachments + .push(aura); + evaluate_layers(runner.state_mut()); + + // ---- reach-guards: the canary is offered for no reason but the default ---- + let ability = &runner.state().objects[&keepguard].abilities[0]; + assert_eq!( + ability.activation_zone, None, + "the pump ability states no zone and moves nothing, so CR 113.6's \ + battlefield default applies" + ); + assert!(ability.activation_restrictions.is_empty()); + assert!( + !matches!(*ability.effect, Effect::ChangeZone { .. }), + "variant-agnostic: the derivation is never entered because the effect \ + is not a ChangeZone; got {:?}", + ability.effect + ); + + assert!( + offers_activation(runner.state(), keepguard), + "the fix must not over-restrict: an ordinary battlefield ability stays \ + offered; legal_actions returned {:?}", + legal_actions(runner.state()) + ); + // ---- revert-invariant reach-guards for the Aura negative below ---- + // + // Read this before touching the block: `!offers_activation(aura)` at the end + // of this test is the **only** runtime assertion in this file that is still + // reached when the production line is reverted. V10/V11/V12 all abort + // earlier on `assert_eq!(ability.activation_zone, Some(Zone::Graveyard))`, a + // parser precondition that itself flips on revert, so on revert they prove + // only what the parser tests already prove. V13 therefore carries the whole + // runtime claim alone, and every guard below is chosen to hold **with and + // without** the fix: object identity, ability count, effect shape, + // restrictions, zone, attachment and affordability are all untouched by the + // one-line parser change. + // + // There is deliberately **no** `activation_zone` assertion here, and one + // must not be added as a tidy-up. That value is exactly what the fix + // changes; asserting it would abort this test on revert *before* the + // negative below is evaluated and destroy the discrimination this block + // exists to protect. + let aura_object = &runner.state().objects[&aura]; + assert_eq!( + aura_object.zone, + Zone::Battlefield, + "reach-guard: the Aura is on the battlefield" + ); + assert_eq!( + aura_object.attached_to, + Some(AttachTarget::Object(host)), + "reach-guard: the Aura is still attached, so no CR 704.5m sweep can \ + explain the negative" + ); + assert_eq!( + aura_object.abilities.len(), + 1, + "reach-guard: Bestial Bloodline has exactly one activated ability" + ); + let aura_ability = &aura_object.abilities[0]; + assert!( + matches!( + *aura_ability.effect, + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Hand, + target: TargetFilter::SelfRef, + .. + } + ), + "reach-guard: expected a Graveyard → Hand self-ChangeZone, got {:?}", + aura_ability.effect + ); + assert!( + aura_ability.activation_restrictions.is_empty() && aura_ability.condition.is_none(), + "reach-guard: no restriction or condition may explain the negative — \ + `can_activate_ability_now` checks the zone BEFORE either" + ); + let Some(AbilityCost::Mana { cost }) = &aura_ability.cost else { + panic!( + "reach-guard: expected the printed {{4}}{{G}} mana cost, got {:?}", + aura_ability.cost + ); + }; + assert!( + can_pay_cost_after_auto_tap(runner.state(), P0, aura, cost), + "reach-guard: {{4}}{{G}} is payable from the floating pool, so \ + affordability cannot be the reason the ability is withheld" + ); + + assert!( + !offers_activation(runner.state(), aura), + "…while the Aura's graveyard-return in the same scenario is withheld" + ); +} + +/// V9 — the Craft hard constraint, behaviorally. CR 702.167a: Craft's cost +/// exiles the permanent **from the battlefield**, so CR 113.6m's `unless` clause +/// exempts it and CR 113.6j makes the battlefield the only payable zone. The +/// synthesized Craft ability must keep `activation_zone: None` and must remain +/// activatable from the battlefield. +/// +/// The reach-guards are load-bearing in both directions: the Craft ability +/// carries `AsSorcery`, so the fixture sits at a main phase with an empty stack +/// and the active player holding priority — otherwise a pass/fail here would be +/// about timing rather than about the zone. +#[test] +fn craft_ability_still_activatable_from_battlefield() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let net = scenario + .add_creature(P0, "Braided Net", 0, 0) + .as_artifact() + .from_oracle_text(BRAIDED_NET_TEXT) + .id(); + // A second artifact to exile as craft material. + scenario + .add_creature(P0, "Bone Saw", 0, 0) + .as_artifact() + .id(); + scenario.with_mana_pool(P0, floating(&[ManaType::Colorless, ManaType::Blue])); + let runner = scenario.build(); + + let object = &runner.state().objects[&net]; + assert_eq!( + object.zone, + Zone::Battlefield, + "reach-guard: the Craft source is on the battlefield" + ); + let craft = object + .abilities + .get(1) + .expect("synthesize_craft adds the craft ability at index 1"); + assert_eq!( + craft.activation_zone, None, + "CR 702.167a + CR 113.6m: Craft functions from the battlefield — the \ + effect-side derivation must never stamp Exile here" + ); + assert!( + craft + .activation_restrictions + .contains(&ActivationRestriction::AsSorcery), + "reach-guard: Craft is sorcery-speed, hence the main-phase fixture" + ); + assert!( + runner.state().stack.is_empty(), + "reach-guard: sorcery-speed timing needs an empty stack" + ); + + let actions = legal_actions(runner.state()); + assert!( + actions.iter().any(|action| matches!( + action, + GameAction::ActivateAbility { + source_id, + ability_index: 1, + .. + } if *source_id == net + )), + "Craft must remain activatable from the battlefield; legal_actions \ + returned {actions:?}" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 783a3c3230..6fcb6332cc 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -263,6 +263,7 @@ mod granted_alt_cost_hand_keyword; mod granted_bloodthirst_5802; mod granted_storm_snapshot; mod granted_sunburst_5337; +mod graveyard_to_hand_activation_zone; mod greater_good_activation; mod green_suns_zenith_regression; mod griffin_rider_conditional_self_buff;