diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index a5c81dff23..20d4693932 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -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. @@ -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() { @@ -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); diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 008eeaef1f..2b0712bd03 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -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() { diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 32082155a5..8e2121d7f7 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -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; diff --git a/crates/engine/tests/integration/top_manifold_key_incarnation.rs b/crates/engine/tests/integration/top_manifold_key_incarnation.rs new file mode 100644 index 0000000000..0221e2218d --- /dev/null +++ b/crates/engine/tests/integration/top_manifold_key_incarnation.rs @@ -0,0 +1,199 @@ +//! CR 113.7a + CR 400.7 + CR 608.2c regression coverage for source-referential +//! activated abilities that remain on the stack across a zone change. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::{Effect, ResolvedAbility, TargetFilter, TargetRef}; +use engine::types::actions::GameAction; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +#[test] +fn top_ability_does_not_follow_new_object_after_key_untaps_it() { + fn contains_self_ref_library_placement(ability: &ResolvedAbility) -> bool { + matches!( + &ability.effect, + Effect::PutAtLibraryPosition { + target: TargetFilter::SelfRef, + .. + } + ) || ability + .sub_ability + .as_deref() + .is_some_and(contains_self_ref_library_placement) + || ability + .else_ability + .as_deref() + .is_some_and(contains_self_ref_library_placement) + } + + fn contains_unimplemented(ability: &ResolvedAbility) -> bool { + matches!(&ability.effect, Effect::Unimplemented { .. }) + || ability + .sub_ability + .as_deref() + .is_some_and(contains_unimplemented) + || ability + .else_ability + .as_deref() + .is_some_and(contains_unimplemented) + } + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_library_top( + P0, + &["Prepared Card A", "Prepared Card B", "Prepared Card C"], + ); + scenario.with_mana_pool( + P0, + vec![ManaUnit::new( + ManaType::Colorless, + ObjectId(9_999), + false, + vec![], + )], + ); + + let top = scenario + .add_enchantment_from_oracle( + P0, + "Sensei's Divining Top", + "{1}: Look at the top three cards of your library, then put them back in any order.\n{T}: Draw a card, then put this artifact on top of its owner's library.", + ) + .as_artifact() + .id(); + let key = scenario + .add_enchantment_from_oracle( + P0, + "Manifold Key", + "{1}, {T}: Untap another target artifact.\n{3}, {T}: Target creature can't be blocked this turn.", + ) + .as_artifact() + .id(); + + let mut runner = scenario.build(); + let initial_hand_size = runner.state().players[0].hand.len(); + let next_library_card = runner.state().players[0].library[1]; + + // CR 602.2b: Put the first Top activation on the stack and capture its + // source incarnation before the intervening Key activation resolves. + runner + .act(GameAction::ActivateAbility { + source_id: top, + ability_index: 1, + }) + .expect("Top's first draw activation must be accepted"); + let first_incarnation = runner.state().objects[&top].incarnation; + let first_ability_incarnation = runner.state().stack[0] + .ability() + .and_then(|ability| ability.source_incarnation); + assert_eq!( + first_ability_incarnation, + Some(first_incarnation), + "ordinary artifacts must capture their source incarnation on the stack" + ); + + // CR 602.2b: Activate Key in response, choose Top, pay {1}, and resolve + // only Key. The first Top activation remains underneath it. + runner + .act(GameAction::ActivateAbility { + source_id: key, + ability_index: 0, + }) + .expect("Key's untap activation must be accepted"); + if matches!( + runner.state().waiting_for, + engine::types::game_state::WaitingFor::TargetSelection { .. } + ) { + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(top)), + }) + .expect("Key must be able to target Top"); + } + assert!( + runner.state().stack.back().is_some_and(|entry| { + entry + .ability() + .is_some_and(|ability| ability.targets.contains(&TargetRef::Object(top))) + }), + "Key's stacked ability must target Top" + ); + runner + .act(GameAction::PassPriority) + .expect("Key's mana payment or priority pass must be accepted"); + assert_eq!( + runner.state().stack.len(), + 2, + "Key must remain above Top's ability" + ); + runner.resolve_top(); + assert_eq!( + runner.state().stack.len(), + 1, + "resolving Key must leave the first Top ability on the stack" + ); + assert!(!runner.state().objects[&top].tapped, "Key must untap Top"); + + // CR 405.1 + CR 608.2c: Activate Top again, then resolve the newer + // activation first. It draws a card and puts the current Top object on top + // of the library, creating a new object incarnation. + runner + .act(GameAction::ActivateAbility { + source_id: top, + ability_index: 1, + }) + .expect("Top's second draw activation must be accepted"); + assert_eq!( + runner.state().stack.len(), + 2, + "both Top abilities must be stacked" + ); + runner.resolve_top(); + assert_eq!( + runner.state().players[0].hand.len(), + initial_hand_size + 1, + "the newer Top ability must draw one prepared card" + ); + assert_eq!(runner.state().objects[&top].zone, Zone::Library); + assert_eq!(runner.state().players[0].library[0], top); + assert_ne!( + runner.state().objects[&top].incarnation, + first_incarnation, + "moving Top to the library must create a new object" + ); + assert_eq!( + runner.state().stack[0] + .ability() + .and_then(|ability| ability.source_incarnation), + Some(first_incarnation), + "the older Top ability must retain its original source incarnation" + ); + let older_ability = runner.state().stack[0] + .ability() + .expect("the older Top activation must carry its resolved ability"); + assert!( + contains_self_ref_library_placement(older_ability), + "the older Top activation must retain its parsed SelfRef library placement" + ); + assert!( + !contains_unimplemented(older_ability), + "the older Top activation must not reach the stale-source guard through an unimplemented parse" + ); + // CR 400.7 + CR 113.7a: The older ability draws Top as a new object. Its + // stale SelfRef placement is a legal no-op, so the stack still settles. + runner.resolve_top(); + assert!( + runner.state().stack.is_empty(), + "both Top abilities must resolve" + ); + assert_eq!( + runner.state().players[0].hand.len(), + initial_hand_size + 2, + "the older Top ability must draw Top" + ); + assert_eq!(runner.state().objects[&top].zone, Zone::Hand); + assert_eq!(runner.state().players[0].library[0], next_library_card); +}