Skip to content
11 changes: 11 additions & 0 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11205,6 +11205,17 @@ fn resolve_chain_body(
);
resolve_ability_chain(state, &trailing_resolved, events, depth + 1)?;
}
} else if ability.forward_result
&& forwarded_objects.is_empty()
&& effect_refs_parent_target(&sub.effect)
{
// CR 608.2c: A forward-result continuation is anchored to the object
// moved by the preceding instruction. If no object moved, that
// instruction has no referent for dependent riders such as "it gains
// haste" or "sacrifice it"; do not let ParentTarget fall back to the
// original ability source. Independent sequential siblings continue
// through the ordinary chain walker below.
return Ok(());
} else if !forwarded_objects.is_empty() {
let mut sub_with_context = sub.as_ref().clone();
// CR 707.10: `CopySpell { SelfRef }` copies the resolving spell
Expand Down
168 changes: 167 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 @@ -12,7 +12,7 @@ use engine::types::ability::{
};
use engine::types::actions::GameAction;
use engine::types::counter::CounterType;
use engine::types::game_state::{ExileLink, ExileLinkKind, WaitingFor};
use engine::types::game_state::{CastPaymentMode, ExileLink, ExileLinkKind, WaitingFor};
use engine::types::identifiers::ObjectId;
use engine::types::keywords::Keyword;
use engine::types::phase::Phase;
Expand All @@ -30,6 +30,7 @@ Whenever one or more +1/+1 counters are put on this creature, put a creature car
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 YAWGMOTHS_VILE_OFFERING_ORACLE: &str = "Put up to one target creature or planeswalker card from a graveyard onto the battlefield under your control. Destroy up to one target creature or planeswalker. Exile Yawgmoth's Vile Offering.";

const ANOINTED_PEACEKEEPER: &str = "Vigilance\n\
As this creature enters, look at an opponent's hand, then choose any card name.\n\
Expand Down Expand Up @@ -236,6 +237,171 @@ fn emperor_of_bones_counter_trigger_uses_returned_creature_in_cast_pipeline() {
);
}

#[test]
fn emperor_of_bones_adapt_pipeline_binds_delayed_sacrifice_to_returned_creature() {
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 swamp_a = scenario.add_basic_land(P0, engine::types::mana::ManaColor::Black);
let swamp_b = scenario.add_basic_land(P0, engine::types::mana::ManaColor::Black);

let mut runner = scenario.build();
runner.state_mut().exile_links.push(ExileLink {
exiled_id: returned,
source_id: emperor,
kind: ExileLinkKind::TrackedBySource,
});

runner
.activate(emperor, 0)
.pay_with(&[swamp_a, swamp_b])
.resolve();

let state = runner.state();
assert_eq!(
state.objects[&returned].zone,
Zone::Battlefield,
"Adapt must resolve Emperor's counter trigger and return the linked creature"
);
assert_eq!(
state.delayed_triggers.len(),
1,
"the counter trigger must install one delayed sacrifice"
);
assert_eq!(
state.delayed_triggers[0].ability.targets,
vec![engine::types::ability::TargetRef::Object(returned)],
"the Adapt-triggered delayed sacrifice must snapshot the returned creature"
);
assert_eq!(
state.objects[&emperor].zone,
Zone::Battlefield,
"Emperor must remain on the battlefield until its own ability is removed"
);
}

#[test]
fn emperor_of_bones_adapt_without_linked_exile_has_no_riders_to_apply() {
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 swamp_a = scenario.add_basic_land(P0, engine::types::mana::ManaColor::Black);
let swamp_b = scenario.add_basic_land(P0, engine::types::mana::ManaColor::Black);

let mut runner = scenario.build();
runner
.activate(emperor, 0)
.pay_with(&[swamp_a, swamp_b])
.resolve();

let state = runner.state();
assert_eq!(
state.objects[&emperor]
.counters
.get(&CounterType::Plus1Plus1)
.copied()
.unwrap_or(0),
2,
"Adapt must still put its counters on Emperor"
);
assert_eq!(
state.delayed_triggers.len(),
0,
"no returned creature means Emperor's haste and delayed Sacrifice riders must not run"
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assert!(
!creature_has_haste_from_transient_effects(state, emperor),
"Emperor must not receive the returned creature's haste rider"
);
assert_eq!(
state.objects[&emperor].zone,
Zone::Battlefield,
"Emperor must remain on the battlefield when no linked creature was exiled"
);
}

#[test]
fn empty_forward_result_preserves_independent_sequential_siblings() {
let mut scenario = GameScenario::new_n_player(2, 7);
scenario.at_phase(Phase::PreCombatMain);
let graveyard_creature = scenario
.add_creature_to_graveyard(P0, "Unreturned Creature", 2, 2)
.id();
let destroy_target = scenario.add_creature(P1, "Destroy Target", 2, 2).id();
let offering = scenario
.add_spell_to_hand_from_oracle(
P0,
"Yawgmoth's Vile Offering",
true,
YAWGMOTHS_VILE_OFFERING_ORACLE,
)
.with_mana_cost(engine::types::mana::ManaCost::zero())
.id();

let mut runner = scenario.build();
let card_id = runner.state().objects[&offering].card_id;
runner
.act(GameAction::CastSpell {
object_id: offering,
card_id,
targets: vec![],
payment_mode: CastPaymentMode::Auto,
})
.expect("Yawgmoth's Vile Offering must be castable for the regression");

for _ in 0..16 {
match runner.state().waiting_for.clone() {
WaitingFor::TargetSelection { selection, .. } => {
let target = if selection.current_slot == 0 {
Some(engine::types::ability::TargetRef::Object(
graveyard_creature,
))
} else {
Some(engine::types::ability::TargetRef::Object(destroy_target))
};
runner
.act(GameAction::ChooseTarget { target })
.expect("target choice must be accepted");
}
WaitingFor::Priority { .. } if !runner.state().stack.is_empty() => {
runner.pass_both_players();
}
_ => break,
}
}

engine::game::zones::move_to_zone(
runner.state_mut(),
graveyard_creature,
Zone::Battlefield,
&mut Vec::new(),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
runner.advance_until_stack_empty();

assert_eq!(
runner.state().objects[&graveyard_creature].zone,
Zone::Battlefield,
"the pre-resolution move must invalidate the selected reanimation target"
);
assert_ne!(
runner.state().objects[&destroy_target].zone,
Zone::Battlefield,
"the independent Destroy sibling must still resolve"
);
assert_eq!(
runner.state().objects[&offering].zone,
Zone::Exile,
"the later self-exile sibling must still resolve"
);
}

/// 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]
Expand Down
Loading