Skip to content
Open
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
46 changes: 46 additions & 0 deletions crates/engine/src/game/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20860,6 +20860,52 @@ pub mod tests {
Some(source),
None,
));

// CR 120.1 multi-authority: a SECOND source also damaged the same dying
// creature this turn. The trigger source's own record must still be found
// among multiple records — this is the `source_id`-identity binding, not
// mere presence of any damage record on the dying creature.
let other_source = ObjectId(30);
state.damage_dealt_this_turn.push_back(DamageRecord {
source_id: other_source,
source_controller: PlayerId(1),
target: TargetRef::Object(dying_creature),
target_controller: PlayerId(0),
amount: 2,
is_combat: true,
..Default::default()
});
assert!(check_trigger_condition(
&state,
&condition,
PlayerId(0),
Some(source),
Some(&event),
));

// A different dying creature was damaged ONLY by the other source, never by
// the trigger source → false, even though a damage record for that dying
// creature exists. Distinguishes identity binding from bare presence.
let other_only_victim = ObjectId(77);
state.damage_dealt_this_turn.push_back(DamageRecord {
source_id: other_source,
source_controller: PlayerId(1),
target: TargetRef::Object(other_only_victim),
target_controller: PlayerId(0),
amount: 2,
is_combat: true,
..Default::default()
});
let other_only_event = GameEvent::CreatureDestroyed {
object_id: other_only_victim,
};
assert!(!check_trigger_condition(
&state,
&condition,
PlayerId(0),
Some(source),
Some(&other_only_event),
));
}

/// CR 701.26 + CR 603.4: `FirstTimeObjectTappedThisTurn` holds only when the
Expand Down
49 changes: 49 additions & 0 deletions crates/engine/src/parser/oracle_trigger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5566,6 +5566,26 @@ fn extract_if_condition_with_card_name(
}
}

