From 537bf573cfe57061c78b8fbf37efb4c92a1b8512 Mon Sep 17 00:00:00 2001 From: traemyn Date: Sun, 9 Aug 2026 22:09:48 -0500 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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" ); }