diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 82b2d0d8a1..b5a8d5d992 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -9,9 +9,9 @@ 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, EffectResolutionResult, EffectScope, FilterProp, + AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, CardPlayMode, CardTypeSetSource, + ControllerRef, CopyRetargetPermission, CostPaidObjectSnapshot, EachDamageRecipient, Effect, + EffectError, EffectKind, EffectOutcomeSignal, EffectResolutionResult, EffectScope, FilterProp, ManaProduction, OpponentMayScope, PlayerFilter, PlayerScope, QuantityExpr, QuantityRef, RepeatContinuation, ResolvedAbility, RevealUntilDisposition, SacrificeCost, SacrificeRequirement, SharedQuality, SharedQualityRelation, SiblingCondition, SubAbilityLink, @@ -11613,6 +11613,27 @@ fn resolve_chain_body( ); resolve_ability_chain(state, &trailing_resolved, events, depth + 1)?; } + } else if ability.forward_result + && forwarded_objects.is_empty() + && 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 + // 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. Walk past dependent sequential siblings + // and resume at the first independent sibling instead of terminating + // the entire printed instruction chain. + 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() { let mut sub_with_context = sub.as_ref().clone(); // CR 707.10: `CopySpell { SelfRef }` copies the resolving spell @@ -11943,6 +11964,83 @@ 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) + ) +} + +/// 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 11c1a355a3..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,13 +6,14 @@ 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, - ContinuousModification, DelayedTriggerCondition, Effect, QuantityExpr, QuantityRef, - ReplacementDefinition, TargetFilter, + AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ChosenAttribute, + ContinuousModification, ControllerRef, DelayedTriggerCondition, Effect, FilterProp, + QuantityExpr, QuantityRef, ReplacementDefinition, ResolvedAbility, TargetChoiceTiming, + TargetFilter, TypedFilter, }; 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 +31,9 @@ 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 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\ @@ -236,6 +240,391 @@ 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" + ); + 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 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; + 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() => break, + _ => break, + } + } + + // 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. + runner + .cast(response) + .target_object(graveyard_creature) + .commit(); + runner.pass_both_players(); + assert_eq!( + runner.state().objects[&graveyard_creature].zone, + Zone::Battlefield, + "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(); + + 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" + ); +} + +#[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" + ); +} + +#[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]