Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
182 changes: 182 additions & 0 deletions crates/engine/src/game/effects/put_on_top.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,35 @@ pub fn resolve(
} else {
crate::game::targeting::resolved_targets(ability, &target_filter, state)
};

// CR 400.7 + CR 113.7a: A source-resolving empty SelfRef/None/ParentTarget must
// not follow a later object that reuses the source ID. This stays after
// `resolved_targets`: an empty ParentTarget can instead resolve a real
// event-context referent, which must not be mistaken for the source
// fallback. Triggered abilities retain the trigger-aware immediate-
// departure successor exceptions through `self_ref_is_current`.
let source_is_current = if ability.trigger_source.is_some() {
ability.self_ref_is_current(state)
} else {
ability.source_is_current(state)
};
let resolves_to_source = matches!(target_filter, TargetFilter::SelfRef)
|| (ability.targets.is_empty()
&& matches!(
target_filter,
TargetFilter::None | TargetFilter::ParentTarget
));
if resolves_to_source
&& effective_targets == [crate::types::ability::TargetRef::Object(ability.source_id)]
&& !source_is_current
{
events.push(GameEvent::EffectResolved {
kind: EffectKind::PutAtLibraryPosition,
source_id: ability.source_id,
subject: None,
});
return Ok(());
}
// CR 608.2c: `effect_object_targets` forwards `ability.targets` verbatim
// for non-slot filters. A dig hand-keep binds `ParentTarget` on the exile
// tail but must not pre-fill a `TrackedSet` bottom pick with the kept card.
Expand Down Expand Up @@ -813,6 +842,91 @@ mod tests {
assert_eq!(state.players[1].library[0], obj_id);
}

/// CR 400.7: `None` uses the same empty-target source fallback as
/// `ParentTarget`; a stale activation cannot move a later incarnation.
#[test]
fn test_put_on_top_none_fallback_does_not_follow_new_source_incarnation() {
let mut state = GameState::new_two_player(42);
let obj_id = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Source".to_string(),
Zone::Battlefield,
);
let mut ability = ResolvedAbility::new(
Effect::PutAtLibraryPosition {
target: TargetFilter::None,
count: QuantityExpr::Fixed { value: 1 },
position: LibraryPosition::Top,
},
vec![],
obj_id,
PlayerId(0),
);
ability.source_incarnation = Some(state.objects[&obj_id].incarnation);

let mut move_events = Vec::new();
crate::game::zones::move_to_zone(&mut state, obj_id, Zone::Graveyard, &mut move_events);
crate::game::zones::move_to_zone(&mut state, obj_id, Zone::Battlefield, &mut move_events);

let mut events = Vec::new();
resolve(&mut state, &ability, &mut events).unwrap();

assert_eq!(state.objects[&obj_id].zone, Zone::Battlefield);
assert!(
!state.players[0].library.contains(&obj_id),
"the stale None fallback must not put the later object in the library"
);
}

/// CR 400.7: `SelfRef` always names the source even when an enclosing
/// chain propagated another target into this ability. A stale source must
/// therefore not move the later incarnation through that path either.
#[test]
fn test_put_on_top_stale_self_ref_ignores_propagated_targets() {
let mut state = GameState::new_two_player(42);
let obj_id = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Source".to_string(),
Zone::Battlefield,
);
let propagated_target = create_object(
&mut state,
CardId(2),
PlayerId(0),
"Propagated target".to_string(),
Zone::Battlefield,
);
let mut ability = ResolvedAbility::new(
Effect::PutAtLibraryPosition {
target: TargetFilter::SelfRef,
count: QuantityExpr::Fixed { value: 1 },
position: LibraryPosition::Top,
},
vec![TargetRef::Object(propagated_target)],
obj_id,
PlayerId(0),
);
ability.source_incarnation = Some(state.objects[&obj_id].incarnation);

let mut move_events = Vec::new();
crate::game::zones::move_to_zone(&mut state, obj_id, Zone::Graveyard, &mut move_events);
crate::game::zones::move_to_zone(&mut state, obj_id, Zone::Battlefield, &mut move_events);

let mut events = Vec::new();
resolve(&mut state, &ability, &mut events).unwrap();

assert_eq!(state.objects[&obj_id].zone, Zone::Battlefield);
assert_eq!(state.objects[&propagated_target].zone, Zone::Battlefield);
assert!(
!state.players[0].library.contains(&obj_id),
"the stale SelfRef must not put the later object in the library"
);
}

