From d79c5eb5d091af6caec764476fa57bc0555fae8e Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 14:06:11 -0500 Subject: [PATCH 1/8] test(engine): cover Emperor Adapt rider binding --- .../issue_1515_emperor_of_bones.rs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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 11c1a355a3..62cea7b50b 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -236,6 +236,54 @@ 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" + ); +} + /// 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] From bd36bec5adadf3b85cc62a3f3f891e6774149368 Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 15:05:39 -0500 Subject: [PATCH 2/8] fix(engine): skip empty forward-result riders --- crates/engine/src/game/effects/mod.rs | 7 ++++ .../issue_1515_emperor_of_bones.rs | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index dc2127303a..602d8da098 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -11205,6 +11205,13 @@ fn resolve_chain_body( ); resolve_ability_chain(state, &trailing_resolved, events, depth + 1)?; } + } else if ability.forward_result && forwarded_objects.is_empty() { + // 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. + 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 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 62cea7b50b..94205887a9 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -284,6 +284,44 @@ fn emperor_of_bones_adapt_pipeline_binds_delayed_sacrifice_to_returned_creature( ); } +#[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" + ); + assert_eq!( + state.objects[&emperor].zone, + Zone::Battlefield, + "Emperor must remain on the battlefield when no linked creature was exiled" + ); +} + /// 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] From b8d5c241336fa0ad27d39d9e7e321f8659a06a82 Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 17:28:43 -0500 Subject: [PATCH 3/8] fix(engine): preserve independent forward-result siblings --- crates/engine/src/game/effects/mod.rs | 8 +- .../issue_1515_emperor_of_bones.rs | 82 ++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 602d8da098..f902d40a2b 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -11205,12 +11205,16 @@ fn resolve_chain_body( ); resolve_ability_chain(state, &trailing_resolved, events, depth + 1)?; } - } else if ability.forward_result && forwarded_objects.is_empty() { + } 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. + // 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(); 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 94205887a9..5efc0c7c63 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -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; @@ -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\ @@ -315,6 +316,10 @@ fn emperor_of_bones_adapt_without_linked_exile_has_no_riders_to_apply() { 0, "no returned creature means Emperor's haste and delayed Sacrifice riders must not run" ); + 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, @@ -322,6 +327,81 @@ fn emperor_of_bones_adapt_without_linked_exile_has_no_riders_to_apply() { ); } +#[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(), + ); + 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] From 222a4593c9e6a513913357c873c38d6750b60742 Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 17:59:21 -0500 Subject: [PATCH 4/8] fix(engine): preserve nested forward-result dependencies --- crates/engine/src/game/effects/mod.rs | 48 +++++++++++++++++-- .../issue_1515_emperor_of_bones.rs | 3 ++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index f902d40a2b..f2e984d1af 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -9,10 +9,10 @@ use crate::game::conditions::{ use crate::game::filter; use crate::game::speed::has_max_speed; use crate::types::ability::{ - AbilityCondition, AbilityCost, AbilityKind, CardPlayMode, CardTypeSetSource, ControllerRef, - CopyRetargetPermission, CostPaidObjectSnapshot, EachDamageRecipient, Effect, EffectError, - EffectKind, EffectOutcomeSignal, EffectScope, FilterProp, OpponentMayScope, PlayerFilter, - PlayerScope, QuantityExpr, QuantityRef, RepeatContinuation, ResolvedAbility, + AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, CardPlayMode, CardTypeSetSource, + ControllerRef, CopyRetargetPermission, CostPaidObjectSnapshot, EachDamageRecipient, Effect, + EffectError, EffectKind, EffectOutcomeSignal, EffectScope, FilterProp, OpponentMayScope, + PlayerFilter, PlayerScope, QuantityExpr, QuantityRef, RepeatContinuation, ResolvedAbility, RevealUntilDisposition, SacrificeCost, SacrificeRequirement, SharedQuality, SharedQualityRelation, SiblingCondition, SubAbilityLink, TapStateChange, TargetChoiceTiming, TargetFilter, TargetRef, ThisWayCause, @@ -5678,6 +5678,44 @@ fn effect_refs_parent_target(effect: &Effect) -> bool { .any(|filter| filter_refs_parent_target(filter)) } +/// CR 608.2c + CR 603.7c: Detect a ParentTarget dependency anywhere in a +/// continuation, including a delayed-trigger payload whose AbilityDefinition +/// is nested inside the current effect. This keeps a missing forward-result +/// object from rebinding an inner rider to the original source while allowing +/// independent sequential siblings to continue. +fn ability_chain_refs_parent_target(ability: &ResolvedAbility) -> bool { + effect_chain_refs_parent_target(&ability.effect) + || ability + .sub_ability + .as_deref() + .is_some_and(ability_chain_refs_parent_target) + || ability + .else_ability + .as_deref() + .is_some_and(ability_chain_refs_parent_target) +} + +fn ability_definition_chain_refs_parent_target(definition: &AbilityDefinition) -> bool { + effect_chain_refs_parent_target(&definition.effect) + || definition + .sub_ability + .as_deref() + .is_some_and(ability_definition_chain_refs_parent_target) + || definition + .else_ability + .as_deref() + .is_some_and(ability_definition_chain_refs_parent_target) +} + +fn effect_chain_refs_parent_target(effect: &Effect) -> bool { + effect_refs_parent_target(effect) + || matches!( + effect, + Effect::CreateDelayedTrigger { effect: definition, .. } + if ability_definition_chain_refs_parent_target(definition) + ) +} + /// CR 115.6: True when the resolving ability head permits zero targets and the /// controller chose none (no `TargetRef::Object` in `ability.targets`). fn optional_head_declined_all_object_targets(ability: &ResolvedAbility) -> bool { @@ -11207,7 +11245,7 @@ fn resolve_chain_body( } } else if ability.forward_result && forwarded_objects.is_empty() - && effect_refs_parent_target(&sub.effect) + && ability_chain_refs_parent_target(sub) { // CR 608.2c: A forward-result continuation is anchored to the object // moved by the preceding instruction. If no object moved, that 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 5efc0c7c63..0df776b402 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -377,6 +377,9 @@ fn empty_forward_result_preserves_independent_sequential_siblings() { } } + // CR 608.2b: Make the first selected target illegal between announcement + // and resolution, so its forward-result move returns no object while the + // independently targeted Destroy sibling remains legal. engine::game::zones::move_to_zone( runner.state_mut(), graveyard_creature, From d3e8810d672606226444d88dbd61b2ae4c03d986 Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 18:07:48 -0500 Subject: [PATCH 5/8] fix(engine): preserve nested forward-result dependencies --- crates/engine/src/game/effects/mod.rs | 76 +++++++++++++-------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index f2e984d1af..8dcf617b1f 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -5678,44 +5678,6 @@ fn effect_refs_parent_target(effect: &Effect) -> bool { .any(|filter| filter_refs_parent_target(filter)) } -/// CR 608.2c + CR 603.7c: Detect a ParentTarget dependency anywhere in a -/// continuation, including a delayed-trigger payload whose AbilityDefinition -/// is nested inside the current effect. This keeps a missing forward-result -/// object from rebinding an inner rider to the original source while allowing -/// independent sequential siblings to continue. -fn ability_chain_refs_parent_target(ability: &ResolvedAbility) -> bool { - effect_chain_refs_parent_target(&ability.effect) - || ability - .sub_ability - .as_deref() - .is_some_and(ability_chain_refs_parent_target) - || ability - .else_ability - .as_deref() - .is_some_and(ability_chain_refs_parent_target) -} - -fn ability_definition_chain_refs_parent_target(definition: &AbilityDefinition) -> bool { - effect_chain_refs_parent_target(&definition.effect) - || definition - .sub_ability - .as_deref() - .is_some_and(ability_definition_chain_refs_parent_target) - || definition - .else_ability - .as_deref() - .is_some_and(ability_definition_chain_refs_parent_target) -} - -fn effect_chain_refs_parent_target(effect: &Effect) -> bool { - effect_refs_parent_target(effect) - || matches!( - effect, - Effect::CreateDelayedTrigger { effect: definition, .. } - if ability_definition_chain_refs_parent_target(definition) - ) -} - /// CR 115.6: True when the resolving ability head permits zero targets and the /// controller chose none (no `TargetRef::Object` in `ability.targets`). fn optional_head_declined_all_object_targets(ability: &ResolvedAbility) -> bool { @@ -11584,6 +11546,44 @@ fn resolve_chain_body( Ok(()) } +/// CR 608.2c + CR 603.7c: Detect a ParentTarget dependency anywhere in a +/// continuation, including a delayed-trigger payload whose AbilityDefinition +/// is nested inside the current effect. This keeps a missing forward-result +/// object from rebinding an inner rider to the original source while allowing +/// independent sequential siblings to continue. +fn ability_chain_refs_parent_target(ability: &ResolvedAbility) -> bool { + effect_chain_refs_parent_target(&ability.effect) + || ability + .sub_ability + .as_deref() + .is_some_and(ability_chain_refs_parent_target) + || ability + .else_ability + .as_deref() + .is_some_and(ability_chain_refs_parent_target) +} + +fn ability_definition_chain_refs_parent_target(definition: &AbilityDefinition) -> bool { + effect_chain_refs_parent_target(&definition.effect) + || definition + .sub_ability + .as_deref() + .is_some_and(ability_definition_chain_refs_parent_target) + || definition + .else_ability + .as_deref() + .is_some_and(ability_definition_chain_refs_parent_target) +} + +fn effect_chain_refs_parent_target(effect: &Effect) -> bool { + effect_refs_parent_target(effect) + || matches!( + effect, + Effect::CreateDelayedTrigger { effect: definition, .. } + if ability_definition_chain_refs_parent_target(definition) + ) +} + fn effect_depends_on_missing_chosen_player(ability: &ResolvedAbility) -> bool { ability .effect From ae316a2cf5380ce999c9292c4b00acf25236d8eb Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 18:31:54 -0500 Subject: [PATCH 6/8] fix(engine): resume independent sibling tails --- crates/engine/src/game/effects/mod.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 8dcf617b1f..e0cf58e724 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -11213,8 +11213,28 @@ fn resolve_chain_body( // 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. + // original ability source. Walk past dependent sequential siblings + // and resume at the first independent sibling instead of terminating + // the entire printed instruction chain. + let mut current = sub.sub_ability.as_deref(); + while let Some(sibling) = current { + if sibling.sub_link == SubAbilityLink::SequentialSibling { + if ability_chain_refs_parent_target(sibling) { + current = sibling.sub_ability.as_deref(); + continue; + } + let mut sibling_resolved = sibling.clone(); + apply_parent_chain_context( + &mut sibling_resolved, + ability, + effect_context_object.as_ref(), + state, + ); + resolve_ability_chain(state, &sibling_resolved, events, depth + 1)?; + break; + } + current = sibling.sub_ability.as_deref(); + } return Ok(()); } else if !forwarded_objects.is_empty() { let mut sub_with_context = sub.as_ref().clone(); From 6894ba18816a76f0af8a48e1e185e31e9c577c21 Mon Sep 17 00:00:00 2001 From: traemyn Date: Wed, 12 Aug 2026 19:16:04 -0500 Subject: [PATCH 7/8] fix(engine): resolve forward-result segments --- crates/engine/src/game/effects/mod.rs | 6 +- .../issue_1515_emperor_of_bones.rs | 94 ++++++++++++++++++- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index e0cf58e724..4051cef068 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -11216,14 +11216,15 @@ fn resolve_chain_body( // original ability source. Walk past dependent sequential siblings // and resume at the first independent sibling instead of terminating // the entire printed instruction chain. - let mut current = sub.sub_ability.as_deref(); + let mut current = Some(sub.as_ref()); while let Some(sibling) = current { if sibling.sub_link == SubAbilityLink::SequentialSibling { - if ability_chain_refs_parent_target(sibling) { + if effect_chain_refs_parent_target(&sibling.effect) { current = sibling.sub_ability.as_deref(); continue; } let mut sibling_resolved = sibling.clone(); + sibling_resolved.sub_ability = None; apply_parent_chain_context( &mut sibling_resolved, ability, @@ -11231,7 +11232,6 @@ fn resolve_chain_body( state, ); resolve_ability_chain(state, &sibling_resolved, events, depth + 1)?; - break; } current = sibling.sub_ability.as_deref(); } 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 0df776b402..24c1f6e1f6 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -7,8 +7,9 @@ use engine::game::scenario::{GameScenario, P0}; use engine::parser::oracle_effect::parse_effect_chain; use engine::types::ability::{ AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ChosenAttribute, - ContinuousModification, DelayedTriggerCondition, Effect, QuantityExpr, QuantityRef, - ReplacementDefinition, TargetFilter, + ContinuousModification, ControllerRef, DelayedTriggerCondition, Effect, FilterProp, + QuantityExpr, QuantityRef, ReplacementDefinition, ResolvedAbility, TargetChoiceTiming, + TargetFilter, TypedFilter, }; use engine::types::actions::GameAction; use engine::types::counter::CounterType; @@ -405,6 +406,95 @@ fn empty_forward_result_preserves_independent_sequential_siblings() { ); } +#[test] +fn empty_forward_result_resolves_independent_sibling_before_dependent_tail() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Forward Result Source", 2, 2) + .id(); + let destroy_target = scenario.add_creature(P1, "Independent Target", 2, 2).id(); + + let dependent_tail = ResolvedAbility::new( + Effect::CreateDelayedTrigger { + condition: DelayedTriggerCondition::AtNextPhase { phase: Phase::End }, + effect: Box::new(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Sacrifice { + target: TargetFilter::ParentTarget, + count: QuantityExpr::Fixed { value: 1 }, + min_count: 0, + }, + )), + uses_tracked_set: false, + }, + vec![], + source, + P0, + ); + let mut independent_sibling = ResolvedAbility::new( + Effect::Destroy { + target: TargetFilter::SpecificObject { id: destroy_target }, + cant_regenerate: false, + }, + vec![engine::types::ability::TargetRef::Object(destroy_target)], + source, + P0, + ); + independent_sibling.sub_link = engine::types::ability::SubAbilityLink::SequentialSibling; + independent_sibling.sub_ability = Some(Box::new({ + let mut tail = dependent_tail; + tail.sub_link = engine::types::ability::SubAbilityLink::SequentialSibling; + tail + })); + + let mut forward_result = ResolvedAbility::new( + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Battlefield, + target: TargetFilter::Typed(TypedFilter { + type_filters: vec![engine::types::ability::TypeFilter::Creature], + controller: None, + properties: vec![FilterProp::InZone { + zone: Zone::Graveyard, + }], + }), + owner_library: false, + enter_transformed: false, + enters_under: Some(ControllerRef::You), + enter_tapped: engine::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: true, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + vec![], + source, + P0, + ) + .sub_ability(independent_sibling); + forward_result.target_choice_timing = TargetChoiceTiming::Resolution; + forward_result.forward_result = true; + + let mut runner = scenario.build(); + let mut events = Vec::new(); + resolve_ability_chain(runner.state_mut(), &forward_result, &mut events, 0) + .expect("empty forward-result chain must resolve"); + + assert_ne!( + runner.state().objects[&destroy_target].zone, + Zone::Battlefield, + "the independent sibling must resolve even when a later dependent tail is skipped" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 0, + "the dependent ParentTarget tail must remain a no-op without a moved object" + ); +} + /// 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] From 3d5c109fb08276f77b8849fd3840da1d00d078de Mon Sep 17 00:00:00 2001 From: traemyn Date: Thu, 13 Aug 2026 13:11:53 -0500 Subject: [PATCH 8/8] fix(engine): prune empty forward-result dependencies --- crates/engine/src/game/effects/mod.rs | 65 +++++--- .../issue_1515_emperor_of_bones.rs | 146 +++++++++++++++++- 2 files changed, 185 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 4051cef068..128b0ebad9 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -11216,24 +11216,14 @@ fn resolve_chain_body( // original ability source. Walk past dependent sequential siblings // and resume at the first independent sibling instead of terminating // the entire printed instruction chain. - let mut current = Some(sub.as_ref()); - while let Some(sibling) = current { - if sibling.sub_link == SubAbilityLink::SequentialSibling { - if effect_chain_refs_parent_target(&sibling.effect) { - current = sibling.sub_ability.as_deref(); - continue; - } - let mut sibling_resolved = sibling.clone(); - sibling_resolved.sub_ability = None; - apply_parent_chain_context( - &mut sibling_resolved, - ability, - effect_context_object.as_ref(), - state, - ); - resolve_ability_chain(state, &sibling_resolved, events, depth + 1)?; - } - current = sibling.sub_ability.as_deref(); + if let Some(mut remaining) = without_missing_forward_result_dependencies(sub) { + apply_parent_chain_context( + &mut remaining, + ability, + effect_context_object.as_ref(), + state, + ); + resolve_ability_chain(state, &remaining, events, depth + 1)?; } return Ok(()); } else if !forwarded_objects.is_empty() { @@ -11604,6 +11594,45 @@ fn effect_chain_refs_parent_target(effect: &Effect) -> bool { ) } +/// CR 608.2c: Remove only continuation nodes whose effects require the absent +/// forward-result object. Preserve independent instructions and both of their +/// continuation edges; when a dependent node is removed, resume at its next +/// `SequentialSibling` rather than treating a dependent `ContinuationStep` as +/// independently executable. +fn without_missing_forward_result_dependencies( + ability: &ResolvedAbility, +) -> Option { + if effect_chain_refs_parent_target(&ability.effect) { + return first_independent_forward_result_sibling(ability.sub_ability.as_deref()); + } + + let mut remaining = ability.clone(); + remaining.sub_ability = ability + .sub_ability + .as_deref() + .and_then(without_missing_forward_result_dependencies) + .map(Box::new); + remaining.else_ability = ability + .else_ability + .as_deref() + .and_then(without_missing_forward_result_dependencies) + .map(Box::new); + Some(remaining) +} + +fn first_independent_forward_result_sibling( + ability: Option<&ResolvedAbility>, +) -> Option { + let mut current = ability; + while let Some(sibling) = current { + if sibling.sub_link == SubAbilityLink::SequentialSibling { + return without_missing_forward_result_dependencies(sibling); + } + current = sibling.sub_ability.as_deref(); + } + None +} + fn effect_depends_on_missing_chosen_player(ability: &ResolvedAbility) -> bool { ability .effect 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 24c1f6e1f6..438d1ad361 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -6,7 +6,7 @@ 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::{ - AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ChosenAttribute, + AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ChosenAttribute, ContinuousModification, ControllerRef, DelayedTriggerCondition, Effect, FilterProp, QuantityExpr, QuantityRef, ReplacementDefinition, ResolvedAbility, TargetChoiceTiming, TargetFilter, TypedFilter, @@ -32,6 +32,8 @@ creature onto the battlefield under your control with a finality counter on it. 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 REANIMATION_RESPONSE_ORACLE: &str = + "Return target creature card from a graveyard to the battlefield under your control."; const ANOINTED_PEACEKEEPER: &str = "Vigilance\n\ As this creature enters, look at an opponent's hand, then choose any card name.\n\ @@ -345,6 +347,15 @@ fn empty_forward_result_preserves_independent_sequential_siblings() { ) .with_mana_cost(engine::types::mana::ManaCost::zero()) .id(); + let response = scenario + .add_spell_to_hand_from_oracle( + P0, + "Reanimation Response", + true, + REANIMATION_RESPONSE_ORACLE, + ) + .with_mana_cost(engine::types::mana::ManaCost::zero()) + .id(); let mut runner = scenario.build(); let card_id = runner.state().objects[&offering].card_id; @@ -371,9 +382,7 @@ fn empty_forward_result_preserves_independent_sequential_siblings() { .act(GameAction::ChooseTarget { target }) .expect("target choice must be accepted"); } - WaitingFor::Priority { .. } if !runner.state().stack.is_empty() => { - runner.pass_both_players(); - } + WaitingFor::Priority { .. } if !runner.state().stack.is_empty() => break, _ => break, } } @@ -381,11 +390,19 @@ fn empty_forward_result_preserves_independent_sequential_siblings() { // CR 608.2b: Make the first selected target illegal between announcement // and resolution, so its forward-result move returns no object while the // independently targeted Destroy sibling remains legal. - engine::game::zones::move_to_zone( - runner.state_mut(), - graveyard_creature, + runner + .cast(response) + .target_object(graveyard_creature) + .commit(); + runner.pass_both_players(); + assert_eq!( + runner.state().objects[&graveyard_creature].zone, Zone::Battlefield, - &mut Vec::new(), + "the production cast/resolution pipeline must move the reanimation target first" + ); + assert!( + !runner.state().stack.is_empty(), + "Yawgmoth's Vile Offering must remain on the stack after the response resolves" ); runner.advance_until_stack_empty(); @@ -495,6 +512,119 @@ fn empty_forward_result_resolves_independent_sibling_before_dependent_tail() { ); } +#[test] +fn empty_forward_result_suppresses_dependent_else_and_resumes_later_sibling() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario.add_creature(P0, "Untapped Source", 2, 2).id(); + let destroy_target = scenario.add_creature(P1, "Reach Guard Target", 2, 2).id(); + + let dependent_else = ResolvedAbility::new( + Effect::CreateDelayedTrigger { + condition: DelayedTriggerCondition::AtNextPhase { phase: Phase::End }, + effect: Box::new(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Sacrifice { + target: TargetFilter::ParentTarget, + count: QuantityExpr::Fixed { value: 1 }, + min_count: 0, + }, + )), + uses_tracked_set: false, + }, + vec![], + source, + P0, + ); + let mut later_sibling = ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 2 }, + player: TargetFilter::Controller, + }, + vec![], + source, + P0, + ); + later_sibling.sub_link = engine::types::ability::SubAbilityLink::SequentialSibling; + + let mut false_condition_sibling = ResolvedAbility::new( + Effect::GainLife { + amount: QuantityExpr::Fixed { value: 1 }, + player: TargetFilter::Controller, + }, + vec![], + source, + P0, + ); + false_condition_sibling.condition = Some(AbilityCondition::SourceIsTapped); + false_condition_sibling.sub_link = engine::types::ability::SubAbilityLink::SequentialSibling; + false_condition_sibling.else_ability = Some(Box::new(dependent_else)); + false_condition_sibling.sub_ability = Some(Box::new(later_sibling)); + + let mut first_independent_sibling = ResolvedAbility::new( + Effect::Destroy { + target: TargetFilter::SpecificObject { id: destroy_target }, + cant_regenerate: false, + }, + vec![engine::types::ability::TargetRef::Object(destroy_target)], + source, + P0, + ); + first_independent_sibling.sub_link = engine::types::ability::SubAbilityLink::SequentialSibling; + first_independent_sibling.sub_ability = Some(Box::new(false_condition_sibling)); + + let mut forward_result = ResolvedAbility::new( + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Battlefield, + target: TargetFilter::Typed(TypedFilter { + type_filters: vec![engine::types::ability::TypeFilter::Creature], + controller: None, + properties: vec![FilterProp::InZone { + zone: Zone::Graveyard, + }], + }), + owner_library: false, + enter_transformed: false, + enters_under: Some(ControllerRef::You), + enter_tapped: engine::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: true, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + vec![], + source, + P0, + ) + .sub_ability(first_independent_sibling); + forward_result.target_choice_timing = TargetChoiceTiming::Resolution; + forward_result.forward_result = true; + + let mut runner = scenario.build(); + let mut events = Vec::new(); + resolve_ability_chain(runner.state_mut(), &forward_result, &mut events, 0) + .expect("conditional empty forward-result chain must resolve"); + + assert_eq!( + runner.state().players[usize::from(P0.0)].life, + 22, + "the false sibling must skip its own effect and resume the later independent sibling" + ); + assert_ne!( + runner.state().objects[&destroy_target].zone, + Zone::Battlefield, + "the first independent sibling must resolve and prove the handoff was reached" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 0, + "the dependent ParentTarget else branch must remain a no-op" + ); +} + /// 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]