Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4d1c01c
Partial: Hawkeye, Avenging Archer
JacobWoodson Aug 4, 2026
562c5f4
Merge branch 'main' into card/hawkeye-avenging-archer
JacobWoodson Aug 4, 2026
5ce359f
fix(engine): source-incarnation identity + leading-position gate for …
JacobWoodson Aug 4, 2026
9abe756
Merge remote-tracking branch 'origin/main' into pr/6976
matthewevans Aug 9, 2026
0cc9ba0
fix(PR-6976): preserve damage source incarnation
matthewevans Aug 9, 2026
9ae0217
fix(PR-6976): retain trailing damage condition
matthewevans Aug 9, 2026
2dea4bf
fix(PR-6976): complete trigger damage condition integration
matthewevans Aug 9, 2026
0619851
fix(PR-6976): classify trigger damage condition on decline
matthewevans Aug 9, 2026
2e2a26b
Merge remote-tracking branch 'origin/main' into pr/6976
matthewevans Aug 9, 2026
078cb88
test(PR-6976): harden Hawkeye trigger reach guard
matthewevans Aug 9, 2026
6345d70
test(PR-6976): complete damage snapshots
matthewevans Aug 9, 2026
7b45a11
fix(PR-6976): preserve trigger source identity
matthewevans Aug 9, 2026
9e8b541
merge: update PR #6976 with main
matthewevans Aug 9, 2026
f842161
fix(PR-6976): retain co-dying damage triggers
matthewevans Aug 9, 2026
9c8565d
merge: update PR #6976 with main
matthewevans Aug 9, 2026
63bfe29
merge: update PR #6976 with main
matthewevans Aug 9, 2026
fdaf88b
fix(PR-6976): bind damage records to exact source incarnation
matthewevans Aug 9, 2026
c36f24f
fix(PR-6976): retain co-departed trigger source context
matthewevans Aug 9, 2026
f79eb87
fix(PR-6976): keep destination zones on self triggers
matthewevans Aug 9, 2026
a55eb53
Merge remote-tracking branch 'origin/main' into pr/6976
matthewevans Aug 9, 2026
9cafb40
fix(PR-6976): compose damage rider grammar
matthewevans Aug 9, 2026
876fb18
Merge remote-tracking branch 'origin/main' into pr/6976
matthewevans Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. }
Expand Down Expand Up @@ -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(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
AbilityCondition::QuantityCheck {
lhs,
rhs,
Expand Down
5 changes: 5 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
6 changes: 6 additions & 0 deletions crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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),
Expand Down
53 changes: 53 additions & 0 deletions crates/engine/src/game/effects/deal_damage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
pub(crate) controller: PlayerId,
pub(crate) source_is_creature: bool,
pub(crate) has_deathtouch: bool,
Expand Down Expand Up @@ -194,6 +198,7 @@ impl DamageContext {
pub(crate) fn from_source(state: &GameState, source_id: ObjectId) -> Option<Self> {
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:
Expand Down Expand Up @@ -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,
Expand All @@ -258,6 +264,7 @@ impl From<DamageContextSnapshot> 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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. }
Expand Down Expand Up @@ -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 /
Expand Down
12 changes: 6 additions & 6 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading