Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions crates/engine/src/game/conditions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ pub(crate) fn eval_recipient_attacking_owner_target(
| AttackTargetFilter::Planeswalker
| AttackTargetFilter::PlayerOrPlaneswalker
| AttackTargetFilter::PlayerOrPermanents
| AttackTargetFilter::Monarch
| AttackTargetFilter::Battle => false,
}
}
Expand Down
73 changes: 73 additions & 0 deletions crates/engine/src/game/trigger_matchers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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(_)
)
)
}
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions crates/engine/src/parser/oracle_trigger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions crates/engine/src/parser/oracle_trigger_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions crates/engine/src/types/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PlayerId>, 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()
);
}
Loading