// CR 603.4 + CR 700.4 + CR 120.1: dies-trigger "if ... dealt damage to it
// this turn" intervening-if (Hawkeye, Avenging Archer) — the intervening-if
// sibling of the event-embedded "a creature dealt damage by ~ this turn
// dies" / "another creature dealt damage this turn by [filter] dies" forms.
// Gated on a PROVEN dies head: the resolver reads the dying creature from
// the death event, so on any other head the clause must stay honestly
// swallowed (a `Condition_If` diagnostic) rather than mis-parse.
if trigger_zone_change == Some((Zone::Battlefield, Zone::Graveyard)) {
if let Some((before, condition, rest)) =
scan_preceded(&lower, parse_dealt_damage_to_it_intervening_if)
{
let pos = before.len();
let clause_len = lower.len() - before.len() - rest.len();
return (
strip_condition_clause(text, pos, clause_len),
Some(condition),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}

if let Some(result) = try_extract_zone_change_object_filter_condition(
&lower,
text,
Expand Down Expand Up @@ -5707,6 +5727,35 @@ fn extract_if_condition_with_card_name(
(text.to_string(), None)
}

/// CR 603.4 + CR 700.4 + CR 120.1: dies-trigger intervening-if
/// "if [~ | this creature | <damage-history source>] dealt damage to it this
/// turn" (Hawkeye, Avenging Archer).
///
/// This is the intervening-if grammatical sibling of the event-embedded forms
/// already parsed in `try_parse_special_trigger_pattern` — "a creature dealt
/// damage by ~ this turn dies" (self source) and "another creature dealt damage
/// this turn by [filter] dies" (filter source). The shared
/// `parse_damage_history_source` helper recognizes every source phrase (`~`,
/// `this creature`, typed filters); the `SelfRef` it returns for `~`/`this
/// creature` is normalized to the canonical `DealtDamageBySourceThisTurn`, and
/// any other source lowers to `DealtDamageThisTurnBySource { source }`.
///
/// "it" is the dying event object; the resolver (`game/triggers.rs`) reads it
/// from the `CreatureDestroyed`/`ZoneChanged` death event, so callers MUST gate
/// this on a proven dies head (battlefield → graveyard). On any other head the
/// clause is left honestly swallowed (a `Condition_If` diagnostic).
fn parse_dealt_damage_to_it_intervening_if(input: &str) -> OracleResult<'_, TriggerCondition> {
let (rest, _) = tag("if ").parse(input)?;
let (rest, source) = super::oracle_replacement::parse_damage_history_source(rest)
.ok_or_else(|| oracle_err(input))?;
let condition = match source {
TargetFilter::SelfRef => TriggerCondition::DealtDamageBySourceThisTurn,
other => TriggerCondition::DealtDamageThisTurnBySource { source: other },
};
let (rest, _) = tag(" dealt damage to it this turn").parse(rest)?;
Ok((rest, condition))
}

fn try_extract_zone_change_object_filter_condition(
lower: &str,
text: &str,
Expand Down
90 changes: 90 additions & 0 deletions crates/engine/src/parser/oracle_trigger_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18499,6 +18499,96 @@ fn trigger_another_creature_damaged_by_spider_you_controlled_dies() {
);
}

#[test]
fn trigger_dies_if_source_dealt_damage_intervening_if() {
// CR 603.4 + CR 700.4 + CR 120.1: Hawkeye, Avenging Archer — the dies-trigger
// intervening-if "if ~ dealt damage to it this turn" must hoist to the
// trigger-level `DealtDamageBySourceThisTurn` condition. Dropping the arm
// leaves `condition == None` (the audit-flagged DroppedCondition) and the
// clause is silently swallowed. The `Draw` execute assertion is the
// reach-guard: it proves the clause was STRIPPED (leaving "draw a card"),
// not that the whole line simply failed to parse.
let def = parse_trigger_line(
"Whenever a creature an opponent controls dies, if Hawkeye dealt damage to it this turn, draw a card.",
"Hawkeye, Avenging Archer",
);
assert_eq!(def.mode, TriggerMode::ChangesZone);
assert_eq!(def.origin, Some(Zone::Battlefield));
assert_eq!(def.destination, Some(Zone::Graveyard));
assert!(
matches!(
&def.valid_card,
Some(TargetFilter::Typed(tf)) if tf.controller == Some(ControllerRef::Opponent)
),
"trigger head must remain 'a creature an opponent controls dies': {:?}",
def.valid_card
);
assert_eq!(
def.condition,
Some(TriggerCondition::DealtDamageBySourceThisTurn)
);
assert!(matches!(
def.execute.as_deref().map(|a| a.effect.as_ref()),
Some(Effect::Draw { .. })
));
}

#[test]
fn trigger_dies_if_filter_source_dealt_damage_intervening_if() {
// CR 603.4 + CR 700.4 + CR 120.1 + CR 608.2i: the filter-source sibling of the
// Hawkeye self-source intervening-if. "if a [filter] dealt damage to it this
// turn" lowers to `DealtDamageThisTurnBySource { source }`, reusing the shared
// `parse_damage_history_source` helper (the same one the event-embedded Shelob
// form uses). The `Draw` execute assertion is the reach-guard.
let def = parse_trigger_line(
"Whenever a creature an opponent controls dies, if a Warrior you controlled dealt damage to it this turn, draw a card.",
"Test Card",
);
assert_eq!(def.mode, TriggerMode::ChangesZone);
assert_eq!(def.origin, Some(Zone::Battlefield));
assert_eq!(def.destination, Some(Zone::Graveyard));
assert_eq!(
def.condition,
Some(TriggerCondition::DealtDamageThisTurnBySource {
source: TargetFilter::Typed(
TypedFilter::default()
.subtype("Warrior".to_string())
.controller(ControllerRef::You)
)
})
);
assert!(matches!(
def.execute.as_deref().map(|a| a.effect.as_ref()),
Some(Effect::Draw { .. })
));
}

#[test]
fn trigger_non_dies_head_does_not_capture_dealt_damage_if() {
// CR 603.4 + CR 700.4: the dies-shape gate. The resolver reads the dying
// creature from the death event, so the "if ... dealt damage to it this turn"
// arm must fire ONLY on a proven battlefield->graveyard head. On a non-dies
// (enters) head the clause must stay honestly unrepresented (condition None,
// left to swallow as a Condition_If diagnostic) rather than mis-parse. Paired
// with `trigger_dies_if_source_dealt_damage_intervening_if` above — same
// clause, dies head -> condition Some — so this negative is non-vacuous: it
// proves the GATE blocks the hoist, not that the phrase is unparseable. The
// `Draw` execute assertion is the reach-guard proving the clause reached the
// extract path (draw still parsed from the residual, as it did pre-fix).
let def = parse_trigger_line(
"When Test Card enters the battlefield, if Test Card dealt damage to it this turn, draw a card.",
"Test Card",
);
assert_eq!(
def.condition, None,
"the dies-shape gate must not hoist the clause on a non-dies (enters) head"
);
assert!(matches!(
def.execute.as_deref().map(|a| a.effect.as_ref()),
Some(Effect::Draw { .. })
));
}

#[test]
fn trigger_you_dealt_damage() {
// CR 120.1: "whenever you're dealt damage" — player damage received.
Expand Down
30 changes: 30 additions & 0 deletions crates/engine/src/parser/swallow_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4842,6 +4842,36 @@ mod tests {
);
}

/// CR 603.4 + CR 700.4 + CR 120.1: Hawkeye, Avenging Archer's death-trigger
/// intervening-if "if Hawkeye dealt damage to it this turn" is now hoisted to
/// a `TriggerCondition::DealtDamageBySourceThisTurn`. Detector G (Condition_If)
/// clears because the trigger's `condition` slot is populated
/// (`has_slot("condition")`), and Detector J (Duration_ThisTurn) clears via
/// the damage-history whitelist. Both fired before the parser arm existed —
/// the audit-flagged DroppedCondition — so reverting the arm re-surfaces both.
#[test]
fn hawkeye_dealt_damage_intervening_if_not_swallowed() {
let parsed = parse_named(
"Reach\nWhenever a creature an opponent controls dies, if Hawkeye dealt \
damage to it this turn, draw a card.\n{T}: Hawkeye deals 1 damage to any \
target.",
"Hawkeye, Avenging Archer",
&["Legendary", "Creature"],
);
assert!(
!has_swallowed_detector(&parsed, "Condition_If"),
"Hawkeye's hoisted intervening-if must not surface as a swallowed \
Condition_If: {:?}",
parsed.parse_warnings
);
assert!(
!has_swallowed_detector(&parsed, "Duration_ThisTurn"),
"Hawkeye's hoisted 'this turn' clause must not surface as a swallowed \
Duration_ThisTurn: {:?}",
parsed.parse_warnings
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

fn find_search_outside_game(def: &AbilityDefinition) -> Option<&Effect> {
if matches!(&*def.effect, Effect::SearchOutsideGame { .. }) {
return Some(&def.effect);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//! Hawkeye, Avenging Archer — death-trigger intervening-if runtime gate.
//!
//! CR 603.4 + CR 700.4 + CR 120.1: "Whenever a creature an opponent controls
//! dies, if Hawkeye dealt damage to it this turn, draw a card." The controller
//! draws ONLY when Hawkeye dealt damage to the dying opponent creature this
//! turn. Before the parser arm existed the intervening-if was dropped
//! (`condition == None`), so the trigger drew unconditionally on any opponent
//! creature death — the audit-flagged DroppedCondition.
//!
//! These two tests share an identical death setup; the only difference is
//! whether a Hawkeye damage record exists this turn. The positive test is the
//! reach-guard proving the trigger fires and draws for this exact opponent-death
//! shape, which makes the negative test non-vacuous: it isolates the condition
//! gate. Reverting the parser fix makes the negative test draw a card and fail.

use engine::game::scenario::{GameRunner, GameScenario, P0, P1};
use engine::types::ability::TargetRef;
use engine::types::actions::GameAction;
use engine::types::game_state::{DamageRecord, WaitingFor};
use engine::types::identifiers::ObjectId;
use engine::types::phase::Phase;

const HAWKEYE_ORACLE: &str = "Reach\nWhenever a creature an opponent controls \
dies, if Hawkeye dealt damage to it this turn, draw a card.\n{T}: Hawkeye \
deals 1 damage to any target.";

fn drain_stack(runner: &mut GameRunner) {
for _ in 0..200 {
if matches!(runner.state().waiting_for, WaitingFor::OrderTriggers { .. }) {
engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut());
continue;
}
match &runner.state().waiting_for {
WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break,
_ => {
if runner.act(GameAction::PassPriority).is_err() {
break;
}
}
}
}
}

/// Kill `victim` via lethal marked damage + SBA, then process any triggers the
/// death produced and resolve the resulting stack.
fn kill_via_sba(runner: &mut GameRunner, victim: ObjectId) {
runner
.state_mut()
.objects
.get_mut(&victim)
.unwrap()
.damage_marked = 1;
let mut sba_events = Vec::new();
engine::game::sba::check_state_based_actions(runner.state_mut(), &mut sba_events);
engine::game::triggers::process_triggers(runner.state_mut(), &sba_events);
drain_stack(runner);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

#[test]
fn hawkeye_draws_when_it_damaged_the_dying_opponent_creature() {
let mut scenario = GameScenario::new();
scenario.at_phase(Phase::PreCombatMain);
scenario.with_library_top(P0, &["Draw Fodder"]);
let hawkeye = scenario
.add_creature_from_oracle(P0, "Hawkeye, Avenging Archer", 3, 3, HAWKEYE_ORACLE)
.id();
let victim = scenario.add_creature(P1, "Damaged Victim", 1, 1).id();

let mut runner = scenario.build();
let hand_before = runner.state().players[0].hand.len();

// Hawkeye dealt 1 damage to the victim this turn (records the same
// `DamageRecord` the deal-damage resolver would).
runner
.state_mut()
.damage_dealt_this_turn
.push_back(DamageRecord {
source_id: hawkeye,
source_controller: P0,
target: TargetRef::Object(victim),
target_controller: P1,
amount: 1,
is_combat: false,
..Default::default()
});

kill_via_sba(&mut runner, victim);

assert_eq!(
runner.state().players[0].hand.len(),
hand_before + 1,
"Hawkeye's controller must draw when Hawkeye dealt damage to the dying \
opponent creature this turn"
);
}

#[test]
fn hawkeye_does_not_draw_when_it_did_not_damage_the_dying_opponent_creature() {
let mut scenario = GameScenario::new();
scenario.at_phase(Phase::PreCombatMain);
scenario.with_library_top(P0, &["Draw Fodder"]);
scenario
.add_creature_from_oracle(P0, "Hawkeye, Avenging Archer", 3, 3, HAWKEYE_ORACLE)
.id();
let victim = scenario.add_creature(P1, "Unharmed Victim", 1, 1).id();

let mut runner = scenario.build();
let hand_before = runner.state().players[0].hand.len();

// No Hawkeye damage record this turn — the intervening-if (CR 603.4) must be
// false, so the trigger never draws. With the fix reverted the condition is
// dropped and this death draws a card unconditionally, failing the assert.
kill_via_sba(&mut runner, victim);

assert_eq!(
runner.state().players[0].hand.len(),
hand_before,
"Hawkeye's controller must NOT draw when Hawkeye never damaged the dying \
opponent creature this turn"
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ mod griffin_rider_conditional_self_buff;
mod hag_noxious_nightmares_menace_grant;
mod halana_alena_partners_where_x;
mod harrow_regression;
mod hawkeye_avenging_archer_dealt_damage_draw;
mod heist_production_path_handoff;
mod hellkite_tyrant_steal_artifacts_2906;
mod heroic_defiance_recipient_color_4590;
Expand Down
Loading