From 61ffe5b168093a59ba6526f0133273459d931957 Mon Sep 17 00:00:00 2001 From: "@Lcola98" <75585494+keloide@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:53:29 +0000 Subject: [PATCH 1/3] fix(parser): retain target for counter instead overrides --- .../engine/src/parser/oracle_effect/lower.rs | 6 + .../engine/src/parser/oracle_effect/tests.rs | 57 ++++++++++ .../issue_6677_wakandan_royal_guard.rs | 104 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 4 files changed, 168 insertions(+) create mode 100644 crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index ed6bc7b445..9ab96584b2 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -2399,6 +2399,8 @@ pub(super) fn rewrite_counter_instead_target_from_antecedent( if !matches!(current_target, TargetFilter::SelfRef) { return false; } + // CR 608.2c + CR 115.1: an instead clause later in the same instruction + // reuses the original chosen target rather than announcing a new target. // Existing attachment-host case — only when the antecedent is itself a `PutCounter`. // Preserved verbatim (clone the host filter) so attachment-host cards stay byte-identical. if let Effect::PutCounter { @@ -2410,6 +2412,10 @@ pub(super) fn rewrite_counter_instead_target_from_antecedent( *current_target = antecedent_target.clone(); return true; } + if matches!(antecedent_target, TargetFilter::Typed(_)) { + *current_target = TargetFilter::ParentTarget; + return true; + } return false; } // FIX A′ — CR 608.2c: an instead-override "Put a +1/+1 counter on it" whose antecedent diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 2812dda149..e208998790 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -19144,6 +19144,63 @@ fn instead_condition_recognizes_that_permanent_is_color() { ); } +/// CR 608.2c + CR 115.1: a conditional counter override whose base clause +/// targeted a creature reuses that chosen object through `ParentTarget`. The +/// override must not turn its bare "it" into the resolving source or request a +/// second target. Wakandan Royal Guard and Elder Cathar cover the unrestricted +/// and controller-qualified target forms of this grammar. +#[test] +fn counter_instead_override_reuses_typed_antecedent_target() { + for effect_text in [ + "Put a +1/+1 counter on target creature. If that creature is another Hero, put two +1/+1 counters on it instead.", + "Put a +1/+1 counter on target creature you control. If that creature is a Human, put two +1/+1 counters on it instead.", + ] { + let ability = parse_effect_chain(effect_text, AbilityKind::Spell); + assert!( + matches!( + ability.effect.as_ref(), + Effect::PutCounter { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Typed(_), + .. + } + ), + "the base counter instruction must retain its typed target for {effect_text:?}; got {:?}", + ability.effect + ); + let override_branch = ability + .sub_ability + .as_deref() + .unwrap_or_else(|| panic!("expected counter override for {effect_text:?}")); + assert!( + matches!( + override_branch.condition.as_ref(), + Some(AbilityCondition::ConditionInstead { inner }) + if matches!(inner.as_ref(), AbilityCondition::TargetMatchesFilter { .. }) + ), + "the override must retain a typed target-match condition for {effect_text:?}; got {:?}", + override_branch.condition + ); + assert!( + matches!( + override_branch.effect.as_ref(), + Effect::PutCounter { + count: QuantityExpr::Fixed { value: 2 }, + target: TargetFilter::ParentTarget, + .. + } + ), + "the override must put two counters on the original target for {effect_text:?}; got {:?}", + override_branch.effect + ); + assert!( + !matches!(ability.effect.as_ref(), Effect::Unimplemented { .. }) + && !matches!(override_branch.effect.as_ref(), Effect::Unimplemented { .. }), + "both clauses must lower without Effect::Unimplemented for {effect_text:?}" + ); + } +} + /// CR 117.1 + CR 400.7j + CR 608.2k + CR 614.1a: Stormscale Anarch class — /// the "discard a card at random" cost paid object is checked against the /// "multicolored" property to gate the override damage. The condition is diff --git a/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs b/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs new file mode 100644 index 0000000000..cf501f27e0 --- /dev/null +++ b/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs @@ -0,0 +1,104 @@ +//! Regression for issue #6677: Wakandan Royal Guard's conditional counter +//! override must keep using the creature chosen for its ETB trigger. +//! +//! The real Oracle text first targets a creature, then says "put two +1/+1 +//! counters on it instead" when that creature is another Hero. The override's +//! bare pronoun must resolve to the original target, never Wakandan Royal Guard. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::counter::CounterType; +use engine::types::identifiers::ObjectId; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; + +const WAKANDAN_ROYAL_GUARD_ORACLE: &str = "Vigilance\n\ + When this creature enters, put a +1/+1 counter on target creature. If that creature is another Hero, put two +1/+1 counters on it instead."; + +fn p1p1_counters(state: &engine::types::game_state::GameState, object: ObjectId) -> u32 { + state + .objects + .get(&object) + .and_then(|card| card.counters.get(&CounterType::Plus1Plus1).copied()) + .unwrap_or(0) +} + +fn resolve_guard_targeting_creature(target_is_hero: bool) -> (u32, u32, u32) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let selected = if target_is_hero { + scenario + .add_creature(P0, "Selected Hero", 2, 2) + .with_subtypes(vec!["Hero"]) + .id() + } else { + scenario.add_creature(P0, "Selected Soldier", 2, 2).id() + }; + let unrelated_hero = scenario + .add_creature(P0, "Unselected Hero", 2, 2) + .with_subtypes(vec!["Hero"]) + .id(); + let guard = scenario + .add_creature_to_hand_from_oracle( + P0, + "Wakandan Royal Guard", + 2, + 2, + WAKANDAN_ROYAL_GUARD_ORACLE, + ) + .with_subtypes(vec!["Human", "Soldier", "Hero"]) + .with_mana_cost(ManaCost::generic(0)) + .id(); + + let mut runner = scenario.build(); + let outcome = runner.cast(guard).target_object(selected).resolve(); + + ( + p1p1_counters(outcome.state(), selected), + p1p1_counters(outcome.state(), guard), + p1p1_counters(outcome.state(), unrelated_hero), + ) +} + +/// CR 603.2 + CR 115.1d + CR 608.2c + CR 122.1: the ETB trigger targets the +/// selected Hero, and its matching instead-override puts two counters on that +/// same object. The zero-counter siblings prove the pronoun did not rebound to +/// either the resolving guard or another legal Hero. +#[test] +fn wakandan_royal_guard_doubles_counters_on_the_selected_hero() { + let (selected, guard, unrelated_hero) = resolve_guard_targeting_creature(true); + + assert_eq!( + selected, 2, + "the selected Hero must receive two +1/+1 counters" + ); + assert_eq!( + guard, 0, + "Wakandan Royal Guard must not receive the counters" + ); + assert_eq!( + unrelated_hero, 0, + "an unselected Hero must not receive the counters" + ); +} + +/// CR 603.2 + CR 115.1d + CR 608.2c + CR 122.1: when the chosen creature is +/// not a Hero, the override does not apply and the printed base instruction +/// still places exactly one counter on that chosen object. +#[test] +fn wakandan_royal_guard_keeps_one_counter_on_a_nonhero_target() { + let (selected, guard, unrelated_hero) = resolve_guard_targeting_creature(false); + + assert_eq!( + selected, 1, + "the non-Hero target must receive the base instruction's one counter" + ); + assert_eq!( + guard, 0, + "Wakandan Royal Guard must not receive the counter" + ); + assert_eq!( + unrelated_hero, 0, + "an unrelated Hero must not satisfy the selected-target condition" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 3de438afaf..957e2d4319 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -633,6 +633,7 @@ mod issue_654_stridehangar_automaton; mod issue_6566_granted_leave_exile; mod issue_6634_aven_courier; mod issue_6643_party_dude_opponents_attacked; +mod issue_6677_wakandan_royal_guard; mod issue_6678_captain_america_shield_tap_defender; mod issue_680_shalai_and_hallar_forgotten_ancient; mod issue_680_shalai_upkeep_move; From 69a9451ca26b9a2fc127fbbbfc851d9f9585da19 Mon Sep 17 00:00:00 2001 From: "@Lcola98" <75585494+keloide@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:30:23 +0000 Subject: [PATCH 2/3] fix(parser): retain counter override event targets --- .../engine/src/parser/oracle_effect/lower.rs | 15 +++-- .../issue_6677_wakandan_royal_guard.rs | 57 ++++++++++++++++++- 2 files changed, 65 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 9ab96584b2..25b8e5b1af 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -2412,11 +2412,18 @@ pub(super) fn rewrite_counter_instead_target_from_antecedent( *current_target = antecedent_target.clone(); return true; } - if matches!(antecedent_target, TargetFilter::Typed(_)) { - *current_target = TargetFilter::ParentTarget; - return true; + match antecedent_target { + // A printed target is selected once for the root instruction; the + // override must inherit that selection rather than open a new slot. + TargetFilter::Typed(_) => *current_target = TargetFilter::ParentTarget, + // Event and parent anaphors already identify the antecedent object + // at resolution. Reuse the same reference for a bare "it" override. + TargetFilter::ParentTarget + | TargetFilter::ParentTargetSlot { .. } + | TargetFilter::TriggeringSource => *current_target = antecedent_target.clone(), + _ => return false, } - return false; + return true; } // FIX A′ — CR 608.2c: an instead-override "Put a +1/+1 counter on it" whose antecedent // is a typed-targeted non-counter clause (Throw from the Saddle's "Target creature you diff --git a/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs b/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs index cf501f27e0..bd2e8f33f1 100644 --- a/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs +++ b/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs @@ -1,19 +1,23 @@ -//! Regression for issue #6677: Wakandan Royal Guard's conditional counter -//! override must keep using the creature chosen for its ETB trigger. +//! Regressions for issue #6677: conditional counter overrides must retain the +//! object established by their antecedent instruction. //! //! The real Oracle text first targets a creature, then says "put two +1/+1 //! counters on it instead" when that creature is another Hero. The override's //! bare pronoun must resolve to the original target, never Wakandan Royal Guard. +//! Emiel the Blessed covers the parallel trigger-event anaphor path. use engine::game::scenario::{GameScenario, P0}; use engine::types::counter::CounterType; use engine::types::identifiers::ObjectId; -use engine::types::mana::ManaCost; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; use engine::types::phase::Phase; const WAKANDAN_ROYAL_GUARD_ORACLE: &str = "Vigilance\n\ When this creature enters, put a +1/+1 counter on target creature. If that creature is another Hero, put two +1/+1 counters on it instead."; +const EMIEL_THE_BLESSED_ORACLE: &str = "{3}: Exile another target creature you control, then return it to the battlefield under its owner's control.\n\ + Whenever another creature you control enters, you may pay {G/W}. If you do, put a +1/+1 counter on it. If it's a Unicorn, put two +1/+1 counters on it instead. ({G/W} can be paid with either {G} or {W}.)"; + fn p1p1_counters(state: &engine::types::game_state::GameState, object: ObjectId) -> u32 { state .objects @@ -102,3 +106,50 @@ fn wakandan_royal_guard_keeps_one_counter_on_a_nonhero_target() { "an unrelated Hero must not satisfy the selected-target condition" ); } + +/// CR 603.2 + CR 608.2c + CR 122.1: Emiel's first counter instruction and +/// its Unicorn override both refer to the entering creature, represented by +/// `TriggeringSource`. The unrelated Unicorn proves the event reference is +/// preserved rather than widened to a battlefield filter. +#[test] +fn emiel_the_blessed_doubles_counters_on_the_entering_unicorn() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let emiel = scenario + .add_creature_from_oracle(P0, "Emiel the Blessed", 4, 4, EMIEL_THE_BLESSED_ORACLE) + .with_subtypes(vec!["Angel"]) + .id(); + let unrelated_unicorn = scenario + .add_creature(P0, "Unrelated Unicorn", 2, 2) + .with_subtypes(vec!["Unicorn"]) + .id(); + let entering_unicorn = scenario + .add_creature_to_hand(P0, "Entering Unicorn", 2, 2) + .with_subtypes(vec!["Unicorn"]) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new(ManaType::Green, emiel, false, vec![])], + ); + + let mut runner = scenario.build(); + let outcome = runner.cast(entering_unicorn).accept_optional().resolve(); + + assert_eq!( + p1p1_counters(outcome.state(), entering_unicorn), + 2, + "the entering Unicorn must receive Emiel's two +1/+1 counters" + ); + assert_eq!( + p1p1_counters(outcome.state(), emiel), + 0, + "Emiel must not receive the entering creature's counters" + ); + assert_eq!( + p1p1_counters(outcome.state(), unrelated_unicorn), + 0, + "an unrelated Unicorn must not satisfy the trigger-event anaphor" + ); +} From 453876adc9287a4b0602aee22807236b2b838adf Mon Sep 17 00:00:00 2001 From: "@Lcola98" <75585494+keloide@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:18:39 +0000 Subject: [PATCH 3/3] fix(parser): bind instead to paid continuation --- .../src/parser/oracle_effect/assembly.rs | 79 +++++++++++++++---- .../issue_6677_wakandan_royal_guard.rs | 51 ++++++++++++ 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index bfb6f616e6..1e62c06d19 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -1625,9 +1625,11 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { // 1 runs as printed, then the tail runs from // `else_ability`. Single-clause bases collapse to the // prior shape (empty tail → no `else_ability`). - // U6-C2: `Instead` is the ONLY handler that binds FirstEmitted - // (CR 608.2c — the override replaces the FIRST printed - // instruction). Do not unify it with the `Last*` selectors. + // U6-C2: `Instead` is the ONLY handler that begins from + // FirstEmitted. Its one structural refinement is an optional + // payment: there, the override replaces the immediate + // `IfYouDo` continuation, not the payment instruction. + // Do not unify it with the `Last*` selectors. let bound = env.resolve( &defs, AntecedentSelector::FirstEmitted, @@ -1649,21 +1651,51 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { append_to_deepest_sub_ability(&mut root, Some(Box::new(next))); } let mut instead = *instead_def.clone(); - // CR 702.33d + CR 707.10: Resolve "create N of those - // tokens" anaphor against the root (the antecedent - // for a multi-clause base is the first printed clause). - rewrite_those_tokens_from_antecedent(&mut instead.effect, &root.effect); - if rewrite_counter_instead_target_from_antecedent( - &mut instead.effect, - &root.effect, - ) { - instead.target_choice_timing = root.target_choice_timing; - } - if has_explicit_player_target(root.effect.as_ref()) { - rewrite_player_anaphor_targets_in_definition(&mut instead); + if instead_replaces_optional_payment_continuation(&root) { + // CR 608.2c: after "you may pay ... If you do, X", + // a later "Y instead" modifies X, not the preceding + // payment instruction. Keep the payment as the root + // and attach the override to its paid continuation. + let continuation = root + .sub_ability + .as_deref_mut() + .expect("the optional-payment continuation was checked"); + rewrite_those_tokens_from_antecedent( + &mut instead.effect, + &continuation.effect, + ); + if rewrite_counter_instead_target_from_antecedent( + &mut instead.effect, + &continuation.effect, + ) { + instead.target_choice_timing = + continuation.target_choice_timing; + } + if has_explicit_player_target(continuation.effect.as_ref()) { + rewrite_player_anaphor_targets_in_definition(&mut instead); + } + instead.else_ability = continuation.sub_ability.take(); + continuation.sub_ability = Some(Box::new(instead)); + } else { + // CR 702.33d + CR 707.10: Resolve "create N of those + // tokens" anaphor against the root (the antecedent + // for a multi-clause base is the first printed clause). + rewrite_those_tokens_from_antecedent( + &mut instead.effect, + &root.effect, + ); + if rewrite_counter_instead_target_from_antecedent( + &mut instead.effect, + &root.effect, + ) { + instead.target_choice_timing = root.target_choice_timing; + } + if has_explicit_player_target(root.effect.as_ref()) { + rewrite_player_anaphor_targets_in_definition(&mut instead); + } + instead.else_ability = root.sub_ability.take(); + root.sub_ability = Some(Box::new(instead)); } - instead.else_ability = root.sub_ability.take(); - root.sub_ability = Some(Box::new(instead)); defs.push(root); if let Some(id) = root_id { env.arena.reinstate(id); @@ -2897,6 +2929,19 @@ fn rebind_condition_instead_damage_anaphor( false } +/// CR 608.2c: identify the structural "you may pay ... If you do, X" form +/// whose immediate paid continuation, rather than the payment itself, can be +/// modified by a following "Y instead" clause. +fn instead_replaces_optional_payment_continuation(root: &AbilityDefinition) -> bool { + matches!(root.effect.as_ref(), Effect::PayCost { .. }) + && root.sub_ability.as_ref().is_some_and(|continuation| { + continuation + .condition + .as_ref() + .is_some_and(AbilityCondition::is_optional_effect_performed) + }) +} + #[cfg(test)] mod arena_tests { use super::*; diff --git a/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs b/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs index bd2e8f33f1..519e1a73f7 100644 --- a/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs +++ b/crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs @@ -137,6 +137,13 @@ fn emiel_the_blessed_doubles_counters_on_the_entering_unicorn() { let mut runner = scenario.build(); let outcome = runner.cast(entering_unicorn).accept_optional().resolve(); + assert!( + outcome.state().players[P0.0 as usize] + .mana_pool + .mana + .is_empty(), + "reach-guard: accepting Emiel's optional payment must consume the supplied green mana" + ); assert_eq!( p1p1_counters(outcome.state(), entering_unicorn), 2, @@ -153,3 +160,47 @@ fn emiel_the_blessed_doubles_counters_on_the_entering_unicorn() { "an unrelated Unicorn must not satisfy the trigger-event anaphor" ); } + +/// CR 603.2 + CR 608.2c + CR 122.1: accepting Emiel's payment still performs +/// the printed one-counter continuation when the entering creature does not +/// satisfy the later Unicorn instead-condition. +#[test] +fn emiel_the_blessed_keeps_one_counter_on_a_nonunicorn() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let emiel = scenario + .add_creature_from_oracle(P0, "Emiel the Blessed", 4, 4, EMIEL_THE_BLESSED_ORACLE) + .with_subtypes(vec!["Angel"]) + .id(); + let entering_cat = scenario + .add_creature_to_hand(P0, "Entering Cat", 2, 2) + .with_subtypes(vec!["Cat"]) + .with_mana_cost(ManaCost::generic(0)) + .id(); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new(ManaType::Green, emiel, false, vec![])], + ); + + let mut runner = scenario.build(); + let outcome = runner.cast(entering_cat).accept_optional().resolve(); + + assert!( + outcome.state().players[P0.0 as usize] + .mana_pool + .mana + .is_empty(), + "reach-guard: accepting Emiel's optional payment must consume the supplied green mana" + ); + assert_eq!( + p1p1_counters(outcome.state(), entering_cat), + 1, + "the entering non-Unicorn must receive the paid continuation's one counter" + ); + assert_eq!( + p1p1_counters(outcome.state(), emiel), + 0, + "Emiel must not receive the entering creature's counter" + ); +}