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/replacement.rs b/crates/engine/src/game/replacement.rs index 1ef4053226..ff08ad6c75 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1003,10 +1003,14 @@ 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::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/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) } } 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..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,19 +6,32 @@ 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, + AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ChosenAttribute, + ContinuousModification, DelayedTriggerCondition, Effect, QuantityExpr, QuantityRef, + ReplacementDefinition, 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::replacements::ReplacementEvent; 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 +166,179 @@ 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)] + ); +} + +/// 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 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() + }; + + 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 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!( + "the replacement must preserve its PayAmountChoice, got {}", + runner.waiting_for_kind() + ); + }; + 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" + ); +}