From 6ed0c4b29a34a4946c91b315abdb7f8e056674f9 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sat, 15 Aug 2026 13:07:57 -0500 Subject: [PATCH 1/5] Fix stacked Sensei's Divining Top abilities --- crates/engine/src/game/effects/put_on_top.rs | 17 ++ crates/engine/src/game/stack.rs | 20 +-- crates/engine/tests/integration/main.rs | 1 + .../top_manifold_key_incarnation.rs | 159 ++++++++++++++++++ 4 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 crates/engine/tests/integration/top_manifold_key_incarnation.rs diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index a5c81dff23..6b8de93f6c 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -59,6 +59,23 @@ pub fn resolve( && ability.targets.is_empty() && ability.parent_target_missing_reason == Some(ParentTargetMissingReason::Dig); + // CR 400.7 + CR 113.7a: An activated ability's SelfRef must not follow a + // later object that reuses the source ID. Triggered abilities retain the + // trigger-aware SelfRef exceptions for their own departure successors. + let stale_self_ref = if ability.trigger_source.is_some() { + !ability.self_ref_is_current(state) + } else { + !ability.source_is_current(state) + }; + if matches!(target_filter, TargetFilter::SelfRef) && stale_self_ref { + events.push(GameEvent::EffectResolved { + kind: EffectKind::PutAtLibraryPosition, + source_id: ability.source_id, + subject: None, + }); + return Ok(()); + } + // CR 608.2c + 603.10a: Delegate to the unified 3-tier dispatch // (`resolved_targets`). `SelfRef` always resolves to the source object; // `None` / `ParentTarget` fall back to the source only when 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 773467c335..1da9f1f9a0 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1028,6 +1028,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..a265b4181b --- /dev/null +++ b/crates/engine/tests/integration/top_manifold_key_incarnation.rs @@ -0,0 +1,159 @@ +//! 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::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() { + 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" + ); + // 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); +} From 70bb03919926a4bf9d5536c4b946ac3fb04912fc Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 12:08:54 -0700 Subject: [PATCH 2/5] fix(PR-7455): guard stale library references Co-authored-by: traemyn --- crates/engine/src/game/effects/put_on_top.rs | 112 +++++++++++++++--- .../top_manifold_key_incarnation.rs | 42 ++++++- 2 files changed, 136 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 6b8de93f6c..7f48bcf89a 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -59,23 +59,6 @@ pub fn resolve( && ability.targets.is_empty() && ability.parent_target_missing_reason == Some(ParentTargetMissingReason::Dig); - // CR 400.7 + CR 113.7a: An activated ability's SelfRef must not follow a - // later object that reuses the source ID. Triggered abilities retain the - // trigger-aware SelfRef exceptions for their own departure successors. - let stale_self_ref = if ability.trigger_source.is_some() { - !ability.self_ref_is_current(state) - } else { - !ability.source_is_current(state) - }; - if matches!(target_filter, TargetFilter::SelfRef) && stale_self_ref { - events.push(GameEvent::EffectResolved { - kind: EffectKind::PutAtLibraryPosition, - source_id: ability.source_id, - subject: None, - }); - return Ok(()); - } - // CR 608.2c + 603.10a: Delegate to the unified 3-tier dispatch // (`resolved_targets`). `SelfRef` always resolves to the source object; // `None` / `ParentTarget` fall back to the source only when @@ -86,6 +69,33 @@ pub fn resolve( } else { crate::game::targeting::resolved_targets(ability, &target_filter, state) }; + + // CR 400.7 + CR 113.7a: A source-resolving empty SelfRef/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) + }; + if ability.targets.is_empty() + && matches!( + target_filter, + TargetFilter::SelfRef | TargetFilter::ParentTarget + ) + && 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. @@ -883,6 +893,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/tests/integration/top_manifold_key_incarnation.rs b/crates/engine/tests/integration/top_manifold_key_incarnation.rs index a265b4181b..544e45d749 100644 --- a/crates/engine/tests/integration/top_manifold_key_incarnation.rs +++ b/crates/engine/tests/integration/top_manifold_key_incarnation.rs @@ -2,7 +2,7 @@ //! activated abilities that remain on the stack across a zone change. use engine::game::scenario::{GameScenario, P0}; -use engine::types::ability::TargetRef; +use engine::types::ability::{Effect, ResolvedAbility, TargetFilter, TargetRef}; use engine::types::actions::GameAction; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaType, ManaUnit}; @@ -11,6 +11,35 @@ 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.as_ref(), + 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.as_ref(), 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( @@ -142,6 +171,17 @@ fn top_ability_does_not_follow_new_object_after_key_untaps_it() { 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(); From 70aed9104b1b3942016759b2a0409608a64b5d5e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 13:08:48 -0700 Subject: [PATCH 3/5] fix(PR-7455): match resolved effects by reference --- .../engine/tests/integration/top_manifold_key_incarnation.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/integration/top_manifold_key_incarnation.rs b/crates/engine/tests/integration/top_manifold_key_incarnation.rs index 544e45d749..0221e2218d 100644 --- a/crates/engine/tests/integration/top_manifold_key_incarnation.rs +++ b/crates/engine/tests/integration/top_manifold_key_incarnation.rs @@ -13,7 +13,7 @@ use engine::types::zones::Zone; fn top_ability_does_not_follow_new_object_after_key_untaps_it() { fn contains_self_ref_library_placement(ability: &ResolvedAbility) -> bool { matches!( - ability.effect.as_ref(), + &ability.effect, Effect::PutAtLibraryPosition { target: TargetFilter::SelfRef, .. @@ -29,7 +29,7 @@ fn top_ability_does_not_follow_new_object_after_key_untaps_it() { } fn contains_unimplemented(ability: &ResolvedAbility) -> bool { - matches!(ability.effect.as_ref(), Effect::Unimplemented { .. }) + matches!(&ability.effect, Effect::Unimplemented { .. }) || ability .sub_ability .as_deref() From f4d5049faf410e0dff31117db9f14bf2d3537d1f Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 14:04:33 -0700 Subject: [PATCH 4/5] fix(PR-7455): guard stale none fallback --- crates/engine/src/game/effects/put_on_top.rs | 42 +++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 7f48bcf89a..5d8f64e185 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -70,7 +70,7 @@ pub fn resolve( crate::game::targeting::resolved_targets(ability, &target_filter, state) }; - // CR 400.7 + CR 113.7a: A source-resolving empty SelfRef/ParentTarget must + // 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 @@ -84,7 +84,7 @@ pub fn resolve( if ability.targets.is_empty() && matches!( target_filter, - TargetFilter::SelfRef | TargetFilter::ParentTarget + TargetFilter::SelfRef | TargetFilter::None | TargetFilter::ParentTarget ) && effective_targets == [crate::types::ability::TargetRef::Object(ability.source_id)] && !source_is_current @@ -840,6 +840,44 @@ 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" + ); + } + /// End-to-end Avenging Angel-class pipeline test. #[test] fn test_put_on_top_ltb_pipeline_returns_to_top_of_library() { From cad27935174e3789ffd5f56b62769297a707b4f5 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sat, 15 Aug 2026 15:04:04 -0700 Subject: [PATCH 5/5] fix(PR-7455): guard propagated stale self references SelfRef ignores propagated targets during resolution, so the stale-incarnation no-op must apply without the empty-target condition. Add a regression that reaches the propagated-target path and fails if the guard is reverted. --- crates/engine/src/game/effects/put_on_top.rs | 59 ++++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/effects/put_on_top.rs b/crates/engine/src/game/effects/put_on_top.rs index 5d8f64e185..20d4693932 100644 --- a/crates/engine/src/game/effects/put_on_top.rs +++ b/crates/engine/src/game/effects/put_on_top.rs @@ -81,11 +81,13 @@ pub fn resolve( } else { ability.source_is_current(state) }; - if ability.targets.is_empty() - && matches!( - target_filter, - TargetFilter::SelfRef | TargetFilter::None | TargetFilter::ParentTarget - ) + 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 { @@ -878,6 +880,53 @@ mod tests { ); } + /// 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() {