Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/engine/src/game/engine_resolution_choices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions crates/engine/src/game/replacement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions crates/engine/src/game/zone_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
};
Expand Down Expand Up @@ -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)
}
}
Expand Down
134 changes: 133 additions & 1 deletion crates/engine/tests/integration/issue_1515_emperor_of_bones.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -153,3 +164,124 @@ 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)]
);
}

#[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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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"
);
}
Loading