diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index 7dac435c22..d5d180f6fa 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -2836,13 +2836,24 @@ pub(super) fn parse_subject_application( // In trigger effects: "they" refers to the triggering player (for player-type // subjects like "an opponent") or the triggering source (for object subjects). // Outside trigger context: anaphoric reference to previously mentioned objects. - if lower == "they" { + // CR 608.2d: an optional "may" modal parallels the "that player may " / + // "the player may " forms above — "they may pay {2}" (Wandering Archaic, + // Umbilicus) is the pronoun-subject counterpart of "that player may pay + // {2}" (Smothering Tithe, Mind Whip); both must set `is_optional` so + // `lower_subject_predicate_ast` marks the lowered ability optional and + // `resolve_they_pronoun`'s existing player/object dispatch is unchanged. + if let Ok((_, is_optional)) = all_consuming(alt(( + value(true, tag::<_, _, OracleError<'_>>("they may")), + value(false, tag("they")), + ))) + .parse(lower.as_str()) + { return Some(SubjectApplication { affected: resolve_they_pronoun(ctx), target: None, multi_target: None, inherits_parent: false, - is_optional: false, + is_optional, }); } @@ -3143,6 +3154,20 @@ fn resolve_they_pronoun(ctx: &mut ParseContext) -> TargetFilter { ) { return TargetFilter::ParentTargetOwner; } + // CR 506.2 + CR 508.5: An attack-trigger intervening-if that names + // "defending player" (`condition_introduces_defending_player`) stamps + // `relative_player_scope = DefendingPlayer` — the nonactive player being + // attacked, not a chosen or previously-targeted player. "They" inside + // such an effect ("they may reveal their hand" — Smart Ass) refers to + // that combat-relative player. Without this arm, "they" fell through to + // the generic `ParentTarget` default, which has no defending-player + // referent to inherit and left the effect unbound. + if matches!( + ctx.relative_player_scope, + Some(ControllerRef::DefendingPlayer) + ) { + return TargetFilter::DefendingPlayer; + } // CR 603.7c + CR 120.3 + CR 506.2: A "deals [combat] damage to a player" or // "attacks a player" trigger introduces the damaged/attacked player as the // event referent (the parser stamps `relative_player_scope = TargetPlayer`). diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 8c0a256f3d..6864d7dd3f 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -977,6 +977,29 @@ fn condition_introduces_defending_player(cond_lower: &str) -> bool { false } +fn parse_if_defending_player(input: &str) -> OracleResult<'_, ()> { + let (rest, ()) = value((), tag::<_, _, OracleError<'_>>("if ")).parse(input)?; + let (rest, _) = opt(tag("the ")).parse(rest)?; + value((), tag("defending player")).parse(rest) +} + +/// CR 506.2 + CR 508.5: An attack-trigger's effect body may name "defending +/// player" as the subject of a per-clause conditional AFTER an intervening +/// imperative ("Whenever ~ attacks, choose a card name. If defending player +/// has no cards ..., they may reveal their hand." — Smart Ass), rather than in +/// the trigger's own head condition. `condition_introduces_defending_player` +/// only sees the head (`cond_lower` is everything before the FIRST comma — +/// CR 603.4's `split_trigger` boundary), so it never observes a defending- +/// player conditional buried later in the effect text. Detecting that +/// separately lets a later "they"/"that player" anaphor in the SAME effect +/// body resolve to `ControllerRef::DefendingPlayer` +/// (`resolve_they_pronoun`'s dedicated arm) instead of falling through to the +/// generic `ParentTarget` default, which has no defending-player referent to +/// inherit. +fn effect_body_introduces_defending_player(effect_lower: &str) -> bool { + nom_primitives::scan_at_word_boundaries(effect_lower, parse_if_defending_player).is_some() +} + /// CR 508.1 + CR 603.2c: "Whenever a player attacks with [N or more] creatures, /// ... that player ..." introduces the ATTACKING player (TriggeringPlayer) as the /// relative-player anaphor for a trailing "that player"/"that player controls" @@ -1327,6 +1350,14 @@ pub(crate) fn parse_trigger_line_with_index_ir( // split path derives the identical scope from the same condition. if let Some(scope) = relative_player_scope_for_condition(&cond_lower) { effect_ctx.relative_player_scope = Some(scope); + } else if effect_body_introduces_defending_player(&effect_lower) { + // CR 506.2 + CR 508.5: the head condition names no relative player + // (`cond_lower` is just "whenever ~ attacks"), but the effect body's + // own per-clause conditional names "defending player" later on + // (Smart Ass). Carry that scope through so a "they"/"that player" + // anaphor in the same body resolves to the combat-relative defending + // player instead of the generic `ParentTarget` fallback. + effect_ctx.relative_player_scope = Some(ControllerRef::DefendingPlayer); } // CR 701.22a + CR 603.2: The completed-scry predicate establishes the // provenance for its "that many" effect body. Keep this as a pure match on diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 16c5194245..9927388818 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -21888,6 +21888,63 @@ fn smothering_tithe_that_player_pays_as_triggering_player() { } } +/// CR 608.2d + CR 608.2k (issue #6477): the bare-pronoun counterpart of +/// `smothering_tithe_that_player_pays_as_triggering_player` — "they may pay" +/// anaphors back to "an opponent" from the trigger condition and must resolve +/// identically to the explicit "that player may pay" phrasing: the opponent +/// who cast the spell pays (not the Wandering Archaic controller), and the +/// payment is optional (CR 608.2d) so a decline can gate the copy. The copy's +/// "that spell" target is the untargeted spell object the trigger condition +/// already named, carried forward per CR 608.2k. +#[test] +fn wandering_archaic_they_pay_as_triggering_player() { + let def = parse_trigger_line( + "Whenever an opponent casts an instant or sorcery spell, they may pay {2}. If they don't, you may copy that spell. You may choose new targets for the copy.", + "Wandering Archaic", + ); + + assert_eq!(def.mode, TriggerMode::SpellCast); + let execute = def.execute.as_ref().expect("should have execute"); + match &*execute.effect { + Effect::PayCost { + payer, + cost: AbilityCost::Mana { cost }, + .. + } => { + assert_eq!( + payer, + &TargetFilter::TriggeringPlayer, + "the opponent who cast the spell pays, not the Wandering Archaic controller" + ); + assert_eq!(cost, &crate::types::mana::ManaCost::generic(2)); + } + other => panic!("expected PayCost, got: {other:?}"), + } + assert!(execute.optional, "they may pay should be optional"); + + let sub = execute + .sub_ability + .as_ref() + .expect("copy should remain chained"); + assert_eq!( + sub.condition, + Some(AbilityCondition::Not { + condition: Box::new(AbilityCondition::effect_performed()) + }), + "the copy is gated on the opponent having declined payment" + ); + assert!(sub.optional, "you may copy that spell"); + match &*sub.effect { + Effect::CopySpell { + target, retarget, .. + } => { + assert_eq!(target, &TargetFilter::TriggeringSource); + assert_eq!(retarget, &CopyRetargetPermission::MayChooseNewTargets); + } + other => panic!("expected CopySpell sub_ability, got: {other:?}"), + } +} + /// CR 603.4: Wedding Ring — "an opponent who controls F draws /// a card" parses the relative clause into an `ObjectCount >= 1` /// intervening-if scoped to the triggering player, ANDed with the @@ -25701,3 +25758,299 @@ fn thieving_skydiver_dependent_continuation_is_never_replicated_or_branch() { "reach guard: Thieving Skydiver must build a multi-node chain (GainControl + Attach continuation), got {node_count}", ); } + +// --------------------------------------------------------------------------- +// CR 608.2c + CR 608.2d (issue #6477 review follow-up): the "they may" subject +// arm in `subject.rs` is a class fix, not a Wandering-Archaic special case — +// every card whose Oracle text puts the "may" modal on the bare pronoun +// "they" (rather than the explicit "that player"/"the player" forms already +// handled) previously fell through `parse_subject_application` with NO match +// (only the exact string "they", with no trailing "may", was accepted). The +// caller's `unwrap_or` fallback then silently substituted +// `SubjectApplication { affected: TargetFilter::Any, is_optional: false, .. }` +// — an unbound target AND a mandatory (non-"may") ability, both wrong. These +// four tests lock in the corrected behavior for every other printed card +// found to share the pattern (via a before/after parse diff), so the fix's +// wider blast radius is intentional and covered, not an unexplained +// side effect. +// --------------------------------------------------------------------------- + +/// Mishra's Command mode 1: "Choose target player. They may discard up to X +/// cards." Before the fix: `Discard { target: Any, .. }`, non-optional — +/// unbound to the just-chosen player and mandatory despite "may". After: the +/// discard binds to `ParentTarget` (the chosen player) and is optional. +#[test] +fn mishras_command_they_may_discard_binds_to_chosen_player_and_is_optional() { + let parsed = parse_oracle_text( + "Choose two \u{2014}\n\u{2022} Choose target player. They may discard up to X cards. Then they draw a card for each card discarded this way.\n\u{2022} This spell deals X damage to target creature.\n\u{2022} This spell deals X damage to target planeswalker.\n\u{2022} Target creature gets +X/+0 and gains haste until end of turn.", + "Mishra's Command", + &[], + &["Sorcery".to_string()], + &[], + ); + let mode1 = parsed + .abilities + .first() + .expect("Mishra's Command must parse mode 1 as the first ability"); + assert!( + matches!(*mode1.effect, Effect::TargetOnly { .. }), + "mode 1's head is the target-player slot, got {:?}", + mode1.effect + ); + let discard = mode1 + .sub_ability + .as_ref() + .expect("the discard must remain chained to the chosen target"); + match &*discard.effect { + Effect::Discard { target, .. } => { + assert_eq!( + target, + &TargetFilter::ParentTarget, + "\"they\" discard must bind to the just-chosen target player, not float unbound" + ); + } + other => panic!("expected Discard, got {other:?}"), + } + assert!( + discard.optional, + "\"they may discard\" must be optional, not mandatory" + ); +} + +/// Undercity Plunder: "Target opponent discards a card. Then they may +/// discard an additional card. If they don't, conjure ..." Before the fix: +/// the second Discard's target was `Any` (unbound) and non-optional, so the +/// "if they don't" branch's condition was unreachable in practice. +#[test] +fn undercity_plunder_they_may_discard_additional_binds_to_parent_target() { + let parsed = parse_oracle_text( + "Target opponent discards a card. Then they may discard an additional card. If they don't, conjure a duplicate of a random card from their library into your hand. It perpetually gains \"You may spend mana as though it were mana of any color to cast this spell.\"", + "Undercity Plunder", + &[], + &["Sorcery".to_string()], + &[], + ); + let head = parsed + .abilities + .first() + .expect("Undercity Plunder must parse the initial discard"); + let second_discard = head + .sub_ability + .as_ref() + .expect("\"they may discard an additional card\" must remain chained"); + match &*second_discard.effect { + Effect::Discard { target, .. } => { + assert_eq!( + target, + &TargetFilter::ParentTarget, + "the additional discard must bind to the same targeted opponent" + ); + } + other => panic!("expected Discard, got {other:?}"), + } + assert!( + second_discard.optional, + "\"they may discard an additional card\" must be optional" + ); + let conjure_gate = second_discard + .sub_ability + .as_ref() + .expect("the \"if they don't\" conjure branch must remain chained"); + assert_eq!( + conjure_gate.condition, + Some(AbilityCondition::Not { + condition: Box::new(AbilityCondition::effect_performed()) + }), + "the conjure branch is gated on declining the additional discard" + ); +} + +/// Tarnation: "Whenever a player commits a crime, they may draw a card." +/// Before the fix: `Draw { target: Any, .. }`, non-optional — the draw had no +/// player bound to it at all. +#[test] +fn tarnation_they_may_draw_binds_to_triggering_player() { + let def = parse_trigger_line( + "Whenever a player commits a crime, they may draw a card. (Targeting opponents, anything they control, and/or cards in their graveyards is a crime.)", + "Tarnation", + ); + let execute = def.execute.as_ref().expect("should have execute"); + match &*execute.effect { + Effect::Draw { target, .. } => { + assert_eq!( + target, + &TargetFilter::TriggeringPlayer, + "\"they\" draws for the player who committed the crime" + ); + } + other => panic!("expected Draw, got {other:?}"), + } + assert!(execute.optional, "\"they may draw\" must be optional"); +} + +/// Smart Ass: "... If defending player has no cards with the chosen name in +/// their hand, they may reveal their hand. If they don't reveal their hand, +/// this creature can't be blocked this turn." CR 506.2: "defending player" is +/// the combat-relative nonactive player being attacked, not a chosen or +/// previously-targeted player — the intervening-if stamps +/// `relative_player_scope = ControllerRef::DefendingPlayer` +/// (`condition_introduces_defending_player`), so "they" must resolve to +/// `TargetFilter::DefendingPlayer` specifically. Before the fix: +/// `RevealHand { target: Any, .. }`, non-optional (the pronoun was unhandled +/// entirely). An earlier version of this fix left `resolve_they_pronoun` +/// without a `DefendingPlayer` arm, so "they" fell through to the generic +/// `ParentTarget` default instead — plausible-looking (not `Any`) but still +/// wrong, since there is no prior target for "defending player" to inherit. +#[test] +fn smart_ass_they_may_reveal_hand_binds_to_defending_player() { + let def = parse_trigger_line( + "Whenever this creature attacks, choose a card name. If defending player has no cards with the chosen name in their hand, they may reveal their hand. If they don't reveal their hand, this creature can't be blocked this turn.", + "Smart Ass", + ); + let execute = def.execute.as_ref().expect("should have execute"); + let reveal = execute + .sub_ability + .as_ref() + .expect("the reveal-hand clause must remain chained to the naming choice"); + match &*reveal.effect { + Effect::RevealHand { target, .. } => { + assert_eq!( + target, + &TargetFilter::DefendingPlayer, + "\"they\" reveal must bind to the combat-relative defending player" + ); + } + other => panic!("expected RevealHand, got {other:?}"), + } + assert!( + reveal.optional, + "\"they may reveal their hand\" must be optional" + ); +} + +/// Sibling case for `smart_ass_they_may_reveal_hand_binds_to_defending_player`: +/// a "they may" pronoun under a DIFFERENT relative-player scope +/// (`ControllerRef::TargetPlayer`, stamped by a "deals combat damage to a +/// player" condition — CR 120.3) must still resolve to `TriggeringPlayer` +/// (the damaged player), not fall into the new `DefendingPlayer` arm. Guards +/// the scope routing in `resolve_they_pronoun`: the two `if` checks read the +/// same `Option` field and are mutually exclusive by +/// construction, but this locks in that the "they may" modal threading +/// doesn't accidentally collapse distinct scopes onto one filter. Mirrors the +/// existing bare-"they" (non-"may") coverage in +/// `parse_unstoppable_slasher_combat_damage_half_life`. "Test Card" is a +/// synthetic grammar-class fixture (see `trigger_you_may_pay_remains_controller`), +/// not a printed card — no real card in the corpus pairs this exact +/// combat-damage-to-a-player condition with a "they may" effect body. +#[test] +fn they_may_after_combat_damage_to_player_binds_to_triggering_player() { + let def = parse_trigger_line( + "Whenever this creature deals combat damage to a player, they may draw a card.", + "Test Card", + ); + let execute = def.execute.as_ref().expect("should have execute"); + match &*execute.effect { + Effect::Draw { target, .. } => { + assert_eq!( + target, + &TargetFilter::TriggeringPlayer, + "\"they\" after \"deals combat damage to a player\" must bind to the \ + damaged player (TriggeringPlayer), not DefendingPlayer" + ); + } + other => panic!("expected Draw, got {other:?}"), + } + assert!(execute.optional, "\"they may draw\" must be optional"); +} + +/// Siege Dragon: "Whenever this creature attacks, if defending player +/// controls no Walls, it deals 2 damage to each creature without flying +/// that player controls." A before/after parse-diff audit of every printed +/// card containing "if defending player" (18 cards, run while developing the +/// `effect_body_introduces_defending_player` fix) surfaced this as a SECOND +/// real defect fixed by the same mechanism: "that player controls" is a +/// possessive-controller reference back to the if-condition's "defending +/// player", and before the fix it resolved to `ControllerRef::You` — Siege +/// Dragon was damaging creatures the ATTACKER controls instead of the +/// defending player's, exactly backwards for an attack-punisher effect. Every +/// other card in that audit (Fear of the Dark, Must Be Knights, Reaper of +/// Night, Robber of the Rich, Septic Rats, Spectral Bears, Spectral Force, +/// Aerial Surveyor, Blurry Beeble, and the static-ability "can't attack/block +/// if defending player ..." cards) parsed identically before and after, +/// confirming the fix's scope is exactly the cards that anaphor back to a +/// body-level "defending player" conditional. +#[test] +fn siege_dragon_that_player_controls_binds_to_defending_player() { + let def = parse_trigger_line( + "Whenever this creature attacks, if defending player controls no Walls, it deals 2 damage to each creature without flying that player controls.", + "Siege Dragon", + ); + let execute = def.execute.as_ref().expect("should have execute"); + match &*execute.effect { + Effect::DamageAll { target, .. } => match target { + TargetFilter::Typed(tf) => { + assert_eq!( + tf.controller, + Some(ControllerRef::DefendingPlayer), + "\"that player controls\" must bind to the defending player named by \ + the if-condition, not the attacker (ControllerRef::You)" + ); + } + other => panic!("expected a Typed target filter, got {other:?}"), + }, + other => panic!("expected DamageAll, got {other:?}"), + } +} + +/// CR 508.5: For an ability of an attacking creature that refers to a defending +/// player, that player is the player the creature attacks. +/// Elder Brain: "Whenever this creature attacks a player, exile all cards +/// from that player's hand, then they draw that many cards. ..." Unlike Smart +/// Ass and Siege Dragon (which name "defending player" via a per-clause +/// conditional buried in the effect body — the NEW +/// `effect_body_introduces_defending_player` path), this trigger's OWN head +/// condition is "whenever ~ attacks a player", which the PRE-EXISTING +/// `condition_introduces_defending_player` check +/// (`relative_player_scope_for_condition`) already recognized and stamped as +/// `ControllerRef::DefendingPlayer` before this fix. What was still broken: +/// `resolve_they_pronoun` had no arm reading that scope at all, so "they" in +/// "they draw that many cards" fell through to the generic `ParentTarget` +/// default regardless of which mechanism set the scope. This test locks in +/// the production Oracle route through both the pre-existing head-condition +/// detector and the new `resolve_they_pronoun` arm together, distinct from +/// the body-conditional route the other two tests cover. +#[test] +fn elder_brain_they_draw_binds_to_defending_player() { + let def = parse_trigger_line( + "Whenever this creature attacks a player, exile all cards from that player's hand, then they draw that many cards. You may play lands and cast spells from among the exiled cards for as long as they remain exiled. If you cast a spell this way, you may spend mana as though it were mana of any color to cast it.", + "Elder Brain", + ); + assert_eq!(def.mode, TriggerMode::Attacks); + let execute = def.execute.as_ref().expect("should have execute"); + assert!( + matches!(&*execute.effect, Effect::ChangeZoneAll { .. }), + "head effect must remain the exile-hand ChangeZoneAll, got {:?}", + execute.effect + ); + let draw = execute + .sub_ability + .as_ref() + .expect("the draw must remain chained to the exile"); + match &*draw.effect { + Effect::Draw { target, count } => { + assert_eq!( + target, + &TargetFilter::DefendingPlayer, + "\"they draw\" must bind to the attacked defending player, not ParentTarget" + ); + assert_eq!( + count, + &QuantityExpr::Ref { + qty: QuantityRef::EventContextAmount + }, + "\"that many cards\" must read the number of cards just exiled" + ); + } + other => panic!("expected Draw, got {other:?}"), + } +} diff --git a/crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs b/crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs new file mode 100644 index 0000000000..fa2d0f8a30 --- /dev/null +++ b/crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs @@ -0,0 +1,308 @@ +//! Issue #6477: Wandering Archaic's "they may pay {2}. If they don't, you may +//! copy that spell" never copied the opponent's spell, whether or not they +//! paid. +//! +//! The parser fix (`oracle_effect/subject.rs`) is covered by +//! `wandering_archaic_they_pay_as_triggering_player` in `oracle_trigger_tests.rs`, +//! which only proves the lowered AST shape (payer + optionality). These tests +//! drive the real trigger-resolution pipeline: an opponent casts an instant, +//! is offered the {2} payment, and either path — decline-then-copy or +//! pay-and-suppress — is exercised through `apply`, never hand-constructed. +//! +//! Oracle text: +//! Whenever an opponent casts an instant or sorcery spell, they may pay +//! {2}. If they don't, you may copy that spell. You may choose new targets +//! for the copy. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; + +const WANDERING_ARCHAIC_ORACLE: &str = "Whenever an opponent casts an instant or sorcery spell, \ + they may pay {2}. If they don't, you may copy that spell. You may choose new targets for the copy."; + +const SHOCK_ORACLE: &str = "Shock deals 2 damage to any target."; + +fn floating_mana(units: &[ManaType]) -> Vec { + units + .iter() + .map(|ty| ManaUnit::new(*ty, ObjectId(0), false, vec![])) + .collect() +} + +/// Build the shared scenario: P0 controls Wandering Archaic, P1 holds Shock +/// (with a red pip to cast plus two floating generic for the optional tax) +/// and has priority to cast it, targeting a bystander creature under P0's +/// control. The target's toughness (10) is well above any damage total these +/// tests deal (up to 4, from the original Shock plus an accepted copy) so it +/// never dies mid-resolution — a dead target would make the second spell's +/// resolution fizzle on an illegal target (CR 608.2b) and mask the copy +/// under test as a false negative. +fn build_scenario() -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature_from_oracle(P0, "Wandering Archaic", 4, 4, WANDERING_ARCHAIC_ORACLE); + let target = scenario.add_creature(P0, "Target Dummy", 2, 10).id(); + + let shock = scenario + .add_spell_to_hand_from_oracle(P1, "Shock", true, SHOCK_ORACLE) + .with_mana_cost(ManaCost::Cost { + generic: 0, + shards: vec![ManaCostShard::Red], + }) + .id(); + scenario.with_mana_pool( + P1, + floating_mana(&[ManaType::Red, ManaType::Colorless, ManaType::Colorless]), + ); + + let mut runner = scenario.build(); + runner.state_mut().active_player = P1; + runner.state_mut().priority_player = P1; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P1 }; + + (runner, shock, target) +} + +/// Cast `shock` targeting `target` through the real `apply` pipeline, +/// submitting the target-selection prompt manually. Deliberately does NOT use +/// the `SpellCast`/`CastCommit` fluent builder's `.resolve()` — that driver +/// auto-answers every `OptionalEffectChoice` it encounters with a `Decline` +/// default (see `drive_resolution`'s `ResolutionPolicy`), which would silently +/// drive past both of Wandering Archaic's optional prompts before the test +/// ever got a chance to intercept them. +fn cast_shock(runner: &mut GameRunner, shock: ObjectId, target: ObjectId) { + let card_id = runner.state().objects[&shock].card_id; + runner + .act(GameAction::CastSpell { + object_id: shock, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("P1 casts Shock"); + + match runner.state().waiting_for.clone() { + WaitingFor::TargetSelection { .. } => { + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(target)], + }) + .expect("select the target creature for Shock"); + } + other => panic!("expected TargetSelection after casting Shock, got {other:?}"), + } +} + +/// Advance the engine until the {2} optional-payment prompt (or the stack +/// empties without one, which would itself be the bug this regresses). +fn drive_to_payment_prompt(runner: &mut GameRunner) { + for _ in 0..100 { + if matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ) { + return; + } + if runner.state().stack.is_empty() { + return; + } + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } +} + +/// Drain to an idle, empty stack after every decision this test cares about +/// has already been made explicitly by the caller (the {2} payment, and — +/// on decline — the follow-up copy choice). A further `OptionalEffectChoice` +/// here is UNEXPECTED and fails loudly rather than being silently declined: +/// a regression that offers the copy even after the opponent paid (or offers +/// it twice) must not be swallowed into the same "2 damage" outcome a +/// correctly-suppressed copy produces — that would make the paid-path test +/// pass whether or not the copy was actually suppressed, defeating its whole +/// point. `CopyRetarget` is the one legitimate additional prompt (issued only +/// once a copy has already been created), so it alone is handled here. +fn drive_to_idle(runner: &mut GameRunner) { + for _ in 0..100 { + match &runner.state().waiting_for { + WaitingFor::CopyRetarget { .. } => { + runner + .act(GameAction::KeepAllCopyTargets) + .expect("keep the copy's original target"); + } + WaitingFor::OptionalEffectChoice { .. } => { + panic!( + "unexpected optional-effect prompt during drive_to_idle: {:?} — \ + every decision this test exercises must already be settled by now", + runner.state().waiting_for + ); + } + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => return, + WaitingFor::Priority { .. } => { + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } + _ => { + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } + } + } +} + +/// The opponent declines the {2} payment, so the "if they don't" branch +/// offers Wandering Archaic's controller the copy. Accepting must put a +/// second Shock on the stack under the controller's control, dealing a +/// second 2 damage to the target (4 total) once both the original and the +/// copy have resolved. +#[test] +fn wandering_archaic_declined_payment_lets_controller_copy_spell() { + let (mut runner, shock, target) = build_scenario(); + + cast_shock(&mut runner, shock, target); + drive_to_payment_prompt(&mut runner); + + // The payment choice must be offered to the casting opponent (P1), not + // Wandering Archaic's controller (P0) — the defect this regresses. "They" + // in "they may pay" anaphors to the opponent named by the trigger + // condition (the parser fact asserted directly by + // `wandering_archaic_they_pay_as_triggering_player`); CR 608.2d only + // governs that an effect's offered choice is announced by the player + // applying the effect, not who that player is. + match runner.state().waiting_for.clone() { + WaitingFor::OptionalEffectChoice { player, .. } => { + assert_eq!( + player, P1, + "the {{2}} payment choice must be offered to the casting opponent" + ); + } + other => panic!("expected the {{2}} optional payment prompt, got {other:?}"), + } + let p1_mana_before = runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .mana_pool + .total(); + + runner + .act(GameAction::DecideOptionalEffect { accept: false }) + .expect("P1 declines the {2} payment"); + + // Declining must not spend the opponent's mana. + assert_eq!( + runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .mana_pool + .total(), + p1_mana_before, + "declining the payment must not deduct the opponent's mana" + ); + + // The "if they don't" branch now offers the copy to Wandering Archaic's + // controller (P0), not the opponent. + for _ in 0..20 { + if matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ) { + break; + } + if runner.act(GameAction::PassPriority).is_err() { + break; + } + } + match runner.state().waiting_for.clone() { + WaitingFor::OptionalEffectChoice { player, .. } => { + assert_eq!( + player, P0, + "the \"you may copy\" choice belongs to Wandering Archaic's controller" + ); + } + other => panic!("expected the \"you may copy\" prompt, got {other:?}"), + } + + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("P0 accepts the copy"); + + drive_to_idle(&mut runner); + + assert!( + runner.state().stack.is_empty(), + "resolution must settle with an empty stack" + ); + assert_eq!( + runner.state().objects[&target].damage_marked, + 4, + "the original Shock plus the accepted copy must deal 2 + 2 = 4 damage" + ); +} + +/// The opponent paying the {2} tax must suppress the copy entirely — only +/// the original Shock resolves. `drive_to_idle` panics on any further +/// `OptionalEffectChoice`, so a regression that still offers the copy after +/// payment fails here instead of coincidentally landing on the same "2 +/// damage" outcome a correctly-suppressed copy produces. +#[test] +fn wandering_archaic_paid_payment_suppresses_copy() { + let (mut runner, shock, target) = build_scenario(); + + cast_shock(&mut runner, shock, target); + drive_to_payment_prompt(&mut runner); + + match runner.state().waiting_for.clone() { + WaitingFor::OptionalEffectChoice { player, .. } => assert_eq!(player, P1), + other => panic!("expected the {{2}} optional payment prompt, got {other:?}"), + } + let p1_mana_before = runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .mana_pool + .total(); + + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("P1 pays the {2}"); + + assert_eq!( + runner + .state() + .players + .iter() + .find(|p| p.id == P1) + .unwrap() + .mana_pool + .total(), + p1_mana_before - 2, + "paying must deduct exactly {{2}} generic from the opponent's pool" + ); + + drive_to_idle(&mut runner); + + assert!( + runner.state().stack.is_empty(), + "resolution must settle with an empty stack" + ); + assert_eq!( + runner.state().objects[&target].damage_marked, + 2, + "paying the tax must suppress the copy — only the original Shock's 2 damage lands" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 1d892a951e..2e91a21c31 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -625,6 +625,7 @@ mod issue_6435_mosswort_bridge_hideaway_play; mod issue_6437_fight_rigging_exiled_card_target; mod issue_6440_mockingbird_uncast_copy_ceiling; mod issue_6459_scheming_symmetry; +mod issue_6477_wandering_archaic_optional_payment; mod issue_6498_portent_of_calamity; mod issue_6499_flickering_ward_protection_exemption; mod issue_6500_loreseekers_stone_hand_cost;