From a308d989fb3d3e4be3a4603dc03fef77b593b694 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 30 Jul 2026 12:25:30 -0500 Subject: [PATCH 1/6] fix(parser): resolve bare 'they may' pronoun subject as optional TriggeringPlayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wandering Archaic's "Whenever an opponent casts an instant or sorcery spell, they may pay {2}. If they don't, you may copy that spell." never worked: the payer resolved to the ability's Controller (the Wandering Archaic player) instead of the casting opponent, and the PayCost step was mandatory instead of optional, so the "if they don't" copy branch could never fire correctly. parse_subject_application already recognized "that player may pay" (Smothering Tithe, Mind Whip) as an optional-modal subject phrase, but the equivalent bare-pronoun phrasing "they may pay" had no matching arm — only the exact string "they" (without a trailing "may") was accepted, so "they may" fell through with no match and the payer/ optionality defaulted to Controller/false. Add a "they may" branch that reuses the existing resolve_they_pronoun dispatch and threads through is_optional, mirroring the "that player may" handling. Fixes #6477. Co-Authored-By: Claude Sonnet 5 --- .../src/parser/oracle_effect/subject.rs | 15 ++++- .../engine/src/parser/oracle_trigger_tests.rs | 55 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index 7dac435c22..7fa4ed125f 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, }); } diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 16c5194245..af833703f0 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -21888,6 +21888,61 @@ fn smothering_tithe_that_player_pays_as_triggering_player() { } } +/// CR 608.2k + CR 608.2d (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 so a decline can gate the copy. +#[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 From af96799a4573fba0d62448527adf77c9179e741d Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 30 Jul 2026 15:35:27 -0500 Subject: [PATCH 2/6] fix(engine): correct CR citation and add production-path coverage for Wandering Archaic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the "they may pay" parser fix: - oracle_trigger_tests.rs cited CR 608.2k for the "they" pronoun resolution, but that rule governs an untargeted object reference persisting through characteristic changes, not player-pronoun resolution. Restrict the citation to the CopySpell "that spell" object reference it actually supports, and cite CR 608.2d (the optional-choice rule) for the "may pay" optionality. - The existing parser test only asserted the lowered AST shape (payer + optionality), not runtime behavior. Add issue_6477_wandering_archaic_optional_payment.rs: an opponent casts an instant through the real apply pipeline, and both branches are exercised — declining the {2} payment offers the controller the copy (accepting deals a second instance of damage), and paying deducts the mana and suppresses the copy entirely. Co-Authored-By: Claude Sonnet 5 --- .../engine/src/parser/oracle_trigger_tests.rs | 6 +- ...6477_wandering_archaic_optional_payment.rs | 292 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 3 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index af833703f0..451ae25172 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -21888,12 +21888,14 @@ fn smothering_tithe_that_player_pays_as_triggering_player() { } } -/// CR 608.2k + CR 608.2d (issue #6477): the bare-pronoun counterpart of +/// 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 so a decline can gate the copy. +/// 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( 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..0e1126e919 --- /dev/null +++ b/crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs @@ -0,0 +1,292 @@ +//! 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; + } + } +} + +/// After the pay/decline decision (and, on decline+accept, the follow-up copy +/// decision), drain everything to an idle, empty stack. Any further +/// `OptionalEffectChoice` encountered (e.g. a re-offered copy retarget) is +/// declined so resolution settles deterministically. +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 { .. } => { + runner + .act(GameAction::DecideOptionalEffect { accept: false }) + .expect("decline any further optional effect"); + } + 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; + } + } + } + } +} + +/// CR 608.2d: 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); + + // CR 608.2d: the payment decision belongs to the casting opponent (P1), + // not Wandering Archaic's controller (P0) — the defect this regresses. + 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" + ); +} + +/// CR 608.2d: the opponent paying the {2} tax must suppress the copy +/// entirely — only the original Shock resolves. +#[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 f708b60fb3..d336d33826 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -624,6 +624,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; From 656677c0b85e8f2e98529ef3de9d65945bf6a852 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 30 Jul 2026 17:42:39 -0500 Subject: [PATCH 3/6] fix(engine): non-discriminating test fix + citation cleanup + class coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round on the Wandering Archaic "they may pay" fix: - drive_to_idle silently sent `accept: false` for any later OptionalEffectChoice, so a regression that incorrectly re-offered the copy after the opponent paid would still land on the same "2 damage" outcome as a correctly-suppressed copy — the paid-path test couldn't actually discriminate. Make it panic on any further OptionalEffectChoice instead: every decision each test cares about is already made explicitly before drive_to_idle runs, so a further prompt is by construction unexpected. - The integration test cited CR 608.2d for the claim that "they" identifies the casting opponent (P1). CR 608.2d governs how an effect's offered choices are announced during resolution, not who a pronoun refers to — that's a parser fact, already covered by wandering_archaic_they_pay_as_triggering_player. Reworded to attribute the identity claim to the parser, not the rule text. - A before/after parse diff surfaced four other printed cards whose parsing changed: Mishra's Command, Undercity Plunder, Tarnation, and Smart Ass. All four share the exact same "bare they + may" pattern Wandering Archaic uses, and were previously broken the same way — parse_subject_application had no match for "they may", so the caller's fallback silently substituted an unbound TargetFilter::Any target and a non-optional ability. The fix's wider blast radius is the intended class fix, not a regression; added four unit tests locking in the corrected (bound + optional) shape for each. Co-Authored-By: Claude Sonnet 5 --- .../engine/src/parser/oracle_trigger_tests.rs | 160 ++++++++++++++++++ ...6477_wandering_archaic_optional_payment.rs | 44 +++-- 2 files changed, 190 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 451ae25172..e09f21d85b 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -25758,3 +25758,163 @@ 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." Before the fix: +/// `RevealHand { target: Any, .. }`, non-optional. +#[test] +fn smart_ass_they_may_reveal_hand_is_optional_and_bound() { + 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_ne!( + target, + &TargetFilter::Any, + "\"they\" reveal must bind to a real player referent, not float unbound" + ); + } + other => panic!("expected RevealHand, got {other:?}"), + } + assert!( + reveal.optional, + "\"they may reveal their hand\" must be optional" + ); +} 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 index 0e1126e919..fa2d0f8a30 100644 --- a/crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs +++ b/crates/engine/tests/integration/issue_6477_wandering_archaic_optional_payment.rs @@ -117,10 +117,16 @@ fn drive_to_payment_prompt(runner: &mut GameRunner) { } } -/// After the pay/decline decision (and, on decline+accept, the follow-up copy -/// decision), drain everything to an idle, empty stack. Any further -/// `OptionalEffectChoice` encountered (e.g. a re-offered copy retarget) is -/// declined so resolution settles deterministically. +/// 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 { @@ -130,9 +136,11 @@ fn drive_to_idle(runner: &mut GameRunner) { .expect("keep the copy's original target"); } WaitingFor::OptionalEffectChoice { .. } => { - runner - .act(GameAction::DecideOptionalEffect { accept: false }) - .expect("decline any further optional effect"); + 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 { .. } => { @@ -149,9 +157,9 @@ fn drive_to_idle(runner: &mut GameRunner) { } } -/// CR 608.2d: 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 +/// 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] @@ -161,8 +169,13 @@ fn wandering_archaic_declined_payment_lets_controller_copy_spell() { cast_shock(&mut runner, shock, target); drive_to_payment_prompt(&mut runner); - // CR 608.2d: the payment decision belongs to the casting opponent (P1), - // not Wandering Archaic's controller (P0) — the defect this regresses. + // 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!( @@ -239,8 +252,11 @@ fn wandering_archaic_declined_payment_lets_controller_copy_spell() { ); } -/// CR 608.2d: the opponent paying the {2} tax must suppress the copy -/// entirely — only the original Shock resolves. +/// 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(); From f8ebe7c12000fbb988f8c6f481fbcbfcceed0fd2 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 30 Jul 2026 23:01:55 -0500 Subject: [PATCH 4/6] fix(parser): carry defending-player scope through trigger-relative context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review round on the "they may pay" parser fix: Smart Ass's "If defending player has no cards ..., they may reveal their hand" set relative_player_scope from the trigger's own head condition only ("whenever this creature attacks", which names no player). The defending-player reference lives in a per-clause conditional buried later in the effect body, past an intervening imperative, so the existing single-authority relative_player_scope_for_condition never saw it. resolve_they_pronoun also had no ControllerRef::DefendingPlayer arm at all, so "they" fell through to the generic ParentTarget default — plausible-looking (not Any) but still wrong, since there's no prior target for "defending player" to inherit. Added effect_body_introduces_defending_player to detect a body-level "if [the] defending player" conditional and carry that scope through when the head condition didn't already establish one, plus the missing resolve_they_pronoun arm mapping DefendingPlayer to TargetFilter::DefendingPlayer (CR 506.2, CR 508.5). A before/after parse-diff audit of every printed card containing "if defending player" (19 cards) found exactly two real changes: Smart Ass's RevealHand target, and a second independent bug in Siege Dragon — "that player controls" resolved to the attacker (ControllerRef::You) instead of the defending player, damaging the attacker's own creatures instead of the opponent's. Every other card in the audit parsed identically, confirming the fix's scope. Added tests for both, plus a sibling case confirming a different relative-player scope (TargetPlayer, from "deals combat damage to a player") still resolves correctly and isn't captured by the new arm. Co-Authored-By: Claude Sonnet 5 --- .../src/parser/oracle_effect/subject.rs | 14 +++ crates/engine/src/parser/oracle_trigger.rs | 41 ++++++++ .../engine/src/parser/oracle_trigger_tests.rs | 97 +++++++++++++++++-- 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index 7fa4ed125f..d5d180f6fa 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -3154,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 826db7e2fa..c8ffa5bff9 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -977,6 +977,39 @@ 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 { + let mut remaining = effect_lower; + while !remaining.is_empty() { + if parse_if_defending_player(remaining).is_ok() { + return true; + } + remaining = match remaining.find(' ') { + Some(i) => remaining[i + 1..].trim_start(), + None => "", + }; + } + false +} + /// 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" @@ -1326,6 +1359,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 e09f21d85b..369032fae5 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -25890,10 +25890,19 @@ fn tarnation_they_may_draw_binds_to_triggering_player() { /// 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." Before the fix: -/// `RevealHand { target: Any, .. }`, non-optional. -#[test] -fn smart_ass_they_may_reveal_hand_is_optional_and_bound() { +/// 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", @@ -25905,10 +25914,10 @@ fn smart_ass_they_may_reveal_hand_is_optional_and_bound() { .expect("the reveal-hand clause must remain chained to the naming choice"); match &*reveal.effect { Effect::RevealHand { target, .. } => { - assert_ne!( + assert_eq!( target, - &TargetFilter::Any, - "\"they\" reveal must bind to a real player referent, not float unbound" + &TargetFilter::DefendingPlayer, + "\"they\" reveal must bind to the combat-relative defending player" ); } other => panic!("expected RevealHand, got {other:?}"), @@ -25918,3 +25927,77 @@ fn smart_ass_they_may_reveal_hand_is_optional_and_bound() { "\"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:?}"), + } +} From 9858be9a187c809bcb19e3a2e3b5701573d2346b Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 31 Jul 2026 05:41:56 -0500 Subject: [PATCH 5/6] test(engine): add Elder Brain production regression, dedupe scope scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth review round on the Wandering Archaic parser fix: - Elder Brain ("Whenever this creature attacks a player, exile all cards from that player's hand, then they draw that many cards...") changed under the parse-diff (Draw.target: ParentTarget -> DefendingPlayer) but had no dedicated regression. Unlike Smart Ass and Siege Dragon, Elder Brain's trigger condition itself ("attacks a player") is already recognized by the pre-existing condition_introduces_defending_player check — what was missing was purely the resolve_they_pronoun arm, which now reads that pre-existing scope the same way it reads the new effect_body_introduces_defending_player-derived one. Added a test exercising this route specifically, distinct from the body- conditional route the other two tests cover. - effect_body_introduces_defending_player duplicated the word-boundary scan loop already shared by oracle_nom::primitives:: scan_at_word_boundaries. Replaced the hand-rolled loop with the shared combinator. Co-Authored-By: Claude Sonnet 5 --- crates/engine/src/parser/oracle_trigger.rs | 12 +---- .../engine/src/parser/oracle_trigger_tests.rs | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index c8ffa5bff9..e2fb04d007 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -997,17 +997,7 @@ fn parse_if_defending_player(input: &str) -> OracleResult<'_, ()> { /// generic `ParentTarget` default, which has no defending-player referent to /// inherit. fn effect_body_introduces_defending_player(effect_lower: &str) -> bool { - let mut remaining = effect_lower; - while !remaining.is_empty() { - if parse_if_defending_player(remaining).is_ok() { - return true; - } - remaining = match remaining.find(' ') { - Some(i) => remaining[i + 1..].trim_start(), - None => "", - }; - } - false + 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, diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 369032fae5..1956d1a2cb 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -26001,3 +26001,54 @@ fn siege_dragon_that_player_controls_binds_to_defending_player() { other => panic!("expected DamageAll, got {other:?}"), } } + +/// 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:?}"), + } +} From 6762923f1a291d44352d4e06079bf5cafd80835b Mon Sep 17 00:00:00 2001 From: matthewevans Date: Fri, 31 Jul 2026 07:11:19 -0700 Subject: [PATCH 6/6] fix(PR-6823): annotate Elder Brain defending player test --- crates/engine/src/parser/oracle_trigger_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 1956d1a2cb..9927388818 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -26002,6 +26002,8 @@ fn siege_dragon_that_player_controls_binds_to_defending_player() { } } +/// 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