From 5b099317f78808753495af9a158a2644748cad14 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 11 Aug 2026 18:33:58 -0700 Subject: [PATCH 01/18] fix(engine): preserve tracked delayed target pins --- crates/engine/src/game/effects/change_zone.rs | 1 + .../src/game/effects/delayed_trigger.rs | 155 ++++++++++++------ crates/engine/src/game/effects/mod.rs | 4 + crates/engine/src/game/filter.rs | 57 ++++++- 4 files changed, 166 insertions(+), 51 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 081ad1e1ef..6f588b3d4d 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -1726,6 +1726,7 @@ pub fn resolve_all( .iter() .filter(|(&id, obj)| { origin_zones.contains(&obj.zone) + && ability.target_pin_is_current(id, state) && crate::game::filter::matches_target_filter( state, id, diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index e11e208931..5074933d30 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -651,53 +651,7 @@ fn concrete_parent_target_filter( filter: &TargetFilter, parent_targets: &[TargetRef], ) -> TargetFilter { - let filter = crate::game::filter::normalize_contextual_filter(filter, parent_targets); - match filter { - TargetFilter::ParentTarget => parent_targets_filter(parent_targets), - // CR 603.7c + CR 608.2c: bind a `ParentTargetSlot { index }` delayed - // condition filter to the concrete parent object at that declared slot - // (single-slot analogue of the `ParentTarget` arm). Out-of-range/empty - // slots fall back to `Any`, matching `parent_targets_filter`'s empty case. - TargetFilter::ParentTargetSlot { index } => parent_targets - .get(index) - .map(|target| match target { - TargetRef::Object(id) => TargetFilter::SpecificObject { id: *id }, - TargetRef::Player(id) => TargetFilter::SpecificPlayer { id: *id }, - }) - .unwrap_or(TargetFilter::Any), - TargetFilter::Not { filter } => TargetFilter::Not { - filter: Box::new(concrete_parent_target_filter(&filter, parent_targets)), - }, - TargetFilter::Or { filters } => TargetFilter::Or { - filters: filters - .iter() - .map(|filter| concrete_parent_target_filter(filter, parent_targets)) - .collect(), - }, - TargetFilter::And { filters } => TargetFilter::And { - filters: filters - .iter() - .map(|filter| concrete_parent_target_filter(filter, parent_targets)) - .collect(), - }, - other => other, - } -} - -fn parent_targets_filter(parent_targets: &[TargetRef]) -> TargetFilter { - let targets: Vec<_> = parent_targets - .iter() - .map(|target| match target { - TargetRef::Object(id) => TargetFilter::SpecificObject { id: *id }, - TargetRef::Player(id) => TargetFilter::SpecificPlayer { id: *id }, - }) - .collect(); - - match targets.as_slice() { - [] => TargetFilter::Any, - [target] => target.clone(), - _ => TargetFilter::Or { filters: targets }, - } + crate::game::filter::normalize_contextual_filter(filter, parent_targets) } fn bind_tracked_set_to_condition(condition: &mut DelayedTriggerCondition, real_id: TrackedSetId) { @@ -1032,6 +986,13 @@ fn bind_tracked_set_to_effect(effect: &mut Effect, real_id: TrackedSetId) { bound_target.rebind_tracked_set_sentinel(real_id); bound_target } + TargetFilter::ParentTarget | TargetFilter::ParentTargetSlot { .. } => { + TargetFilter::TrackedSetFiltered { + id: real_id, + filter: Box::new(target.clone()), + caused_by: None, + } + } _ => TargetFilter::TrackedSet { id: real_id }, }; *effect = Effect::ChangeZoneAll { @@ -1126,6 +1087,9 @@ fn filter_refs_parent_object_anaphor(filter: &TargetFilter) -> bool { filters.iter().any(filter_refs_parent_object_anaphor) } TargetFilter::Not { filter } => filter_refs_parent_object_anaphor(filter), + TargetFilter::TrackedSetFiltered { filter, .. } => { + filter_refs_parent_object_anaphor(filter) + } _ => false, } } @@ -3774,6 +3738,103 @@ mod tests { ); } + /// CR 400.7 + CR 603.7c (issue #7100): an end-step return referring to + /// the parent-selected tracked-set member must retain that anaphor through + /// the ChangeZoneAll upgrade. A later zone change makes the same object id + /// a new object, which the creation-time incarnation pin excludes. + #[test] + fn tracked_set_delayed_change_zone_preserves_parent_pin_across_reentry() { + let mut state = GameState::new_two_player(42); + let creature = crate::game::zones::create_object( + &mut state, + CardId(1), + PlayerId(0), + "Eerie Interlude target".to_string(), + Zone::Battlefield, + ); + let set_id = TrackedSetId(1); + state.tracked_object_sets.insert(set_id, vec![creature]); + state.chain_tracked_set_id = Some(set_id); + state.next_tracked_set_id = 2; + + let effect = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: Some(Zone::Battlefield), + destination: Zone::Exile, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + ); + let create = ResolvedAbility::new( + Effect::CreateDelayedTrigger { + condition: DelayedTriggerCondition::AtNextPhase { phase: Phase::End }, + effect: Box::new(effect), + uses_tracked_set: true, + }, + vec![TargetRef::Object(creature)], + ObjectId(100), + PlayerId(0), + ); + let mut events = Vec::new(); + resolve(&mut state, &create, &mut events).expect("delayed trigger installs"); + + let delayed = &state.delayed_triggers[0].ability; + assert!(delayed.target_pin_is_current(creature, &state)); + assert_eq!(delayed.targets, vec![TargetRef::Object(creature)]); + assert!(matches!( + delayed.effect, + Effect::ChangeZoneAll { + target: TargetFilter::TrackedSetFiltered { + id, + filter, + caused_by: None, + }, + .. + } if id == set_id && matches!(filter.as_ref(), TargetFilter::ParentTarget) + )); + + let mut current_state = state.clone(); + let current_delayed = current_state.delayed_triggers[0].ability.clone(); + crate::game::effects::resolve_ability_chain( + &mut current_state, + ¤t_delayed, + &mut Vec::new(), + 0, + ) + .expect("current delayed trigger resolves"); + assert_eq!( + current_state.objects[&creature].zone, + Zone::Exile, + "the still-current tracked-set member must be affected" + ); + + crate::game::zones::move_to_zone(&mut state, creature, Zone::Graveyard, &mut Vec::new()); + crate::game::zones::move_to_zone(&mut state, creature, Zone::Battlefield, &mut Vec::new()); + let delayed = state.delayed_triggers[0].ability.clone(); + assert!( + !delayed.target_pin_is_current(creature, &state), + "the returned object is a new incarnation" + ); + + crate::game::effects::resolve_ability_chain(&mut state, &delayed, &mut events, 0) + .expect("delayed trigger resolves"); + assert_eq!( + state.objects[&creature].zone, + Zone::Battlefield, + "the stale tracked-set member must not be exiled" + ); + } + /// CR 603.7c (issue #5972): binding preserves an explicit Battlefield /// origin when upgrading `ChangeZone { TrackedSet }` → `ChangeZoneAll`. #[test] diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index b649049d30..0830e42e7e 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -5738,6 +5738,9 @@ fn effect_parent_ref_slots(effect: &Effect) -> Vec<&TargetFilter> { Effect::UnattachAll { attachment, .. } if attachment.is_context_ref() => { slots.push(attachment) } + Effect::ChangeZoneAll { target, .. } if filter_refs_parent_target(target) => { + slots.push(target) + } _ => {} } slots @@ -5814,6 +5817,7 @@ fn filter_refs_parent_target(filter: &TargetFilter) -> bool { filters.iter().any(filter_refs_parent_target) } TargetFilter::Not { filter } => filter_refs_parent_target(filter), + TargetFilter::TrackedSetFiltered { filter, .. } => filter_refs_parent_target(filter), _ => false, } } diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index fa6597cb02..63efcc6699 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -1030,11 +1030,11 @@ fn entered_perturbs_quantity( }) } -/// CR 608.2c: Resolve contextual parent-target exclusions before a mass-effect scan. +/// CR 608.2c: Resolve contextual parent-target references before an object scan. /// -/// This intentionally supports only `Not(ParentTarget)` and -/// `Not(ParentTargetSlot { index })` inside composite filters. Positive -/// `ParentTarget` / `ParentTargetSlot` inside `And` / `Or` remains unresolved here. +/// `ParentTarget` and `ParentTargetSlot` are resolution-time references, not +/// object-matcher predicates. Normalize them to their concrete parent targets +/// before a composite filter reaches `matches_target_filter`. pub fn normalize_contextual_filter( filter: &TargetFilter, parent_targets: &[TargetRef], @@ -1084,6 +1084,11 @@ pub fn normalize_contextual_filter( TargetFilter::Not { filter: inner } => TargetFilter::Not { filter: Box::new(normalize_contextual_filter(inner, parent_targets)), }, + TargetFilter::ParentTarget => parent_targets_filter(parent_targets), + TargetFilter::ParentTargetSlot { index } => parent_targets + .get(*index) + .map(target_ref_filter) + .unwrap_or(TargetFilter::Any), TargetFilter::Or { filters } => TargetFilter::Or { filters: filters .iter() @@ -1096,10 +1101,36 @@ pub fn normalize_contextual_filter( .map(|inner| normalize_contextual_filter(inner, parent_targets)) .collect(), }, + TargetFilter::TrackedSetFiltered { + id, + filter, + caused_by, + } => TargetFilter::TrackedSetFiltered { + id: *id, + filter: Box::new(normalize_contextual_filter(filter, parent_targets)), + caused_by: *caused_by, + }, _ => filter.clone(), } } +fn parent_targets_filter(parent_targets: &[TargetRef]) -> TargetFilter { + match parent_targets { + [] => TargetFilter::Any, + [target] => target_ref_filter(target), + targets => TargetFilter::Or { + filters: targets.iter().map(target_ref_filter).collect(), + }, + } +} + +fn target_ref_filter(target: &TargetRef) -> TargetFilter { + match target { + TargetRef::Object(id) => TargetFilter::SpecificObject { id: *id }, + TargetRef::Player(id) => TargetFilter::SpecificPlayer { id: *id }, + } +} + /// Context bundle passed into filter evaluation. /// /// Bundles the source object, its controller, and — when available — the resolving @@ -10486,6 +10517,24 @@ mod tests { ); } + #[test] + fn normalize_contextual_filter_binds_positive_parent_target_inside_tracked_set() { + let filter = TargetFilter::TrackedSetFiltered { + id: crate::types::identifiers::TrackedSetId(3), + filter: Box::new(TargetFilter::ParentTarget), + caused_by: None, + }; + + assert_eq!( + normalize_contextual_filter(&filter, &[TargetRef::Object(ObjectId(7))]), + TargetFilter::TrackedSetFiltered { + id: crate::types::identifiers::TrackedSetId(3), + filter: Box::new(TargetFilter::SpecificObject { id: ObjectId(7) }), + caused_by: None, + } + ); + } + #[test] fn normalize_contextual_filter_with_multiple_parent_targets_excludes_all_of_them() { let filter = TargetFilter::Not { From d0a61c327e6f85cd8afabc38f53daa409fc0453b Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 11 Aug 2026 18:44:55 -0700 Subject: [PATCH 02/18] fix(engine): borrow tracked delayed effect in test --- crates/engine/src/game/effects/delayed_trigger.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index 5074933d30..72e6fddfc8 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -3792,7 +3792,7 @@ mod tests { assert!(delayed.target_pin_is_current(creature, &state)); assert_eq!(delayed.targets, vec![TargetRef::Object(creature)]); assert!(matches!( - delayed.effect, + &delayed.effect, Effect::ChangeZoneAll { target: TargetFilter::TrackedSetFiltered { id, From e6e8cab42814f899161abadfd43c0cf25e99d5fe Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 11 Aug 2026 18:53:47 -0700 Subject: [PATCH 03/18] fix(engine): compare tracked set id by value --- crates/engine/src/game/effects/delayed_trigger.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index 72e6fddfc8..263e4401da 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -3800,7 +3800,7 @@ mod tests { caused_by: None, }, .. - } if id == set_id && matches!(filter.as_ref(), TargetFilter::ParentTarget) + } if *id == set_id && matches!(filter.as_ref(), TargetFilter::ParentTarget) )); let mut current_state = state.clone(); From bd2c1696f68b0bb21dd412e473be8528e9f7d68a Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 00:11:28 -0700 Subject: [PATCH 04/18] fix(engine): consume filtered tracked sets --- crates/engine/src/game/effects/change_zone.rs | 21 +++++++++-- crates/engine/src/game/filter.rs | 36 +++++++++++++++---- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 6f588b3d4d..778793425b 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -1759,9 +1759,10 @@ pub fn resolve_all( }; // Clean up consumed tracked set after scanning. - if let TargetFilter::TrackedSet { id } = &effective_filter { + if let TargetFilter::TrackedSet { id } | TargetFilter::TrackedSetFiltered { id, .. } = + &effective_filter + { state.tracked_object_sets.remove(id); - // CR 608.2c: drop the consumed set's member-cause provenance in lockstep. state.tracked_set_member_causes.remove(id); } @@ -7634,6 +7635,12 @@ mod tests { let set_id = TrackedSetId(state.next_tracked_set_id); state.next_tracked_set_id += 1; state.tracked_object_sets.insert(set_id, vec![exiled]); + state.tracked_set_member_causes.insert( + set_id, + [(exiled, crate::types::ability::ThisWayCause::Exiled)] + .into_iter() + .collect(), + ); let ability = ResolvedAbility::new( Effect::ChangeZoneAll { @@ -7661,6 +7668,8 @@ mod tests { Zone::Battlefield, "Exiled creature must return to the battlefield when TrackedSetId(0) is resolved" ); + assert!(!state.tracked_object_sets.contains_key(&set_id)); + assert!(!state.tracked_set_member_causes.contains_key(&set_id)); } /// Zimone's Experiment: tracked-set routing must scan the members' actual zone @@ -7695,6 +7704,12 @@ mod tests { state .tracked_object_sets .insert(set_id, vec![land, creature]); + state.tracked_set_member_causes.insert( + set_id, + [(land, crate::types::ability::ThisWayCause::Exiled)] + .into_iter() + .collect(), + ); state.chain_tracked_set_id = Some(set_id); let land_filter = TargetFilter::Typed(TypedFilter { @@ -7728,6 +7743,8 @@ mod tests { assert_eq!(state.objects[&land].zone, Zone::Battlefield); assert!(state.objects[&land].tapped); assert_eq!(state.objects[&creature].zone, Zone::Library); + assert!(!state.tracked_object_sets.contains_key(&set_id)); + assert!(!state.tracked_set_member_causes.contains_key(&set_id)); } #[test] diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 63efcc6699..9c5b1ab4da 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -1030,8 +1030,6 @@ fn entered_perturbs_quantity( }) } -/// CR 608.2c: Resolve contextual parent-target references before an object scan. -/// /// `ParentTarget` and `ParentTargetSlot` are resolution-time references, not /// object-matcher predicates. Normalize them to their concrete parent targets /// before a composite filter reaches `matches_target_filter`. @@ -1046,9 +1044,8 @@ pub fn normalize_contextual_filter( TargetFilter::ParentTarget | TargetFilter::ParentTargetSlot { .. } ) => { - // CR 608.2c: exclude the concrete parent object(s). `ParentTarget` - // excludes every parent object; `ParentTargetSlot { index }` excludes - // only the object at that one declared slot. + // `ParentTarget` excludes every parent object; `ParentTargetSlot { index }` + // excludes only the object at that one declared slot. let object_ids: Vec = match inner.as_ref() { TargetFilter::ParentTargetSlot { index } => parent_targets .get(*index) @@ -1088,7 +1085,7 @@ pub fn normalize_contextual_filter( TargetFilter::ParentTargetSlot { index } => parent_targets .get(*index) .map(target_ref_filter) - .unwrap_or(TargetFilter::Any), + .unwrap_or(TargetFilter::None), TargetFilter::Or { filters } => TargetFilter::Or { filters: filters .iter() @@ -1110,6 +1107,11 @@ pub fn normalize_contextual_filter( filter: Box::new(normalize_contextual_filter(filter, parent_targets)), caused_by: *caused_by, }, + TargetFilter::ChosenDamageSource { filter } => TargetFilter::ChosenDamageSource { + filter: filter + .as_ref() + .map(|inner| Box::new(normalize_contextual_filter(inner, parent_targets))), + }, _ => filter.clone(), } } @@ -10535,6 +10537,28 @@ mod tests { ); } + #[test] + fn normalize_contextual_filter_missing_positive_parent_target_slot_matches_nothing() { + assert_eq!( + normalize_contextual_filter(&TargetFilter::ParentTargetSlot { index: 1 }, &[]), + TargetFilter::None, + ); + } + + #[test] + fn normalize_contextual_filter_binds_parent_target_inside_chosen_damage_source() { + let filter = TargetFilter::ChosenDamageSource { + filter: Some(Box::new(TargetFilter::ParentTargetSlot { index: 0 })), + }; + + assert_eq!( + normalize_contextual_filter(&filter, &[TargetRef::Object(ObjectId(7))]), + TargetFilter::ChosenDamageSource { + filter: Some(Box::new(TargetFilter::SpecificObject { id: ObjectId(7) })), + }, + ); + } + #[test] fn normalize_contextual_filter_with_multiple_parent_targets_excludes_all_of_them() { let filter = TargetFilter::Not { From 2c5c41756c563e2dbeffdf459560bd5dc7724f4e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 01:27:47 -0700 Subject: [PATCH 05/18] fix(engine): retain filtered tracked sets through chains --- crates/engine/src/game/effects/change_zone.rs | 4 +--- crates/engine/src/game/effects/delayed_trigger.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 778793425b..37fa7dbbb3 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -1759,9 +1759,7 @@ pub fn resolve_all( }; // Clean up consumed tracked set after scanning. - if let TargetFilter::TrackedSet { id } | TargetFilter::TrackedSetFiltered { id, .. } = - &effective_filter - { + if let TargetFilter::TrackedSet { id } = &effective_filter { state.tracked_object_sets.remove(id); state.tracked_set_member_causes.remove(id); } diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index 263e4401da..38b86b7f13 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -651,7 +651,15 @@ fn concrete_parent_target_filter( filter: &TargetFilter, parent_targets: &[TargetRef], ) -> TargetFilter { - crate::game::filter::normalize_contextual_filter(filter, parent_targets) + // This condition-binding path historically treats an absent declared + // parent slot as unconstrained; preserve that contract while the shared + // mass-effect normalizer correctly uses `None` to match no objects. + match filter { + TargetFilter::ParentTargetSlot { index } if parent_targets.get(*index).is_none() => { + TargetFilter::Any + } + _ => crate::game::filter::normalize_contextual_filter(filter, parent_targets), + } } fn bind_tracked_set_to_condition(condition: &mut DelayedTriggerCondition, real_id: TrackedSetId) { From 94de57ff5e8a3f230e4687911c0f72c8c804f642 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 01:29:37 -0700 Subject: [PATCH 06/18] fix(engine): bind delayed condition slots from root --- .../engine/src/game/effects/delayed_trigger.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index 38b86b7f13..2643a62e01 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -127,7 +127,13 @@ pub fn resolve( } } - bind_contextual_filter_to_condition(&mut condition, &ability.targets); + let root_chain_targets = crate::game::targeting::parent_chain_targets_from_root(state, ability); + let condition_parent_targets = if root_chain_targets.is_empty() { + &ability.targets + } else { + &root_chain_targets + }; + bind_contextual_filter_to_condition(&mut condition, condition_parent_targets); // CR 603.7b: "until your next turn" is fixed at CREATION. The parser emits the // symbolic `AfterCreationTurn` floor (compile-time AST has no runtime turn @@ -651,15 +657,7 @@ fn concrete_parent_target_filter( filter: &TargetFilter, parent_targets: &[TargetRef], ) -> TargetFilter { - // This condition-binding path historically treats an absent declared - // parent slot as unconstrained; preserve that contract while the shared - // mass-effect normalizer correctly uses `None` to match no objects. - match filter { - TargetFilter::ParentTargetSlot { index } if parent_targets.get(*index).is_none() => { - TargetFilter::Any - } - _ => crate::game::filter::normalize_contextual_filter(filter, parent_targets), - } + crate::game::filter::normalize_contextual_filter(filter, parent_targets) } fn bind_tracked_set_to_condition(condition: &mut DelayedTriggerCondition, real_id: TrackedSetId) { From 72ae06f6ffa9ce4fcfa876c81d251e4beaf426c9 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 02:45:21 -0700 Subject: [PATCH 07/18] fix(engine): scope delayed target incarnation checks --- crates/engine/src/game/effects/change_zone.rs | 18 ++++++++++++++++-- .../engine/src/game/effects/delayed_trigger.rs | 7 ++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 37fa7dbbb3..6319e3e221 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -1646,6 +1646,16 @@ pub fn resolve_all( let effective_filter = crate::game::targeting::resolve_tracked_set_sentinel(state, effective_filter); + // CR 400.7 + CR 603.7c: only a tracked set whose member filter still names + // the delayed ability's parent object is governed by its incarnation pin. + // Ordinary tracked-set returns (for example Niko, Light of Hope) must be + // able to return a card that the earlier leg moved to exile. + let tracked_members_name_parent_object = matches!( + &target_filter, + TargetFilter::TrackedSetFiltered { filter, .. } + if super::delayed_trigger::filter_refs_parent_object_anaphor(filter) + ); + // CR 608.2c: Re-derive scan zones after the tracked-set sentinel binds — // the initial `origin`/`target` snapshot may have defaulted to the // battlefield before `chain_tracked_set_id` was populated (Zimone's @@ -1726,7 +1736,8 @@ pub fn resolve_all( .iter() .filter(|(&id, obj)| { origin_zones.contains(&obj.zone) - && ability.target_pin_is_current(id, state) + && (!tracked_members_name_parent_object + || ability.target_pin_is_current(id, state)) && crate::game::filter::matches_target_filter( state, id, @@ -1759,8 +1770,11 @@ pub fn resolve_all( }; // Clean up consumed tracked set after scanning. - if let TargetFilter::TrackedSet { id } = &effective_filter { + if let TargetFilter::TrackedSet { id } | TargetFilter::TrackedSetFiltered { id, .. } = + &effective_filter + { state.tracked_object_sets.remove(id); + // CR 608.2c: drop the consumed set's member-cause provenance in lockstep. state.tracked_set_member_causes.remove(id); } diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index 2643a62e01..e188baa5f9 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -1072,7 +1072,7 @@ fn bind_tracked_set_to_ability_chain(ability: &mut ResolvedAbility, real_id: Tra /// /// `_ => false` IS CORRECT HERE. `TargetFilter` is a broad, open enum and the /// shipped template ends the same way. Do NOT try to exhaust it. -fn filter_refs_parent_object_anaphor(filter: &TargetFilter) -> bool { +pub(super) fn filter_refs_parent_object_anaphor(filter: &TargetFilter) -> bool { match filter { TargetFilter::ParentTarget | TargetFilter::ParentTargetSlot { .. } => true, // CR 608.2h + CR 108.3: these derive a PLAYER, not an object. @@ -1278,10 +1278,11 @@ mod tests { concrete_parent_target_filter(&TargetFilter::ParentTargetSlot { index: 0 }, &parents), TargetFilter::SpecificObject { id: ObjectId(7) }, ); - // Out-of-range slot falls back to `Any`, matching the empty-slice case. + // A missing positive slot matches nothing; `Any` would over-fire the + // delayed condition against unrelated objects. assert_eq!( concrete_parent_target_filter(&TargetFilter::ParentTargetSlot { index: 5 }, &parents), - TargetFilter::Any, + TargetFilter::None, ); } From a158a98d381edfec0652df300f36fce9f558f13e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 03:13:11 -0700 Subject: [PATCH 08/18] fix(engine): permit tracked-set return members --- crates/engine/src/game/effects/change_zone.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 6319e3e221..790cf72e23 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -1648,13 +1648,14 @@ pub fn resolve_all( // CR 400.7 + CR 603.7c: only a tracked set whose member filter still names // the delayed ability's parent object is governed by its incarnation pin. - // Ordinary tracked-set returns (for example Niko, Light of Hope) must be - // able to return a card that the earlier leg moved to exile. + // Ordinary tracked-set returns (for example Niko, Light of Hope) are + // already constrained by their exile origin and must be able to return a + // card that the earlier leg moved there. let tracked_members_name_parent_object = matches!( &target_filter, TargetFilter::TrackedSetFiltered { filter, .. } if super::delayed_trigger::filter_refs_parent_object_anaphor(filter) - ); + ) && dest_zone != Zone::Battlefield; // CR 608.2c: Re-derive scan zones after the tracked-set sentinel binds — // the initial `origin`/`target` snapshot may have defaulted to the From 14c8e7c4aff692ae087726a5aaa84a3396b2cc6b Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 03:34:28 -0700 Subject: [PATCH 09/18] fix(engine): retain delayed exile return targets --- crates/engine/src/game/effects/change_zone.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 790cf72e23..d9e9f34025 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -480,7 +480,19 @@ pub fn resolve( // chosen-targets, the unified 3-tier dispatch shared by zone-change-style // effects whose subject can be the source itself, an event-context // referent, or a pre-selected target. See `targeting::resolved_targets`. - let effective_targets = crate::game::targeting::resolved_targets(ability, target_filter, state); + // CR 400.7 + CR 608.2c: A phase-delayed "return it" instruction follows + // this resolution's own Exile move. The delayed snapshot predates that + // move, so its incarnation pin is intentionally stale by the time this + // Exile → Battlefield instruction fires; the explicit exile origin remains + // the identity guard. Do not apply the general ParentTarget pin filter here. + let effective_targets = if origin == Some(Zone::Exile) + && dest_zone == Zone::Battlefield + && matches!(target_filter, TargetFilter::ParentTarget) + { + ability.targets.clone() + } else { + crate::game::targeting::resolved_targets(ability, target_filter, state) + }; let targeted_objects = crate::game::effects::effect_object_targets(target_filter, &effective_targets); // CR 730.3c: when this effect references the object that just left the From fc0c23e9864a23a721b61e97e6f67a2f311dfac5 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 03:55:56 -0700 Subject: [PATCH 10/18] fix(engine): validate delayed exile return identity --- crates/engine/src/game/effects/change_zone.rs | 266 ++++++++++++++++-- 1 file changed, 248 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index d9e9f34025..b897c717db 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -15,7 +15,7 @@ use crate::types::events::GameEvent; use crate::types::game_state::{ GameState, PendingCounterPostAction, PendingZoneChangeDelivery, WaitingFor, }; -use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; +use crate::types::identifiers::{ObjectId, ObjectIncarnationRef, TrackedSetId}; use crate::types::player::PlayerId; use crate::types::proposed_event::ProposedEvent; use crate::types::zones::{EtbTapState, Zone}; @@ -206,6 +206,73 @@ fn tracked_set_member_zones(state: &GameState, filter: &TargetFilter) -> Option< (!zones.is_empty()).then_some(zones) } +/// CR 400.7 + CR 603.7c: Return every concrete tracked-set identity nested in +/// a filter tree. A delayed mass move consumes every set it reads, including a +/// set wrapped by an `And`, `Or`, or `Not` composition. +fn tracked_set_ids(filter: &TargetFilter, ids: &mut Vec) { + match filter { + TargetFilter::TrackedSet { id } => { + if !ids.contains(id) { + ids.push(*id); + } + } + TargetFilter::TrackedSetFiltered { id, filter, .. } => { + if !ids.contains(id) { + ids.push(*id); + } + tracked_set_ids(filter, ids); + } + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + for filter in filters { + tracked_set_ids(filter, ids); + } + } + TargetFilter::Not { filter } => tracked_set_ids(filter, ids), + _ => {} + } +} + +/// CR 400.7 + CR 603.7c: A delayed tracked-set move must validate its +/// incarnation pins when any nested set member filter names the creation-time +/// parent object. The anaphor walk is the shared recursive authority. +fn tracked_set_filter_names_parent_object(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::TrackedSetFiltered { filter, .. } => { + super::delayed_trigger::filter_refs_parent_object_anaphor(filter) + } + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + filters.iter().any(tracked_set_filter_names_parent_object) + } + TargetFilter::Not { filter } => tracked_set_filter_names_parent_object(filter), + _ => false, + } +} + +/// CR 603.7c: A direct delayed Exile → Battlefield return may follow the +/// parent ability's exile move, so its creation-time target pin is one +/// incarnation behind the expected exile object. Accept that exact successor, +/// but not an object that later left exile and returned as another incarnation. +fn delayed_exile_return_targets(state: &GameState, ability: &ResolvedAbility) -> Vec { + ability + .targets + .iter() + .filter(|target| match target { + TargetRef::Player(_) => true, + TargetRef::Object(id) => ability + .target_incarnations + .iter() + .find(|pin| pin.object_id == *id) + .is_none_or(|pin| { + pin.is_current(state) + || state.objects.get(id).is_some_and(|object| { + pin.incarnation.checked_add(1) == Some(object.incarnation) + }) + }), + }) + .cloned() + .collect() +} + /// CR 110.2a: Resolve the optional `enters_under` controller override to a /// concrete `PlayerId` for any battlefield-entry effect. Shared by `ChangeZone`, /// `ChangeZoneAll`, and `Manifest` so every entry path resolves the reference @@ -480,16 +547,15 @@ pub fn resolve( // chosen-targets, the unified 3-tier dispatch shared by zone-change-style // effects whose subject can be the source itself, an event-context // referent, or a pre-selected target. See `targeting::resolved_targets`. - // CR 400.7 + CR 608.2c: A phase-delayed "return it" instruction follows - // this resolution's own Exile move. The delayed snapshot predates that - // move, so its incarnation pin is intentionally stale by the time this - // Exile → Battlefield instruction fires; the explicit exile origin remains - // the identity guard. Do not apply the general ParentTarget pin filter here. + // CR 603.7c: A phase-delayed "return it" instruction follows this + // resolution's own Exile move. Its creation-time pin is therefore one + // incarnation behind the expected exile object, but a later leave-and-return + // creates a further incarnation that the delayed trigger must not affect. let effective_targets = if origin == Some(Zone::Exile) && dest_zone == Zone::Battlefield && matches!(target_filter, TargetFilter::ParentTarget) { - ability.targets.clone() + delayed_exile_return_targets(state, ability) } else { crate::game::targeting::resolved_targets(ability, target_filter, state) }; @@ -1663,11 +1729,8 @@ pub fn resolve_all( // Ordinary tracked-set returns (for example Niko, Light of Hope) are // already constrained by their exile origin and must be able to return a // card that the earlier leg moved there. - let tracked_members_name_parent_object = matches!( - &target_filter, - TargetFilter::TrackedSetFiltered { filter, .. } - if super::delayed_trigger::filter_refs_parent_object_anaphor(filter) - ) && dest_zone != Zone::Battlefield; + let tracked_members_name_parent_object = + tracked_set_filter_names_parent_object(&target_filter) && dest_zone != Zone::Battlefield; // CR 608.2c: Re-derive scan zones after the tracked-set sentinel binds — // the initial `origin`/`target` snapshot may have defaulted to the @@ -1782,13 +1845,13 @@ pub fn resolve_all( matching }; - // Clean up consumed tracked set after scanning. - if let TargetFilter::TrackedSet { id } | TargetFilter::TrackedSetFiltered { id, .. } = - &effective_filter - { - state.tracked_object_sets.remove(id); + // Clean up every tracked set consumed by the filter tree after scanning. + let mut consumed_tracked_sets = Vec::new(); + tracked_set_ids(&effective_filter, &mut consumed_tracked_sets); + for id in consumed_tracked_sets { + state.tracked_object_sets.remove(&id); // CR 608.2c: drop the consumed set's member-cause provenance in lockstep. - state.tracked_set_member_causes.remove(id); + state.tracked_set_member_causes.remove(&id); } // CR 614.12a + CR 614.13a: when a mass entry brings in one or more devourers @@ -7697,6 +7760,173 @@ mod tests { assert!(!state.tracked_set_member_causes.contains_key(&set_id)); } + /// CR 603.7c + CR 400.7: A delayed return may follow its own exile move, + /// but the named card must not return after it later leaves exile and comes + /// back as a new object. + #[test] + fn delayed_exile_return_rejects_a_reexiled_target_incarnation() { + let mut state = GameState::new_two_player(42); + let card = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Delayed Return Subject".to_string(), + Zone::Battlefield, + ); + let initial_pin = ObjectIncarnationRef::from_object(&state.objects[&card]); + let mut events = Vec::new(); + + zones::move_to_zone(&mut state, card, Zone::Exile, &mut events); + zones::move_to_zone(&mut state, card, Zone::Graveyard, &mut events); + zones::move_to_zone(&mut state, card, Zone::Exile, &mut events); + + let mut delayed_return = ResolvedAbility::new( + Effect::ChangeZone { + origin: Some(Zone::Exile), + destination: Zone::Battlefield, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + vec![TargetRef::Object(card)], + ObjectId(100), + PlayerId(0), + ); + delayed_return.target_incarnations = vec![initial_pin]; + + resolve(&mut state, &delayed_return, &mut events).expect("delayed return resolves"); + + assert_eq!( + state.objects[&card].zone, + Zone::Exile, + "a re-exiled object is a new incarnation and must not be returned" + ); + } + + /// CR 400.7 + CR 603.7c: Parent-bound tracked-set membership and its + /// cleanup remain correct when the set is nested in every composite filter + /// shape accepted by the filter normalizer. + #[test] + fn nested_parent_bound_tracked_sets_apply_pins_and_cleanup() { + let nested_filters = vec![ + ( + TargetFilter::And { + filters: vec![ + TargetFilter::TrackedSetFiltered { + id: TrackedSetId(1), + filter: Box::new(TargetFilter::ParentTarget), + caused_by: None, + }, + TargetFilter::Any, + ], + }, + false, + ), + ( + TargetFilter::Or { + filters: vec![ + TargetFilter::TrackedSetFiltered { + id: TrackedSetId(1), + filter: Box::new(TargetFilter::ParentTarget), + caused_by: None, + }, + TargetFilter::TrackedSetFiltered { + id: TrackedSetId(1), + filter: Box::new(TargetFilter::SpecificObject { id: ObjectId(2) }), + caused_by: None, + }, + ], + }, + true, + ), + ( + TargetFilter::Not { + filter: Box::new(TargetFilter::TrackedSetFiltered { + id: TrackedSetId(1), + filter: Box::new(TargetFilter::ParentTarget), + caused_by: None, + }), + }, + true, + ), + ]; + + for (target, peer_should_move) in nested_filters { + let mut state = GameState::new_two_player(42); + let parent = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Stale Parent".to_string(), + Zone::Exile, + ); + let peer = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Tracked Peer".to_string(), + Zone::Exile, + ); + let set_id = TrackedSetId(1); + state.tracked_object_sets.insert(set_id, vec![parent, peer]); + state.tracked_set_member_causes.insert( + set_id, + [(parent, crate::types::ability::ThisWayCause::Exiled)] + .into_iter() + .collect(), + ); + + let mut ability = ResolvedAbility::new( + Effect::ChangeZoneAll { + origin: Some(Zone::Exile), + destination: Zone::Graveyard, + target, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enter_with_counters: vec![], + face_down_profile: None, + library_position: None, + random_order: false, + }, + vec![TargetRef::Object(parent)], + ObjectId(100), + PlayerId(0), + ); + ability.target_incarnations = vec![ObjectIncarnationRef::of(parent, 1)]; + let mut events = Vec::new(); + + resolve_all(&mut state, &ability, &mut events).expect("nested set move resolves"); + + assert_eq!( + state.objects[&parent].zone, + Zone::Exile, + "a stale parent target must not be moved through a nested tracked set" + ); + assert_eq!( + state.objects[&peer].zone, + if peer_should_move { + Zone::Graveyard + } else { + Zone::Exile + }, + "the non-parent branch must retain its own nested-filter behavior" + ); + assert!( + !state.tracked_object_sets.contains_key(&set_id) + && !state.tracked_set_member_causes.contains_key(&set_id), + "every nested tracked-set consumer must clear both membership and provenance" + ); + } + } + /// Zimone's Experiment: tracked-set routing must scan the members' actual zone /// (library) when `origin` is None (issue #2368). #[test] From cbf323f30b9f5befbe92695584ec0f5c55667921 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 04:10:48 -0700 Subject: [PATCH 11/18] fix(engine): satisfy delayed target pin lint --- crates/engine/src/game/effects/change_zone.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index b897c717db..366a377368 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -211,11 +211,8 @@ fn tracked_set_member_zones(state: &GameState, filter: &TargetFilter) -> Option< /// set wrapped by an `And`, `Or`, or `Not` composition. fn tracked_set_ids(filter: &TargetFilter, ids: &mut Vec) { match filter { - TargetFilter::TrackedSet { id } => { - if !ids.contains(id) { - ids.push(*id); - } - } + TargetFilter::TrackedSet { id } if !ids.contains(id) => ids.push(*id), + TargetFilter::TrackedSet { .. } => {} TargetFilter::TrackedSetFiltered { id, filter, .. } => { if !ids.contains(id) { ids.push(*id); From a1f6852cb0e759088d41d4bdf419585601c78865 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 04:32:58 -0700 Subject: [PATCH 12/18] fix(engine): retain filtered tracked sets for sibling moves --- crates/engine/src/game/effects/change_zone.rs | 58 +++++-------------- 1 file changed, 13 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 366a377368..a9406df326 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -15,7 +15,7 @@ use crate::types::events::GameEvent; use crate::types::game_state::{ GameState, PendingCounterPostAction, PendingZoneChangeDelivery, WaitingFor, }; -use crate::types::identifiers::{ObjectId, ObjectIncarnationRef, TrackedSetId}; +use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; use crate::types::proposed_event::ProposedEvent; use crate::types::zones::{EtbTapState, Zone}; @@ -206,29 +206,6 @@ fn tracked_set_member_zones(state: &GameState, filter: &TargetFilter) -> Option< (!zones.is_empty()).then_some(zones) } -/// CR 400.7 + CR 603.7c: Return every concrete tracked-set identity nested in -/// a filter tree. A delayed mass move consumes every set it reads, including a -/// set wrapped by an `And`, `Or`, or `Not` composition. -fn tracked_set_ids(filter: &TargetFilter, ids: &mut Vec) { - match filter { - TargetFilter::TrackedSet { id } if !ids.contains(id) => ids.push(*id), - TargetFilter::TrackedSet { .. } => {} - TargetFilter::TrackedSetFiltered { id, filter, .. } => { - if !ids.contains(id) { - ids.push(*id); - } - tracked_set_ids(filter, ids); - } - TargetFilter::And { filters } | TargetFilter::Or { filters } => { - for filter in filters { - tracked_set_ids(filter, ids); - } - } - TargetFilter::Not { filter } => tracked_set_ids(filter, ids), - _ => {} - } -} - /// CR 400.7 + CR 603.7c: A delayed tracked-set move must validate its /// incarnation pins when any nested set member filter names the creation-time /// parent object. The anaphor walk is the shared recursive authority. @@ -1842,13 +1819,13 @@ pub fn resolve_all( matching }; - // Clean up every tracked set consumed by the filter tree after scanning. - let mut consumed_tracked_sets = Vec::new(); - tracked_set_ids(&effective_filter, &mut consumed_tracked_sets); - for id in consumed_tracked_sets { - state.tracked_object_sets.remove(&id); + // A bare tracked set has one consumer. A `TrackedSetFiltered` can have a + // later sibling consumer (for example, Winding Way's "the rest" clause), + // so its producer owns that set's eventual cleanup. + if let TargetFilter::TrackedSet { id } = &effective_filter { + state.tracked_object_sets.remove(id); // CR 608.2c: drop the consumed set's member-cause provenance in lockstep. - state.tracked_set_member_causes.remove(&id); + state.tracked_set_member_causes.remove(id); } // CR 614.12a + CR 614.13a: when a mass entry brings in one or more devourers @@ -7808,11 +7785,10 @@ mod tests { ); } - /// CR 400.7 + CR 603.7c: Parent-bound tracked-set membership and its - /// cleanup remain correct when the set is nested in every composite filter - /// shape accepted by the filter normalizer. + /// CR 400.7 + CR 603.7c: Parent-bound tracked-set membership preserves the + /// delayed ability's incarnation pins through composite filter shapes. #[test] - fn nested_parent_bound_tracked_sets_apply_pins_and_cleanup() { + fn nested_parent_bound_tracked_sets_apply_pins() { let nested_filters = vec![ ( TargetFilter::And { @@ -7917,9 +7893,9 @@ mod tests { "the non-parent branch must retain its own nested-filter behavior" ); assert!( - !state.tracked_object_sets.contains_key(&set_id) - && !state.tracked_set_member_causes.contains_key(&set_id), - "every nested tracked-set consumer must clear both membership and provenance" + state.tracked_object_sets.contains_key(&set_id) + && state.tracked_set_member_causes.contains_key(&set_id), + "a filtered member selection must retain its set for a later sibling consumer" ); } } @@ -7956,12 +7932,6 @@ mod tests { state .tracked_object_sets .insert(set_id, vec![land, creature]); - state.tracked_set_member_causes.insert( - set_id, - [(land, crate::types::ability::ThisWayCause::Exiled)] - .into_iter() - .collect(), - ); state.chain_tracked_set_id = Some(set_id); let land_filter = TargetFilter::Typed(TypedFilter { @@ -7995,8 +7965,6 @@ mod tests { assert_eq!(state.objects[&land].zone, Zone::Battlefield); assert!(state.objects[&land].tapped); assert_eq!(state.objects[&creature].zone, Zone::Library); - assert!(!state.tracked_object_sets.contains_key(&set_id)); - assert!(!state.tracked_set_member_causes.contains_key(&set_id)); } #[test] From 48dabe29f195794d1222163dae0f3e2e3b0f248f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 04:33:41 -0700 Subject: [PATCH 13/18] test(engine): refresh optional prompt census pins --- crates/engine/src/game/engine.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 276f2590d7..ac0a6ed95c 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16035,9 +16035,9 @@ mod stage2_injector_tests { // 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:6300".to_string(), - "game/effects/mod.rs:6377".to_string(), - "game/effects/mod.rs:9570".to_string(), + "game/effects/mod.rs:6304".to_string(), + "game/effects/mod.rs:6381".to_string(), + "game/effects/mod.rs:9574".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. @@ -16355,7 +16355,7 @@ mod stage2_injector_tests { // // SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and // neither does this branch — total still 37, partition still 5/7/25. - "game/engine.rs:11942".to_string(), + "game/engine.rs:16689".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ From 10bb65ebf1ed8bc6d08682709efd62cda52d45d4 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 04:54:02 -0700 Subject: [PATCH 14/18] fix(engine): preserve delayed return target authority --- crates/engine/src/game/effects/change_zone.rs | 199 ++++++++++++------ 1 file changed, 132 insertions(+), 67 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index a9406df326..ded15e4639 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -528,6 +528,7 @@ pub fn resolve( let effective_targets = if origin == Some(Zone::Exile) && dest_zone == Zone::Battlefield && matches!(target_filter, TargetFilter::ParentTarget) + && !ability.target_incarnations.is_empty() { delayed_exile_return_targets(state, ability) } else { @@ -1698,13 +1699,20 @@ pub fn resolve_all( let effective_filter = crate::game::targeting::resolve_tracked_set_sentinel(state, effective_filter); + // CR 608.2c: A delayed `ChangeZone` that names its parent object is + // upgraded to a tracked-set mass move. Bind that anaphor to the delayed + // ability's creation-time targets before matching the set; otherwise a + // `ParentTarget` inner filter has no live parent context at the later phase + // trigger and incorrectly selects no members (Niko, Light of Hope). + let effective_filter = + crate::game::filter::normalize_contextual_filter(&effective_filter, &ability.targets); + // CR 400.7 + CR 603.7c: only a tracked set whose member filter still names // the delayed ability's parent object is governed by its incarnation pin. - // Ordinary tracked-set returns (for example Niko, Light of Hope) are - // already constrained by their exile origin and must be able to return a - // card that the earlier leg moved there. - let tracked_members_name_parent_object = - tracked_set_filter_names_parent_object(&target_filter) && dest_zone != Zone::Battlefield; + // A parent-bound return may enter the battlefield, so destination does not + // weaken the pin: after a later zone change, that same object id denotes a + // different game object and must not be returned. + let tracked_members_name_parent_object = tracked_set_filter_names_parent_object(&target_filter); // CR 608.2c: Re-derive scan zones after the tracked-set sentinel binds — // the initial `origin`/`target` snapshot may have defaulted to the @@ -7785,6 +7793,61 @@ mod tests { ); } + /// CR 608.2c: An unpinned ParentTarget return still resolves through the + /// current event context. The delayed-return pin exception must not bypass + /// that normal target authority merely because its zones are Exile → + /// Battlefield. + #[test] + fn unpinned_exile_return_uses_event_context_parent_target() { + let mut state = GameState::new_two_player(42); + let card = create_object( + &mut state, + CardId(3), + PlayerId(0), + "Event Context Subject".to_string(), + Zone::Exile, + ); + state.current_trigger_event = Some(GameEvent::ZoneChanged { + object_id: card, + from: Some(Zone::Battlefield), + to: Zone::Exile, + record: Box::new(ZoneChangeRecord::test_minimal( + card, + Some(Zone::Battlefield), + Zone::Exile, + )), + }); + let ability = ResolvedAbility::new( + Effect::ChangeZone { + origin: Some(Zone::Exile), + destination: Zone::Battlefield, + target: TargetFilter::ParentTarget, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + vec![], + ObjectId(100), + PlayerId(0), + ); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events).expect("event-context return resolves"); + + assert_eq!( + state.objects[&card].zone, + Zone::Battlefield, + "an unpinned ParentTarget return must use the triggering event's object" + ); + } + /// CR 400.7 + CR 603.7c: Parent-bound tracked-set membership preserves the /// delayed ability's incarnation pins through composite filter shapes. #[test] @@ -7832,71 +7895,73 @@ mod tests { ), ]; - for (target, peer_should_move) in nested_filters { - let mut state = GameState::new_two_player(42); - let parent = create_object( - &mut state, - CardId(1), - PlayerId(0), - "Stale Parent".to_string(), - Zone::Exile, - ); - let peer = create_object( - &mut state, - CardId(2), - PlayerId(0), - "Tracked Peer".to_string(), - Zone::Exile, - ); - let set_id = TrackedSetId(1); - state.tracked_object_sets.insert(set_id, vec![parent, peer]); - state.tracked_set_member_causes.insert( - set_id, - [(parent, crate::types::ability::ThisWayCause::Exiled)] - .into_iter() - .collect(), - ); + for destination in [Zone::Graveyard, Zone::Battlefield] { + for (target, peer_should_move) in &nested_filters { + let mut state = GameState::new_two_player(42); + let parent = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Stale Parent".to_string(), + Zone::Exile, + ); + let peer = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Tracked Peer".to_string(), + Zone::Exile, + ); + let set_id = TrackedSetId(1); + state.tracked_object_sets.insert(set_id, vec![parent, peer]); + state.tracked_set_member_causes.insert( + set_id, + [(parent, crate::types::ability::ThisWayCause::Exiled)] + .into_iter() + .collect(), + ); - let mut ability = ResolvedAbility::new( - Effect::ChangeZoneAll { - origin: Some(Zone::Exile), - destination: Zone::Graveyard, - target, - enters_under: None, - enter_tapped: EtbTapState::Unspecified, - enter_with_counters: vec![], - face_down_profile: None, - library_position: None, - random_order: false, - }, - vec![TargetRef::Object(parent)], - ObjectId(100), - PlayerId(0), - ); - ability.target_incarnations = vec![ObjectIncarnationRef::of(parent, 1)]; - let mut events = Vec::new(); + let mut ability = ResolvedAbility::new( + Effect::ChangeZoneAll { + origin: Some(Zone::Exile), + destination, + target: target.clone(), + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enter_with_counters: vec![], + face_down_profile: None, + library_position: None, + random_order: false, + }, + vec![TargetRef::Object(parent)], + ObjectId(100), + PlayerId(0), + ); + ability.target_incarnations = vec![ObjectIncarnationRef::of(parent, 1)]; + let mut events = Vec::new(); - resolve_all(&mut state, &ability, &mut events).expect("nested set move resolves"); + resolve_all(&mut state, &ability, &mut events).expect("nested set move resolves"); - assert_eq!( - state.objects[&parent].zone, - Zone::Exile, - "a stale parent target must not be moved through a nested tracked set" - ); - assert_eq!( - state.objects[&peer].zone, - if peer_should_move { - Zone::Graveyard - } else { - Zone::Exile - }, - "the non-parent branch must retain its own nested-filter behavior" - ); - assert!( - state.tracked_object_sets.contains_key(&set_id) - && state.tracked_set_member_causes.contains_key(&set_id), - "a filtered member selection must retain its set for a later sibling consumer" - ); + assert_eq!( + state.objects[&parent].zone, + Zone::Exile, + "a stale parent target must not be moved through a nested tracked set" + ); + assert_eq!( + state.objects[&peer].zone, + if *peer_should_move { + destination + } else { + Zone::Exile + }, + "the non-parent branch must retain its own nested-filter behavior" + ); + assert!( + state.tracked_object_sets.contains_key(&set_id) + && state.tracked_set_member_causes.contains_key(&set_id), + "a filtered member selection must retain its set for a later sibling consumer" + ); + } } } From 11eb502e90a493bfeecda2c433c4021276c17c9d Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 05:10:20 -0700 Subject: [PATCH 15/18] fix(engine): retain immediate delayed return successor --- crates/engine/src/game/effects/change_zone.rs | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index ded15e4639..2593f6bcf0 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -222,26 +222,36 @@ fn tracked_set_filter_names_parent_object(filter: &TargetFilter) -> bool { } } -/// CR 603.7c: A direct delayed Exile → Battlefield return may follow the -/// parent ability's exile move, so its creation-time target pin is one -/// incarnation behind the expected exile object. Accept that exact successor, -/// but not an object that later left exile and returned as another incarnation. +/// CR 603.7c: A delayed Exile → Battlefield return follows the parent +/// ability's own exile move. Its creation-time target pin is therefore one +/// incarnation behind the expected exile object; a later zone change creates a +/// further incarnation which must not be returned. +fn target_pin_is_current_or_delayed_exile_successor( + state: &GameState, + ability: &ResolvedAbility, + object_id: ObjectId, +) -> bool { + ability + .target_incarnations + .iter() + .find(|pin| pin.object_id == object_id) + .is_none_or(|pin| { + pin.is_current(state) + || state.objects.get(&object_id).is_some_and(|object| { + pin.incarnation.checked_add(1) == Some(object.incarnation) + }) + }) +} + fn delayed_exile_return_targets(state: &GameState, ability: &ResolvedAbility) -> Vec { ability .targets .iter() .filter(|target| match target { TargetRef::Player(_) => true, - TargetRef::Object(id) => ability - .target_incarnations - .iter() - .find(|pin| pin.object_id == *id) - .is_none_or(|pin| { - pin.is_current(state) - || state.objects.get(id).is_some_and(|object| { - pin.incarnation.checked_add(1) == Some(object.incarnation) - }) - }), + TargetRef::Object(id) => { + target_pin_is_current_or_delayed_exile_successor(state, ability, *id) + } }) .cloned() .collect() @@ -1709,9 +1719,9 @@ pub fn resolve_all( // CR 400.7 + CR 603.7c: only a tracked set whose member filter still names // the delayed ability's parent object is governed by its incarnation pin. - // A parent-bound return may enter the battlefield, so destination does not - // weaken the pin: after a later zone change, that same object id denotes a - // different game object and must not be returned. + // The Exile → Battlefield return immediately following the pinned exile is + // its one permitted successor; a later zone change is still a different + // object and must not be returned. let tracked_members_name_parent_object = tracked_set_filter_names_parent_object(&target_filter); // CR 608.2c: Re-derive scan zones after the tracked-set sentinel binds — @@ -1729,6 +1739,7 @@ pub fn resolve_all( } else { origin_zones }; + let delayed_exile_return = origin_zones == [Zone::Exile] && dest_zone == Zone::Battlefield; let track_exiled_by_source = crate::game::exile_links::should_track_exiled_by_source(state, ability.source_id, ability); @@ -1795,7 +1806,11 @@ pub fn resolve_all( .filter(|(&id, obj)| { origin_zones.contains(&obj.zone) && (!tracked_members_name_parent_object - || ability.target_pin_is_current(id, state)) + || if delayed_exile_return { + target_pin_is_current_or_delayed_exile_successor(state, ability, id) + } else { + ability.target_pin_is_current(id, state) + }) && crate::game::filter::matches_target_filter( state, id, From 063cddbeb616105a9adcacf87a86ad9a4bd897bb Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 05:11:19 -0700 Subject: [PATCH 16/18] fix(ci): restore prompt census baseline --- crates/engine/src/game/engine.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index ac0a6ed95c..276f2590d7 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16035,9 +16035,9 @@ mod stage2_injector_tests { // 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:6304".to_string(), - "game/effects/mod.rs:6381".to_string(), - "game/effects/mod.rs:9574".to_string(), + "game/effects/mod.rs:6300".to_string(), + "game/effects/mod.rs:6377".to_string(), + "game/effects/mod.rs:9570".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. @@ -16355,7 +16355,7 @@ mod stage2_injector_tests { // // SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and // neither does this branch — total still 37, partition still 5/7/25. - "game/engine.rs:16689".to_string(), + "game/engine.rs:11942".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ From c4239958a2453f4d586011810e87809166c9901d Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 05:40:28 -0700 Subject: [PATCH 17/18] test(engine): pin Niko delayed return identity --- crates/engine/src/game/engine.rs | 6 +++--- .../tests/integration/niko_light_of_hope.rs | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 276f2590d7..00ddfb6073 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16035,9 +16035,9 @@ mod stage2_injector_tests { // 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:6300".to_string(), - "game/effects/mod.rs:6377".to_string(), - "game/effects/mod.rs:9570".to_string(), + "game/effects/mod.rs:6304".to_string(), + "game/effects/mod.rs:6381".to_string(), + "game/effects/mod.rs:9574".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/tests/integration/niko_light_of_hope.rs b/crates/engine/tests/integration/niko_light_of_hope.rs index 09e3c9c790..f582e93cbe 100644 --- a/crates/engine/tests/integration/niko_light_of_hope.rs +++ b/crates/engine/tests/integration/niko_light_of_hope.rs @@ -11,7 +11,8 @@ use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; use engine::game::zones::create_object; use engine::parser::parse_oracle_text; use engine::types::ability::{ - AbilityDefinition, ControllerRef, Duration, Effect, PlayerScope, TargetFilter, TypeFilter, + AbilityDefinition, ControllerRef, Duration, Effect, PlayerScope, TargetFilter, TargetRef, + TypeFilter, }; use engine::types::actions::GameAction; use engine::types::game_state::WaitingFor; @@ -342,6 +343,22 @@ fn c_opponent_turn_co_fire_reverts_and_returns() { "copy active while the effect persists" ); assert_eq!(outcome.zone_of(donor), Zone::Exile); + let delayed_return = outcome + .state() + .delayed_triggers + .iter() + .find(|trigger| trigger.ability.targets.contains(&TargetRef::Object(donor))) + .expect("Niko's delayed return must retain its exiled donor"); + assert_eq!( + delayed_return + .ability + .target_incarnations + .iter() + .find(|pin| pin.object_id == donor) + .map(|pin| pin.incarnation), + Some(outcome.state().objects[&donor].incarnation), + "the return must pin the donor's exile incarnation at delayed-trigger creation" + ); // Advance to the opponent's (P1's) end step. The end-step prune runs at End // entry (before end-step triggers), so the turn-agnostic copy expires here. From fcecfb46b35e6b5f2c741f4f321172566eec2ed8 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 06:07:07 -0700 Subject: [PATCH 18/18] test(engine): assert Niko return pin at end step --- .../tests/integration/niko_light_of_hope.rs | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/engine/tests/integration/niko_light_of_hope.rs b/crates/engine/tests/integration/niko_light_of_hope.rs index f582e93cbe..f07adbbadb 100644 --- a/crates/engine/tests/integration/niko_light_of_hope.rs +++ b/crates/engine/tests/integration/niko_light_of_hope.rs @@ -349,14 +349,16 @@ fn c_opponent_turn_co_fire_reverts_and_returns() { .iter() .find(|trigger| trigger.ability.targets.contains(&TargetRef::Object(donor))) .expect("Niko's delayed return must retain its exiled donor"); + let delayed_return_pin = delayed_return + .ability + .target_incarnations + .iter() + .find(|pin| pin.object_id == donor) + .map(|pin| pin.incarnation) + .expect("Niko's delayed return must retain the donor pin"); assert_eq!( - delayed_return - .ability - .target_incarnations - .iter() - .find(|pin| pin.object_id == donor) - .map(|pin| pin.incarnation), - Some(outcome.state().objects[&donor].incarnation), + delayed_return_pin, + outcome.state().objects[&donor].incarnation, "the return must pin the donor's exile incarnation at delayed-trigger creation" ); @@ -377,6 +379,11 @@ fn c_opponent_turn_co_fire_reverts_and_returns() { "Shard", "turn-agnostic copy must revert at the FIRST (opponent's) end step" ); + assert_eq!( + runner.state().objects[&donor].incarnation, + delayed_return_pin, + "the donor must still be the pin's current exile object before its delayed return resolves" + ); // Co-fire: the return delayed trigger (AtNextPhase{End}) resolves at the same // end step, so the exiled creature is back on the battlefield.