diff --git a/crates/engine/src/game/conditions.rs b/crates/engine/src/game/conditions.rs index 62b5a99d65..f2001df805 100644 --- a/crates/engine/src/game/conditions.rs +++ b/crates/engine/src/game/conditions.rs @@ -151,6 +151,7 @@ pub(crate) fn eval_recipient_attacking_owner_target( | AttackTargetFilter::Planeswalker | AttackTargetFilter::PlayerOrPlaneswalker | AttackTargetFilter::PlayerOrPermanents + | AttackTargetFilter::Monarch | AttackTargetFilter::Battle => false, } } diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index fda1e6af13..e851ad5aba 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -1883,6 +1883,19 @@ fn attack_target_matches( if !attack_target_type_matches(target, filter) { return false; } + // CR 725.1: "attacks the monarch" additionally requires the defending + // player to currently hold the monarch designation. The monarch is a + // dynamic single-player identity, so it cannot be evaluated by the pure + // type matcher above — it is checked here against `state.monarch`. If no + // player is the monarch (CR 725.1), the trigger does not fire (The Spear + // of Bashenga). + if matches!(filter, crate::types::triggers::AttackTargetFilter::Monarch) { + let defending_player = + attack_target_defending_player(state, target, fallback_defending_player); + if state.monarch != Some(defending_player) { + return false; + } + } } if trigger.valid_target.is_some() { @@ -1913,6 +1926,12 @@ pub(super) fn attack_target_type_matches( ) | ( crate::types::triggers::AttackTargetFilter::Battle, crate::game::combat::AttackTarget::Battle(_) + ) | ( + // CR 725.1: "attacks the monarch" is a Player-type attack; the + // monarch-identity constraint is applied statefully in + // `attack_target_matches` (The Spear of Bashenga). + crate::types::triggers::AttackTargetFilter::Monarch, + crate::game::combat::AttackTarget::Player(_) ) ) } @@ -5009,6 +5028,60 @@ mod tests { TriggerDefinition::new(mode) } + /// Issue #5249 — The Spear of Bashenga: "Whenever equipped creature attacks + /// the monarch, ...". `AttackTargetFilter::Monarch` is a Player-type attack + /// whose defending player must currently hold the monarch designation + /// (CR 725.1). The identity check is stateful (`state.monarch`), so it lives + /// in `attack_target_matches`, not the pure type matcher. Attacking the + /// monarch matches; attacking a non-monarch player does not; and with no + /// monarch in the game (CR 725.1) it never matches — the revert canary. + #[test] + fn attack_target_matches_monarch_requires_monarch_defender() { + let mut state = setup(); + let mut trigger = make_trigger(TriggerMode::Attacks); + trigger.attack_target_filter = Some(crate::types::triggers::AttackTargetFilter::Monarch); + let source_id = ObjectId(99); + + // P1 is the monarch; attacking P1 matches. + state.monarch = Some(PlayerId(1)); + assert!( + attack_target_matches( + &trigger, + &state, + crate::game::combat::AttackTarget::Player(PlayerId(1)), + PlayerId(1), + &test_trigger_source_context(&state, source_id), + ), + "attacking the monarch (P1) must match" + ); + + // P0 is NOT the monarch; attacking P0 must NOT match (the reported bug). + assert!( + !attack_target_matches( + &trigger, + &state, + crate::game::combat::AttackTarget::Player(PlayerId(0)), + PlayerId(0), + &test_trigger_source_context(&state, source_id), + ), + "attacking a non-monarch player must NOT match" + ); + + // No monarch in the game (CR 725.1) → never matches, even for the + // fallback defending player. + state.monarch = None; + assert!( + !attack_target_matches( + &trigger, + &state, + crate::game::combat::AttackTarget::Player(PlayerId(1)), + PlayerId(1), + &test_trigger_source_context(&state, source_id), + ), + "with no monarch, the monarch attack-target filter must never match" + ); + } + /// CR 701.31 / CR 701.31d / CR 901.11: the unified `match_planeswalked` matcher /// reads the `PlaneswalkRole` off the trigger's mode. `Any` fires for every /// `Planeswalked` event regardless of endpoint (The Doctor's Childhood Barn's diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 6864d7dd3f..7da417976d 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -938,6 +938,12 @@ fn parse_referenced_player_phrase(input: &str) -> OracleResult<'_, ()> { value((), tag("another player")), value((), tag("an opponent")), value((), tag("a player")), + // CR 508.1a + CR 725.1: "attacks the monarch ... that player controls" — + // the monarch is the attacked (defending) player chosen at attack + // declaration, so the trailing "that player" anaphor binds to + // `ControllerRef::DefendingPlayer` (The Spear of Bashenga: "destroy + // target tapped nonland permanent that player controls"). + value((), tag("the monarch")), )) .parse(input) } @@ -10313,6 +10319,12 @@ fn try_parse_event( // `valid_target = AttachedTo` (Curse of Predation, Curse of Chaos, // Curse of Inertia). value(AttackTargetFilter::Player, tag(" enchanted player")), + // CR 508.1a + CR 725.1: "attacks the monarch" — a Player-type + // attack whose defending player must currently hold the monarch + // designation. The monarch-identity check is stateful and lives + // in `attack_target_matches`, not in this pure type parse (The + // Spear of Bashenga). + value(AttackTargetFilter::Monarch, tag(" the monarch")), value(AttackTargetFilter::Battle, tag(" a battle")), )) .parse(input) diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 9927388818..5c28c8694d 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -3394,6 +3394,66 @@ fn trigger_attacks_enchanted_player_scopes_to_attached_player() { ); } +/// Issue #5249 — The Spear of Bashenga: "Whenever equipped creature attacks +/// the monarch, destroy target tapped nonland permanent that player controls." +/// The " the monarch" defender scope must parse to +/// `attack_target_filter = Monarch` so the trigger fires only when the equipped +/// creature attacks whoever currently holds the monarch designation (CR 725.1). +/// Before the fix there was no " the monarch" arm, so the trigger degraded to a +/// bare `Attacks` with no attack-target scope and fired on every attack. +/// Runtime firing / non-firing is covered by the discriminating integration test +/// `spear_of_bashenga_attacks_monarch_5249`. +#[test] +fn trigger_attacks_the_monarch_scopes_to_monarch_filter() { + let def = parse_trigger_line( + "Whenever equipped creature attacks the monarch, destroy target tapped nonland permanent that player controls.", + "The Spear of Bashenga", + ); + assert_eq!(def.mode, TriggerMode::Attacks); + assert_eq!( + def.attack_target_filter, + Some(AttackTargetFilter::Monarch), + "'attacks the monarch' must scope the attack target to the Monarch filter" + ); + // The subject "equipped creature" scopes the attacker via `valid_card` + // (a creature filter), NOT `valid_source`/`valid_target`. The monarch + // identity is carried by the Monarch attack-target filter itself. + assert!( + def.valid_card.is_some(), + "equipped-creature subject must populate valid_card, got {:?}", + def.valid_card + ); + assert_eq!( + def.valid_source, None, + "monarch attack subject is an object (equipped creature), not a player" + ); + assert_eq!( + def.valid_target, None, + "monarch identity is checked by the Monarch filter, not via valid_target" + ); + // The destroy target is a tapped nonland permanent controlled by the + // defending (monarch) player — resolved via `ControllerRef::DefendingPlayer`. + let effect = def + .execute + .as_ref() + .map(|e| e.effect.as_ref()) + .expect("trigger must have an execute effect"); + assert!( + !matches!(effect, Effect::Unimplemented { .. }), + "destroy effect must not be Unimplemented: {effect:?}" + ); + match effect { + Effect::Destroy { target, .. } => { + let json = format!("{target:?}"); + assert!( + json.contains("DefendingPlayer"), + "destroy target must be controlled by DefendingPlayer, got {target:?}" + ); + } + other => panic!("expected Effect::Destroy, got {other:?}"), + } +} + #[test] fn opponent_attacks_that_player_library_binds_to_triggering_player() { let def = parse_trigger_line( diff --git a/crates/engine/src/types/triggers.rs b/crates/engine/src/types/triggers.rs index c5d413a2a3..0e3d443a40 100644 --- a/crates/engine/src/types/triggers.rs +++ b/crates/engine/src/types/triggers.rs @@ -196,6 +196,14 @@ pub enum AttackTargetFilter { /// `PlayerOrPlaneswalker`, which excludes battles. Control of the defended /// planeswalker/battle is compared per CR 109.4. PlayerOrPermanents, + /// CR 508.1a + CR 725.1: "attacks the monarch" — a Player-type attack whose + /// defending player must currently hold the monarch designation + /// (`state.monarch`). The monarch is a single dynamic player identity, so + /// unlike the other scope variants this cannot be evaluated by the pure type + /// matcher — it is resolved in the stateful `attack_target_matches` against + /// `state.monarch` (The Spear of Bashenga). If there is no monarch + /// (CR 725.1 — no monarch until an effect creates one), it never matches. + Monarch, } /// CR 701.31 + CR 701.31d: which role the trigger's source must occupy in the diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 31bf648b24..124d13ee25 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -851,6 +851,7 @@ mod sliver_static_grants; mod snow_mana_production; mod sothera_supervoid_edict_reanimate; mod spark_double_as_enters; +mod spear_of_bashenga_attacks_monarch_5249; mod special_action_x_runtime; mod specialize_runtime; mod spellstutter_sprite_counter_with_x; diff --git a/crates/engine/tests/integration/spear_of_bashenga_attacks_monarch_5249.rs b/crates/engine/tests/integration/spear_of_bashenga_attacks_monarch_5249.rs new file mode 100644 index 0000000000..cd92ede92f --- /dev/null +++ b/crates/engine/tests/integration/spear_of_bashenga_attacks_monarch_5249.rs @@ -0,0 +1,144 @@ +//! Issue #5249 — The Spear of Bashenga: "Whenever equipped creature attacks +//! the monarch, destroy target tapped nonland permanent that player controls." +//! +//! Before the fix, the parser had no " the monarch" arm, so the trigger +//! degraded to a bare `Attacks` with no attack-target scope — it fired on EVERY +//! attack and prompted for a destroy target regardless of who was the monarch. +//! The fix adds `AttackTargetFilter::Monarch`, a Player-type attack whose +//! defending player must currently hold the monarch designation (CR 725.1), +//! checked statefully in `attack_target_matches` against `state.monarch`. +//! +//! These integration tests drive the real combat pipeline and discriminate all +//! three directions: +//! 1. Attack the monarch (P1) → the trigger fires and reaches the stack. +//! 2. Attack a non-monarch player → the trigger does NOT fire (the reported +//! bug; this is the revert canary). +//! 3. No monarch in the game → the trigger does NOT fire (CR 725.1). +//! +//! CR references: +//! - CR 508.1a: The active player chooses which creatures will attack. +//! - CR 725.1: The monarch is a designation a player can have; there is no +//! monarch until an effect creates one. + +use engine::game::combat::AttackTarget; +use engine::game::effects::attach::attach_to; +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::trigger_index::reindex_object_triggers; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; + +const P2: PlayerId = PlayerId(2); + +const SPEAR_OF_BASHENGA_ORACLE: &str = + "When The Spear of Bashenga enters, if there is no monarch, you become the monarch.\n\ + Equipped creature gets +2/+2 and has vigilance.\n\ + Whenever equipped creature attacks the monarch, destroy target tapped nonland \ + permanent that player controls.\n\ + Equip {2}"; + +/// Count stack entries sourced from `source`. +fn stack_triggers_from(runner: &GameRunner, source: ObjectId) -> usize { + runner + .state() + .stack + .iter() + .filter(|e| e.source_id == source) + .count() +} + +/// Build a 3-player scenario: P0 controls a creature equipped with The Spear of +/// Bashenga; `monarch` (if any) holds the monarch designation; the defender of +/// the attack (`defender`) controls a tapped creature (a tapped nonland +/// permanent) that the destroy effect can target. Returns the runner, the Spear +/// object id, the attacker, and the tapped target on the defender. +fn setup(monarch: Option, defender: PlayerId) -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new_n_player(3, 42); + scenario.at_phase(Phase::PreCombatMain); + + let attacker = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let spear = scenario + .add_creature(P0, "The Spear of Bashenga", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .from_oracle_text(SPEAR_OF_BASHENGA_ORACLE) + .id(); + + // The defender controls a tapped creature — a legal destroy target. + let victim = scenario.add_creature(defender, "Tapped Bear", 2, 2).id(); + + for _ in 0..10 { + scenario.add_card_to_library_top(P0, "Plains"); + } + + let mut runner = scenario.build(); + runner.state_mut().monarch = monarch; + runner.state_mut().objects.get_mut(&victim).unwrap().tapped = true; + + attach_to(runner.state_mut(), spear, attacker); + evaluate_layers(runner.state_mut()); + reindex_object_triggers(runner.state_mut(), spear); + + (runner, spear, attacker) +} + +/// P0's equipped creature attacks the monarch (P1) → the Spear's trigger fires +/// and reaches the stack. +#[test] +fn spear_of_bashenga_fires_when_attacking_the_monarch() { + let (mut runner, spear, attacker) = setup(Some(P1), P1); + + runner.advance_to_combat(); + runner + .declare_attackers(&[(attacker, AttackTarget::Player(P1))]) + .expect("DeclareAttackers must succeed"); + + assert!( + stack_triggers_from(&runner, spear) >= 1, + "attacking the monarch (P1) must fire The Spear of Bashenga's destroy trigger, \ + got stack {:?}", + runner.stack_names() + ); +} + +/// Revert canary: P1 is the monarch, but P0's equipped creature attacks P2 (a +/// NON-monarch player). The trigger must NOT fire. On the unfixed code (no +/// " the monarch" arm) the trigger degrades to a scope-less `Attacks` and fires +/// here — so this assertion fails when the fix is reverted. +#[test] +fn spear_of_bashenga_does_not_fire_when_attacking_non_monarch() { + let (mut runner, spear, attacker) = setup(Some(P1), P2); + + runner.advance_to_combat(); + runner + .declare_attackers(&[(attacker, AttackTarget::Player(P2))]) + .expect("DeclareAttackers must succeed"); + + assert_eq!( + stack_triggers_from(&runner, spear), + 0, + "attacking a NON-monarch player (P2 while P1 is monarch) must NOT fire the trigger, \ + got stack {:?}", + runner.stack_names() + ); +} + +/// With no monarch in the game (CR 725.1), the trigger must not fire even though +/// the equipped creature attacks a player. +#[test] +fn spear_of_bashenga_does_not_fire_with_no_monarch() { + let (mut runner, spear, attacker) = setup(None, P1); + + runner.advance_to_combat(); + runner + .declare_attackers(&[(attacker, AttackTarget::Player(P1))]) + .expect("DeclareAttackers must succeed"); + + assert_eq!( + stack_triggers_from(&runner, spear), + 0, + "with no monarch (CR 725.1) the trigger must not fire, got stack {:?}", + runner.stack_names() + ); +}