Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5b09931
fix(engine): preserve tracked delayed target pins
matthewevans Aug 12, 2026
d0a61c3
fix(engine): borrow tracked delayed effect in test
matthewevans Aug 12, 2026
e6e8cab
fix(engine): compare tracked set id by value
matthewevans Aug 12, 2026
bd2c169
fix(engine): consume filtered tracked sets
matthewevans Aug 12, 2026
2c5c417
fix(engine): retain filtered tracked sets through chains
matthewevans Aug 12, 2026
94de57f
fix(engine): bind delayed condition slots from root
matthewevans Aug 12, 2026
72ae06f
fix(engine): scope delayed target incarnation checks
matthewevans Aug 12, 2026
a158a98
fix(engine): permit tracked-set return members
matthewevans Aug 12, 2026
14c8e7c
fix(engine): retain delayed exile return targets
matthewevans Aug 12, 2026
fc0c23e
fix(engine): validate delayed exile return identity
matthewevans Aug 12, 2026
cbf323f
fix(engine): satisfy delayed target pin lint
matthewevans Aug 12, 2026
a1f6852
fix(engine): retain filtered tracked sets for sibling moves
matthewevans Aug 12, 2026
48dabe2
test(engine): refresh optional prompt census pins
matthewevans Aug 12, 2026
10bb65e
fix(engine): preserve delayed return target authority
matthewevans Aug 12, 2026
11eb502
fix(engine): retain immediate delayed return successor
matthewevans Aug 12, 2026
063cddb
fix(ci): restore prompt census baseline
matthewevans Aug 12, 2026
c423995
test(engine): pin Niko delayed return identity
matthewevans Aug 12, 2026
fcecfb4
test(engine): assert Niko return pin at end step
matthewevans Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
322 changes: 320 additions & 2 deletions crates/engine/src/game/effects/change_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TargetRef> {
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
Expand Down Expand Up @@ -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)
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down Expand Up @@ -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);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading