diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 081ad1e1ef..2593f6bcf0 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -206,6 +206,57 @@ fn tracked_set_member_zones(state: &GameState, filter: &TargetFilter) -> Option< (!zones.is_empty()).then_some(zones) } +/// 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 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) => { + target_pin_is_current_or_delayed_exile_successor(state, ability, *id) + } + }) + .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,7 +531,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 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.target_incarnations.is_empty() + { + delayed_exile_return_targets(state, ability) + } 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 @@ -1646,6 +1709,21 @@ 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. + // 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 — // the initial `origin`/`target` snapshot may have defaulted to the // battlefield before `chain_tracked_set_id` was populated (Zimone's @@ -1661,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); @@ -1726,6 +1805,12 @@ pub fn resolve_all( .iter() .filter(|(&id, obj)| { origin_zones.contains(&obj.zone) + && (!tracked_members_name_parent_object + || 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, @@ -1757,7 +1842,9 @@ pub fn resolve_all( matching }; - // Clean up consumed tracked set after scanning. + // 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. @@ -7633,6 +7720,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 { @@ -7660,6 +7753,231 @@ 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)); + } + + /// 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 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] + fn nested_parent_bound_tracked_sets_apply_pins() { + 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 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, + 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"); + + 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" + ); + } + } } /// Zimone's Experiment: tracked-set routing must scan the members' actual zone diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index e11e208931..e188baa5f9 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,53 +657,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 +992,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 { @@ -1105,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. @@ -1126,6 +1093,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, } } @@ -1308,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, ); } @@ -3774,6 +3745,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/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/src/game/filter.rs b/crates/engine/src/game/filter.rs index fa6597cb02..9c5b1ab4da 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -1030,11 +1030,9 @@ fn entered_perturbs_quantity( }) } -/// CR 608.2c: Resolve contextual parent-target exclusions before a mass-effect 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], @@ -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) @@ -1084,6 +1081,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::None), TargetFilter::Or { filters } => TargetFilter::Or { filters: filters .iter() @@ -1096,10 +1098,41 @@ 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, + }, + TargetFilter::ChosenDamageSource { filter } => TargetFilter::ChosenDamageSource { + filter: filter + .as_ref() + .map(|inner| Box::new(normalize_contextual_filter(inner, parent_targets))), + }, _ => 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 +10519,46 @@ 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_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 { diff --git a/crates/engine/tests/integration/niko_light_of_hope.rs b/crates/engine/tests/integration/niko_light_of_hope.rs index 09e3c9c790..f07adbbadb 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,24 @@ 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"); + 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_pin, + 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. @@ -360,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.