From 735c6c0ad39f72a3c5f95dbe8c880e5c76e8e41d Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:10:55 +0900 Subject: [PATCH] fix(engine): evaluate 'attacks alone' relative to the attacker, not the ability source Closes #5328. TriggerCondition::MinCoAttackers counted co-attackers by excluding the ability SOURCE (source_id) from the tally. 'Attacks alone' is encoded as Not(MinCoAttackers{minimum:1}); when a non-attacking observer (Exalted / Agent 13, Sharon Carter) stays back and a DIFFERENT creature attacks alone, source_id is not in combat, so excluding it is a no-op. The lone attacker stays counted, MinCoAttackers returns true, and Not(...) makes the 'attacks alone' ability silently fail to fire. Exclude the creature that fired the trigger instead: read the single attacker named by the per-attacker AttackersDeclared event (produced by matching_attack_events and re-checked at resolution, CR 603.4), falling back to source_id when the event names no single attacker (self-referential paths, unchanged). source_id still resolves the filter's source object so Training's power-relative co-attacker comparison (CR 702.149a) is unaffected. CR 506.5 (attacks alone = only creature declared) + CR 508.1a + CR 603.2c + CR 702.83b (Exalted). Adds tests/attacks_alone_observer_5328.rs pinning both the fire-when-another-attacks-alone case (was 0 clues, now 1) and the two-attackers negative control. --- crates/engine/src/game/triggers.rs | 30 ++++- .../tests/attacks_alone_observer_5328.rs | 114 ++++++++++++++++++ 2 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 crates/engine/tests/attacks_alone_observer_5328.rs diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index be6b8176aa..6f3d8271be 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -6099,18 +6099,36 @@ pub(crate) fn check_trigger_condition( TriggerCondition::EchoDue => source_id .and_then(|id| state.objects.get(&id)) .is_some_and(|obj| obj.echo_due), - // CR 508.1a + CR 603.2c: Count co-attackers excluding the source creature. - // CR 702.149a: when `filter` is present, only co-attackers matching it - // count (e.g. Training's "another creature with power greater than this - // creature's power"); the filter is resolved with the source creature as - // its source object so power-relative comparisons read the source. + // CR 506.5 + CR 508.1a + CR 603.2c: Count co-attackers excluding the + // creature that fired this trigger instance. "Attacks alone" (CR 702.83b, + // Exalted) and "attacks with another" (CR 702.149a, Training) are both + // evaluated relative to the ATTACKING creature named by the trigger + // event, not the ability's source: an observer (Exalted / Agent 13, + // Sharon Carter) that stays back while another creature attacks alone is + // not itself in combat, so excluding `source_id` would leave the lone + // attacker counted as a co-attacker and wrongly report co-attackers >= 1. + // The per-attacker `AttackersDeclared` event (produced by + // `matching_attack_events` and re-checked at resolution, CR 603.4) names + // exactly the triggering attacker. `source_id` still resolves the + // filter's source object so Training's power-relative comparison reads + // the source creature. Falls back to `source_id` when the event does not + // name a single attacker (e.g. a self-referential trigger where the + // source is itself the attacker). TriggerCondition::MinCoAttackers { minimum, filter } => { + let triggering_attacker = match trigger_event { + Some(GameEvent::AttackersDeclared { attacker_ids, .. }) + if attacker_ids.len() == 1 => + { + Some(attacker_ids[0]) + } + _ => source_id, + }; state.combat.as_ref().is_some_and(|combat| { let co_attacker_count = combat .attackers .iter() .filter(|a| { - a.object_id != source_id.unwrap_or(ObjectId(0)) + Some(a.object_id) != triggering_attacker && state .objects .get(&a.object_id) diff --git a/crates/engine/tests/attacks_alone_observer_5328.rs b/crates/engine/tests/attacks_alone_observer_5328.rs new file mode 100644 index 0000000000..29540eb58e --- /dev/null +++ b/crates/engine/tests/attacks_alone_observer_5328.rs @@ -0,0 +1,114 @@ +//! Issue #5328: the "attacks alone" mechanic must be evaluated relative to the +//! creature that attacked, not the ability's source. When the observer (here +//! "Agent 13, Sharon Carter") stays back and a DIFFERENT creature you control +//! attacks alone, the trigger must still fire; when two creatures attack, it +//! must not. +//! +//! https://github.com/phase-rs/phase/issues/5328 +//! +//! Root cause: `TriggerCondition::MinCoAttackers` excluded the ability SOURCE +//! from the co-attacker tally. For a non-attacking observer, `source_id` is not +//! in combat, so the lone attacker stayed counted and `Not(MinCoAttackers{1})` +//! evaluated false — the "attacks alone" ability silently failed to fire. +//! CR 506.5 + CR 702.83b: a creature attacks alone iff it is the only creature +//! declared as an attacker. + +use engine::game::combat::AttackTarget; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; + +const ORACLE: &str = "Whenever a creature you control attacks alone, investigate."; + +fn count_clues(runner: &GameRunner) -> usize { + runner + .state() + .battlefield + .iter() + .filter(|id| { + runner + .state() + .objects + .get(id) + .is_some_and(|obj| obj.name.eq_ignore_ascii_case("Clue")) + }) + .count() +} + +/// Drive DeclareAttackers → end of combat with `attackers` (all at P1), no +/// blockers, resolving any triggered abilities that land on the stack. +fn drive_combat(runner: &mut GameRunner, attackers: Vec) { + runner.pass_both_players(); + let attacks: Vec<_> = attackers + .iter() + .map(|&id| (id, AttackTarget::Player(P1))) + .collect(); + runner + .act(GameAction::DeclareAttackers { + attacks, + bands: vec![], + }) + .expect("declare attackers"); + for _ in 0..8 { + match &runner.state().waiting_for { + WaitingFor::Priority { .. } => { + if runner.state().stack.is_empty() + && !matches!(runner.state().phase, Phase::CombatDamage) + { + runner.pass_both_players(); + } else { + break; + } + } + WaitingFor::DeclareBlockers { .. } => { + runner + .act(GameAction::DeclareBlockers { + assignments: vec![], + }) + .expect("declare blockers"); + } + _ => break, + } + } +} + +/// Clues produced with the observer on the battlefield and `num_others` +/// non-observer attackers (the observer never attacks). +fn clues_when_others_attack(num_others: usize) -> usize { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // The observer stays home; only the "other" creatures attack. + scenario.add_creature_from_oracle(P0, "Agent 13, Sharon Carter", 2, 2, ORACLE); + let attackers: Vec = (0..num_others) + .map(|i| scenario.add_creature(P0, &format!("Bear{i}"), 2, 2).id()) + .collect(); + let mut runner = scenario.build(); + + drive_combat(&mut runner, attackers); + runner.advance_until_stack_empty(); + count_clues(&runner) +} + +#[test] +fn other_creature_attacks_alone_fires_even_when_observer_stays_back() { + // The bug: without the fix this is 0 — the lone attacker is wrongly counted + // as a co-attacker of the non-attacking observer, so the trigger never fires. + assert_eq!( + clues_when_others_attack(1), + 1, + "CR 506.5: a single non-observer attacker attacks alone → investigate once" + ); +} + +#[test] +fn two_other_creatures_attacking_do_not_fire_attacks_alone() { + // Positive control on the opposite side: two attackers is NOT attacking + // alone, so the ability must stay silent. + assert_eq!( + clues_when_others_attack(2), + 0, + "CR 506.5: two attackers is not 'attacks alone' → no investigate" + ); +}