Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion crates/engine/src/parser/oracle_effect/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -2410,7 +2412,18 @@ pub(super) fn rewrite_counter_instead_target_from_antecedent(
*current_target = antecedent_target.clone();
return true;
}
return false;
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 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
Expand Down
57 changes: 57 additions & 0 deletions crates/engine/src/parser/oracle_effect/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
155 changes: 155 additions & 0 deletions crates/engine/tests/integration/issue_6677_wakandan_royal_guard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! 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, 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
.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"
);
}

/// 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"
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading