From 537bf573cfe57061c78b8fbf37efb4c92a1b8512 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sun, 9 Aug 2026 22:09:48 -0500 Subject: [PATCH 1/6] Fix Emperor of Bones --- crates/engine/src/game/replacement.rs | 13 ++- .../issue_1515_emperor_of_bones.rs | 103 +++++++++++++++++- 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 1ef4053226..149eb79ca2 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1003,10 +1003,17 @@ pub fn replacement_choice_waiting_for(player: PlayerId, state: &GameState) -> Wa } /// CR 614.12a: Park on the replacement choice for `player`, unless a downstream -/// effect (a Devour as-enters Sacrifice `EffectZoneChoice`) already surfaced its -/// own interactive prompt — then leave it so the pending choice isn't clobbered. +/// as-enters effect already surfaced its own interactive prompt. Leave that prompt +/// in place so the entry choice completes before the surrounding ability resumes. pub fn park_waiting_for(state: &mut GameState, player: PlayerId) { - if matches!(state.waiting_for, WaitingFor::EffectZoneChoice { .. }) { + if matches!( + state.waiting_for, + WaitingFor::EffectZoneChoice { .. } + | WaitingFor::CopyTargetChoice { .. } + | WaitingFor::ChooseOneOfBranch { .. } + | WaitingFor::NamedChoice { .. } + | WaitingFor::ReturnAsAuraTarget { .. } + ) { return; } state.waiting_for = replacement_choice_waiting_for(player, state); diff --git a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs index 4594815bfd..6ba617b29c 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -6,19 +6,30 @@ use engine::game::effects::resolve_ability_chain; use engine::game::scenario::{GameScenario, P0}; use engine::parser::oracle_effect::parse_effect_chain; use engine::types::ability::{ - AbilityKind, ContinuousModification, DelayedTriggerCondition, Effect, TargetFilter, + AbilityKind, ChoiceType, ChosenAttribute, ContinuousModification, DelayedTriggerCondition, + Effect, TargetFilter, }; +use engine::types::actions::GameAction; use engine::types::counter::CounterType; use engine::types::game_state::{ExileLink, ExileLinkKind, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::keywords::Keyword; use engine::types::phase::Phase; +use engine::types::player::PlayerId; use engine::types::zones::Zone; const EMPEROR_COUNTER_TRIGGER_EFFECT: &str = "put a creature card exiled with this creature onto \ the battlefield under your control with a finality counter on it. it gains haste. sacrifice it at \ the beginning of the next end step."; +const ANOINTED_PEACEKEEPER: &str = "Vigilance\n\ +As this creature enters, look at an opponent's hand, then choose any card name.\n\ +Spells your opponents cast with the chosen name cost {2} more to cast.\n\ +Activated abilities of sources with the chosen name cost {2} more to activate unless they're mana abilities."; + +const P1: PlayerId = PlayerId(1); +const NAMED_CARD: &str = "Llanowar Elves"; + fn creature_has_haste_from_transient_effects( state: &engine::types::game_state::GameState, creature: ObjectId, @@ -153,3 +164,93 @@ fn issue_1515_emperor_of_bones_binds_haste_and_delayed_sacrifice_to_returned_cre "the delayed sacrifice must not sacrifice Emperor" ); } + +/// CR 614.12a + CR 400.7j: An as-enters choice on the returned permanent must +/// complete without losing later instructions that refer to that permanent. +#[test] +fn emperor_of_bones_resumes_riders_after_anointed_peacekeepers_as_enters_choices() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + let emperor = scenario.add_creature(P0, "Emperor of Bones", 2, 2).id(); + let _opponent_card = scenario.add_card_to_hand(P1, "Opponent Secret"); + let peacekeeper = { + let mut builder = scenario.add_creature_to_exile(P0, "Anointed Peacekeeper", 3, 3); + builder.from_oracle_text(ANOINTED_PEACEKEEPER); + builder.id() + }; + + let mut runner = scenario.build(); + runner.state_mut().all_card_names = std::sync::Arc::from([NAMED_CARD.to_string()]); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: peacekeeper, + source_id: emperor, + kind: ExileLinkKind::TrackedBySource, + }); + + let definition = parse_effect_chain(EMPEROR_COUNTER_TRIGGER_EFFECT, AbilityKind::Spell); + let ability = build_resolved_from_def(&definition, emperor, P0); + let mut events = Vec::new(); + resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0) + .expect("Emperor of Bones return must reach Peacekeeper's as-enters choice"); + + let WaitingFor::NamedChoice { + choice_type, + options, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "Peacekeeper must ask which opponent to look at, got {}", + runner.waiting_for_kind() + ); + }; + assert!(matches!(choice_type, ChoiceType::Opponent { .. })); + assert_eq!(options, vec![P1.0.to_string()]); + runner + .act(GameAction::ChooseOption { + choice: P1.0.to_string(), + }) + .expect("choose the opponent whose hand Peacekeeper looks at"); + + let WaitingFor::NamedChoice { choice_type, .. } = runner.state().waiting_for.clone() else { + panic!( + "Peacekeeper must ask for a card name after looking, got {}", + runner.waiting_for_kind() + ); + }; + assert!(matches!(choice_type, ChoiceType::CardName)); + runner + .act(GameAction::ChooseOption { + choice: NAMED_CARD.to_string(), + }) + .expect("choose the card name for Peacekeeper"); + + let state = runner.state(); + let returned = &state.objects[&peacekeeper]; + assert_eq!(returned.zone, Zone::Battlefield); + assert!(returned.chosen_attributes.iter().any( + |attribute| matches!(attribute, ChosenAttribute::CardName(name) if name == NAMED_CARD) + )); + assert_eq!( + returned + .counters + .get(&CounterType::Finality) + .copied() + .unwrap_or(0), + 1, + "Peacekeeper must retain Emperor's finality entry modifier" + ); + assert!( + creature_has_haste_from_transient_effects(state, peacekeeper), + "Emperor's forwarded haste rider must resume after both as-enters choices" + ); + assert_eq!( + state.delayed_triggers.len(), + 1, + "Emperor's delayed sacrifice rider must resume after both as-enters choices" + ); + assert_eq!( + state.delayed_triggers[0].ability.targets, + vec![engine::types::ability::TargetRef::Object(peacekeeper)] + ); +} From 6f952471aaf7ab05d2cd6b1b46cde6ee2cba4a3e Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 08:25:53 -0500 Subject: [PATCH 2/6] Fix replacement prompt preservation --- crates/engine/src/game/replacement.rs | 9 ++---- .../issue_1515_emperor_of_bones.rs | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 149eb79ca2..ff08ad6c75 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1008,12 +1008,9 @@ pub fn replacement_choice_waiting_for(player: PlayerId, state: &GameState) -> Wa pub fn park_waiting_for(state: &mut GameState, player: PlayerId) { if matches!( state.waiting_for, - WaitingFor::EffectZoneChoice { .. } - | WaitingFor::CopyTargetChoice { .. } - | WaitingFor::ChooseOneOfBranch { .. } - | WaitingFor::NamedChoice { .. } - | WaitingFor::ReturnAsAuraTarget { .. } - ) { + WaitingFor::CopyTargetChoice { .. } | WaitingFor::ReturnAsAuraTarget { .. } + ) || super::engine_resolution_choices::handles(&state.waiting_for) + { return; } state.waiting_for = replacement_choice_waiting_for(player, state); diff --git a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs index 6ba617b29c..0abced5655 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -254,3 +254,34 @@ fn emperor_of_bones_resumes_riders_after_anointed_peacekeepers_as_enters_choices vec![engine::types::ability::TargetRef::Object(peacekeeper)] ); } + +#[test] +fn park_waiting_for_preserves_search_choice() { + let mut scenario = GameScenario::new(); + let library_card = scenario.add_card_to_library_top(P0, "Forest"); + let mut runner = scenario.build(); + runner.state_mut().waiting_for = WaitingFor::SearchChoice { + player: P0, + library_owner: Some(P0), + cards: vec![library_card], + count: 1, + reveal: true, + up_to: false, + allows_partial_find: false, + constraint: Default::default(), + split: None, + }; + + engine::game::replacement::park_waiting_for(runner.state_mut(), P0); + + let WaitingFor::SearchChoice { cards, .. } = &runner.state().waiting_for else { + panic!( + "an existing SearchChoice must remain active, got {}", + runner.waiting_for_kind() + ); + }; + assert!( + cards.contains(&library_card), + "the preserved SearchChoice must retain the seeded library card" + ); +} From 64cce71026ed6de413d27b4528656eee8f894063 Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 08:45:53 -0500 Subject: [PATCH 3/6] Fix replacement prompt precedence --- crates/engine/src/game/engine_resolution_choices.rs | 1 + crates/engine/src/game/zone_pipeline.rs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index fa08fddddc..2c4634025e 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -3961,6 +3961,7 @@ pub(super) fn handle_resolution_choice( primary_enter_tapped, rest_destination, }; + state.waiting_for = WaitingFor::Priority { player }; let events_before_partition = events.len(); match apply_search_partition( state, diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 88e578cc00..71a33bde4b 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -861,7 +861,7 @@ pub(crate) fn move_object_with_terminal( ReplacementResult::NeedsChoice(player) => { // CR 616.1: park at the single unparked origin (mirrors // `execute_zone_move`'s NeedsChoice arm) so the prompt surfaces. - replacement::park_waiting_for(state, player); + state.waiting_for = replacement::replacement_choice_waiting_for(player, state); // CR 701.24a: stash the requested library placement on the // parked record so the resume path // (`engine_replacement::handle_replacement_choice`) threads it @@ -938,7 +938,7 @@ pub(crate) fn move_object_with_terminal( // `valid_card: None` class is destination-gated to Graveyard), so // this is unreachable for the current pool — parked for // correctness if a future to-Hand redirect surfaces a choice. - replacement::park_waiting_for(state, player); + state.waiting_for = replacement::replacement_choice_waiting_for(player, state); ZoneMoveTerminalResult::NeedsChoice(player) } }; @@ -3134,7 +3134,7 @@ fn execute_zone_move_with_applied_terminal( // delivery-tail NeedsChoice path above is NOT parked here — its // wait state is already set by the counter-pause / devour machinery // (`replacement_pause_delivery_result` reads it). - replacement::park_waiting_for(state, player); + state.waiting_for = replacement::replacement_choice_waiting_for(player, state); ZoneMoveTerminalResult::NeedsChoice(player) } } From a9a20eaa3671b15e101b90f51fe2ab87443b619f Mon Sep 17 00:00:00 2001 From: traemyn Date: Mon, 10 Aug 2026 10:10:31 -0500 Subject: [PATCH 4/6] test: cover replacement prompt pause and resume --- .../issue_1515_emperor_of_bones.rs | 101 ++++++++++++++---- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs index 0abced5655..e0085b9eea 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -6,8 +6,9 @@ use engine::game::effects::resolve_ability_chain; use engine::game::scenario::{GameScenario, P0}; use engine::parser::oracle_effect::parse_effect_chain; use engine::types::ability::{ - AbilityKind, ChoiceType, ChosenAttribute, ContinuousModification, DelayedTriggerCondition, - Effect, TargetFilter, + AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ChosenAttribute, + ContinuousModification, DelayedTriggerCondition, Effect, QuantityExpr, QuantityRef, + ReplacementDefinition, TargetFilter, }; use engine::types::actions::GameAction; use engine::types::counter::CounterType; @@ -16,6 +17,7 @@ use engine::types::identifiers::ObjectId; use engine::types::keywords::Keyword; use engine::types::phase::Phase; use engine::types::player::PlayerId; +use engine::types::replacements::ReplacementEvent; use engine::types::zones::Zone; const EMPEROR_COUNTER_TRIGGER_EFFECT: &str = "put a creature card exiled with this creature onto \ @@ -255,33 +257,88 @@ fn emperor_of_bones_resumes_riders_after_anointed_peacekeepers_as_enters_choices ); } +/// A synthetic as-enters replacement that opens a PayAmountChoice before the +/// returning permanent finishes entering. This mirrors the shape of a printed +/// Moved replacement while keeping the regression independent of card data. +fn pay_amount_choice_replacement() -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Battlefield) + .valid_card(TargetFilter::SelfRef) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::PayCost { + cost: AbilityCost::PayLife { + amount: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + }, + scale: None, + payer: TargetFilter::Controller, + }, + )) +} + +/// CR 614.12a + CR 400.7j: A previously unlisted resolution-owned prompt must +/// survive the replacement pause and resume the Emperor continuation. #[test] -fn park_waiting_for_preserves_search_choice() { - let mut scenario = GameScenario::new(); - let library_card = scenario.add_card_to_library_top(P0, "Forest"); - let mut runner = scenario.build(); - runner.state_mut().waiting_for = WaitingFor::SearchChoice { - player: P0, - library_owner: Some(P0), - cards: vec![library_card], - count: 1, - reveal: true, - up_to: false, - allows_partial_find: false, - constraint: Default::default(), - split: None, +fn emperor_of_bones_preserves_pay_amount_choice_through_replacement_pipeline() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + let emperor = scenario.add_creature(P0, "Emperor of Bones", 2, 2).id(); + let peacekeeper = { + let mut builder = scenario.add_creature_to_exile(P0, "Anointed Peacekeeper", 3, 3); + builder.from_oracle_text(ANOINTED_PEACEKEEPER); + builder.id() }; - engine::game::replacement::park_waiting_for(runner.state_mut(), P0); + let mut runner = scenario.build(); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: peacekeeper, + source_id: emperor, + kind: ExileLinkKind::TrackedBySource, + }); + runner + .state_mut() + .objects + .get_mut(&peacekeeper) + .unwrap() + .replacement_definitions = vec![pay_amount_choice_replacement()].into(); - let WaitingFor::SearchChoice { cards, .. } = &runner.state().waiting_for else { + let definition = parse_effect_chain(EMPEROR_COUNTER_TRIGGER_EFFECT, AbilityKind::Spell); + let ability = build_resolved_from_def(&definition, emperor, P0); + let mut events = Vec::new(); + resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0) + .expect("Emperor of Bones return must reach the replacement PayAmountChoice"); + + let WaitingFor::PayAmountChoice { + player, min, max, .. + } = runner.state().waiting_for.clone() + else { panic!( - "an existing SearchChoice must remain active, got {}", + "the replacement must preserve its PayAmountChoice, got {}", runner.waiting_for_kind() ); }; - assert!( - cards.contains(&library_card), - "the preserved SearchChoice must retain the seeded library card" + assert_eq!(player, P0); + assert_eq!(min, 0); + assert!(max > 0); + + runner + .act(GameAction::SubmitPayAmount { amount: 0 }) + .expect("answer the replacement PayAmountChoice through GameRunner::act"); + + let state = runner.state(); + assert_eq!(state.objects[&peacekeeper].zone, Zone::Battlefield); + assert!(matches!( + state.waiting_for, + WaitingFor::Priority { player: P0 } + )); + assert!(state.stack.is_empty()); + assert_eq!( + state.delayed_triggers.len(), + 1, + "the Emperor delayed sacrifice rider must resume after the replacement choice" ); } From f02cfc60c01138e52ffa70b54363659f24fc422e Mon Sep 17 00:00:00 2001 From: traemyn Date: Tue, 11 Aug 2026 14:18:57 -0500 Subject: [PATCH 5/6] test: cover Emperor counter trigger pipeline --- .../issue_1515_emperor_of_bones.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs index e0085b9eea..818a892bc7 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -23,6 +23,13 @@ use engine::types::zones::Zone; const EMPEROR_COUNTER_TRIGGER_EFFECT: &str = "put a creature card exiled with this creature onto \ the battlefield under your control with a finality counter on it. it gains haste. sacrifice it at \ the beginning of the next end step."; +const EMPEROR_ORACLE: &str = + "At the beginning of combat on your turn, exile up to one target card from a graveyard.\n\ +{1}{B}: Adapt 2.\n\ +Whenever one or more +1/+1 counters are put on this creature, put a creature card exiled with this \ +creature onto the battlefield under your control with a finality counter on it. It gains haste. \ +Sacrifice it at the beginning of the next end step."; +const PUT_COUNTER_ORACLE: &str = "Put a +1/+1 counter on target creature."; const ANOINTED_PEACEKEEPER: &str = "Vigilance\n\ As this creature enters, look at an opponent's hand, then choose any card name.\n\ @@ -167,6 +174,59 @@ fn issue_1515_emperor_of_bones_binds_haste_and_delayed_sacrifice_to_returned_cre ); } +/// CR 122.1 + CR 603.2 + CR 608.2c: Drive the printed counter trigger through +/// the reducer so the returned creature, rather than the trigger source, owns +/// both anaphoric riders. +#[test] +fn emperor_of_bones_counter_trigger_uses_returned_creature_in_cast_pipeline() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let emperor = scenario + .add_creature_from_oracle(P0, "Emperor of Bones", 2, 2, EMPEROR_ORACLE) + .id(); + let returned = scenario + .add_creature_to_exile(P0, "Linked Gravebeast", 3, 3) + .id(); + let counter_spell = scenario + .add_spell_to_hand_from_oracle(P0, "Counter Placement", false, PUT_COUNTER_ORACLE) + .id(); + + let mut runner = scenario.build(); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: returned, + source_id: emperor, + kind: ExileLinkKind::TrackedBySource, + }); + + runner.cast(counter_spell).target_object(emperor).resolve(); + + let state = runner.state(); + assert_eq!( + state.objects[&returned].zone, + Zone::Battlefield, + "the counter trigger must return the linked creature through apply()" + ); + assert_eq!( + state.objects[&emperor].zone, + Zone::Battlefield, + "the counter trigger must not sacrifice Emperor while resolving" + ); + assert!( + creature_has_haste_from_transient_effects(state, returned), + "the printed haste rider must bind to the returned creature" + ); + assert!( + !creature_has_haste_from_transient_effects(state, emperor), + "the printed haste rider must not bind to Emperor" + ); + assert_eq!(state.delayed_triggers.len(), 1); + assert_eq!( + state.delayed_triggers[0].ability.targets, + vec![engine::types::ability::TargetRef::Object(returned)], + "the delayed sacrifice must snapshot the returned creature" + ); +} + /// CR 614.12a + CR 400.7j: An as-enters choice on the returned permanent must /// complete without losing later instructions that refer to that permanent. #[test] From 8e218b7cac28fb4410267ca65ac75d90a4ed05ce Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 11 Aug 2026 14:03:59 -0700 Subject: [PATCH 6/6] test(PR-7243): assert Emperor finality entry modifier --- .../tests/integration/issue_1515_emperor_of_bones.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs index 818a892bc7..11c1a355a3 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -206,6 +206,15 @@ fn emperor_of_bones_counter_trigger_uses_returned_creature_in_cast_pipeline() { Zone::Battlefield, "the counter trigger must return the linked creature through apply()" ); + assert_eq!( + state.objects[&returned] + .counters + .get(&CounterType::Finality) + .copied() + .unwrap_or(0), + 1, + "the returned creature must receive Emperor's finality entry modifier" + ); assert_eq!( state.objects[&emperor].zone, Zone::Battlefield,