/// End-to-end Avenging Angel-class pipeline test.
#[test]
fn test_put_on_top_ltb_pipeline_returns_to_top_of_library() {
Expand Down Expand Up @@ -866,6 +980,74 @@ mod tests {
assert!(!state.players[0].graveyard.contains(&angel_id));
}

/// CR 400.7: An LTB `ParentTarget` fallback names the object that died,
/// not a later object with the same storage ID. Drive the trigger through
/// the real zone-change, trigger, stack, and resolver pipeline, then move
/// the card back before the trigger resolves to prove the new object stays
/// on the battlefield.
#[test]
fn test_put_on_top_ltb_reentry_does_not_follow_new_object() {
use crate::game::stack::resolve_top;
use crate::game::triggers::process_triggers;
use crate::types::ability::{AbilityDefinition, AbilityKind, TriggerDefinition};
use crate::types::triggers::TriggerMode;

let mut state = GameState::new_two_player(42);
let angel_id = create_object(
&mut state,
CardId(1),
PlayerId(0),
"Avenging Angel".to_string(),
Zone::Battlefield,
);

let mut trigger = TriggerDefinition::new(TriggerMode::ChangesZone);
trigger.origin = Some(Zone::Battlefield);
trigger.destination = Some(Zone::Graveyard);
trigger.valid_card = Some(TargetFilter::SelfRef);
trigger.trigger_zones = vec![Zone::Graveyard];
trigger.execute = Some(Box::new(AbilityDefinition::new(
AbilityKind::Spell,
Effect::PutAtLibraryPosition {
target: TargetFilter::ParentTarget,
count: QuantityExpr::Fixed { value: 1 },
position: LibraryPosition::Top,
},
)));
state
.objects
.get_mut(&angel_id)
.unwrap()
.trigger_definitions
.push(trigger);

let mut death_events = Vec::new();
crate::game::zones::move_to_zone(&mut state, angel_id, Zone::Graveyard, &mut death_events);
let died_incarnation = state.objects[&angel_id].incarnation;
process_triggers(&mut state, &death_events);
assert_eq!(state.stack.len(), 1, "LTB trigger did not reach the stack");

let mut reentry_events = Vec::new();
crate::game::zones::move_to_zone(
&mut state,
angel_id,
Zone::Battlefield,
&mut reentry_events,
);
assert_ne!(
state.objects[&angel_id].incarnation, died_incarnation,
"re-entering must create a new object incarnation"
);

let mut resolve_events = Vec::new();
resolve_top(&mut state, &mut resolve_events);
assert_eq!(state.objects[&angel_id].zone, Zone::Battlefield);
assert!(
!state.players[0].library.contains(&angel_id),
"the stale LTB trigger must not put the new object on top of the library"
);
}

#[test]
fn test_resolve_puts_card_nth_from_top() {
let mut state = GameState::new_two_player(42);
Expand Down
20 changes: 10 additions & 10 deletions crates/engine/src/game/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,22 +102,22 @@ fn push_to_stack_with_firing(
entry.kind,
StackEntryKind::ActivatedAbility { .. } | StackEntryKind::TriggeredAbility { .. }
) {
if let Some(ability) = entry.ability_mut() {
// CR 400.7 + CR 113.7a: Capture the source incarnation for every
// activated or triggered ability, including non-transforming
// permanents. The transformation guard below has a narrower scope.
if ability.source_incarnation.is_none() {
ability
.set_source_incarnation_recursive(source_ref.map(|source| source.incarnation));
}
}

let source = state
.objects
.get(&entry.source_id)
.filter(|object| object.back_face.is_some());
let count = source.map(|object| object.transformation_count);
if let Some(ability) = entry.ability_mut() {
// CR 608.2h + CR 113.7a: Every activated/triggered ability needs
// its source incarnation, not only a transforming source. Effects
// such as "~'s controller loses life" use it to read the source's
// current controller while it remains in its expected zone and its
// LKI controller after it leaves, without rebinding a re-entered
// object that reuses this storage id.
if ability.source_incarnation.is_none() {
ability
.set_source_incarnation_recursive(source_ref.map(|source| source.incarnation));
}
// CR 701.27f: delayed triggered abilities already carry their
// creation-time generation and must not be restamped when fired.
if ability.context.source_transformation_count.is_none() {
Expand Down
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,7 @@ mod tinybones_joins_up_multi_target;
mod tobita_master_of_winds_flying_grant;
mod tom_bombadil_lore_counter_gate;
mod tombstone_stairwell_per_player_tokens;
mod top_manifold_key_incarnation;
mod top_of_library_mixed_permission;
mod total_war_attacking_player_scope;
mod tracked_set_anaphor_quantity_binds;
Expand Down
Loading
Loading