diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index f5b67b493b..b49eb0513d 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -1244,6 +1244,32 @@ fn modal_marker_ir( } } +/// CR 608.2c + CR 608.2k: The subject a modal mode body may inherit for +/// pronoun anaphora. Only a real non-self, non-`Any` trigger subject routes a +/// mode-body "it"/"that creature" to the triggering object (e.g. an +/// equipped-creature trigger's "that creature"). A `SelfRef`/`Any` subject is +/// cleared so mode-internal referents bind mode-body anaphors: a typed target +/// introduced by the mode's OWN earlier sentence takes "it" via +/// `parent_target_available` → `ParentTarget`, and "that player" resolves to +/// `ParentTargetController` (CR 608.2c: instructions are read in written +/// order; the mode's own earlier instruction is the nearest antecedent). +/// Trigger-established facts that outrank the subject are unaffected: the +/// DamageDone player scope is re-seeded per mode by +/// `modal_relative_player_scope_for_trigger`, and `object_pronoun_ref` pins +/// (including a specific untargeted object a trigger condition introduced; +/// CR 608.2k) still serve bare pronouns whenever no mode-internal referent +/// precedes them. +/// Restores the filtering the retired pre-IR path applied via +/// `derive_modal_subject` (#6811 ported the context threading but dropped the +/// filter; issue #7031 — plus the silent "that player" → TriggeringPlayer +/// misbind the same commit introduced, e.g. Disciple of Perdition). +fn mode_anaphor_subject(subject: Option) -> Option { + match subject { + Some(TargetFilter::SelfRef | TargetFilter::Any) => None, + other => other, + } +} + fn parse_modal_mode_irs( modes: &[ModeAst], kind: AbilityKind, @@ -1253,6 +1279,7 @@ fn parse_modal_mode_irs( .iter() .map(|mode| { let mut mode_ctx = base_ctx.clone(); + mode_ctx.subject = mode_anaphor_subject(mode_ctx.subject.take()); mode_ctx.diagnostics.clear(); let mut ability = parse_ability_ir_with_context(&mode.body, kind, &mut mode_ctx); guard_unsupported_mode_qualifiers_ir(&mut ability, kind, &mode_ctx); @@ -4461,4 +4488,380 @@ When The Ruinous Wrecking Crew enters, choose up to X —\n\ "standard Tiered modes must be unaffected by the shared-effect arm" ); } + + // ---- #7031 — modal mode-body subject filter (`mode_anaphor_subject`) ---- + + fn chain_has_unimplemented(ability: &AbilityDefinition) -> bool { + matches!(*ability.effect, Effect::Unimplemented { .. }) + || ability + .sub_ability + .as_ref() + .is_some_and(|s| chain_has_unimplemented(s)) + } + + const SAURON_DINO_DEVOTEE_ORACLE: &str = "Flying\nWhenever Sauron enters or attacks, choose one —\n• Cure Cancer — You gain 3 life.\n• Turn People into Dinosaurs — Put a saurian counter on another target creature. It's a green Dinosaur with base power and toughness 5/5 for as long as it has a saurian counter on it."; + + /// SHAPE (R8, #7031): Sauron, Dino Devotee's mode-2 second sentence + /// ("It's a green Dinosaur with base power and toughness 5/5 for as long + /// as it has a saurian counter on it") lowers to the full animation + /// payload bound to the MODE'S TARGET — the CR 608.2c nearest antecedent + /// introduced by the mode's own first sentence. Revert-fail: with the + /// mode-body subject filter reverted, the leaked `SelfRef` subject makes + /// the contracted-copula honest-bind gate decline and the clause falls to + /// `Effect::Unimplemented`, failing both the zero-Unimplemented + /// reach-guard and the modification-set equality. + #[test] + fn sauron_dino_devotee_mode_body_animation_binds_parent_target() { + use crate::types::ability::{ContinuousModification, FilterProp, TypeFilter}; + use crate::types::card_type::{CoreType, SubtypeSet}; + use crate::types::counter::{CounterMatch, CounterType}; + use crate::types::mana::ManaColor; + use crate::types::Duration; + + let parsed = parse_oracle_text( + SAURON_DINO_DEVOTEE_ORACLE, + "Sauron, Dino Devotee", + &[], + &[], + &[], + ); + let trigger = parsed + .triggers + .first() + .expect("enters-or-attacks trigger present"); + let execute = trigger.execute.as_deref().expect("modal execute"); + assert_eq!(execute.mode_abilities.len(), 2, "two modes"); + + // Zero-Unimplemented reach-guard: makes every negative below + // non-vacuous (an Unimplemented chain would short-circuit them). + for (i, mode) in execute.mode_abilities.iter().enumerate() { + assert!( + !chain_has_unimplemented(mode), + "mode {i} must lower with zero Unimplemented: {mode:?}" + ); + } + + // Mode-2 head: the counter placement that introduces the antecedent. + let mode2 = &execute.mode_abilities[1]; + match mode2.effect.as_ref() { + Effect::PutCounter { + counter_type, + target: TargetFilter::Typed(filter), + .. + } => { + assert_eq!(counter_type, &CounterType::Generic("saurian".to_string())); + assert_eq!(filter.type_filters, vec![TypeFilter::Creature]); + assert!( + filter.properties.contains(&FilterProp::Another), + "'another target creature' keeps the Another property" + ); + } + other => panic!("mode 2 head must be PutCounter on a typed target, got {other:?}"), + } + + // Mode-2 second sentence: the restored animation payload. + let sub = mode2 + .sub_ability + .as_deref() + .expect("mode 2 second sentence present"); + // CR 611.2b: the peeled `for as long as` duration survives on the + // sub-ability wrapper, exactly where the pre-fix export carried it. + let expected_condition = StaticCondition::RecipientHasCounters { + counters: CounterMatch::OfType(CounterType::Generic("saurian".to_string())), + minimum: 1, + maximum: None, + }; + assert_eq!( + sub.duration, + Some(Duration::ForAsLongAs { + condition: expected_condition + }), + "wrapper duration must be ForAsLongAs{{RecipientHasCounters saurian >= 1}}" + ); + match sub.effect.as_ref() { + Effect::GenericEffect { + static_abilities, + target, + .. + } => { + assert_eq!( + target, + &Some(TargetFilter::ParentTarget), + "the animation must bind the MODE'S TARGET (CR 608.2c)" + ); + let grant = static_abilities.first().expect("one continuous grant"); + assert_eq!( + grant.affected, + Some(TargetFilter::ParentTarget), + "affected must be the mode's target, never the trigger source" + ); + // CR 613.4b (base P/T, Layer 7b) + CR 613.1e (color, Layer 5) + + // CR 613.1d (type, Layer 4) + CR 205.1a (subtype replacement: + // RemoveAllSubtypes precedes the granted subtype). + assert_eq!( + grant.modifications, + vec![ + ContinuousModification::SetPower { value: 5 }, + ContinuousModification::SetToughness { value: 5 }, + ContinuousModification::SetColor { + colors: vec![ManaColor::Green] + }, + ContinuousModification::AddType { + core_type: CoreType::Creature + }, + ContinuousModification::RemoveAllSubtypes { + set: SubtypeSet::Creature + }, + ContinuousModification::AddSubtype { + subtype: "Dinosaur".to_string() + }, + ], + "the exact v0.35.2 animation payload must be restored" + ); + } + other => panic!("mode 2 second sentence must be GenericEffect, got {other:?}"), + } + } + + const DISCIPLE_OF_PERDITION_ORACLE: &str = "When this creature dies, choose one. If you have exactly 13 life, you may choose both instead.\n• You draw a card and you lose 1 life.\n• Exile target opponent's graveyard. That player loses 1 life."; + + /// SHAPE (R8b, #7031 second discriminating card): Disciple of Perdition's + /// mode-2 "That player loses 1 life" binds to the TARGETED OPPONENT + /// (`ParentTargetController` — the CR 608.2c anaphor to the player target + /// chosen earlier in the same mode), restoring the verified v0.35.2 + /// binding. Revert-fail: with the filter reverted the leaked trigger + /// subject flips the "that player" arm to `TriggeringPlayer` — the silent + /// misbind #6811 introduced (verified in the pre-fix export). + #[test] + fn disciple_of_perdition_that_player_binds_to_targeted_opponent() { + let parsed = parse_oracle_text( + DISCIPLE_OF_PERDITION_ORACLE, + "Disciple of Perdition", + &[], + &[], + &[], + ); + let trigger = parsed.triggers.first().expect("dies trigger present"); + let execute = trigger.execute.as_deref().expect("modal execute"); + assert_eq!(execute.mode_abilities.len(), 2, "two modes"); + // Zero-Unimplemented reach-guard for the binding assertion below. + for (i, mode) in execute.mode_abilities.iter().enumerate() { + assert!( + !chain_has_unimplemented(mode), + "mode {i} must lower with zero Unimplemented: {mode:?}" + ); + } + + let mode2 = &execute.mode_abilities[1]; + // Reach-guard: the head is the graveyard exile that introduces the + // player-target antecedent. + assert!( + matches!(mode2.effect.as_ref(), Effect::ChangeZoneAll { .. }), + "mode 2 head must be the graveyard exile, got {:?}", + mode2.effect + ); + let sub = mode2 + .sub_ability + .as_deref() + .expect("mode 2 second sentence present"); + match sub.effect.as_ref() { + Effect::LoseLife { target, .. } => { + assert_eq!( + target, + &Some(TargetFilter::ParentTargetController), + "'That player' must bind to the targeted opponent \ + (ParentTargetController), not TriggeringPlayer" + ); + } + other => panic!("mode 2 second sentence must be LoseLife, got {other:?}"), + } + } + + const BLIZZARD_SPECTER_ORACLE: &str = "Flying\nWhenever this creature deals combat damage to a player, choose one —\n• That player returns a permanent they control to its owner's hand.\n• That player discards a card."; + + /// SHAPE (R8b negative sibling): Blizzard Specter's "That player" modes + /// KEEP their `TriggeringPlayer` binding — the DamageDone trigger scope + /// (`modal_relative_player_scope_for_trigger`; CR 608.2c back-reference to + /// the damaged player established by the trigger event) is re-seeded per + /// mode and outranks the cleared subject in the "that player" rung ladder. + /// This proves the subject filter clears ONLY the subject axis, not the + /// trigger-established player scope. + #[test] + fn blizzard_specter_that_player_keeps_triggering_player_scope() { + let parsed = parse_oracle_text(BLIZZARD_SPECTER_ORACLE, "Blizzard Specter", &[], &[], &[]); + let trigger = parsed + .triggers + .first() + .expect("combat-damage trigger present"); + let execute = trigger.execute.as_deref().expect("modal execute"); + assert_eq!(execute.mode_abilities.len(), 2, "two modes"); + // Zero-Unimplemented reach-guard. + for (i, mode) in execute.mode_abilities.iter().enumerate() { + assert!( + !chain_has_unimplemented(mode), + "mode {i} must lower with zero Unimplemented: {mode:?}" + ); + } + + // Mode 1: "That player returns a permanent they control ..." — the + // bounce is scoped to the damaged player's permanents. + match execute.mode_abilities[0].effect.as_ref() { + Effect::Bounce { + target: TargetFilter::Typed(filter), + .. + } => { + assert_eq!( + filter.controller, + Some(ControllerRef::TriggeringPlayer), + "mode 1 'that player' must stay the damaged player" + ); + } + other => panic!("mode 1 must be Bounce of a typed permanent, got {other:?}"), + } + // Mode 2: "That player discards a card." + match execute.mode_abilities[1].effect.as_ref() { + Effect::Discard { target, .. } => { + assert_eq!( + target, + &TargetFilter::TriggeringPlayer, + "mode 2 'that player' must stay the damaged player" + ); + } + other => panic!("mode 2 must be Discard, got {other:?}"), + } + } + + // R7 — `mode_anaphor_subject` building-block arms. The inline `"; or"` + // modal form funnels through the same `parse_modal_mode_irs` seam as the + // bullet form, so these arms exercise the filter at its production entry. + + fn lower_inline_modes(body: &str, ctx: &ParseContext) -> Vec { + let modal = try_parse_inline_modal_ir(body, ctx).expect("inline modal must parse"); + modal + .modes + .iter() + .map(|mode| lower_ability_ir(&mode.ability)) + .collect() + } + + /// Extract the single continuous grant's affected filter of a + /// `GenericEffect` chain node. + fn generic_effect_affected(ability: &AbilityDefinition) -> TargetFilter { + match ability.effect.as_ref() { + Effect::GenericEffect { + static_abilities, .. + } => static_abilities + .first() + .expect("one continuous grant") + .affected + .clone() + .expect("grant carries an affected filter"), + other => panic!("expected GenericEffect, got {other:?}"), + } + } + + const INLINE_MODAL_WITH_INTERNAL_REFERENT: &str = "Choose one — Draw a card; or Put a +1/+1 counter on target creature. It gains flying until end of turn."; + const INLINE_MODAL_WITHOUT_INTERNAL_REFERENT: &str = + "Choose one — Draw a card; or It gains flying until end of turn."; + + /// R7(a): a `SelfRef` trigger subject is CLEARED at the mode seam, so the + /// mode-internal typed referent binds the mode-body "It" (CR 608.2c — + /// the mode's own earlier instruction is the nearest antecedent). + #[test] + fn mode_anaphor_subject_clears_self_subject_for_mode_internal_referent() { + let ctx = ParseContext { + subject: Some(TargetFilter::SelfRef), + in_trigger: true, + ..Default::default() + }; + let modes = lower_inline_modes(INLINE_MODAL_WITH_INTERNAL_REFERENT, &ctx); + assert_eq!(modes.len(), 2); + for (i, mode) in modes.iter().enumerate() { + assert!( + !chain_has_unimplemented(mode), + "mode {i} must lower with zero Unimplemented: {mode:?}" + ); + } + let sub = modes[1] + .sub_ability + .as_deref() + .expect("mode 2 second sentence present"); + assert_eq!( + generic_effect_affected(sub), + TargetFilter::ParentTarget, + "cleared self-subject: mode-body 'It' binds the mode's own target" + ); + } + + /// R7(b): a real typed (non-self) trigger subject flows through the + /// filter's identity arm and keeps its threading — the mode-body "It" + /// resolves to the triggering object (CR 608.2k), NOT the mode target. + #[test] + fn mode_anaphor_subject_retains_typed_subject() { + let ctx = ParseContext { + subject: Some(TargetFilter::Typed(TypedFilter::creature())), + in_trigger: true, + ..Default::default() + }; + let modes = lower_inline_modes(INLINE_MODAL_WITH_INTERNAL_REFERENT, &ctx); + let sub = modes[1] + .sub_ability + .as_deref() + .expect("mode 2 second sentence present"); + assert_eq!( + generic_effect_affected(sub), + TargetFilter::TriggeringSource, + "retained typed subject: mode-body 'It' binds the triggering object" + ); + } + + /// R7(c): an `object_pronoun_ref` pin (for example, a specific untargeted + /// object previously referred to by a trigger condition; CR 608.2k) + /// SURVIVES the subject filter — with no + /// mode-internal referent preceding the pronoun, `parent_target_available` + /// is false, the ParentTarget branch cannot fire, and `resolve_it_pronoun` + /// still serves the pin. + #[test] + fn mode_anaphor_subject_preserves_object_pronoun_pin_without_referent() { + let ctx = ParseContext { + subject: Some(TargetFilter::SelfRef), + object_pronoun_ref: Some(TargetFilter::SelfRef), + in_trigger: true, + ..Default::default() + }; + let modes = lower_inline_modes(INLINE_MODAL_WITHOUT_INTERNAL_REFERENT, &ctx); + assert_eq!( + generic_effect_affected(&modes[1]), + TargetFilter::SelfRef, + "with no mode-internal referent the pinned object still serves 'It'" + ); + } + + /// R7(d): with a mode-internal typed referent PRECEDING the pronoun, the + /// mode-internal referent outranks the pin — the CR 608.2c + /// nearest-antecedent reading within the mode's own instruction sequence + /// (Oracle templating uses "this card"/the card name, not "it", when the + /// source is meant across an intervening referent). The combination + /// (SelfRef/Any subject + zone-pin + modal body) is pool-empty today; this + /// pin makes the precedence deliberate so a future contradicting card + /// forces a conscious change. + #[test] + fn mode_anaphor_subject_mode_internal_referent_outranks_pin() { + let ctx = ParseContext { + subject: Some(TargetFilter::SelfRef), + object_pronoun_ref: Some(TargetFilter::SelfRef), + in_trigger: true, + ..Default::default() + }; + let modes = lower_inline_modes(INLINE_MODAL_WITH_INTERNAL_REFERENT, &ctx); + let sub = modes[1] + .sub_ability + .as_deref() + .expect("mode 2 second sentence present"); + assert_eq!( + generic_effect_affected(sub), + TargetFilter::ParentTarget, + "a mode-internal referent preceding the pronoun outranks the pin" + ); + } } diff --git a/crates/engine/tests/integration/arbalest_engineers_modal_pump_anaphor.rs b/crates/engine/tests/integration/arbalest_engineers_modal_pump_anaphor.rs new file mode 100644 index 0000000000..a78c961261 --- /dev/null +++ b/crates/engine/tests/integration/arbalest_engineers_modal_pump_anaphor.rs @@ -0,0 +1,150 @@ +//! Arbalest Engineers — PINNED-GREEN COMPOSITION GUARD for the #7031 modal +//! mode-body subject filter. **This test is deliberately NON-DISCRIMINATING: +//! it is green both BEFORE and AFTER the fix.** +//! +//! Why it exists anyway: Arbalest's mode 2 second sentence "It gains trample +//! and haste until end of turn" reaches the correct `ParentTarget` binding by +//! TWO different layers depending on the fix: +//! * pre-fix: the mode body parses with the leaked `SelfRef` subject, emits +//! `GenericEffect{affected: SelfRef}`, and the chunk-level anaphor rewrite +//! (`replace_target_with_parent`, gated by `ctx_has_typed_trigger_subject`) +//! repairs it post-hoc to `ParentTarget`; +//! * post-fix: the cleared subject lets the bare-"it" branch bind +//! `ParentTarget` at parse time, and the rewrite is a fixed point. +//! +//! Both orders converge on identical output. This guard pins that convergence +//! so a FUTURE rewrite-layer change cannot silently regress the class the +//! rewrite still covers. +//! +//! CR 608.2c: the mode's own earlier instruction ("Put a +1/+1 counter on +//! target creature") is the nearest antecedent for the mode-body "It". +//! CR 122.1: the +1/+1 counter placed by the mode's first instruction. +//! +//! Two-authority hostile fixture: the trigger source (Arbalest) and the mode's +//! target are BOTH on the battlefield; the grant must land on the target +//! (positive reach-guard) and NOT on Arbalest (negative). + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; + +const ARBALEST_ORACLE: &str = "When this creature enters, choose one —\n• This creature deals 1 damage to any target.\n• Put a +1/+1 counter on target creature. It gains trample and haste until end of turn.\n• Create a tapped Powerstone token. (It's an artifact with \"{T}: Add {C}. This mana can't be spent to cast a nonartifact spell.\")"; + +/// Drive the ETB trigger: choose mode 2 (index 1), target `target`, resolve. +fn drive_etb_mode_two(runner: &mut GameRunner, target: ObjectId) { + let mut chose_mode = false; + let mut remaining_target = Some(target); + for _ in 0..100 { + match runner.state().waiting_for.clone() { + WaitingFor::OrderTriggers { .. } => { + runner + .act(GameAction::OrderTriggers { order: vec![0] }) + .or_else(|_| runner.act(GameAction::OrderTriggers { order: vec![] })) + .expect("order triggers"); + } + WaitingFor::AbilityModeChoice { .. } => { + runner + .act(GameAction::SelectModes { indices: vec![1] }) + .expect("select mode 2"); + chose_mode = true; + } + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::TargetSelection { .. } => { + let target = remaining_target + .take() + .expect("exactly one target prompt expected"); + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }) + .expect("choose target"); + } + WaitingFor::Priority { .. } => { + if chose_mode && runner.state().stack.is_empty() { + return; + } + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } + other => panic!( + "unexpected WaitingFor while driving Arbalest's ETB trigger: {}", + other.variant_name() + ), + } + } + panic!("Arbalest's ETB trigger did not resolve within the step budget"); +} + +#[test] +fn arbalest_mode_two_grants_trample_haste_to_target_not_source() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // The mode's target, controlled by the opponent (two-authority fixture: + // source and target both alive on the battlefield). + let victim = scenario.add_creature(P1, "Pump Victim", 2, 2).id(); + // A second legal target so the engine surfaces a real target prompt. + let _bystander = scenario.add_creature(P1, "Bystander", 1, 1).id(); + + // Arbalest Engineers cast from hand (cost zeroed) so a REAL enters event + // fires the ETB trigger through the production pipeline. + let arbalest = scenario + .add_creature_to_hand_from_oracle(P0, "Arbalest Engineers", 2, 2, ARBALEST_ORACLE) + .with_mana_cost(ManaCost::generic(0)) + .id(); + + let mut runner = scenario.build(); + + let card_id = runner.state().objects[&arbalest].card_id; + runner + .act(GameAction::CastSpell { + object_id: arbalest, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("casting Arbalest Engineers must succeed"); + + drive_etb_mode_two(&mut runner, victim); + + // Positive reach-guard: the mode's first instruction resolved (CR 122.1). + assert_eq!( + runner.state().objects[&victim] + .counters + .get(&CounterType::Plus1Plus1) + .copied(), + Some(1), + "mode 2 must put a +1/+1 counter on the target" + ); + + // The grant lands on the MODE'S TARGET (CR 608.2c nearest antecedent) — + // whichever layer (parse-time binding or chunk rewrite) produced the + // ParentTarget binding. + let victim_obj = &runner.state().objects[&victim]; + assert!( + victim_obj.has_keyword(&Keyword::Trample), + "the mode's target must gain trample" + ); + assert!( + victim_obj.has_keyword(&Keyword::Haste), + "the mode's target must gain haste" + ); + + // Negative (guarded by the positives above): the trigger SOURCE gets + // neither keyword. + let arbalest_obj = &runner.state().objects[&arbalest]; + assert!( + !arbalest_obj.has_keyword(&Keyword::Trample), + "the trigger source must NOT gain trample" + ); + assert!( + !arbalest_obj.has_keyword(&Keyword::Haste), + "the trigger source must NOT gain haste" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 9ee8a0b6f3..adeaaf0e5d 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1006,6 +1006,7 @@ mod alania_divergent_storm; mod angelic_field_marshal_lieutenant_2885; mod anointed_peacekeeper_chosen_opponent; mod another_target_imperative_subject_strip; +mod arbalest_engineers_modal_pump_anaphor; mod archon_compound_target_2344; mod ashes_of_the_fallen_owner_scope_5864; mod ashling_delayed_sacrifice_unless_pay; @@ -1191,6 +1192,7 @@ mod same_is_true_type_statics; mod sandman_reanimate_self_and_land_s25; mod sandswirl_wanderglyph_attacked_you_cant_cast; mod sarkhan_dragon_ascendant_behold; +mod sauron_dino_devotee_modal_anaphor_7031; mod scarblade_malice_delayed_dies_762; mod scry_choice_not_clobbered_by_triggers; mod scry_substituted_draw_per_card_replacement; diff --git a/crates/engine/tests/integration/sauron_dino_devotee_modal_anaphor_7031.rs b/crates/engine/tests/integration/sauron_dino_devotee_modal_anaphor_7031.rs new file mode 100644 index 0000000000..19f80ee5d6 --- /dev/null +++ b/crates/engine/tests/integration/sauron_dino_devotee_modal_anaphor_7031.rs @@ -0,0 +1,413 @@ +//! Sauron, Dino Devotee (issue #7031) — runtime proof that a bullet-line +//! triggered-modal mode body's "It's a …" anaphor binds to the MODE'S OWN +//! TARGET (the creature that just received the saurian counter), never the +//! trigger source. +//! +//! Sauron's printed Oracle text (mode 2): +//! "Turn People into Dinosaurs — Put a saurian counter on another target +//! creature. It's a green Dinosaur with base power and toughness 5/5 for as +//! long as it has a saurian counter on it." +//! +//! Regression mechanism (#6811 → #7031): the native-IR modal path threaded the +//! trigger context's `subject: Some(SelfRef)` into mode-body parsing without +//! the `derive_modal_subject` filtering the pre-IR path applied. The mode-body +//! "It's" then resolved to `SelfRef`, the contracted-copula honest-bind gate +//! declined to animate the trigger source, and the clause fell to +//! `Effect::Unimplemented` — no animation at runtime. +//! +//! CR 608.2c: instructions are followed in written order — the mode's own +//! earlier instruction ("Put a saurian counter on another target creature") +//! is the nearest antecedent for the mode-body "It". +//! CR 611.2b: "for as long as" durations — the effect ends when the condition +//! stops being true and does not resume. +//! CR 611.2c: the set of objects the continuous effect applies to is fixed +//! when the effect begins (the recipient is snapshotted). +//! CR 613.4b: base power/toughness setting applies in Layer 7b. +//! CR 122.1: counters placed by the resolving mode. +//! CR 700.2b + CR 603.3c: a mode with no legal targets cannot be chosen. +//! +//! Revert-fail (R1): with the mode-body subject filter reverted, mode 2's +//! second sentence is `Unimplemented`, so Foe Bear keeps (2,2) and the (5,5) +//! assertion fails. + +use engine::game::combat::AttackTarget; +use engine::game::derived::derive_display_state; +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::zones::move_to_zone; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const SAURON_ORACLE: &str = "Flying\nWhenever Sauron enters or attacks, choose one —\n• Cure Cancer — You gain 3 life.\n• Turn People into Dinosaurs — Put a saurian counter on another target creature. It's a green Dinosaur with base power and toughness 5/5 for as long as it has a saurian counter on it."; + +fn saurian() -> CounterType { + CounterType::Generic("saurian".to_string()) +} + +/// Post-layer P/T read from object fields (mirrors the established +/// integration-test helper pattern, e.g. `heroic_defiance_recipient_color_4590`). +fn power_toughness(runner: &GameRunner, id: ObjectId) -> (i32, i32) { + let obj = runner.state().objects.get(&id).expect("object present"); + (obj.power.unwrap_or(0), obj.toughness.unwrap_or(0)) +} + +fn saurian_counters(runner: &GameRunner, id: ObjectId) -> u32 { + runner + .state() + .objects + .get(&id) + .expect("object present") + .counters + .get(&saurian()) + .copied() + .unwrap_or(0) +} + +const DISCIPLE_OF_PERDITION_ORACLE: &str = "When this creature dies, choose one. If you have exactly 13 life, you may choose both instead.\n• You draw a card and you lose 1 life.\n• Exile target opponent's graveyard. That player loses 1 life."; + +/// Drive Sauron's attack trigger to resolution: order triggers, select +/// `modes`, answer the target prompt with `target` (if the chosen mode has +/// one), then pass priority until the stack is empty. Bounded loop guards +/// against a stall. Modeled on the Grenzo #2346 driver. +fn drive_attack_trigger(runner: &mut GameRunner, modes: &[usize], target: Option) { + let mut chose_mode = false; + let mut remaining_target = target; + for _ in 0..200 { + match runner.state().waiting_for.clone() { + WaitingFor::OrderTriggers { .. } => { + runner + .act(GameAction::OrderTriggers { order: vec![0] }) + .or_else(|_| runner.act(GameAction::OrderTriggers { order: vec![] })) + .expect("order triggers"); + } + WaitingFor::AbilityModeChoice { .. } => { + runner + .act(GameAction::SelectModes { + indices: modes.to_vec(), + }) + .expect("select mode"); + chose_mode = true; + } + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::TargetSelection { .. } => { + let target = remaining_target + .take() + .expect("a target prompt requires a declared target"); + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }) + .expect("choose target"); + } + WaitingFor::Priority { .. } => { + if chose_mode && runner.state().stack.is_empty() { + return; + } + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } + other => panic!( + "unexpected WaitingFor while driving Sauron's trigger: {}", + other.variant_name() + ), + } + } + panic!("Sauron's trigger did not resolve within the step budget"); +} + +/// R1 + R2: mode 2 animates the MODE'S TARGET into a green Dinosaur with base +/// P/T 5/5 (CR 613.4b), the source is untouched (the source-vs-target +/// diagonal), and the `for as long as` duration is LIVE (CR 611.2b): removing +/// the saurian counter ends the effect. +#[test] +fn sauron_mode_two_animates_mode_target_and_duration_is_live() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + // P0 controls Sauron (trigger source; 4/4 printed). + let sauron = scenario + .add_creature_from_oracle(P0, "Sauron, Dino Devotee", 4, 4, SAURON_ORACLE) + .id(); + // P1 controls TWO creatures so the engine surfaces a real target-selection + // prompt (a single legal target could auto-resolve). + let foe_bear = scenario.add_creature(P1, "Foe Bear", 2, 2).id(); + let _foe_wolf = scenario.add_creature(P1, "Foe Wolf", 3, 1).id(); + + let mut runner = scenario.build(); + + // The attack limb of "Whenever Sauron enters or attacks" fires the trigger. + runner.advance_to_combat(); + runner + .declare_attackers(&[(sauron, AttackTarget::Player(P1))]) + .expect("declare attackers"); + + // Choose mode 2 ("Turn People into Dinosaurs", index 1), target Foe Bear. + drive_attack_trigger(&mut runner, &[1], Some(foe_bear)); + + // CR 122.1: the mode's first instruction placed the saurian counter on the + // mode's target (positive reach-guard for the animation assertions below). + assert_eq!( + saurian_counters(&runner, foe_bear), + 1, + "mode 2 must put a saurian counter on the mode's target" + ); + + // R1 (revert-fail): CR 613.4b Layer 7b — the mode-body "It's a green + // Dinosaur with base power and toughness 5/5" binds to the MODE'S TARGET. + // Reverted, the clause is Unimplemented and Foe Bear stays (2,2). + assert_eq!( + power_toughness(&runner, foe_bear), + (5, 5), + "the mode's target must have base power and toughness 5/5" + ); + + // Source-vs-target diagonal (both alive): the trigger source must NOT be + // animated and must NOT have a counter. + assert_eq!( + power_toughness(&runner, sauron), + (4, 4), + "the trigger source must not be animated by the mode body" + ); + assert_eq!( + saurian_counters(&runner, sauron), + 0, + "the trigger source must not receive the saurian counter" + ); + + // R2: CR 611.2b — the duration condition is evaluated LIVE against the + // snapshotted recipient (CR 611.2c): removing the saurian counter ends the + // effect and Foe Bear reverts to its printed 2/2. + runner + .state_mut() + .objects + .get_mut(&foe_bear) + .expect("Foe Bear present") + .counters + .remove(&saurian()); + evaluate_layers(runner.state_mut()); + derive_display_state(runner.state_mut()); + assert_eq!( + power_toughness(&runner, foe_bear), + (2, 2), + "removing the saurian counter must end the for-as-long-as effect (CR 611.2b)" + ); +} + +/// R4 (negative sibling mode): mode 1 ("Cure Cancer") gains 3 life and +/// modifies no creature. The life-gain positive is the reach-guard for the +/// no-creature-modified negatives. +#[test] +fn sauron_mode_one_gains_life_and_touches_no_creature() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + let sauron = scenario + .add_creature_from_oracle(P0, "Sauron, Dino Devotee", 4, 4, SAURON_ORACLE) + .id(); + let foe_bear = scenario.add_creature(P1, "Foe Bear", 2, 2).id(); + let _foe_wolf = scenario.add_creature(P1, "Foe Wolf", 3, 1).id(); + + let mut runner = scenario.build(); + let p0_life_before = runner.state().players[0].life; + + runner.advance_to_combat(); + runner + .declare_attackers(&[(sauron, AttackTarget::Player(P1))]) + .expect("declare attackers"); + + // Choose mode 1 ("Cure Cancer", index 0) — no target prompt. + drive_attack_trigger(&mut runner, &[0], None); + + // Positive reach-guard: the chosen mode resolved (life +3). + assert_eq!( + runner.state().players[0].life - p0_life_before, + 3, + "mode 1 must gain its controller 3 life" + ); + // Adjacent-mode negatives, guarded by the positive above. + assert_eq!( + power_toughness(&runner, foe_bear), + (2, 2), + "mode 1 must not modify any creature" + ); + assert_eq!( + saurian_counters(&runner, foe_bear), + 0, + "mode 1 must not place a saurian counter" + ); +} + +/// R5 (no-legal-target path): with no other creature on the battlefield, the +/// "Turn People into Dinosaurs" mode is illegal (CR 700.2b — "another target +/// creature" has no legal target) and must be excluded from the mode surface; +/// resolution proceeds via the legal mode (CR 603.3c). +#[test] +fn sauron_mode_two_illegal_without_another_creature() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + + // Board with ONLY Sauron: "another target creature" has no legal target. + let sauron = scenario + .add_creature_from_oracle(P0, "Sauron, Dino Devotee", 4, 4, SAURON_ORACLE) + .id(); + + let mut runner = scenario.build(); + let p0_life_before = runner.state().players[0].life; + + runner.advance_to_combat(); + runner + .declare_attackers(&[(sauron, AttackTarget::Player(P1))]) + .expect("declare attackers"); + + // Drive to the mode surface and prove mode 2 is excluded there. + let mut saw_mode_choice = false; + for _ in 0..50 { + match runner.state().waiting_for.clone() { + WaitingFor::OrderTriggers { .. } => { + runner + .act(GameAction::OrderTriggers { order: vec![0] }) + .or_else(|_| runner.act(GameAction::OrderTriggers { order: vec![] })) + .expect("order triggers"); + } + WaitingFor::AbilityModeChoice { + unavailable_modes, .. + } => { + saw_mode_choice = true; + // CR 700.2b: the targetless mode is surfaced as unavailable. + assert_eq!( + unavailable_modes, + vec![1], + "mode 2 must be unavailable with no other creature on the battlefield" + ); + // The engine must also REJECT an attempt to choose it. + assert!( + runner + .act(GameAction::SelectModes { indices: vec![1] }) + .is_err(), + "selecting the illegal mode must be rejected (CR 700.2b)" + ); + break; + } + WaitingFor::Priority { .. } => { + if runner.act(GameAction::PassPriority).is_err() { + break; + } + } + other => panic!( + "unexpected WaitingFor while driving to the mode surface: {}", + other.variant_name() + ), + } + } + assert!( + saw_mode_choice, + "the trigger must surface a mode choice (mode 1 is still legal, so the \ + trigger is not dropped outright)" + ); + + // Resolution proceeds via the legal mode (CR 603.3c). + drive_attack_trigger(&mut runner, &[0], None); + assert_eq!( + runner.state().players[0].life - p0_life_before, + 3, + "the legal mode must still resolve" + ); +} + +/// Disciple's second mode must carry the selected opponent from its graveyard +/// target into the following "That player loses 1 life" instruction. +/// +/// CR 608.2c: the mode's instructions are followed in written order, so the +/// targeted opponent is the nearest antecedent for "That player". +/// CR 700.4 + CR 603.6c: moving Disciple from the battlefield to a graveyard +/// fires its dies trigger. +#[test] +fn disciple_mode_two_life_loss_binds_to_targeted_opponent() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + let disciple = scenario + .add_creature_from_oracle( + P0, + "Disciple of Perdition", + 1, + 1, + DISCIPLE_OF_PERDITION_ORACLE, + ) + .id(); + let graveyard_card = scenario + .add_creature_to_graveyard(P1, "Foe Bear", 2, 2) + .id(); + let mut runner = scenario.build(); + let p0_life_before = runner.state().players[0].life; + let p1_life_before = runner.state().players[1].life; + + let mut events = Vec::new(); + move_to_zone(runner.state_mut(), disciple, Zone::Graveyard, &mut events); + engine::game::triggers::process_triggers(runner.state_mut(), &events); + + let mut chose_mode = false; + for _ in 0..100 { + match runner.state().waiting_for.clone() { + WaitingFor::OrderTriggers { .. } => { + runner + .act(GameAction::OrderTriggers { order: vec![0] }) + .or_else(|_| runner.act(GameAction::OrderTriggers { order: vec![] })) + .expect("order Disciple's dies trigger"); + } + WaitingFor::AbilityModeChoice { .. } => { + runner + .act(GameAction::SelectModes { indices: vec![1] }) + .expect("select Disciple's graveyard-exile mode"); + chose_mode = true; + } + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::TargetSelection { .. } => { + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Player(P1)), + }) + .expect("target the opponent's graveyard"); + } + WaitingFor::Priority { .. } => { + if chose_mode && runner.state().stack.is_empty() { + break; + } + runner + .act(GameAction::PassPriority) + .expect("pass priority while resolving Disciple's trigger"); + } + other => panic!( + "unexpected WaitingFor while resolving Disciple's dies trigger: {}", + other.variant_name() + ), + } + } + + assert!( + chose_mode, + "Disciple's dies trigger must reach its mode choice" + ); + assert!( + runner.state().stack.is_empty(), + "Disciple's selected mode must resolve within the step budget" + ); + assert_eq!( + runner.state().objects[&graveyard_card].zone, + Zone::Exile, + "mode 2 must exile the targeted opponent's graveyard" + ); + assert_eq!( + runner.state().players[0].life, + p0_life_before, + "the trigger controller must not lose life" + ); + assert_eq!( + runner.state().players[1].life, + p1_life_before - 1, + "the targeted opponent must lose exactly 1 life" + ); +}