Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
191 changes: 190 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,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,
Expand Down Expand Up @@ -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"
);
}
Loading