diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index b07ede8562..ea45c01ce8 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 { .. } @@ -6017,6 +6018,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 626b3d04ab..48fb363b59 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -2577,6 +2577,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 6fcd57f3e7..752df35b28 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -3871,6 +3871,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(), @@ -7405,6 +7408,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/game/effects/deal_damage.rs b/crates/engine/src/game/effects/deal_damage.rs index b3f05ac400..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,17 @@ 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: 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, 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, @@ -4039,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/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 9abbcd5852..91a1bc4cf8 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3264,6 +3264,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 { .. } @@ -11531,6 +11534,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/engine.rs b/crates/engine/src/game/engine.rs index cc4e638302..b30586e2bf 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16031,12 +16031,12 @@ 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`. - // Main's debug-entry (+3) and counter-reproduction (+5/+10) - // shifts combine with #6958's paid-cast outcome exclusion (+13). - // None creates an `OptionalEffect` prompt. - "game/effects/mod.rs:6249".to_string(), - "game/effects/mod.rs:6326".to_string(), - "game/effects/mod.rs:9519".to_string(), + // shifts combine with #6958's paid-cast outcome exclusion and + // #6976's conditional-branch exclusions. None creates an + // `OptionalEffect` prompt. Re-pinned against the merged source. + "game/effects/mod.rs:6252".to_string(), + "game/effects/mod.rs:6329".to_string(), + "game/effects/mod.rs:9522".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 3d0069b14f..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); @@ -4288,6 +4295,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 +4354,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, @@ -10055,11 +10088,17 @@ fn evaluate_trigger_condition_with_source( GameEvent::ZoneChanged { object_id, .. } => Some(*object_id), _ => None, }); - match (source_id, dying_creature) { - (Some(src), Some(subj)) => state.damage_dealt_this_turn.iter().any(|r| { - r.source_id == src - && damage_record_matches_dying_object(state, r, subj, trigger_event) - }), + 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(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, } } @@ -10871,7 +10910,23 @@ 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. -fn damage_record_matches_dying_object( +/// CR 400.7 + CR 603.10a: True when `record`'s source matches the trigger +/// 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, +) -> bool { + record + .source_incarnation + .is_none_or(|recorded_incarnation| { + source_context.identity.reference.incarnation == recorded_incarnation + }) +} + +pub(crate) fn damage_record_matches_dying_object( state: &GameState, record: &DamageRecord, object_id: ObjectId, @@ -21442,6 +21497,129 @@ 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 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), + )); + + 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 \ + incarnation's damage record (CR 400.7)" + ); } /// CR 701.26 + CR 603.4: `FirstTimeObjectTappedThisTurn` holds only when the diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index d96c55f005..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, @@ -3178,6 +3197,19 @@ 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(parse_trigger_event_target_damaged_by_source_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 @@ -4912,7 +4944,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 d3600e631b..75df6f4562 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 { .. } @@ -5666,6 +5667,36 @@ 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. + // + // 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) + { + 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), + ); + } + } + } + // CR 603.4 + CR 205.3: "if it's [not] a " on the triggering event's // subject (Captain Marvel: "if it's not a Kree"). Registered BEFORE the // zone-change filter path so recognized subtypes route to @@ -5840,6 +5871,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 517226beb7..c73a6d1bdf 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -18684,6 +18684,131 @@ 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_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)" + ); + 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] 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 e0e08d68a7..151d32ce73 100644 --- a/crates/engine/src/parser/swallow_check.rs +++ b/crates/engine/src/parser/swallow_check.rs @@ -4649,12 +4649,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 { @@ -4933,6 +4934,91 @@ 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"], + ); + // 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 \ + 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 + ); + } + + /// 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/ability.rs b/crates/engine/src/types/ability.rs index 567446cbac..9257c2629f 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -10264,6 +10264,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, @@ -19739,6 +19743,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: /// @@ -20258,7 +20267,8 @@ impl AbilityCondition { signal: EffectOutcomeSignal::CurrentScopeSucceeded | EffectOutcomeSignal::Guessed { .. }, } => false, - AbilityCondition::AdditionalCostPaidInstead + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn + | AbilityCondition::AdditionalCostPaidInstead | AbilityCondition::AlternativeManaCostPaid | AbilityCondition::EventOutcomeWon | AbilityCondition::SourceEnteredThisTurn diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e34d5dd02e..20b7696d34 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -1905,6 +1905,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, @@ -1966,6 +1975,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/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/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..af38f91989 --- /dev/null +++ b/crates/engine/tests/integration/hawkeye_avenging_archer_dealt_damage_draw.rs @@ -0,0 +1,267 @@ +//! 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. +//! +//! 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::Effect; +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 \ + deals 1 damage to any target."; + +/// 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") +} + +/// 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] +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(); + // 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(); + hand_priority(&mut runner, P0); + let idx = tap_damage_index(&runner, hawkeye); + + // 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(); + + // 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); +} + +/// 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(); + 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(); + + // 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(), + hand_before, + "Hawkeye's controller must NOT draw when Hawkeye never damaged the dying \ + 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); + 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); +} + +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() => return, + _ => { + runner + .act(GameAction::PassPriority) + .expect("priority pass must process the Hawkeye trigger pipeline"); + } + } + } + panic!("Hawkeye trigger pipeline did not settle after 200 priority passes"); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 8687f979c2..df53f94fbd 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -266,6 +266,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; 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,