From 4d1c01cb2019535910fa17214e4e436781e1d5a9 Mon Sep 17 00:00:00 2001 From: Jacob Woodson Date: Mon, 3 Aug 2026 23:56:21 -0500 Subject: [PATCH 01/14] Partial: Hawkeye, Avenging Archer --- crates/engine/src/game/triggers.rs | 46 +++++++ crates/engine/src/parser/oracle_trigger.rs | 49 +++++++ .../engine/src/parser/oracle_trigger_tests.rs | 90 +++++++++++++ crates/engine/src/parser/swallow_check.rs | 30 +++++ ...wkeye_avenging_archer_dealt_damage_draw.rs | 121 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 6 files changed, 337 insertions(+) create mode 100644 crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index e5a2a52461..e5fd875384 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -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 diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index b8f6a3ecf9..9f744ff21e 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -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), + ); + } + } + if let Some(result) = try_extract_zone_change_object_filter_condition( &lower, text, @@ -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 | ] 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, diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 621ec80729..9f26f5b492 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -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. diff --git a/crates/engine/src/parser/swallow_check.rs b/crates/engine/src/parser/swallow_check.rs index 4871de316b..6732d7e1d3 100644 --- a/crates/engine/src/parser/swallow_check.rs +++ b/crates/engine/src/parser/swallow_check.rs @@ -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 + ); + } + fn find_search_outside_game(def: &AbilityDefinition) -> Option<&Effect> { if matches!(&*def.effect, Effect::SearchOutsideGame { .. }) { return Some(&def.effect); diff --git a/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs b/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs new file mode 100644 index 0000000000..12107a8c1c --- /dev/null +++ b/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs @@ -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); +} + +#[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" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 38a7059789..b3d1021f92 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -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; From 5ce359fabf651f3cdaa67c4100ef2a1e6f12d451 Mon Sep 17 00:00:00 2001 From: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:49:38 -0500 Subject: [PATCH 02/14] fix(engine): source-incarnation identity + leading-position gate for Hawkeye dies-trigger Addresses the four review blockers on #6976 (Hawkeye, Avenging Archer): - CR 400.7: DamageRecord now snapshots source_incarnation, and the DealtDamageBySourceThisTurn match compares it (new damage_record_source_incarnation_matches) so a re-entered source -- same ObjectId, bumped incarnation, a new object per CR 400.7 -- no longer inherits a prior incarnation's damage. Captured in the production deal-damage resolver alongside target_incarnation. Adds a post-zone-change regression. - CR 603.4: restrict the "if ~ dealt damage to it this turn" hoist to the leading effect position (before blank); a trailing resolution-time if ("draw a card if ~ dealt damage to it this turn") stays in the effect chain rather than being promoted to an intervening-if. Adds a trailing-if regression. - The Hawkeye integration test now drives the real {T} activation through the production pipeline (activate -> target -> deal damage -> SBA death -> dies trigger -> draw) instead of hand-injecting a DamageRecord. - The swallow-check test asserts the typed DealtDamageBySourceThisTurn condition and Draw effect before the negative diagnostic checks, and adds a paired trailing-resolution-time case. Verification: cargo fmt; phase-engine lib + integration Hawkeye tests green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 --- crates/engine/src/game/effects/deal_damage.rs | 7 + crates/engine/src/game/triggers.rs | 90 +++++++++++ crates/engine/src/parser/oracle_trigger.rs | 22 ++- .../engine/src/parser/oracle_trigger_tests.rs | 29 ++++ crates/engine/src/parser/swallow_check.rs | 58 ++++++- crates/engine/src/types/game_state.rs | 10 ++ ...wkeye_avenging_archer_dealt_damage_draw.rs | 143 ++++++++++-------- 7 files changed, 290 insertions(+), 69 deletions(-) diff --git a/crates/engine/src/game/effects/deal_damage.rs b/crates/engine/src/game/effects/deal_damage.rs index 34a9645b07..45754d65af 100644 --- a/crates/engine/src/game/effects/deal_damage.rs +++ b/crates/engine/src/game/effects/deal_damage.rs @@ -734,12 +734,19 @@ pub(crate) fn apply_damage_after_replacement( // source as it was when the damage was dealt — the source may later // change type, leave the battlefield (CR 113.7a LKI), or be removed. let src = state.objects.get(&ctx.source_id); + // CR 400.7: Snapshot the source's incarnation at damage time so an + // exact-source look-back (`DealtDamageBySourceThisTurn`) does not credit + // a re-entered permanent (same ObjectId, bumped incarnation — a new + // object) with damage its prior incarnation dealt. `None` when the + // source is already gone (CR 113.7a): no live incarnation to snapshot. + let source_incarnation = src.map(|object| object.incarnation); let mut record = DamageRecord { source_id: ctx.source_id, source_controller: ctx.controller, target: t.clone(), target_controller, target_incarnation, + source_incarnation, // CR 120.4a: the permanent was dealt only the lethal portion; the // excess is recorded against the controller by the redirect below. amount: primary_amount, diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index e5fd875384..eb3902c603 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -9533,8 +9533,11 @@ fn evaluate_trigger_condition_with_source( _ => None, }); match (source_id, dying_creature) { + // CR 400.7: match the source by ObjectId AND incarnation — a + // re-entered source is a new object that dealt no prior damage. (Some(src), Some(subj)) => state.damage_dealt_this_turn.iter().any(|r| { r.source_id == src + && damage_record_source_incarnation_matches(state, r, src) && damage_record_matches_dying_object(state, r, subj, trigger_event) }), _ => false, @@ -10346,6 +10349,31 @@ pub(crate) fn check_trigger_condition( /// not a later object reusing the same storage id. Death bumps the live /// incarnation, and the card can move again before an intervening-if recheck, /// so subtract the death move and every subsequent move of that object. +/// CR 400.7: True when `record`'s source is the SAME incarnation of `source_id` +/// that is asking now. A permanent that dealt damage, left, and re-entered keeps +/// its `ObjectId` but bumps its incarnation, so the current object is a NEW +/// object (CR 400.7) that did not deal the recorded damage; exact-source +/// look-backs must reject it. `None` recorded incarnation is lenient (legacy +/// records / fixtures). A recorded incarnation with no live source object is +/// rejected: the asking incarnation cannot be confirmed identical. Unlike the +/// dying-object helper this needs no later-move arithmetic — the source of a +/// dies-trigger intervening-if (e.g. Hawkeye) is a bystander still on the +/// battlefield, not the object mid-zone-change. +fn damage_record_source_incarnation_matches( + state: &GameState, + record: &DamageRecord, + source_id: ObjectId, +) -> bool { + let Some(recorded_incarnation) = record.source_incarnation else { + return true; + }; + state + .objects + .get(&source_id) + .map(|object| object.incarnation) + == Some(recorded_incarnation) +} + fn damage_record_matches_dying_object( state: &GameState, record: &DamageRecord, @@ -20908,6 +20936,68 @@ pub mod tests { )); } + /// CR 400.7: a source that dealt damage, left, and re-entered keeps its + /// `ObjectId` but bumps its incarnation, so it is a NEW object that dealt no + /// prior damage. `DealtDamageBySourceThisTurn` must therefore compare the + /// snapshotted source incarnation, not merely the `ObjectId` — otherwise the + /// re-entered permanent spuriously satisfies the intervening-if and, for + /// Hawkeye, draws a card for damage a distinct prior incarnation dealt. + #[test] + fn test_dealt_damage_by_source_rejects_reentered_incarnation() { + use crate::types::game_state::DamageRecord; + + let mut state = setup(); + let source = ObjectId(10); + let dying_creature = ObjectId(20); + state.objects.insert( + source, + GameObject::new( + source, + CardId(1), + PlayerId(0), + "Damage source".to_string(), + Zone::Battlefield, + ), + ); + // The source's incarnation WHEN it dealt the damage. + let damage_time_incarnation = state.objects.get(&source).unwrap().incarnation; + + // Record damage attributed to that exact incarnation. + state.damage_dealt_this_turn.push_back(DamageRecord { + source_id: source, + source_controller: PlayerId(0), + target: TargetRef::Object(dying_creature), + target_controller: PlayerId(0), + source_incarnation: Some(damage_time_incarnation), + amount: 3, + is_combat: false, + ..Default::default() + }); + + let condition = TriggerCondition::DealtDamageBySourceThisTurn; + let event = GameEvent::CreatureDestroyed { + object_id: dying_creature, + }; + + // Same incarnation still on the battlefield → the record is its own → true. + assert!(check_trigger_condition( + &state, + &condition, + PlayerId(0), + Some(source), + Some(&event), + )); + + // CR 400.7: the source leaves and re-enters (same ObjectId, bumped + // incarnation). The stale record must no longer credit the new object. + state.objects.get_mut(&source).unwrap().bump_incarnation(); + assert!( + !check_trigger_condition(&state, &condition, PlayerId(0), Some(source), Some(&event),), + "a re-entered source (bumped incarnation) must not match a prior \ + incarnation's damage record (CR 400.7)" + ); + } + /// CR 701.26 + CR 603.4: `FirstTimeObjectTappedThisTurn` holds only when the /// tapped object (carried by the `PermanentTapped` event) has become tapped /// exactly once this turn (ledger == 1). A second tap (== 2) fails the diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 9f744ff21e..887624144e 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -5573,16 +5573,26 @@ fn extract_if_condition_with_card_name( // 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. + // + // CR 603.4: an intervening-if IMMEDIATELY follows the trigger condition, so + // the clause must be in the LEADING effect position. A trailing form + // ("draw a card if ~ dealt damage to it this turn") is a resolution-time + // conditional, not an intervening-if, and must stay in the effect chain to + // be evaluated on resolution — never hoisted to `condition`. Requiring the + // scan's `before` to be blank enforces the leading position (the earlier + // "then if" / sentence-boundary guards above only reject cross-clause ifs). 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), - ); + if before.trim().is_empty() { + let pos = before.len(); + let clause_len = lower.len() - before.len() - rest.len(); + return ( + strip_condition_clause(text, pos, clause_len), + Some(condition), + ); + } } } diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 9f26f5b492..fc74b1457b 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -18589,6 +18589,35 @@ fn trigger_non_dies_head_does_not_capture_dealt_damage_if() { )); } +#[test] +fn trigger_dies_trailing_if_dealt_damage_stays_resolution_time() { + // CR 603.4: an intervening-if IMMEDIATELY follows the trigger condition. The + // TRAILING form "draw a card if ~ dealt damage to it this turn" is a + // resolution-time conditional, NOT an intervening-if, so it must stay in the + // effect chain (condition None) rather than be hoisted to the trigger-level + // `condition`. Paired with `trigger_dies_if_source_dealt_damage_intervening_if` + // (same clause + same dies head in LEADING position -> condition Some), so + // this negative is non-vacuous: it proves the leading-position guard blocks + // the hoist for the trailing form, on the very head where the leading form + // DOES hoist. The `Draw` execute assertion is the reach-guard proving the + // clause reached the extract path with the draw preserved. + let def = parse_trigger_line( + "Whenever a creature an opponent controls dies, draw a card if Hawkeye dealt damage to it this turn.", + "Hawkeye, Avenging Archer", + ); + assert_eq!(def.mode, TriggerMode::ChangesZone); + assert_eq!(def.origin, Some(Zone::Battlefield)); + assert_eq!(def.destination, Some(Zone::Graveyard)); + assert_eq!( + def.condition, None, + "a trailing resolution-time `if` must NOT be hoisted to an intervening-if (CR 603.4)" + ); + 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. diff --git a/crates/engine/src/parser/swallow_check.rs b/crates/engine/src/parser/swallow_check.rs index 6732d7e1d3..4e345d3ddf 100644 --- a/crates/engine/src/parser/swallow_check.rs +++ b/crates/engine/src/parser/swallow_check.rs @@ -4558,12 +4558,13 @@ mod tests { use crate::parser::oracle_ir::diagnostic::OracleDiagnostic; use crate::types::ability::{ AbilityDefinition, AbilityKind, DamageModification, Effect, OutsideGameSourcePool, - QuantityExpr, TargetFilter, + QuantityExpr, TargetFilter, TriggerCondition, }; use crate::types::identifiers::TrackedSetId; use crate::types::keywords::Keyword; use crate::types::mana::ManaCost; use crate::types::statics::StaticMode; + use crate::types::triggers::TriggerMode; use crate::types::zones::Zone; fn parse(text: &str, types: &[&str]) -> crate::parser::oracle::ParsedAbilities { @@ -4858,6 +4859,31 @@ mod tests { "Hawkeye, Avenging Archer", &["Legendary", "Creature"], ); + // Positive reach-guard: the negative diagnostic assertions below are only + // meaningful if the typed carrier is actually present. Assert the dies + // trigger carries `DealtDamageBySourceThisTurn` AND a `Draw` effect FIRST, + // so a broad suppression or an unrelated carrier that merely silences the + // detectors cannot make this test pass while the Hawkeye condition is + // absent or misclassified. + let dies_trigger = parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::ChangesZone) + .expect("Hawkeye's dies trigger must parse"); + assert_eq!( + dies_trigger.condition, + Some(TriggerCondition::DealtDamageBySourceThisTurn), + "the dies trigger must carry the hoisted intervening-if condition: {:?}", + dies_trigger.condition + ); + assert!( + matches!( + dies_trigger.execute.as_deref().map(|a| a.effect.as_ref()), + Some(Effect::Draw { .. }) + ), + "the dies trigger must retain its `draw a card` effect after the clause is stripped: {:?}", + dies_trigger.execute + ); assert!( !has_swallowed_detector(&parsed, "Condition_If"), "Hawkeye's hoisted intervening-if must not surface as a swallowed \ @@ -4872,6 +4898,36 @@ mod tests { ); } + /// CR 603.4: the paired trailing-resolution-time case. When the same + /// "if ~ dealt damage to it this turn" clause appears in TRAILING position + /// ("draw a card if …") it is a resolution-time conditional, not an + /// intervening-if, so it must NOT be hoisted to the trigger `condition` + /// (leading-position guard in `extract_if_condition_with_card_name`). This + /// pairs with `hawkeye_dealt_damage_intervening_if_not_swallowed` above — + /// same clause, LEADING position -> condition Some — so the `None` assertion + /// here is non-vacuous: it proves the position guard, not that the phrase is + /// unparseable. + #[test] + fn hawkeye_trailing_dealt_damage_if_not_hoisted() { + let parsed = parse_named( + "Reach\nWhenever a creature an opponent controls dies, draw a card if \ + Hawkeye dealt damage to it this turn.\n{T}: Hawkeye deals 1 damage to \ + any target.", + "Hawkeye, Avenging Archer", + &["Legendary", "Creature"], + ); + let dies_trigger = parsed + .triggers + .iter() + .find(|t| t.mode == TriggerMode::ChangesZone) + .expect("the dies trigger must parse"); + assert_eq!( + dies_trigger.condition, None, + "a trailing resolution-time `if` must not be hoisted to an intervening-if (CR 603.4): {:?}", + dies_trigger.condition + ); + } + fn find_search_outside_game(def: &AbilityDefinition) -> Option<&Effect> { if matches!(&*def.effect, Effect::SearchOutsideGame { .. }) { return Some(&def.effect); diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index d3543664b6..2796818284 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -1862,6 +1862,15 @@ pub struct DamageRecord { /// `None` preserves compatibility with legacy records and player targets. #[serde(default, skip_serializing_if = "Option::is_none")] pub target_incarnation: Option, + /// CR 400.7: Incarnation of the source object when it dealt this damage. A + /// permanent that leaves and re-enters keeps its `ObjectId` but bumps its + /// incarnation, becoming a NEW object that did not deal earlier damage. + /// Exact-source look-backs (`DealtDamageBySourceThisTurn`) must therefore + /// compare the source's incarnation, not just its `ObjectId`. `None` + /// preserves compatibility with legacy records and with the CR 113.7a + /// source-already-gone case (no live incarnation to snapshot). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_incarnation: Option, pub amount: u32, #[serde(default)] pub is_combat: bool, @@ -1923,6 +1932,7 @@ impl Default for DamageRecord { target: TargetRef::Player(PlayerId(0)), target_controller: PlayerId(0), target_incarnation: None, + source_incarnation: None, amount: 0, is_combat: false, source_name: String::new(), diff --git a/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs b/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs index 12107a8c1c..48338680ed 100644 --- a/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs +++ b/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs @@ -7,16 +7,18 @@ //! (`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. +//! The positive test drives Hawkeye's REAL `{T}` activated ability through the +//! production pipeline (`runner.activate(..).target_object(..).resolve()`): the +//! deal-damage resolver records the `DamageRecord` (source id + incarnation), a +//! state-based action kills the 1/1 target, and the dies trigger's intervening-if +//! is evaluated against that genuine record. Nothing is hand-injected, so the +//! test exercises the exact failure path the fix prevents — a revert of the +//! parser arm makes the trigger draw unconditionally and the negative test fail. use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; -use engine::types::ability::TargetRef; +use engine::types::ability::Effect; use engine::types::actions::GameAction; -use engine::types::game_state::{DamageRecord, WaitingFor}; +use engine::types::game_state::WaitingFor; use engine::types::identifiers::ObjectId; use engine::types::phase::Phase; @@ -24,36 +26,22 @@ 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; - } - } - } - } +/// Index of Hawkeye's `{T}: deals 1 damage to any target` activated ability. +fn tap_damage_index(runner: &GameRunner, hawkeye: ObjectId) -> usize { + runner.state().objects[&hawkeye] + .abilities + .iter() + .position(|a| matches!(a.effect.as_ref(), Effect::DealDamage { .. })) + .expect("Hawkeye must carry a DealDamage ({T}) activated ability") } -/// 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); +/// Give `player` priority in their own pre-combat main so an activated ability +/// can be declared (mirrors the setup other activated-ability integration tests +/// use after `scenario.build()`). +fn hand_priority(runner: &mut GameRunner, player: engine::types::player::PlayerId) { + runner.state_mut().active_player = player; + runner.state_mut().priority_player = player; + runner.state_mut().waiting_for = WaitingFor::Priority { player }; } #[test] @@ -64,34 +52,27 @@ fn hawkeye_draws_when_it_damaged_the_dying_opponent_creature() { let hawkeye = scenario .add_creature_from_oracle(P0, "Hawkeye, Avenging Archer", 3, 3, HAWKEYE_ORACLE) .id(); + // A 1/1 so Hawkeye's own 1 damage is lethal — the same activation that + // records the damage also causes the death, driving the whole chain + // (deal damage → SBA death → dies trigger → intervening-if → draw). 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(); + hand_priority(&mut runner, P0); + let idx = tap_damage_index(&runner, hawkeye); - // 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() - }); + // CR 602.2 + CR 120.1: activate Hawkeye's {T}, target the opponent 1/1, and + // let the production pipeline deal the damage, run SBA, and resolve the + // resulting dies trigger. The deal-damage resolver writes the authoritative + // `DamageRecord`; the intervening-if reads it — no hand-injected record. + let outcome = runner + .activate(hawkeye, idx) + .target_object(victim) + .resolve(); - 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" - ); + // CR 603.4: the intervening-if is true (Hawkeye dealt damage to the dying + // creature this turn), so exactly one card is drawn on resolution. + outcome.assert_hand_drawn(P0, 1); } #[test] @@ -107,10 +88,14 @@ fn hawkeye_does_not_draw_when_it_did_not_damage_the_dying_opponent_creature() { 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); + // The victim dies WITHOUT Hawkeye ever dealing it damage — a + // Hawkeye-independent death (lethal marked damage + SBA), so there is + // deliberately no `DamageRecord` for the intervening-if to find. The absence + // of the record — not the death mechanism — is what the assertion isolates. + // CR 603.4: the intervening-if must be false, so the trigger never draws. + // With the parser fix reverted the condition is dropped and this death draws + // unconditionally, failing the assert. + kill_untouched_victim(&mut runner, victim); assert_eq!( runner.state().players[0].hand.len(), @@ -119,3 +104,37 @@ fn hawkeye_does_not_draw_when_it_did_not_damage_the_dying_opponent_creature() { opponent creature this turn" ); } + +/// Kill `victim` via lethal marked damage + SBA — a death Hawkeye had no part +/// in — then process the death's triggers and drain the resulting stack. Used +/// only by the negative test, where the point is that NO Hawkeye damage record +/// exists; the death itself is intentionally Hawkeye-independent. +fn kill_untouched_victim(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); +} + +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; + } + } + } + } +} From 0cc9ba017e132e208d6870c0490ddf492173571f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 23:42:03 -0700 Subject: [PATCH 03/14] fix(PR-6976): preserve damage source incarnation --- crates/engine/src/game/effects/deal_damage.rs | 58 +++++++++++++++++-- crates/engine/src/types/ability.rs | 4 ++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/effects/deal_damage.rs b/crates/engine/src/game/effects/deal_damage.rs index da851e5aa4..badd621f98 100644 --- a/crates/engine/src/game/effects/deal_damage.rs +++ b/crates/engine/src/game/effects/deal_damage.rs @@ -28,6 +28,10 @@ use crate::types::proposed_event::ProposedEvent; #[derive(Clone, Copy)] pub(crate) struct DamageContext { pub(crate) source_id: ObjectId, + /// CR 400.7: The source incarnation observed before the damage event is + /// applied. This remains authoritative if the source changes zones while a + /// replacement effect pauses the event. + pub(crate) source_incarnation: Option, pub(crate) controller: PlayerId, pub(crate) source_is_creature: bool, pub(crate) has_deathtouch: bool, @@ -194,6 +198,7 @@ impl DamageContext { pub(crate) fn from_source(state: &GameState, source_id: ObjectId) -> Option { state.objects.get(&source_id).map(|obj| Self { source_id, + source_incarnation: Some(obj.incarnation), controller: obj.controller, source_is_creature: obj.card_types.core_types.contains(&CoreType::Creature), // CR 613.1f + CR 702.2 + CR 702.15 + CR 702.80 + CR 702.90: @@ -240,6 +245,7 @@ impl DamageContext { pub(crate) fn fallback(source_id: ObjectId, controller: PlayerId) -> Self { Self { source_id, + source_incarnation: None, controller, source_is_creature: false, has_deathtouch: false, @@ -258,6 +264,7 @@ impl From for DamageContext { fn from(snapshot: DamageContextSnapshot) -> Self { Self { source_id: snapshot.source_id, + source_incarnation: snapshot.source_incarnation, controller: snapshot.controller, source_is_creature: snapshot.source_is_creature, has_deathtouch: snapshot.has_deathtouch, @@ -278,6 +285,7 @@ impl From<&DamageContext> for DamageContextSnapshot { fn from(ctx: &DamageContext) -> Self { Self { source_id: ctx.source_id, + source_incarnation: ctx.source_incarnation, controller: ctx.controller, source_is_creature: ctx.source_is_creature, has_deathtouch: ctx.has_deathtouch, @@ -762,12 +770,10 @@ pub(crate) fn apply_damage_after_replacement( // source as it was when the damage was dealt — the source may later // change type, leave the battlefield (CR 113.7a LKI), or be removed. let src = state.objects.get(&ctx.source_id); - // CR 400.7: Snapshot the source's incarnation at damage time so an - // exact-source look-back (`DealtDamageBySourceThisTurn`) does not credit - // a re-entered permanent (same ObjectId, bumped incarnation — a new - // object) with damage its prior incarnation dealt. `None` when the - // source is already gone (CR 113.7a): no live incarnation to snapshot. - let source_incarnation = src.map(|object| object.incarnation); + // CR 400.7: Use the incarnation captured with the damage context, not + // a post-application live lookup. The latter can name a later object + // after a replacement pause or zone change. + let source_incarnation = ctx.source_incarnation; let mut record = DamageRecord { source_id: ctx.source_id, source_controller: ctx.controller, @@ -4046,6 +4052,46 @@ mod tests { ); } + #[test] + fn damage_record_keeps_the_context_source_incarnation() { + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let target = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Target".to_string(), + Zone::Battlefield, + ); + let ctx = DamageContext::from_source(&state, source).unwrap(); + let source_incarnation = ctx.source_incarnation; + + // CR 400.7: a replacement pause can resume after the source has become + // a new object. The already-created damage context remains authoritative. + state.objects.get_mut(&source).unwrap().incarnation += 1; + + let event = ProposedEvent::Damage { + source_id: source, + target: TargetRef::Object(target), + amount: 1, + is_combat: false, + applied: HashSet::new(), + }; + let mut events = Vec::new(); + apply_damage_after_replacement(&mut state, &ctx, event, false, &mut events); + + assert_eq!( + state.damage_dealt_this_turn[0].source_incarnation, source_incarnation, + "damage records must retain the pre-pause source incarnation" + ); + } + /// CR 202.3d + CR 702.102b: A damage record snapshots its source's mana value /// and colors so mana-value/color-gated "damage dealt by a source this turn" /// look-back filters (which reconstruct a synthetic source from the record) can diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 8462b068df..06dc6e47e1 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -10222,6 +10222,10 @@ pub enum ExcessRecipient { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct DamageContextSnapshot { pub source_id: ObjectId, + /// CR 400.7: Exact source identity captured when the damage context was + /// created. A replacement pause must not rebind a later incarnation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_incarnation: Option, pub controller: PlayerId, pub source_is_creature: bool, pub has_deathtouch: bool, From 9ae02170ae370c26fce3baea498318d9f0fe5d73 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 23:46:23 -0700 Subject: [PATCH 04/14] fix(PR-6976): retain trailing damage condition --- crates/engine/src/game/effects/mod.rs | 29 +++++++++++++++++++ crates/engine/src/game/triggers.rs | 2 +- .../src/parser/oracle_effect/conditions.rs | 15 ++++++++++ .../engine/src/parser/oracle_trigger_tests.rs | 14 ++++++--- crates/engine/src/types/ability.rs | 5 ++++ 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 23ec37deb2..579e2423a0 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -11505,6 +11505,35 @@ pub(crate) fn evaluate_condition( ability: &ResolvedAbility, ) -> bool { match condition { + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn => { + let Some(dying_object) = + state + .current_trigger_event + .as_ref() + .and_then(|event| match event { + GameEvent::CreatureDestroyed { object_id } + | GameEvent::ZoneChanged { object_id, .. } => Some(*object_id), + _ => None, + }) + else { + return false; + }; + let source_incarnation = ability + .trigger_source_incarnation() + .or(ability.source_incarnation); + state.damage_dealt_this_turn.iter().any(|record| { + record.source_id == ability.source_id + && record + .source_incarnation + .is_none_or(|recorded| source_incarnation == Some(recorded)) + && crate::game::triggers::damage_record_matches_dying_object( + state, + record, + dying_object, + state.current_trigger_event.as_ref(), + ) + }) + } // CR 702.33d + CR 702.33f + CR 608.2c: Parameterized additional-cost // gating. The default shape (`variant: None`, `min_count: 1`) reads the // legacy single-bool flag used by Gift / Buyback / Bargain / Evidence / diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index d3ae7ec8db..1927ef7145 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -10774,7 +10774,7 @@ fn damage_record_source_incarnation_matches( == Some(recorded_incarnation) } -fn damage_record_matches_dying_object( +pub(crate) fn damage_record_matches_dying_object( state: &GameState, record: &DamageRecord, object_id: ObjectId, diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index cf45a7aaee..58045eb067 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -3178,6 +3178,21 @@ pub(super) fn strip_suffix_conditional( }; let condition_text = lower[if_pos + " if ".len()..].trim_end_matches('.').trim(); + // CR 608.2c + CR 603.4: trailing "if ~ dealt damage to it this turn" is + // a resolution-time rider, not an intervening-if. The event target is + // carried by the resolving trigger entry. + if ctx.in_trigger + && all_consuming(tag::<_, _, OracleError<'_>>( + "~ dealt damage to it this turn", + )) + .parse(condition_text) + .is_ok() + { + return ( + Some(AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn), + text[..if_pos].trim().to_string(), + ); + } // CR 608.2d: "it has " is in NON_REHOMEABLE_CONDITION_PREFIXES, so this // source-referential mana-symbol eligibility check must be recognized BEFORE // the rehomeable bail or it would never run. effect_prefix/effect_text are diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index bccee658ed..4de7333957 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -18641,10 +18641,16 @@ fn trigger_dies_trailing_if_dealt_damage_stays_resolution_time() { def.condition, None, "a trailing resolution-time `if` must NOT be hoisted to an intervening-if (CR 603.4)" ); - assert!(matches!( - def.execute.as_deref().map(|a| a.effect.as_ref()), - Some(Effect::Draw { .. }) - )); + let execute = def + .execute + .as_deref() + .expect("trailing rider must parse an execute"); + assert!(matches!(execute.effect.as_ref(), Effect::Draw { .. })); + assert_eq!( + execute.condition, + Some(AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn), + "the trailing condition must stay on the resolving effect" + ); } #[test] diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 06dc6e47e1..b62ae45410 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -19667,6 +19667,11 @@ pub enum EffectOutcomeSignal { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AbilityCondition { + /// CR 608.2c + CR 400.7: Resolution-time rider on a dies trigger: the + /// triggering creature was dealt damage this turn by this exact source + /// incarnation. Unlike an intervening-if, this is checked only while the + /// effect resolves. + TriggerEventTargetDamagedBySourceThisTurn, /// CR 702.33d + CR 702.33f + CR 608.2c: An optional additional cost was paid /// during casting. Parameterized for kicker variant gating: /// From 2dea4bf8c3d7dcaa533e7b431bd84f4346d2b597 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 8 Aug 2026 23:56:12 -0700 Subject: [PATCH 05/14] fix(PR-6976): complete trigger damage condition integration --- crates/engine/src/game/ability_rw.rs | 6 +++++- crates/engine/src/game/ability_scan.rs | 5 +++++ crates/engine/src/game/coverage.rs | 6 ++++++ crates/engine/src/parser/oracle_effect/conditions.rs | 3 ++- crates/engine/src/parser/oracle_trigger.rs | 3 ++- crates/engine/src/types/ability.rs | 3 ++- 6 files changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 01fcf6e73f..b037b1dd3b 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -1886,7 +1886,8 @@ fn legacy_ability_condition(x: &AbilityCondition) -> bool { AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => { conditions.iter().any(legacy_ability_condition) } - AbilityCondition::ObjectsShareQuality { .. } + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn + | AbilityCondition::ObjectsShareQuality { .. } | AbilityCondition::TargetMatchesFilter { .. } | AbilityCondition::SourceMatchesFilter { .. } | AbilityCondition::PostReplacementDamageSourceMatchesFilter { .. } @@ -6001,6 +6002,9 @@ fn rw_quantity_ref(x: &QuantityRef) -> RwProfile { fn rw_ability_condition(x: &AbilityCondition) -> RwProfile { match x { + // CR 608.2c + CR 603.3b: the damage record is frozen at the trigger + // event; a sibling cannot alter whether this source dealt that damage. + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn => frozen_source_read(), AbilityCondition::QuantityCheck { lhs, rhs, diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 34d475a890..c9bd5c5d70 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -2566,6 +2566,11 @@ fn scan_quantity_expr(x: &QuantityExpr, mode: ScanMode) -> Axes { fn scan_ability_condition(x: &AbilityCondition, mode: ScanMode) -> Axes { match x { + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn => Axes { + event: true, + sibling: false, + projected: false, + }, AbilityCondition::AdditionalCostPaid { subject, .. } => { let mut acc = Axes::NONE; acc = acc.or(scan_object_scope(subject)); diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 8edbec0429..cd30248969 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -3864,6 +3864,9 @@ fn fmt_comparator(c: &Comparator) -> &'static str { /// Format an `AbilityCondition` as a human-readable string for the parse-details overlay. fn fmt_ability_condition(cond: &AbilityCondition) -> String { match cond { + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn => { + "trigger event target was damaged by source this turn".into() + } AbilityCondition::AdditionalCostPaid { .. } => "additional cost was paid".into(), AbilityCondition::AdditionalCostPaidInstead => "additional cost was paid (instead)".into(), AbilityCondition::AlternativeManaCostPaid => "alternative mana cost was paid".into(), @@ -7397,6 +7400,9 @@ fn condition_feature(cond: &AbilityCondition) -> (&'static str, FeatureSupport) match cond { // Handled by `evaluate_condition` / `resolve_ability_chain` // (crates/engine/src/game/effects/mod.rs). + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn => { + ("TriggerEventTargetDamagedBySourceThisTurn", Handled) + } AbilityCondition::AdditionalCostPaid { .. } => ("AdditionalCostPaid", Handled), AbilityCondition::AdditionalCostPaidInstead => ("AdditionalCostPaidInstead", Handled), AbilityCondition::AlternativeManaCostPaid => ("AlternativeManaCostPaid", Handled), diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index 58045eb067..84d2a9ad13 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -4922,7 +4922,8 @@ pub(crate) fn ability_condition_to_static_condition( // outcomes, reveals, resolved targets, zone-change events, player-scope // iteration); only meaningful inside `resolve_ability_chain`, never as // a continuous-effect gate. - AbilityCondition::EffectOutcome { .. } + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn + | AbilityCondition::EffectOutcome { .. } | AbilityCondition::EventOutcomeWon | AbilityCondition::CoinFlipOutcome { .. } | AbilityCondition::WhenYouDo diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index deea0e1126..dcffa3823f 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -517,7 +517,8 @@ fn rewrite_cost_x_in_condition(cond: &mut crate::types::ability::AbilityConditio AbilityCondition::ConditionInstead { inner } => rewrite_cost_x_in_condition(inner), AbilityCondition::Not { condition } => rewrite_cost_x_in_condition(condition), // Carry no `QuantityExpr` and nest no condition — nothing to bind. - AbilityCondition::AdditionalCostPaid { .. } + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn + | AbilityCondition::AdditionalCostPaid { .. } | AbilityCondition::AdditionalCostPaidInstead | AbilityCondition::AlternativeManaCostPaid | AbilityCondition::EffectOutcome { .. } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index b62ae45410..577dd28582 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -20191,7 +20191,8 @@ impl AbilityCondition { signal: EffectOutcomeSignal::CurrentScopeSucceeded | EffectOutcomeSignal::Guessed { .. }, } => false, - AbilityCondition::AdditionalCostPaidInstead + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn + | AbilityCondition::AdditionalCostPaidInstead | AbilityCondition::AlternativeManaCostPaid | AbilityCondition::EventOutcomeWon | AbilityCondition::SourceEnteredThisTurn From 0619851271f13cee36949e679226d453d5c49c82 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 00:10:16 -0700 Subject: [PATCH 06/14] fix(PR-6976): classify trigger damage condition on decline --- crates/engine/src/game/effects/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 579e2423a0..35629f5cb4 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3248,6 +3248,9 @@ fn should_resolve_subability_on_optional_decline(ability: &ResolvedAbility) -> b // optional-decline branch selector — it reads the flip, not the // declined effect. | AbilityCondition::CoinFlipOutcome { .. } + // The frozen trigger-event damage read is independent of an + // optional-effect decision, so it cannot select a decline branch. + | AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn | AbilityCondition::WhenYouDo | AbilityCondition::WasCast { .. } | AbilityCondition::CastDuringPhase { .. } From 078cb88056ed36c6edbf6ba004dcc50d6019da8f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 00:33:08 -0700 Subject: [PATCH 07/14] test(PR-6976): harden Hawkeye trigger reach guard --- ...wkeye_avenging_archer_dealt_damage_draw.rs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs b/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs index 48338680ed..d6156e689f 100644 --- a/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs +++ b/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs @@ -21,6 +21,7 @@ use engine::types::actions::GameAction; use engine::types::game_state::WaitingFor; use engine::types::identifiers::ObjectId; use engine::types::phase::Phase; +use engine::types::zones::Zone; 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 \ @@ -118,6 +119,19 @@ fn kill_untouched_victim(runner: &mut GameRunner, victim: ObjectId) { .damage_marked = 1; let mut sba_events = Vec::new(); engine::game::sba::check_state_based_actions(runner.state_mut(), &mut sba_events); + assert!( + sba_events.iter().any(|event| { + matches!( + event, + engine::types::events::GameEvent::ZoneChanged { + object_id, + to: Zone::Graveyard, + .. + } if *object_id == victim + ) + }), + "the untouched victim must die before the trigger pipeline is processed" + ); engine::game::triggers::process_triggers(runner.state_mut(), &sba_events); drain_stack(runner); } @@ -129,12 +143,13 @@ fn drain_stack(runner: &mut GameRunner) { continue; } match &runner.state().waiting_for { - WaitingFor::Priority { .. } if runner.state().stack.is_empty() => break, + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => return, _ => { - if runner.act(GameAction::PassPriority).is_err() { - break; - } + runner + .act(GameAction::PassPriority) + .expect("priority pass must process the Hawkeye trigger pipeline"); } } } + panic!("Hawkeye trigger pipeline did not settle after 200 priority passes"); } From 6345d70a25ffb079e906e9a1dad590c98e36d686 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 00:59:03 -0700 Subject: [PATCH 08/14] test(PR-6976): complete damage snapshots --- crates/engine/tests/integration/flame_spill_excess_damage.rs | 1 + crates/phase-ai/src/policies/tests/removal_lethality.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/engine/tests/integration/flame_spill_excess_damage.rs b/crates/engine/tests/integration/flame_spill_excess_damage.rs index aa0c3afbbd..e9d91c2533 100644 --- a/crates/engine/tests/integration/flame_spill_excess_damage.rs +++ b/crates/engine/tests/integration/flame_spill_excess_damage.rs @@ -523,6 +523,7 @@ fn t8_shape_flame_spill_and_ram_through_absorb_excess_rider() { fn t9_snapshot_carries_excess_recipient_across_resume() { let snap = DamageContextSnapshot { source_id: ObjectId(1), + source_incarnation: None, controller: P0, source_is_creature: true, has_deathtouch: false, diff --git a/crates/phase-ai/src/policies/tests/removal_lethality.rs b/crates/phase-ai/src/policies/tests/removal_lethality.rs index 60cc49629a..68654a1000 100644 --- a/crates/phase-ai/src/policies/tests/removal_lethality.rs +++ b/crates/phase-ai/src/policies/tests/removal_lethality.rs @@ -636,6 +636,7 @@ fn triggering_source_damage_stays_neutral() { fn vanilla_damage_snapshot() -> DamageContextSnapshot { DamageContextSnapshot { source_id: ObjectId(1), + source_incarnation: None, controller: AI, source_is_creature: false, has_deathtouch: false, From 7b45a11d136bbe26a04781cfce55e481dc281102 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 01:31:30 -0700 Subject: [PATCH 09/14] fix(PR-6976): preserve trigger source identity --- crates/engine/src/game/engine.rs | 8 ++-- crates/engine/src/game/triggers.rs | 61 +++++++++++++++++------------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index fa50b0e638..447d45a160 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -15963,9 +15963,11 @@ mod stage2_injector_tests { // `:6210/:6287/:9475 => :6212/:6289/:9477`. The producers remain byte-identical. // #7018 adds the 16-line distinct-player-scope continuation gate above all // three producers: `:6212/:6289/:9477 => :6228/:6305/:9493`. - "game/effects/mod.rs:6228".to_string(), - "game/effects/mod.rs:6305".to_string(), - "game/effects/mod.rs:9493".to_string(), + // #6976 adds three conditional-branch exclusions above all three; none + // creates an `OptionalEffect` prompt, so the producer set is unchanged. + "game/effects/mod.rs:6231".to_string(), + "game/effects/mod.rs:6308".to_string(), + "game/effects/mod.rs:9496".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 1927ef7145..3c6c5777ef 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -9930,14 +9930,17 @@ fn evaluate_trigger_condition_with_source( GameEvent::ZoneChanged { object_id, .. } => Some(*object_id), _ => None, }); - match (source_id, dying_creature) { + match (source_context, dying_creature) { // CR 400.7: match the source by ObjectId AND incarnation — a // re-entered source is a new object that dealt no prior damage. - (Some(src), Some(subj)) => state.damage_dealt_this_turn.iter().any(|r| { - r.source_id == src - && damage_record_source_incarnation_matches(state, r, src) - && damage_record_matches_dying_object(state, r, subj, trigger_event) - }), + (Some(source), Some(subj)) => { + let source_id = source.identity.reference.object_id; + state.damage_dealt_this_turn.iter().any(|r| { + r.source_id == source_id + && damage_record_source_incarnation_matches(r, source) + && damage_record_matches_dying_object(state, r, subj, trigger_event) + }) + } _ => false, } } @@ -10749,29 +10752,20 @@ pub(crate) fn check_trigger_condition( /// not a later object reusing the same storage id. Death bumps the live /// incarnation, and the card can move again before an intervening-if recheck, /// so subtract the death move and every subsequent move of that object. -/// CR 400.7: True when `record`'s source is the SAME incarnation of `source_id` -/// that is asking now. A permanent that dealt damage, left, and re-entered keeps -/// its `ObjectId` but bumps its incarnation, so the current object is a NEW -/// object (CR 400.7) that did not deal the recorded damage; exact-source -/// look-backs must reject it. `None` recorded incarnation is lenient (legacy -/// records / fixtures). A recorded incarnation with no live source object is -/// rejected: the asking incarnation cannot be confirmed identical. Unlike the -/// dying-object helper this needs no later-move arithmetic — the source of a -/// dies-trigger intervening-if (e.g. Hawkeye) is a bystander still on the -/// battlefield, not the object mid-zone-change. +/// CR 400.7: True when `record`'s source matches the trigger source's observed +/// incarnation. The observation, rather than a later live object lookup, is +/// authoritative: a source may die alongside the damaged creature (Rot Wolf), +/// while a re-entered source is a distinct object. `None` recorded incarnation +/// is lenient for legacy records and fixtures. fn damage_record_source_incarnation_matches( - state: &GameState, record: &DamageRecord, - source_id: ObjectId, + source_context: &TriggerSourceContext, ) -> bool { - let Some(recorded_incarnation) = record.source_incarnation else { - return true; - }; - state - .objects - .get(&source_id) - .map(|object| object.incarnation) - == Some(recorded_incarnation) + record + .source_incarnation + .is_none_or(|recorded_incarnation| { + source_context.identity.reference.incarnation == recorded_incarnation + }) } pub(crate) fn damage_record_matches_dying_object( @@ -21445,9 +21439,24 @@ pub mod tests { Some(&event), )); + let prior_source_context = trigger_source_context_for_latch( + &state, + state.objects.get(&source).expect("source exists"), + ); + // CR 400.7: the source leaves and re-enters (same ObjectId, bumped // incarnation). The stale record must no longer credit the new object. state.objects.get_mut(&source).unwrap().bump_incarnation(); + assert!( + check_trigger_condition_with_source( + &state, + &condition, + PlayerId(0), + Some(&prior_source_context), + Some(&event), + ), + "the prior source context must still receive its own damage trigger" + ); assert!( !check_trigger_condition(&state, &condition, PlayerId(0), Some(source), Some(&event),), "a re-entered source (bumped incarnation) must not match a prior \ From f842161f5a6a331032942ec25192f2986be696b0 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 02:04:28 -0700 Subject: [PATCH 10/14] fix(PR-6976): retain co-dying damage triggers --- crates/engine/src/game/triggers.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 3c6c5777ef..ac7ba30cf5 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -10752,11 +10752,11 @@ pub(crate) fn check_trigger_condition( /// not a later object reusing the same storage id. Death bumps the live /// incarnation, and the card can move again before an intervening-if recheck, /// so subtract the death move and every subsequent move of that object. -/// CR 400.7: True when `record`'s source matches the trigger source's observed -/// incarnation. The observation, rather than a later live object lookup, is -/// authoritative: a source may die alongside the damaged creature (Rot Wolf), -/// while a re-entered source is a distinct object. `None` recorded incarnation -/// is lenient for legacy records and fixtures. +/// CR 400.7 + CR 603.10a: True when `record`'s source matches the trigger +/// source. A live battlefield source must have the same incarnation; a +/// post-event off-battlefield context may have already bumped incarnation while +/// observing a simultaneous death (Rot Wolf), so it remains valid LKI. `None` +/// recorded incarnation is lenient for legacy records and fixtures. fn damage_record_source_incarnation_matches( record: &DamageRecord, source_context: &TriggerSourceContext, @@ -10765,6 +10765,7 @@ fn damage_record_source_incarnation_matches( .source_incarnation .is_none_or(|recorded_incarnation| { source_context.identity.reference.incarnation == recorded_incarnation + || source_context.identity.expected_zone != Zone::Battlefield }) } From fdaf88be68ee35dd14436dab5fe4423a9d2813bb Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 05:50:24 -0700 Subject: [PATCH 11/14] fix(PR-6976): bind damage records to exact source incarnation --- crates/engine/src/game/triggers.rs | 9 +- ...wkeye_avenging_archer_dealt_damage_draw.rs | 112 ++++++++++++++++++ 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index dc4843e282..93d6b8a7b6 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -10878,10 +10878,10 @@ pub(crate) fn check_trigger_condition( /// incarnation, and the card can move again before an intervening-if recheck, /// so subtract the death move and every subsequent move of that object. /// CR 400.7 + CR 603.10a: True when `record`'s source matches the trigger -/// source. A live battlefield source must have the same incarnation; a -/// post-event off-battlefield context may have already bumped incarnation while -/// observing a simultaneous death (Rot Wolf), so it remains valid LKI. `None` -/// recorded incarnation is lenient for legacy records and fixtures. +/// source. `TriggerSourceContext` preserves the pre-zone-change incarnation, +/// including when source and subject die together, so an off-battlefield +/// context never licenses a later same-id incarnation. `None` recorded +/// incarnation is lenient for legacy records and fixtures. fn damage_record_source_incarnation_matches( record: &DamageRecord, source_context: &TriggerSourceContext, @@ -10890,7 +10890,6 @@ fn damage_record_source_incarnation_matches( .source_incarnation .is_none_or(|recorded_incarnation| { source_context.identity.reference.incarnation == recorded_incarnation - || source_context.identity.expected_zone != Zone::Battlefield }) } diff --git a/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs b/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs index d6156e689f..af38f91989 100644 --- a/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs +++ b/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs @@ -76,6 +76,118 @@ fn hawkeye_draws_when_it_damaged_the_dying_opponent_creature() { outcome.assert_hand_drawn(P0, 1); } +/// Runs Hawkeye's real damage activation, then kills Hawkeye and its damaged +/// victim in the same SBA pass. When `reenter_hawkeye` is true, the permanent +/// leaves and returns before that co-death, so the pending damage record belongs +/// to its prior incarnation. +fn co_dying_hawkeye_after_damage(reenter_hawkeye: bool) -> GameRunner { + 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(); + // Hawkeye's activation deals one damage without killing this 2/2. The + // common SBA pass below performs the simultaneous death that exercises the + // off-battlefield trigger-source context. + let victim = scenario.add_creature(P1, "Damaged Victim", 2, 2).id(); + + let mut runner = scenario.build(); + hand_priority(&mut runner, P0); + let idx = tap_damage_index(&runner, hawkeye); + { + let _ = runner + .activate(hawkeye, idx) + .target_object(victim) + .resolve(); + } + + let damage_incarnation = runner.state().objects[&hawkeye].incarnation; + assert!( + runner.state().damage_dealt_this_turn.iter().any(|record| { + record.source_id == hawkeye + && record.target == engine::types::ability::TargetRef::Object(victim) + && record.source_incarnation == Some(damage_incarnation) + }), + "the real Hawkeye activation must record its source incarnation before co-death" + ); + + if reenter_hawkeye { + let mut reentry_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + hawkeye, + Zone::Exile, + &mut reentry_events, + ); + engine::game::zones::move_to_zone( + runner.state_mut(), + hawkeye, + Zone::Battlefield, + &mut reentry_events, + ); + assert_ne!( + runner.state().objects[&hawkeye].incarnation, + damage_incarnation, + "the return must create Hawkeye's later incarnation" + ); + } + + runner + .state_mut() + .objects + .get_mut(&hawkeye) + .unwrap() + .damage_marked = 3; + runner + .state_mut() + .objects + .get_mut(&victim) + .unwrap() + .damage_marked = 2; + let mut death_events = Vec::new(); + engine::game::sba::check_state_based_actions(runner.state_mut(), &mut death_events); + assert!( + [hawkeye, victim].into_iter().all(|object_id| { + death_events.iter().any(|event| { + matches!( + event, + engine::types::events::GameEvent::ZoneChanged { + object_id: moved, + to: Zone::Graveyard, + .. + } if *moved == object_id + ) + }) + }), + "Hawkeye and its victim must enter the same death-trigger processing batch" + ); + engine::game::triggers::process_triggers(runner.state_mut(), &death_events); + drain_stack(&mut runner); + runner +} + +#[test] +fn hawkeye_co_dying_with_its_damaged_victim_draws_from_lki() { + let runner = co_dying_hawkeye_after_damage(false); + + assert_eq!( + runner.state().players[0].hand.len(), + 1, + "the pre-move source incarnation must still satisfy Hawkeye's trigger after co-death" + ); +} + +#[test] +fn reentered_hawkeye_co_dying_with_its_old_victim_does_not_draw() { + let runner = co_dying_hawkeye_after_damage(true); + + assert!( + runner.state().players[0].hand.is_empty(), + "damage by Hawkeye's prior incarnation must not satisfy its re-entered incarnation's trigger" + ); +} + #[test] fn hawkeye_does_not_draw_when_it_did_not_damage_the_dying_opponent_creature() { let mut scenario = GameScenario::new(); From c36f24fa72d64b994919eff9c144ae6777ec3224 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 06:09:44 -0700 Subject: [PATCH 12/14] fix(PR-6976): retain co-departed trigger source context --- crates/engine/src/game/triggers.rs | 46 +++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 93d6b8a7b6..61654df6eb 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -4288,6 +4288,40 @@ fn collect_pending_triggers_with_collection( { continue; } + // CR 603.10a + CR 400.7: This observer left in the same + // departure batch, so its own ZoneChanged record—not the + // post-move object in `state`—owns the source identity. The + // latter has already bumped its incarnation and would force + // damage-history conditions to choose between accepting every + // off-battlefield same-id source or rejecting valid co-death + // LKI. The record context preserves both cases precisely. + let observer_source_context = events.iter().find_map(|observer_event| { + let GameEvent::ZoneChanged { + object_id, + record, + .. + } = observer_event + else { + return None; + }; + if *object_id != observer_id || !record.co_departed.contains(moved_id) { + return None; + } + match crate::types::game_state::battlefield_departure_trigger_source_context( + observer_event, + ) { + crate::types::game_state::BattlefieldDepartureSourceContext::Present( + context, + ) => Some(context), + crate::types::game_state::BattlefieldDepartureSourceContext::Absent + | crate::types::game_state::BattlefieldDepartureSourceContext::Malformed => { + None + } + } + }); + let Some(observer_source_context) = observer_source_context else { + continue; + }; // CR 303.4b + CR 603.10a: Restore the observer's LKI // `attached_to` so `ControllerRef::EnchantedPlayer` resolves // correctly for co-departed Curse Auras whose live @@ -4313,19 +4347,11 @@ fn collect_pending_triggers_with_collection( } } let matched_triggers = { - let Some(obj) = state.objects.get(&observer_id) else { - // Restore before continuing. - if let Some(obs_obj) = state.objects.get_mut(&observer_id) { - obs_obj.attached_to = saved_attached_to; - } - continue; - }; - collect_matching_triggers( + collect_matching_triggers_from_context( state, event, events, - obj, - obj.entered_battlefield_turn.unwrap_or(0), + observer_source_context, Some(Zone::Battlefield), &mut batched_this_pass, &mut registered_this_event, From f79eb8708328264505514f6ce9322b2f31aee6d4 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 06:32:29 -0700 Subject: [PATCH 13/14] fix(PR-6976): keep destination zones on self triggers --- crates/engine/src/game/triggers.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 61654df6eb..02df951016 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -2090,7 +2090,14 @@ fn collect_matching_triggers_inner( } else { trig_def.trigger_zones.contains(&zone) }; - if !zones_match && use_latched_trigger_entries { + // A zone-change record may let the moving source's own trigger + // function from its destination zone. An observer's LKI only + // proves it functioned from `zone_filter` immediately before the + // event; it must not gain the destination's trigger zone. + if !zones_match + && use_latched_trigger_entries + && matches!(visit, TriggerSourceVisit::EventSubject) + { if let GameEvent::ZoneChanged { record, to, .. } = event { zones_match = record.from_zone == Some(source_context.identity.expected_zone) && trig_def.trigger_zones.contains(to); From 9cafb40d415e03e6ed5d165539f8e9fe4a2b4adc Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 9 Aug 2026 07:18:15 -0700 Subject: [PATCH 14/14] fix(PR-6976): compose damage rider grammar --- .../src/parser/oracle_effect/conditions.rs | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index e420329cfe..808243b40f 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -use crate::parser::oracle_nom::error::{OracleError, OracleResult}; +use crate::parser::oracle_nom::error::{oracle_err, OracleError, OracleResult}; use nom::branch::alt; use nom::bytes::complete::{tag, take_until}; use nom::character::complete::char; @@ -3168,6 +3168,25 @@ fn parse_colored_mana_symbol_count_target_condition(text: &str) -> Option OracleResult<'_, ()> { + let (rest, source) = crate::parser::oracle_replacement::parse_damage_history_source(input) + .ok_or_else(|| oracle_err(input))?; + let (rest, _) = tag(" dealt damage").parse(rest)?; + let (rest, _) = tag(" to it").parse(rest)?; + let (rest, _) = tag(" this turn").parse(rest)?; + match source { + TargetFilter::SelfRef => Ok((rest, ())), + _ => Err(nom::Err::Error(OracleError::new( + input, + nom::error::ErrorKind::Verify, + ))), + } +} + pub(super) fn strip_suffix_conditional( text: &str, ctx: &mut ParseContext, @@ -3182,11 +3201,9 @@ pub(super) fn strip_suffix_conditional( // a resolution-time rider, not an intervening-if. The event target is // carried by the resolving trigger entry. if ctx.in_trigger - && all_consuming(tag::<_, _, OracleError<'_>>( - "~ dealt damage to it this turn", - )) - .parse(condition_text) - .is_ok() + && all_consuming(parse_trigger_event_target_damaged_by_source_this_turn) + .parse(condition_text) + .is_ok() { return ( Some(AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn),