diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 1390e2e31f..bc362ef627 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -63,12 +63,30 @@ fn resolve_forward_result_search_attach_host( let targets = forward_result_attach_host_targets(ability); match target { TargetFilter::SelfRef => Some(AttachTarget::Object(ability.source_id)), + // CR 303.4b + CR 109.4: "attached to you" (Lynde, Cheerful Tormentor) + // binds the Aura host to the resolving ability's controller. + TargetFilter::Controller => Some(AttachTarget::Player(ability.controller)), TargetFilter::ParentTarget => targets .first() .map(|target| match target { TargetRef::Object(id) => AttachTarget::Object(*id), TargetRef::Player(id) => AttachTarget::Player(*id), }) + .or_else(|| { + // CR 603.2 + CR 608.2c + CR 701.3a: Event-subject return Auras + // ("return this … attached to that creature" — Dragon Breath, + // Smoke Shroud) bind ParentTarget to the trigger-event referent + // (entering creature), not the Aura's prior host. + crate::game::targeting::resolve_event_context_target( + state, + &TargetFilter::ParentTarget, + ability.source_id, + ) + .map(|target| match target { + TargetRef::Object(id) => AttachTarget::Object(id), + TargetRef::Player(id) => AttachTarget::Player(id), + }) + }) .or_else(|| { // CR 303.4b + CR 608.2c: Aura search-put "attached to that/enchanted // player" binds ParentTarget to the source's enchanted host when no diff --git a/crates/engine/src/game/effects/delayed_trigger.rs b/crates/engine/src/game/effects/delayed_trigger.rs index 7b70e2ae6a..aae9b21cc5 100644 --- a/crates/engine/src/game/effects/delayed_trigger.rs +++ b/crates/engine/src/game/effects/delayed_trigger.rs @@ -44,8 +44,19 @@ pub fn resolve( // chosen object. This ordering is mandatory: running the contextual bind // first would pre-empt the tracked-set rewrite and break the "those cards" // cards. + // CR 603.7: Prefer the active nonempty resolution-chain set, then the latest + // nonempty published set. An empty chain id (stale pre-choice publish) must + // not shadow a later nonempty set (Storm Herald delayed exile). let tracked_set_id = if uses_tracked_set { - crate::game::targeting::latest_tracked_set_id(state) + state + .chain_tracked_set_id + .filter(|id| { + state + .tracked_object_sets + .get(id) + .is_some_and(|objects| !objects.is_empty()) + }) + .or_else(|| crate::game::targeting::latest_tracked_set_id(state)) } else { None }; @@ -127,7 +138,7 @@ pub fn resolve( ) .map(|t| vec![t]) .unwrap_or_default() - } else if super::effect_refs_parent_target(&delayed_ability.effect) { + } else if super::ability_refs_parent_target(&delayed_ability) { parent_target_snapshot(state, ability) } else if effect_references_last_created(&delayed_ability.effect) && !state.last_created_token_ids.is_empty() @@ -170,13 +181,28 @@ pub fn resolve( // and activated-ability sources may not already carry trigger provenance; // capture their current incarnation at creation rather than later rebinding // the stored ObjectId. CR 400.7. + // + // CR 400.7 + CR 603.7c: when the delayed ability's source is still the same + // ObjectId, refresh zone + incarnation to that object's creation-time + // location. The parent trigger often latched while the source was on the + // battlefield (Gift of Immortality's dies trigger), but SBAs may already + // have moved that Aura to the graveyard (bumping incarnation) before the + // delayed return is created. SelfRef resolution requires a zone+incarnation + // match (`source_is_current_via_zone_match`); keeping the BF/pre-move stamp + // would make the end-step SelfRef ChangeZone no-op. let source_context = ability.trigger_source.clone().or_else(|| { state .objects .get(&ability.source_id) .map(|source| super::super::triggers::trigger_source_context_for_latch(state, source)) }); - if let Some(source_context) = source_context { + if let Some(mut source_context) = source_context { + if source_context.identity.reference.object_id == delayed_ability.source_id { + if let Some(obj) = state.objects.get(&delayed_ability.source_id) { + source_context.identity.expected_zone = obj.zone; + source_context.identity.reference.incarnation = obj.incarnation; + } + } delayed_ability.set_trigger_source_recursive(source_context); } diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 9961801baf..6272dcbcfd 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2479,7 +2479,12 @@ fn apply_parent_chain_context( fn should_propagate_parent_targets(ability: &ResolvedAbility, sub: &ResolvedAbility) -> bool { sub.targets.is_empty() && !ability.targets.is_empty() - && sub.target_choice_timing != TargetChoiceTiming::Resolution + && (sub.target_choice_timing != TargetChoiceTiming::Resolution + // CR 608.2c + CR 303.4f: TargetOnly → ChangeZone[+Attach ParentTarget] + // (Necrotic Plague) stamps Resolution on the return clause, but the + // chosen host is still the parent's bound target — propagate it so + // nested Attach does not fall through to the trigger-event referent. + || change_zone_forwards_chosen_attach_host(sub)) && !(sub .effect .target_filter() @@ -2487,6 +2492,26 @@ fn should_propagate_parent_targets(ability: &ResolvedAbility, sub: &ResolvedAbil && !effect_refs_parent_target(&sub.effect)) } +/// CR 701.3a + CR 303.4f: `forward_result` ChangeZone nesting Attach→ParentTarget +/// carries the parent's chosen host (not a fresh Resolution-time object pick). +fn change_zone_forwards_chosen_attach_host(sub: &ResolvedAbility) -> bool { + sub.forward_result + && matches!( + &sub.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) + && matches!( + sub.sub_ability.as_deref().map(|s| &s.effect), + Some(Effect::Attach { + target: TargetFilter::ParentTarget, + .. + }) + ) +} + fn waits_for_resolution_choice(waiting_for: &WaitingFor) -> bool { matches!( waiting_for, @@ -5279,6 +5304,14 @@ pub(crate) fn publish_tracked_set(state: &mut GameState, affected_ids: Vec bool { .is_some_and(ability_refs_triggering_source) } +/// True when any effect in the ability chain references `ParentTarget` +/// (including nested sub/else abilities). Used by delayed-trigger snapshotting +/// so an Attach host on a ChangeZone sub-chain (Gift of Immortality #4956) still +/// freezes the parent referent at creation time. +fn ability_refs_parent_target(ability: &ResolvedAbility) -> bool { + effect_refs_parent_target(&ability.effect) + || ability + .sub_ability + .as_deref() + .is_some_and(ability_refs_parent_target) + || ability + .else_ability + .as_deref() + .is_some_and(ability_refs_parent_target) +} + /// CR 603.7 + CR 109.5: Replace the first `TargetRef::Object` in a target /// slice with the supplied object id. Used by the `repeat_for: TrackedSetSize` /// per-iteration rebind so the i-th iteration's parent reference (e.g., @@ -6472,26 +6521,86 @@ fn filter_refs_post_replacement_event_target(filter: &TargetFilter) -> bool { /// effect carries an event-context recipient (`TriggeringPlayer`, etc.), bind /// that referent into `targets` before payer/effect resolution. Shared by the /// unless-pay interceptor and the main effect path (issue #2361). +/// +/// CR 603.2 + CR 701.3a + CR 303.4f: Event-subject return Auras nest +/// `Attach { target: ParentTarget }` under a `forward_result` ChangeZone whose +/// own target is `SelfRef` (Dragon Breath, Smoke Shroud). Hydrate the nested +/// host onto the Attach sub so zone entry binds the trigger-event creature +/// instead of falling through to CR 303.4f Aura choice. +/// +/// Do **not** inject an event referent when the ability chain already carries a +/// bound parent target (Necrotic Plague: controller's chosen creature wins over +/// the creature that died). `forward_result_attach_host_targets` prefers a +/// filled Attach sub over `ability.targets`, so an eager hydrate would overwrite +/// the explicit choice. fn hydrate_event_context_targets<'a>( state: &GameState, ability: &'a ResolvedAbility, ) -> Cow<'a, ResolvedAbility> { - if !ability.targets.is_empty() { - return Cow::Borrowed(ability); - } - let Some(filter) = extract_event_context_filter(&ability.effect) else { - return Cow::Borrowed(ability); + let root_ref = if ability.targets.is_empty() { + extract_event_context_filter(&ability.effect).and_then(|filter| { + crate::game::targeting::resolve_event_context_target(state, filter, ability.source_id) + }) + } else { + None }; - let Some(target_ref) = - crate::game::targeting::resolve_event_context_target(state, filter, ability.source_id) - else { - return Cow::Borrowed(ability); + // Only hydrate an event-context Attach host when the chain has no bound + // parent target yet — an explicit/snapshotted choice must stand. + let nested_host_ref = if ability.targets.is_empty() { + nested_forward_result_attach_parent_target_ref(state, ability) + } else { + None }; + + if root_ref.is_none() && nested_host_ref.is_none() { + return Cow::Borrowed(ability); + } + let mut resolved = ability.clone(); - resolved.targets = vec![target_ref]; + if let Some(target_ref) = root_ref { + resolved.targets = vec![target_ref]; + } + if let Some(host_ref) = nested_host_ref { + if let Some(sub) = resolved.sub_ability.as_mut() { + sub.targets = vec![host_ref]; + } + } Cow::Owned(resolved) } +/// CR 603.2 + CR 701.3a: Resolve `ParentTarget` for a nested forward-result +/// Attach host when the chain still has no bound parent target. +fn nested_forward_result_attach_parent_target_ref( + state: &GameState, + ability: &ResolvedAbility, +) -> Option { + if !ability.forward_result { + return None; + } + // CR 608.2c + CR 303.4f: A chosen/snapshotted parent target on the ability + // (Necrotic Plague's "target creature one of their opponents controls") must + // not be overwritten by the trigger-event referent (the creature that died). + if !ability.targets.is_empty() { + return None; + } + let sub = ability.sub_ability.as_ref()?; + if !sub.targets.is_empty() { + return None; + } + let Effect::Attach { + target: TargetFilter::ParentTarget, + .. + } = &sub.effect + else { + return None; + }; + crate::game::targeting::resolve_event_context_target( + state, + &TargetFilter::ParentTarget, + ability.source_id, + ) +} + /// CR 603.2: Filters that auto-resolve from `state.current_trigger_event` during /// hydration / unless-pay payer resolution (issue #2361, Kain #1335). fn hydratable_event_context_filter(filter: &TargetFilter) -> bool { @@ -10292,15 +10401,58 @@ fn resolve_chain_body( // isn't an implicit tracked-set consumer), prepend the moved // card as a target so `ParentTarget` consumers downstream // resolve to it. - if !forwarded_objects.is_empty() { + // CR 303.4g + CR 303.4i (#4956): `forward_result` Attach is realized via + // `enter_attached_to` on the zone move. If that move Remained, there is + // no ZoneChanged event — running Attach would stamp `attached_to` onto + // an Aura that never left its origin zone. + if ability.forward_result + && forwarded_objects.is_empty() + && matches!( + ability.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) + && matches!(sub.effect, Effect::Attach { .. }) + { + // CR 303.4g + CR 608.2c: Zero cards returned — skip the Attach that + // would have bound SelfRef attachments on entry, but still run + // trailing instructions (Cass: "then attach any number of Equipment + // … to that creature"). The skipped Attach node holds the chosen + // host target; ParentTarget on the trailing Attach must inherit it. + if let Some(trailing) = sub.sub_ability.as_ref() { + let mut trailing_resolved = trailing.as_ref().clone(); + if should_propagate_parent_targets(sub, &trailing_resolved) { + trailing_resolved.targets = sub.targets.clone(); + } else if should_propagate_parent_targets(ability, &trailing_resolved) { + trailing_resolved.targets = ability.targets.clone(); + } + apply_parent_chain_context( + &mut trailing_resolved, + ability, + effect_context_object.as_ref(), + state, + ); + resolve_ability_chain(state, &trailing_resolved, events, depth + 1)?; + } + } else if !forwarded_objects.is_empty() { let mut sub_with_context = sub.as_ref().clone(); // CR 707.10: `CopySpell { SelfRef }` copies the resolving spell // itself (Sevinne's Reclamation, Chain cycle). `forward_result` // rebinding `source_id` to the just-moved permanent would make // `copy_spell::resolve` look up the wrong stack entry after // `resolve_top` has popped the spell (issue #2860). + // + // CR 603.7c + CR 201.5: `CreateDelayedTrigger` must keep the + // creating ability's source. SelfRef inside the delayed body means + // "this card" (Gift of Immortality #4956), not the just-returned + // host. ParentTarget anaphora still receive the moved object via + // `targets` below (snapshot at delayed-trigger creation). if !copy_spell_self_ref_keeps_resolving_spell_source(sub) { - sub_with_context.source_id = forwarded_objects[0]; + if !matches!(sub.effect, Effect::CreateDelayedTrigger { .. }) { + sub_with_context.source_id = forwarded_objects[0]; + } if matches!( ability.effect, Effect::Conjure { @@ -10325,7 +10477,30 @@ fn resolve_chain_body( .. } ); - if !attach_target_is_last_created + // CR 608.2c: ParentTarget hosts inherit an explicit parent + // choice when present (Necrotic Plague). When none was chosen, + // fall back to the ability source as host — CR 301.5b + + // CR 701.3a put→attach-to-~ (Iron Man, Armored Skyhunter). + // Do not append the source on top of an already-bound host: + // that would let ParentTarget resolve to the returned Aura. + let attach_target_is_parent = matches!( + &sub.effect, + Effect::Attach { + target: TargetFilter::ParentTarget, + .. + } + ); + if attach_target_is_parent { + if sub_with_context.targets.is_empty() { + if !ability.targets.is_empty() { + sub_with_context.targets = ability.targets.clone(); + } else { + sub_with_context + .targets + .push(TargetRef::Object(ability.source_id)); + } + } + } else if !attach_target_is_last_created && !sub_with_context .targets .iter() diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 11613e3db6..053c6931eb 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -4669,6 +4669,31 @@ pub(super) fn handle_resolution_choice( // Issue #423 audit: no cards chosen — this branch moves no // objects and emits no battlefield-exit events, so no // dies-trigger collection is needed. + // + // CR 603.7: Terminal empty `up_to` must still rebind a fresh + // empty chain tracked set before the continuation drains, or a + // following `TargetFilter::TrackedSet` can observe a prior + // non-empty set. Mid-pause empty publishes stay skipped at the + // NeedsAura / NeedsChoice call sites (`mid_pause: true`). + if matches!( + effect_kind, + EffectKind::Sacrifice + | EffectKind::ChangeZone + | EffectKind::BounceAll + | EffectKind::Tap + | EffectKind::Untap + | EffectKind::PutAtLibraryPosition + | EffectKind::CastFromZone + ) && state.active_ability_continuation().is_some() + { + publish_effect_zone_choice_tracked_set( + state, + effect_kind, + &[], + library_position, + false, + ); + } state.last_effect_count = Some(0); events.push(GameEvent::EffectResolved { kind: effect_kind, @@ -4829,6 +4854,17 @@ pub(super) fn handle_resolution_choice( } } crate::game::zone_pipeline::ZoneMoveTerminalResult::NeedsAuraAttachmentChoice => { + // CR 608.2c + CR 603.7 + CR 303.4f: Publish the + // selection before pausing for Aura host choice — + // this early return skips the terminal publish + // below (Storm Herald "Exile those Auras"). + publish_effect_zone_choice_tracked_set( + state, + effect_kind, + &chosen_ids, + library_position, + true, + ); crate::game::triggers::append_and_collect_logical_zone_trigger_segment( state, &mut logical_zone_change_group, @@ -4885,6 +4921,16 @@ pub(super) fn handle_resolution_choice( // `effects/mod.rs::drain_pending_change_zone_iteration` // resumes the loop after this replacement // choice resolves (issue #535). + // CR 608.2c + CR 603.7: Publish selection before + // the replacement pause — same early-return gap + // as NeedsAuraAttachmentChoice above. + publish_effect_zone_choice_tracked_set( + state, + effect_kind, + &chosen_ids, + library_position, + true, + ); crate::game::triggers::append_and_collect_logical_zone_trigger_segment( state, &mut logical_zone_change_group, @@ -5437,34 +5483,17 @@ pub(super) fn handle_resolution_choice( GameEvent::PermanentSacrificed { object_id, .. } => Some(*object_id), _ => None, }) - .collect() - } else if matches!(effect_kind, EffectKind::PutAtLibraryPosition) - && matches!(library_position, Some(LibraryPosition::Bottom)) - && state.active_ability_continuation().is_some() - { - // CR 608.2c: Expressive Iteration's bottom pick narrows the - // tracked set to the remaining looked-at library cards so the - // chained exile step cannot re-select the bottomed card. - state - .chain_tracked_set_id - .and_then(|id| state.tracked_object_sets.get(&id).cloned()) - .unwrap_or_default() - .into_iter() - .filter(|id| !chosen.contains(id)) - .filter(|id| { - state - .objects - .get(id) - .is_some_and(|obj| obj.zone == Zone::Library) - }) - .collect() + .collect::>() } else { chosen.clone() }; - let tracked_id = TrackedSetId(state.next_tracked_set_id); - state.next_tracked_set_id += 1; - state.tracked_object_sets.insert(tracked_id, tracked); - state.chain_tracked_set_id = Some(tracked_id); + publish_effect_zone_choice_tracked_set( + state, + effect_kind, + &tracked, + library_position, + false, + ); } state.last_effect_count = Some(chosen.len() as i32); events.push(GameEvent::EffectResolved { @@ -6603,6 +6632,82 @@ fn action_result_outcome( }) } +/// CR 608.2c + CR 603.7: Publish the EffectZoneChoice selection as the chain +/// tracked set when a continuation will consume it ("those Auras", plotted +/// cards, etc.). +/// +/// Must also run on mid-delivery pauses (`NeedsAuraAttachmentChoice` / +/// replacement `NeedsChoice`): those early-return before the terminal publish +/// at the end of the EffectZoneChoice arm, and Storm Herald's delayed exile +/// would otherwise bind `TrackedSet` against an unbound sentinel. +/// +/// `mid_pause`: when true, an empty selection is not published yet (Aura host +/// choice / replacement ordering still open). When false (terminal completion), +/// an empty `up_to` selection must rebind a fresh empty chain set so a following +/// `TargetFilter::TrackedSet` cannot reuse a prior non-empty set. +fn publish_effect_zone_choice_tracked_set( + state: &mut GameState, + effect_kind: EffectKind, + chosen: &[ObjectId], + library_position: Option, + mid_pause: bool, +) { + if !matches!( + effect_kind, + EffectKind::Sacrifice + | EffectKind::ChangeZone + | EffectKind::BounceAll + | EffectKind::Tap + | EffectKind::Untap + | EffectKind::PutAtLibraryPosition + | EffectKind::CastFromZone + ) || state.active_ability_continuation().is_none() + { + return; + } + // Distinguish mid-pause "nothing to publish yet" from a genuine empty + // narrowed set (PutAtLibraryPosition Bottom). The latter must still rebind + // `chain_tracked_set_id` so a chained TrackedSet exile cannot re-select + // cards that just left the library (CR 608.2c). + let mut narrowed = false; + let tracked = if matches!(effect_kind, EffectKind::Sacrifice) { + // Sacrifice publishes from PermanentSacrificed events at the completion + // seam; callers pass the sacrificed ids already. + chosen.to_vec() + } else if matches!(effect_kind, EffectKind::PutAtLibraryPosition) + && matches!(library_position, Some(LibraryPosition::Bottom)) + { + narrowed = true; + // CR 608.2c: Expressive Iteration's bottom pick narrows the tracked set + // to the remaining looked-at library cards so the chained exile step + // cannot re-select the bottomed card. + state + .chain_tracked_set_id + .and_then(|id| state.tracked_object_sets.get(&id).cloned()) + .unwrap_or_default() + .into_iter() + .filter(|id| !chosen.contains(id)) + .filter(|id| { + state + .objects + .get(id) + .is_some_and(|obj| obj.zone == Zone::Library) + }) + .collect() + } else { + chosen.to_vec() + }; + // Pause-only: skip empty until the selection is terminal. Terminal empty + // (including narrowed-to-empty Bottom) must still rebind. + if tracked.is_empty() && mid_pause && !narrowed { + return; + } + let tracked_id = TrackedSetId(state.next_tracked_set_id); + state.next_tracked_set_id += 1; + state.tracked_object_sets.insert(tracked_id, tracked); + state.chain_tracked_set_id = Some(tracked_id); +} + fn set_priority(state: &mut GameState, player: crate::types::player::PlayerId) { state.waiting_for = WaitingFor::Priority { player }; state.priority_player = player; @@ -9512,4 +9617,130 @@ mod tests { assert_eq!(active_plane(&state), Some(deck_second)); assert!(state.planar_deck.contains(&deck_top)); } + + /// CR 603.7: Terminal `up_to` EffectZoneChoice with zero cards selected must + /// rebind a fresh empty chain tracked set through the production + /// `handle_resolution_choice` path so a following TrackedSet consumer cannot + /// reuse a prior non-empty set. Mid-pause empty publishes stay skipped. + #[test] + fn terminal_empty_up_to_effect_zone_choice_rebinds_empty_tracked_set() { + use crate::types::ability::{ + CastingPermission, Effect, PermissionGrantee, ResolvedAbility, + }; + use crate::types::game_state::PendingContinuation; + use crate::types::identifiers::TrackedSetId; + use crate::types::zones::EtbTapState; + + let mut state = GameState::new_two_player(42); + let source = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Source".to_string(), + Zone::Battlefield, + ); + let eligible = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Eligible Aura".to_string(), + Zone::Graveyard, + ); + let stale = create_object( + &mut state, + CardId(5), + PlayerId(0), + "Stale".to_string(), + Zone::Exile, + ); + state + .tracked_object_sets + .insert(TrackedSetId(1), vec![stale]); + state.next_tracked_set_id = 2; + state.chain_tracked_set_id = Some(TrackedSetId(1)); + + // Mid-pause empty must not rebind (Storm Herald Aura-host pause). + publish_effect_zone_choice_tracked_set(&mut state, EffectKind::ChangeZone, &[], None, true); + assert_eq!(state.chain_tracked_set_id, Some(TrackedSetId(1))); + assert_eq!( + state.tracked_object_sets.get(&TrackedSetId(1)), + Some(&vec![stale]) + ); + + // Continuation consumes the chain tracked set — must observe the fresh + // empty set from terminal zero-choice, not the stale prior members. + state.park_ability_continuation(PendingContinuation::new( + Box::new(ResolvedAbility::new( + Effect::GrantCastingPermission { + permission: CastingPermission::Plotted { turn_plotted: 0 }, + target: TargetFilter::TrackedSet { + id: TrackedSetId(0), + }, + grantee: PermissionGrantee::ObjectOwner, + }, + vec![], + source, + PlayerId(0), + )), + &state, + )); + + let waiting = WaitingFor::EffectZoneChoice { + player: PlayerId(0), + cards: vec![eligible], + count: 1, + min_count: 0, + up_to: true, + source_id: source, + effect_kind: EffectKind::ChangeZone, + zone: Zone::Graveyard, + destination: Some(Zone::Battlefield), + enter_tapped: EtbTapState::Unspecified, + enter_transformed: false, + enters_under_player: None, + enters_attacking: false, + owner_library: false, + track_exiled_by_source: false, + face_down_profile: None, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + count_param: 0, + library_position: None, + is_cost_payment: false, + enters_modified_if: None, + duration: None, + }; + state.waiting_for = waiting.clone(); + + let mut events = Vec::new(); + handle_resolution_choice( + &mut state, + waiting, + GameAction::SelectCards { cards: vec![] }, + &mut events, + ) + .expect("terminal empty up_to EffectZoneChoice resolves"); + + assert_eq!( + state.chain_tracked_set_id, + Some(TrackedSetId(2)), + "production empty up_to path must rebind a fresh chain tracked set" + ); + assert!(state + .tracked_object_sets + .get(&TrackedSetId(2)) + .is_some_and(|objects| objects.is_empty())); + assert!( + state + .objects + .get(&stale) + .is_some_and(|obj| obj.casting_permissions.is_empty()), + "TrackedSet continuation must not grant against the prior non-empty set" + ); + assert_eq!( + state.objects.get(&eligible).map(|obj| obj.zone), + Some(Zone::Graveyard), + "zero-choice must leave eligible cards unmoved" + ); + } } diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index e97a7d852c..b440078162 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -2952,6 +2952,9 @@ fn execute_zone_move_with_applied_terminal( } } } + // CR 303.4i specified-host Remain is handled after delivery when + // `attach_to` fails / SBA (CR 704.5m). Pre-move filter checks while + // the Aura is still in GY falsely Remained legal Gift/Lynde hosts. } if let Some((controller, aura_id, legal_targets)) = pending_aura_choice { let delivery_start = events.len(); diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 3f9a8d473c..bfb6f616e6 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -98,6 +98,33 @@ fn is_multi_target_player_subject_definition(def: &AbilityDefinition) -> bool { }) } +/// CR 701.3a + CR 303.4f: Stamp `forward_result` on a ChangeZone→Battlefield that +/// nests Attach, including when that return sits under TargetOnly (Necrotic Plague). +fn stamp_forward_result_on_battlefield_attach_return(def: &mut AbilityDefinition) { + let nests_attach = matches!( + def.sub_ability.as_deref().map(|s| &*s.effect), + Some(Effect::Attach { .. }) + ); + if nests_attach + && matches!( + &*def.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) + { + def.forward_result = true; + return; + } + if let Some(sub) = def.sub_ability.as_mut() { + stamp_forward_result_on_battlefield_attach_return(sub); + } + if let Some(else_ability) = def.else_ability.as_mut() { + stamp_forward_result_on_battlefield_attach_return(else_ability); + } +} + /// CR 608.2c: A bare verb after a multi-target player subject continues that /// subject. A printed player subject (especially `you`) starts an independent /// actor-relative instruction instead. @@ -1893,7 +1920,36 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { // `parse_target_with_ctx` during chunk parse, so a targeted "of their // choice" routes target selection to the scoped (upkeep) player. def.target_chooser = clause_ir.target_chooser.clone(); + // CR 701.3a + CR 303.4f: ChangeZone→Battlefield with an Attach sub + // (Gift of Immortality delayed reattach) must nest Attach on the + // ChangeZone with `forward_result` BEFORE delayed wrapping. Sibling- + // pushing then wrapping each unit would yield CDT{ChangeZone} + + // CDT{Attach} instead of CDT{ChangeZone[+Attach]}. TargetOnly already + // nests before wrap; mirror that for this attach-on-return shape. let clause_sub = if is_target_only { + def.sub_ability = clause_ir.parsed.sub_ability.clone(); + // CR 701.3a + CR 303.4f: TargetOnly → ChangeZone[+Attach] (Necrotic + // Plague) must stamp `forward_result` on the nested return so the + // chosen host propagates into Attach (see + // `change_zone_forwards_chosen_attach_host`). + if let Some(sub) = def.sub_ability.as_mut() { + stamp_forward_result_on_battlefield_attach_return(sub); + } + None + } else if matches!( + &*def.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) && matches!( + clause_ir.parsed.sub_ability.as_deref().map(|s| &*s.effect), + Some(Effect::Attach { .. }) + ) { + // CR 303.4f + CR 608.2c: forward the moved Aura into Attach so + // `resolve_forward_result_search_attach_host` stamps `attach_to` + // and skips the CR 303.4f host-choice consult. + def.forward_result = true; def.sub_ability = clause_ir.parsed.sub_ability.clone(); None } else { @@ -2252,12 +2308,16 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { }, ), ); - // CR 608.2c: Lift condition/optional/repeat/player_scope to outer wrapper. - let lifted_condition = inner.condition.clone(); - let lifted_optional = inner.optional; - let lifted_optional_for = inner.optional_for; - let lifted_repeat_for = inner.repeat_for.clone(); - let lifted_player_scope = inner.player_scope.clone(); + // CR 608.2c: Lift condition/optional/repeat/player_scope to the + // outer wrapper — move, don't clone. Leaving + // `OptionalEffectPerformed` on the delayed payload (Next of Kin + // #4956) would re-check that creation-time "if you do" signal when + // the delayed trigger fires at end step and skip the return. + let lifted_condition = std::mem::take(&mut inner.condition); + let lifted_optional = std::mem::replace(&mut inner.optional, false); + let lifted_optional_for = std::mem::take(&mut inner.optional_for); + let lifted_repeat_for = std::mem::take(&mut inner.repeat_for); + let lifted_player_scope = std::mem::take(&mut inner.player_scope); // CR 608.2c: The `CreateDelayedTrigger` wrapper — not its payload — // is the node that occupies this clause's slot in the parent's // `sub_ability` chain, so it must carry the clause's `sub_link` diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index b35b1b84ca..c1c79add74 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -1890,12 +1890,13 @@ pub(super) fn parse_targeted_action_ast( rest }; let rest_lower = &lower[lower.len() - rest.len()..]; - let (trailing_target_text, trailing_dest) = super::strip_return_destination_ext(rest); + let (trailing_target_text, trailing_dest, trailing_dest_remainder) = + super::strip_return_destination_ext_with_remainder(rest); let (leading_target_text, leading_dest) = super::strip_leading_return_destination_ext(rest); - let (target_text, dest) = if leading_dest.is_some() { - (leading_target_text, leading_dest) + let (target_text, dest, dest_remainder) = if leading_dest.is_some() { + (leading_target_text, leading_dest, "") } else { - (trailing_target_text, trailing_dest) + (trailing_target_text, trailing_dest, trailing_dest_remainder) }; let (is_mass, target_text) = if let Some((_, rest)) = nom_on_lower(target_text, &target_text.to_ascii_lowercase(), |input| { @@ -2015,6 +2016,10 @@ pub(super) fn parse_targeted_action_ast( enter_with_counters: d.enter_with_counters, }) } else { + // CR 701.3a + CR 303.4f: "return … to the battlefield attached + // to " (Gift of Immortality, Next of Kin, Lynde). + let attach_host = + super::sequence::parse_search_attach_host(dest_remainder).map(|(h, _)| h); Some(TargetedImperativeAst::ReturnToBattlefield { target, origin, @@ -2024,6 +2029,7 @@ pub(super) fn parse_targeted_action_ast( enters_attacking: d.enters_attacking, enter_with_counters: d.enter_with_counters, face_down: d.face_down, + attach_host, }) } } @@ -2299,6 +2305,8 @@ pub(super) fn lower_targeted_action_ast(ast: TargetedImperativeAst) -> Effect { count, }, // CR 400.7: Return to battlefield is a zone change, not a bounce. + // CR 701.3a: `attach_host` is an ability-level sub_ability (Attach under + // ChangeZone with forward_result), recovered in `lower_imperative_family_ast`. TargetedImperativeAst::ReturnToBattlefield { target, origin, @@ -2308,6 +2316,7 @@ pub(super) fn lower_targeted_action_ast(ast: TargetedImperativeAst) -> Effect { enters_attacking, enter_with_counters, face_down, + attach_host: _, } => Effect::ChangeZone { origin, destination: Zone::Battlefield, @@ -5506,23 +5515,45 @@ pub(super) fn parse_utility_imperative_ast( return Some(UtilityImperativeAst::SwitchPT { target }); } } - // CR 400.7j + CR 608.2h: Zack Fair — "attach an Equipment that was attached - // to ~ to that creature". The attachment is battlefield Equipment whose - // host was the ability source (including LKI after self-sacrifice). + // CR 400.7j + CR 608.2h: Zack Fair / Cass — "attach [an / any number of] + // Equipment that was/were attached to ~/it to that creature". The attachment + // is battlefield Equipment whose host was the ability source or the + // triggering object (AttachedToSource + trigger_source LKI). "to that + // creature" after a chosen attach-host is ParentTarget (Cass), not the + // dies-trigger TriggeringSource demonstrative. if let Some((multi_target, recipient_text)) = nom_on_lower(text, lower, |input| { let (input, _) = tag("attach ").parse(input)?; - let (input, _) = opt(tag("an ")).parse(input)?; - let (input, multi_target) = opt(value( - MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 1 }), - tag("up to one "), + let (input, multi_target) = alt(( + value(Some(MultiTargetSpec::unlimited(0)), tag("any number of ")), + value( + Some(MultiTargetSpec::up_to(QuantityExpr::Fixed { value: 1 })), + tag("up to one "), + ), + map(opt(tag("an ")), |_| None), )) .parse(input)?; - let (input, _) = tag("equipment that was attached to ").parse(input)?; - let (input, _) = alt((tag("~"), tag("this equipment"))).parse(input)?; + let (input, _) = tag("equipment that ").parse(input)?; + let (input, _) = alt((tag("was "), tag("were "))).parse(input)?; + let (input, _) = tag("attached to ").parse(input)?; + let (input, _) = alt((tag("~"), tag("it"), tag("this equipment"))).parse(input)?; let (input, _) = tag(" to ").parse(input)?; Ok((input, multi_target)) }) { - let (target, _target_rem) = parse_attach_recipient(recipient_text, ctx); + // CR 608.2c: "to that creature" names the chosen Aura host from the prior + // return/attach clause (Cass), not the dying creature. Force ParentTarget + // for that demonstrative; other recipients keep attach-recipient dispatch. + let (target, _target_rem) = { + let recipient_lower = recipient_text.trim().to_ascii_lowercase(); + if tag::<_, _, OracleError<'_>>("that creature") + .parse(recipient_lower.as_str()) + .ok() + .is_some_and(|(rest, _)| rest.trim().is_empty()) + { + (TargetFilter::ParentTarget, "") + } else { + parse_attach_recipient(recipient_text, ctx) + } + }; #[cfg(debug_assertions)] assert_no_compound_remainder(_target_rem, text); if _target_rem.trim().is_empty() { @@ -12094,6 +12125,47 @@ pub(super) fn lower_imperative_family_ast(ast: ImperativeFamilyAst) -> ParsedEff clause.multi_target = multi_target; clause } + // CR 701.3a + CR 303.4f: "return … to the battlefield attached to " + // (Gift of Immortality, Next of Kin, Lynde). Bare Effect lowering cannot + // carry the Attach sub-chain — nest it here (Cloak / SearchLibrary pattern) + // so assembly can wrap a single ChangeZone[+Attach] unit in CreateDelayedTrigger. + ImperativeFamilyAst::Structured(ImperativeAst::Targeted( + TargetedImperativeAst::ReturnToBattlefield { + target, + origin, + enter_transformed, + enters_under, + enter_tapped, + enters_attacking, + enter_with_counters, + face_down, + attach_host: Some(host), + }, + )) => { + let mut clause = parsed_clause(lower_targeted_action_ast( + TargetedImperativeAst::ReturnToBattlefield { + target, + origin, + enter_transformed, + enters_under, + enter_tapped, + enters_attacking, + enter_with_counters, + face_down, + attach_host: None, + }, + )); + // CR 701.3a: the returning Aura/permanent is SelfRef; the host is the + // anaphor captured on the IR (`that creature` / `you`). + clause.sub_ability = Some(Box::new(AbilityDefinition::new( + AbilityKind::Spell, + Effect::Attach { + attachment: TargetFilter::SelfRef, + target: host, + }, + ))); + clause + } // All other arms produce a bare Effect with no sub_ability chain. other => parsed_clause(lower_imperative_family_effect(other)), } @@ -14651,6 +14723,42 @@ mod tests { assert_eq!(multi_target, None); } + #[test] + fn parse_attach_any_number_equipment_were_attached_to_it_to_parent_target() { + // Cass, Hand of Vengeance — dies-trigger "that creature" is the chosen + // Aura host (ParentTarget), not TriggeringSource. + let input = "attach any number of Equipment that were attached to it to that creature"; + let lower = input.to_lowercase(); + let mut ctx = ParseContext { + subject: Some(TargetFilter::SelfRef), + ..Default::default() + }; + let result = parse_utility_imperative_ast(input, &lower, &mut ctx); + let Some(UtilityImperativeAst::Attach { + attachment, + target, + multi_target, + }) = result + else { + panic!("{input}: expected Attach, got {result:?}"); + }; + match attachment { + TargetFilter::Typed(tf) => { + assert!(tf + .type_filters + .iter() + .any(|t| matches!(t, TypeFilter::Subtype(s) if s == "Equipment"))); + assert!(tf.properties.contains(&FilterProp::AttachedToSource)); + } + other => panic!("expected typed Equipment filter, got {other:?}"), + } + assert!( + matches!(target, TargetFilter::ParentTarget), + "Cass Equipment host must be ParentTarget, got {target:?}" + ); + assert_eq!(multi_target, Some(MultiTargetSpec::unlimited(0))); + } + #[test] fn parse_attach_target_equipment_to_target_creature() { let input = "attach target Equipment you control to target creature you control"; diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index f3716dd26b..28b66f5769 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -6284,6 +6284,9 @@ fn strip_trailing_battlefield_riders(after_destination: &str) -> (&str, Battlefi } /// Detect "return ... to " destination phrase, including "transformed" flag. +/// Thin wrapper over [`strip_return_destination_ext_with_remainder`] for call sites +/// that discard the attach-host remainder (unit tests + legacy helpers). +#[allow(dead_code)] // exercised from `oracle_effect/tests.rs` (cfg(test) sibling) pub(super) fn strip_return_destination_ext(text: &str) -> (&str, Option) { let (target, dest, _) = strip_return_destination_ext_with_remainder(text); (target, dest) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 0bb81c1fb3..bdc430c51a 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -1853,6 +1853,39 @@ fn try_parse_die_exile_rider(lower: &str, kind: AbilityKind) -> Option bool { + matches!(self, Self::TrackedSetPlural) + } +} + +fn parse_leave_battlefield_rider_subject( + input: &str, +) -> OracleResult<'_, LeaveBattlefieldRiderSubject> { + alt(( + value( + LeaveBattlefieldRiderSubject::TrackedSetPlural, + alt(( + tag("those auras"), + tag("those enchantments"), + tag("those permanents"), + tag("those creatures"), + tag("them"), + )), + ), + map(parse_leave_battlefield_rider_ref, |_| { + LeaveBattlefieldRiderSubject::Singular + }), + )) + .parse(input) +} + fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { value( (), @@ -1864,6 +1897,7 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { // prefix-collides with the "it"/"the …"/"that …"/"this …" arms below. tag("~"), tag("it"), + tag("them"), tag("the card"), tag("the creature"), tag("the permanent"), @@ -1875,6 +1909,12 @@ fn parse_leave_battlefield_rider_ref(input: &str) -> OracleResult<'_, ()> { tag("this creature"), tag("this land"), tag("this permanent"), + // Storm Herald: "If those Auras would leave the battlefield, exile them + // instead of putting them anywhere else." + tag("those auras"), + tag("those enchantments"), + tag("those permanents"), + tag("those creatures"), )), ) .parse(input) @@ -1954,7 +1994,7 @@ pub(crate) fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Opti let (rest, _) = nom::combinator::opt(tag::<_, _, OracleError<'_>>("if ")) .parse(lower) .ok()?; - let (rest, _) = parse_leave_battlefield_rider_ref(rest).ok()?; + let (rest, subject) = parse_leave_battlefield_rider_subject(rest).ok()?; let (rest, _) = tag::<_, _, OracleError<'_>>(" would leave the battlefield, ") .parse(rest) .ok()?; @@ -1969,6 +2009,16 @@ pub(crate) fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Opti .ok()?; parse_optional_period_and_end(rest)?; + // CR 603.7 + CR 614.1a: Plural "those Auras/… them" riders install on the + // just-returned tracked set (Storm Herald), not a singular ParentTarget. + let target = if subject.is_tracked_set_plural() { + TargetFilter::TrackedSet { + id: TrackedSetId(0), + } + } else { + TargetFilter::Any + }; + Some(Effect::AddTargetReplacement { // CR 400.7: The standalone rider is bound to the lifetime of the object it // is installed on; stamp the expiry here so the lifetime is self-contained @@ -1980,7 +2030,7 @@ pub(crate) fn try_parse_leave_battlefield_exile_replacement(lower: &str) -> Opti replacement: Box::new( leave_battlefield_exile_replacement().expiry(RestrictionExpiry::UntilHostLeavesPlay), ), - target: TargetFilter::Any, + target, }) } @@ -15778,6 +15828,28 @@ fn try_parse_verb_and_target<'a>( rem, )) } else { + // CR 701.3a + CR 303.4f: consume "attached to " from the + // destination remainder so it does not fall through as an + // Unimplemented follow-up clause (Gift of Immortality #4956). + // Prefer `dest_remainder` over target-parse leftovers such as + // "from your graveyard" (Smoke Shroud / Dragon Breath) so the + // host rider is not skipped when `parse_target` leaves a zone + // phrase in `rem`. Preserve the unconsumed suffix for + // continuation parsing (Cass ", then attach …"; Storm Herald + // ". Exile those …"). + let attach_source = if nom_primitives::scan_contains( + &dest_remainder.to_ascii_lowercase(), + "attached to", + ) { + dest_remainder + } else { + rem + }; + let (attach_host, rem) = match sequence::parse_search_attach_host(attach_source) + { + Some((host, rest)) => (Some(host), rest), + None => (None, rem), + }; Some(( TargetedImperativeAst::ReturnToBattlefield { target, @@ -15788,6 +15860,7 @@ fn try_parse_verb_and_target<'a>( enters_attacking: d.enters_attacking, enter_with_counters: d.enter_with_counters, face_down: d.face_down, + attach_host, }, rem, )) @@ -24521,6 +24594,7 @@ fn hand_reveal_target_to_controller_ref(target: &TargetFilter) -> Option bool { is_exile_effect(effect) + || is_battlefield_return_effect(effect) || is_token_creating_effect(effect) || is_mass_coerce_static(effect) || matches!( @@ -24532,6 +24606,21 @@ fn publishes_tracked_set_from_resolution(effect: &Effect) -> bool { ) } +/// CR 603.7 + CR 400.7: A return/put onto the battlefield publishes the moved +/// objects as the chain tracked set (Storm Herald "those Auras", Returned cause). +fn is_battlefield_return_effect(effect: &Effect) -> bool { + matches!( + effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } | Effect::ChangeZoneAll { + destination: Zone::Battlefield, + .. + } + ) +} + /// CR 608.2c + CR 701.21a: Does this clause publish, at resolution, a chain /// tracked set that a LATER set-AGGREGATE anaphor ("their total power", "the /// greatest power among them") would reduce? @@ -24766,6 +24855,9 @@ fn contains_explicit_tracked_set_pronoun(lower: &str) -> bool { || scan_contains_phrase(lower, "those permanents") || scan_contains_phrase(lower, "those creatures") || scan_contains_phrase(lower, "those tokens") + // Storm Herald: "Exile those Auras at the beginning of your next end step." + || scan_contains_phrase(lower, "those auras") + || scan_contains_phrase(lower, "those enchantments") || scan_contains_phrase(lower, "the exiled card") || scan_contains_phrase(lower, "the exiled permanent") || scan_contains_phrase(lower, "the exiled creature") diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 973b5df69f..6d541951db 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -117,7 +117,13 @@ fn parse_search_attach_host_phrase(input: &str) -> OracleResult<'_, TargetFilter .parse(input) } -fn parse_search_attach_host(text: &str) -> Option { +/// CR 701.3a + CR 303.4f: Parse an "attached to " rider from return / +/// search destination remainders (Gift of Immortality, Next of Kin, Lynde). +/// +/// Returns `(host, unconsumed_remainder)` so rules-bearing suffixes after the +/// host phrase (`, then attach …`, `. Exile those Auras …`) stay available for +/// normal continuation parsing (Cass, Hand of Vengeance; Storm Herald). +pub(crate) fn parse_search_attach_host(text: &str) -> Option<(TargetFilter, &str)> { let lower = text.to_ascii_lowercase(); nom_on_lower(text, &lower, |input| { let (input, _) = take_until("attached to").parse(input)?; @@ -125,7 +131,6 @@ fn parse_search_attach_host(text: &str) -> Option { let (input, filter) = parse_search_attach_host_phrase(input)?; Ok((input, filter)) }) - .map(|(filter, _)| filter) } /// CR 608.2c + CR 701.23i: Strip a leading player-subject from a search-result @@ -5242,7 +5247,9 @@ pub(super) fn parse_intrinsic_continuation_ast( let attach_host = if nom_primitives::scan_contains(&full_lower, "attached to") || nom_primitives::scan_contains(&lower, "attached to") { - parse_search_attach_host(&full_lower).or(Some(TargetFilter::Any)) + parse_search_attach_host(&full_lower) + .map(|(host, _)| host) + .or(Some(TargetFilter::Any)) } else { None }; @@ -12929,29 +12936,67 @@ mod tests { assert_eq!( super::parse_search_attach_host( "put it onto the battlefield attached to that creature, then shuffle" - ), + ) + .map(|(host, _)| host), Some(TargetFilter::ParentTarget) ); assert_eq!( super::parse_search_attach_host( "put it onto the battlefield attached to target creature. if you search your library this way, shuffle" - ), + ) + .map(|(host, _)| host), Some(TargetFilter::Typed(TypedFilter::creature())) ); assert_eq!( super::parse_search_attach_host( "put it onto the battlefield attached to target player, then shuffle" - ), + ) + .map(|(host, _)| host), Some(TargetFilter::Player) ); assert_eq!( super::parse_search_attach_host( "put that card onto the battlefield attached to ~, then shuffle" - ), + ) + .map(|(host, _)| host), Some(TargetFilter::SelfRef) ); } + #[test] + fn search_attach_host_preserves_continuation_remainder() { + // Cass, Hand of Vengeance: host phrase ends at the comma; ", then attach …" + // must remain for continuation parsing. + let (host, rem) = super::parse_search_attach_host( + "attached to target creature, then attach any number of Equipment that were attached to it to that creature", + ) + .expect("attach host"); + assert_eq!( + host, + TargetFilter::Typed(crate::types::ability::TypedFilter::creature()) + ); + assert_eq!( + rem.trim(), + "then attach any number of Equipment that were attached to it to that creature", + "Cass continuation must survive attach-host parse" + ); + + // Storm Herald: host phrase ends at the period; exile delayed clause must remain. + let (host, rem) = super::parse_search_attach_host( + "attached to creatures you control. Exile those Auras at the beginning of your next end step.", + ) + .expect("attach host"); + assert!( + matches!(host, TargetFilter::Typed(_)), + "Storm Herald host must parse as typed filter, got {host:?}" + ); + assert_eq!( + rem.trim(), + "Exile those Auras at the beginning of your next end step.", + "Storm Herald exile clause must survive attach-host parse" + ); + } + #[test] fn attach_one_of_them_reflexive_gate_is_not_dig_from_among() { let dig = make_dig_effect(); diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index c2a3e32d50..a66da498a0 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -1002,6 +1002,12 @@ pub(crate) enum TargetedImperativeAst { /// a Forest land."). Lowered to a default vanilla-2/2 `face_down_profile`, /// refined by a trailing "It's a " `FaceDownProfileSpec`. face_down: bool, + /// CR 701.3a + CR 303.4f/i: Optional "attached to " rider on the + /// return (Gift of Immortality, Next of Kin, Lynde). When set, lowering + /// nests `Effect::Attach { SelfRef → host }` under the ChangeZone with + /// `forward_result` so the Aura enters attached and skips the CR 303.4f + /// host-choice consult. + attach_host: Option, }, /// CR 400.6: Return to a specific non-hand, non-battlefield zone (zone change). ReturnToZone { diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 826db7e2fa..79bebe7898 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -1878,12 +1878,21 @@ pub(crate) fn lower_trigger_ir(ir: &TriggerIr) -> TriggerDefinition { && def.destination == Some(Zone::Graveyard) { def.trigger_zones = vec![Zone::Graveyard]; - } else if let Some(zone) = def - .execute - .as_deref() - .and_then(|execute| self_recursion_trigger_zone(execute, modifiers.effect_lower.as_str())) - { - def.trigger_zones = vec![zone]; + } else if !matches!(def.valid_card, Some(TargetFilter::AttachedTo)) { + // CR 603.6c + CR 603.6e + CR 113.6 + CR 704.5m: A SelfRef return "from your + // graveyard" in the effect body implies the source is already in that + // zone when the trigger fires — EXCEPT for AttachedTo dies triggers + // (Necrotic Plague: "When enchanted creature dies, … Return this card + // from its owner's graveyard"). Those fire while the Aura is still on + // the battlefield; SBAs move it to the GY before the effect resolves, where + // CR 603.6e lets the Aura's trigger find that Aura card. + // Applying self-recursion here would park the trigger in the GY only, + // so the enchanted-creature death never matches. + if let Some(zone) = def.execute.as_deref().and_then(|execute| { + self_recursion_trigger_zone(execute, modifiers.effect_lower.as_str()) + }) { + def.trigger_zones = vec![zone]; + } } // CR 608.2c: Off-battlefield source-return triggers (Senu, Keen-Eyed diff --git a/crates/engine/src/parser/oracle_util.rs b/crates/engine/src/parser/oracle_util.rs index 9ebaf51c3b..ee30023825 100644 --- a/crates/engine/src/parser/oracle_util.rs +++ b/crates/engine/src/parser/oracle_util.rs @@ -2204,6 +2204,9 @@ pub fn normalize_card_name_refs(text: &str, card_name: &str) -> String { let short_name = &effective_name[..of_pos]; let lower_short = short_name.to_lowercase(); // structural: not dispatch — guarding single-word short names only + // CR 201.5 / CR 201.5c: "Next of Kin" short name "Next" must not + // rewrite temporal "the next end step" → "the ~ end step" (Gift of + // Immortality peer class; issue #4956). let is_common_english_word = !short_name.contains(' ') && matches!( lower_short.as_str(), @@ -2223,6 +2226,7 @@ pub fn normalize_card_name_refs(text: &str, card_name: &str) -> String { | "back" | "away" | "off" + | "next" ); // CR 201.3a: a card's "of"-derived short name normalizes to `~` // (interchangeable name reference). Suppress this ONLY when the diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 0785d07ea6..dcc1bedae3 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -23300,6 +23300,13 @@ impl ResolvedAbility { /// /// Off-battlefield zone-match currency alone (co-departure mis-latch) does /// not suffice — CR 400.7e own-departure successor proof is required. + /// + /// CR 400.7 + CR 603.6c + CR 704.5m: A further exception is an Aura that + /// latched its AttachedTo dies trigger while still on the battlefield + /// (Necrotic Plague), then moved to the graveyard by SBAs before + /// resolution. `SelfRef` return-from-GY must bind that graveyard object + /// without rewriting `trigger_source` (which would flip `source_read` to + /// ExactLive and drop LKI attachments/counters for other dies triggers). pub fn self_ref_is_current(&self, state: &crate::types::game_state::GameState) -> bool { if self.source_is_current(state) { // CR 400.7e: co-departure mis-latch — zone-match currency alone cannot @@ -23316,6 +23323,88 @@ impl ResolvedAbility { return true; }; self.self_ref_own_departure_successor(state) + || self.self_ref_post_sba_graveyard_return(state) + } + + /// CR 400.7 + CR 704.5m: True when this ability returns `SelfRef` from the + /// graveyard and the source is now there after an SBA move that followed a + /// battlefield latch (AttachedTo dies → Aura to GY before resolution). + /// + /// Provenance mirrors `self_ref_own_departure_successor`: the BF→GY record + /// must carry the captured source identity (incarnation), and no later + /// same-id zone change may have occurred — a same-id blink/re-entry must + /// not be rebound as the old source. + fn self_ref_post_sba_graveyard_return( + &self, + state: &crate::types::game_state::GameState, + ) -> bool { + let Some(source) = self.trigger_source.as_ref() else { + return false; + }; + if source.identity.expected_zone != crate::types::zones::Zone::Battlefield { + return false; + } + if source.identity.reference.object_id != self.source_id { + return false; + } + if !self.effect_returns_self_ref_from_graveyard() { + return false; + } + if !state + .objects + .get(&self.source_id) + .is_some_and(|object| object.zone == crate::types::zones::Zone::Graveyard) + { + return false; + } + + // CR 400.7e: Find the BF→GY departure whose captured identity matches + // the ability's latched stamp, then require it is still the latest + // same-id zone change this turn. + let Some(departure_index) = state + .zone_changes_this_turn + .iter() + .enumerate() + .rev() + .find_map(|(index, record)| { + (record.object_id == self.source_id + && record.from_zone == Some(crate::types::zones::Zone::Battlefield) + && record.to_zone == crate::types::zones::Zone::Graveyard + && record.trigger_source_context().is_some_and(|event_source| { + event_source.identity.reference == source.identity.reference + })) + .then_some(index) + }) + else { + return false; + }; + + state + .zone_changes_this_turn + .iter() + .skip(departure_index + 1) + .all(|later| later.object_id != self.source_id) + } + + /// True when this ability (or a nested sub) is a `ChangeZone` of `SelfRef` + /// whose origin is the graveyard. + fn effect_returns_self_ref_from_graveyard(&self) -> bool { + fn change_zone_self_from_gy(effect: &Effect) -> bool { + matches!( + effect, + Effect::ChangeZone { + origin: Some(crate::types::zones::Zone::Graveyard), + target: TargetFilter::SelfRef, + .. + } + ) + } + fn walk(ability: &ResolvedAbility) -> bool { + change_zone_self_from_gy(&ability.effect) + || ability.sub_ability.as_deref().is_some_and(walk) + || ability.else_ability.as_deref().is_some_and(walk) + } + walk(self) } /// CR 608.2c: Bind a tracked-set sentinel (`TrackedSetId(0)`) to a CONCRETE @@ -26559,6 +26648,112 @@ mod tests { "relatch must early-true without requiring own-departure successor proof" ); } + + fn gy_return_ability( + source_id: ObjectId, + source_context: TriggerSourceContext, + ) -> ResolvedAbility { + let mut ability = ResolvedAbility::new( + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Battlefield, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + vec![], + source_id, + P0, + ); + ability.trigger_source = Some(source_context); + ability + } + + /// CR 400.7 + CR 704.5m: post-SBA BF→GY departure with matching captured + /// identity permits SelfRef return-from-GY (Necrotic Plague). + #[test] + fn self_ref_post_sba_gy_return_binds_matching_bf_departure() { + let mut scenario = GameScenario::new(); + let source = scenario.add_vanilla(P0, 2, 2); + let mut runner = scenario.build(); + + let bf_context = { + let obj = runner.state().objects.get(&source).unwrap(); + crate::game::triggers::trigger_source_context_for_latch(runner.state(), obj) + }; + let mut events = Vec::new(); + move_to_zone(runner.state_mut(), source, Zone::Graveyard, &mut events); + + let record = { + let mut record = ZoneChangeRecord::test_minimal( + source, + Some(Zone::Battlefield), + Zone::Graveyard, + ); + record.trigger_source_context = Some(bf_context.clone()); + record.turn_zone_change_index = 0; + record + }; + runner.state_mut().zone_changes_this_turn = vec![record].into(); + + let ability = gy_return_ability(source, bf_context); + assert!( + ability.self_ref_is_current(runner.state()), + "matching BF→GY departure must bind SelfRef return-from-GY" + ); + } + + /// CR 400.7e: a same-id second zone change after the BF→GY departure + /// must not rebind SelfRef to the stale source. + #[test] + fn self_ref_post_sba_gy_return_rejects_stale_after_second_zone_change() { + let mut scenario = GameScenario::new(); + let source = scenario.add_vanilla(P0, 2, 2); + let mut runner = scenario.build(); + + let bf_context = { + let obj = runner.state().objects.get(&source).unwrap(); + crate::game::triggers::trigger_source_context_for_latch(runner.state(), obj) + }; + let mut events = Vec::new(); + move_to_zone(runner.state_mut(), source, Zone::Graveyard, &mut events); + move_to_zone(runner.state_mut(), source, Zone::Exile, &mut events); + + let gy_record = { + let mut record = ZoneChangeRecord::test_minimal( + source, + Some(Zone::Battlefield), + Zone::Graveyard, + ); + record.trigger_source_context = Some(bf_context.clone()); + record.turn_zone_change_index = 0; + record + }; + let exile_record = { + let mut record = + ZoneChangeRecord::test_minimal(source, Some(Zone::Graveyard), Zone::Exile); + record.turn_zone_change_index = 1; + record + }; + // Leave the object in GY for the zone gate so only the second-move + // provenance rejects — mirrors a same-id return that later left again. + move_to_zone(runner.state_mut(), source, Zone::Graveyard, &mut events); + runner.state_mut().zone_changes_this_turn = vec![gy_record, exile_record].into(); + + let ability = gy_return_ability(source, bf_context); + assert!( + !ability.self_ref_is_current(runner.state()), + "second same-id zone change must reject post-SBA SelfRef return" + ); + } } } diff --git a/crates/engine/tests/integration/issue_4956_gift_of_immortality_reattach.rs b/crates/engine/tests/integration/issue_4956_gift_of_immortality_reattach.rs new file mode 100644 index 0000000000..a0945b6869 --- /dev/null +++ b/crates/engine/tests/integration/issue_4956_gift_of_immortality_reattach.rs @@ -0,0 +1,1356 @@ +//! Issue #4956: Gift of Immortality delayed Aura reattach must specify the +//! host ("that creature" / "you") instead of opening CR 303.4f Aura choice. +//! +//! Peers: Next of Kin (attach to the put creature), Lynde (attach to you). + +use engine::game::effects::attach::{attach_to, attach_to_player}; +use engine::game::game_object::AttachTarget; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::triggers::process_triggers; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{ + AbilityCondition, DelayedTriggerCondition, Effect, TargetFilter, TargetRef, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::keywords::Keyword; +use engine::types::mana::ManaCost; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const GIFT_ORACLE: &str = "Enchant creature\n\ +When enchanted creature dies, return that card to the battlefield under its \ +owner's control. Return this card to the battlefield attached to that creature \ +at the beginning of the next end step."; + +const NEXT_OF_KIN_ORACLE: &str = "Enchant creature\n\ +When enchanted creature dies, you may put a creature card you own with lesser \ +mana value from your hand or from the command zone onto the battlefield. If \ +you do, return this card to the battlefield attached to that creature at the \ +beginning of the next end step."; + +const LYNDE_ORACLE: &str = "Deathtouch\n\ +Whenever a Curse is put into your graveyard from the battlefield, return it to \ +the battlefield attached to you at the beginning of the next end step.\n\ +At the beginning of your upkeep, you may attach a Curse attached to you to one \ +of your opponents. If you do, draw two cards."; + +const SMOKE_SHROUD_ORACLE: &str = "Enchant creature\n\ +Enchanted creature gets +1/+1 and has flying.\n\ +When a Ninja you control enters, you may return this card from your graveyard \ +to the battlefield attached to that creature."; + +const DRAGON_BREATH_ORACLE: &str = "Enchant creature\n\ +Enchanted creature has haste.\n\ +{R}: Enchanted creature gets +1/+0 until end of turn.\n\ +When a creature with mana value 6 or greater enters, you may return this card \ +from your graveyard to the battlefield attached to that creature."; + +const CASS_ORACLE: &str = "Vigilance\n\ +Whenever Cass or another creature you control dies, if it was enchanted or \ +equipped, return any number of Aura cards that were attached to it from your \ +graveyard to the battlefield attached to target creature, then attach any \ +number of Equipment that were attached to it to that creature."; + +const STORM_HERALD_ORACLE: &str = "Haste\n\ +When this creature enters, return any number of Aura cards from your graveyard \ +to the battlefield attached to creatures you control. Exile those Auras at the \ +beginning of your next end step. If those Auras would leave the battlefield, \ +exile them instead of putting them anywhere else."; + +const NECROTIC_PLAGUE_ORACLE: &str = "Enchant creature\n\ +Enchanted creature has \"At the beginning of your upkeep, sacrifice this creature.\"\n\ +When enchanted creature dies, its controller chooses target creature one of \ +their opponents controls. Return this card from its owner's graveyard to the \ +battlefield attached to that creature."; + +/// Event-subject GY return with nested Attach→ParentTarget (Smoke Shroud / Dragon Breath). +fn event_subject_return_attach_host( + parsed: &engine::parser::oracle::ParsedAbilities, +) -> &TargetFilter { + let trigger = parsed + .triggers + .iter() + .find(|t| { + matches!( + t.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::ChangeZone { + destination: Zone::Battlefield, + .. + }) + ) + }) + .expect("GY return trigger"); + let execute = trigger.execute.as_ref().expect("execute"); + assert!( + execute.forward_result, + "event-subject return must stamp forward_result for Attach nest" + ); + let attach = execute.sub_ability.as_ref().expect("Attach nest"); + match attach.effect.as_ref() { + Effect::Attach { + attachment: TargetFilter::SelfRef, + target, + } => target, + other => panic!("expected Attach SelfRef→host, got {other:?}"), + } +} + +fn ability_chain_contains_equipment_attach( + def: &engine::types::ability::AbilityDefinition, +) -> bool { + let is_equipment_attach = matches!( + def.effect.as_ref(), + Effect::Attach { + attachment: TargetFilter::Typed(tf), + .. + } if tf.type_filters.iter().any(|f| { + matches!(f, engine::types::ability::TypeFilter::Subtype(s) if s == "Equipment") + }) + ); + is_equipment_attach + || def + .sub_ability + .as_deref() + .is_some_and(ability_chain_contains_equipment_attach) + || def + .else_ability + .as_deref() + .is_some_and(ability_chain_contains_equipment_attach) +} + +fn ability_chain_contains_delayed_exile(def: &engine::types::ability::AbilityDefinition) -> bool { + let is_exile = match def.effect.as_ref() { + Effect::CreateDelayedTrigger { effect, .. } => { + matches!( + effect.effect.as_ref(), + Effect::ChangeZone { + destination: Zone::Exile, + .. + } + ) || ability_chain_contains_delayed_exile(effect) + } + Effect::ChangeZone { + destination: Zone::Exile, + .. + } => true, + _ => false, + }; + is_exile + || def + .sub_ability + .as_deref() + .is_some_and(ability_chain_contains_delayed_exile) + || def + .else_ability + .as_deref() + .is_some_and(ability_chain_contains_delayed_exile) +} + +fn effect_is_unimplemented(effect: &Effect) -> bool { + matches!(effect, Effect::Unimplemented { .. }) +} + +fn count_cdts(def: &engine::types::ability::AbilityDefinition) -> usize { + let head = matches!(def.effect.as_ref(), Effect::CreateDelayedTrigger { .. }) as usize; + head + def.sub_ability.as_deref().map(count_cdts).unwrap_or(0) + + def.else_ability.as_deref().map(count_cdts).unwrap_or(0) +} + +fn gift_delayed_attach_host(parsed: &engine::parser::oracle::ParsedAbilities) -> &TargetFilter { + let trigger = parsed.triggers.first().expect("dies trigger"); + let execute = trigger.execute.as_ref().expect("execute"); + let cdt = execute + .sub_ability + .as_ref() + .expect("CreateDelayedTrigger sibling"); + let Effect::CreateDelayedTrigger { + condition, effect, .. + } = cdt.effect.as_ref() + else { + panic!("expected CreateDelayedTrigger, got {:?}", cdt.effect); + }; + assert_eq!( + condition, + &DelayedTriggerCondition::AtNextPhase { phase: Phase::End } + ); + let inner = effect.as_ref(); + assert!( + inner.forward_result, + "delayed ChangeZone must forward_result into Attach" + ); + let attach = inner.sub_ability.as_ref().expect("Attach nest"); + match (inner.effect.as_ref(), attach.effect.as_ref()) { + ( + Effect::ChangeZone { + destination: Zone::Battlefield, + target: TargetFilter::SelfRef, + .. + }, + Effect::Attach { + attachment: TargetFilter::SelfRef, + target, + }, + ) => target, + other => panic!("unexpected Gift delayed body shape: {other:?}"), + } +} + +fn drain_priority(runner: &mut GameRunner) { + drain_priority_preferring(runner, &[]); +} + +/// Drain priority/resolution prompts, preferring `preferred` object ids when +/// choosing from EffectZoneChoice / target slots (Cass host, Storm Aura, etc.). +fn drain_priority_preferring( + runner: &mut GameRunner, + preferred: &[engine::types::identifiers::ObjectId], +) { + for _ in 0..256 { + match &runner.state().waiting_for { + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => return, + WaitingFor::ReturnAsAuraTarget { + legal_targets, + returned_id, + .. + } => { + if preferred.is_empty() { + panic!( + "CR 303.4f Aura host choice must not open when attach_to is specified; \ + waiting_for = {:?}", + runner.state().waiting_for + ); + } + // Storm Herald "attached to creatures you control" is a Typed + // multi-host filter — CR 303.4f choice is rules-correct when + // more than one creature is legal. Prefer an explicit host. + let pick = preferred + .iter() + .find_map(|id| { + legal_targets + .iter() + .find(|t| matches!(t, TargetRef::Object(oid) if oid == id)) + .cloned() + }) + .or_else(|| legal_targets.first().cloned()) + .unwrap_or(TargetRef::Object(*returned_id)); + runner + .act(GameAction::ChooseTarget { target: Some(pick) }) + .expect("choose Aura host"); + } + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + WaitingFor::OptionalEffectChoice { .. } => { + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accept optional"); + } + WaitingFor::EffectZoneChoice { cards, .. } => { + let pick = preferred + .iter() + .copied() + .find(|id| cards.contains(id)) + .or_else(|| cards.first().copied()) + .expect("zone choice candidate"); + runner + .act(GameAction::SelectCards { cards: vec![pick] }) + .expect("choose zone cards"); + } + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::TargetSelection { .. } => { + if let Some(pref) = preferred.first() { + // Prefer an explicit host when present among legal targets. + let legal = match &runner.state().waiting_for { + WaitingFor::TriggerTargetSelection { + target_slots, + selection, + .. + } + | WaitingFor::TargetSelection { + target_slots, + selection, + .. + } => target_slots + .get(selection.current_slot) + .map(|s| s.legal_targets.clone()) + .unwrap_or_default(), + _ => vec![], + }; + let target = legal + .iter() + .find(|t| matches!(t, TargetRef::Object(id) if id == pref)) + .cloned() + .or_else(|| legal.first().cloned()); + runner + .act(GameAction::ChooseTarget { target }) + .expect("choose target"); + } else { + runner + .choose_first_legal_target() + .expect("choose first legal target"); + } + } + WaitingFor::MultiTargetSelection { + legal_targets, + min_targets, + max_targets, + .. + } => { + let mut chosen: Vec<_> = preferred + .iter() + .copied() + .filter(|id| legal_targets.contains(id)) + .take(*max_targets) + .collect(); + if chosen.len() < *min_targets { + for id in legal_targets { + if chosen.len() >= *min_targets { + break; + } + if !chosen.contains(id) { + chosen.push(*id); + } + } + } + runner + .act(GameAction::SelectCards { cards: chosen }) + .expect("multi-target select"); + } + _ => { + if runner.act(GameAction::PassPriority).is_err() { + return; + } + } + } + } + panic!( + "drain_priority exceeded bound; waiting_for = {:?}", + runner.state().waiting_for + ); +} + +fn advance_through_delayed_end(runner: &mut GameRunner) { + for _ in 0..256 { + // Stop once the delayed trigger has fired and End/Cleanup priority is + // idle — do not keep walking into later turns. + if runner.state().delayed_triggers.is_empty() + && runner.state().stack.is_empty() + && matches!(runner.state().phase, Phase::End | Phase::Cleanup) + && matches!(runner.state().waiting_for, WaitingFor::Priority { .. }) + { + return; + } + match &runner.state().waiting_for { + WaitingFor::ReturnAsAuraTarget { .. } => { + panic!( + "delayed reattach must not prompt for Aura host; waiting_for = {:?}", + runner.state().waiting_for + ); + } + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + WaitingFor::DeclareAttackers { .. } => { + // Lynde (and similar) can be a legal attacker; empty declaration + // lets auto-advance reach the End step where the delayed fires. + runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .expect("declare no attackers"); + } + WaitingFor::DeclareBlockers { .. } => { + runner + .act(GameAction::DeclareBlockers { + assignments: vec![], + }) + .expect("declare no blockers"); + } + WaitingFor::OptionalEffectChoice { .. } => { + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accept optional"); + } + _ => { + if runner.act(GameAction::PassPriority).is_err() { + panic!( + "priority pass stalled; phase={:?} dt={} stack={} wf={:?}", + runner.state().phase, + runner.state().delayed_triggers.len(), + runner.state().stack.len(), + runner.state().waiting_for, + ); + } + } + } + } + panic!( + "advance_through_delayed_end exceeded bound; phase = {:?}, dt = {}, stack = {}, wf = {:?}", + runner.state().phase, + runner.state().delayed_triggers.len(), + runner.state().stack.len(), + runner.state().waiting_for + ); +} + +#[test] +fn gift_of_immortality_delayed_reattach_shape() { + let parsed = parse_oracle_text( + GIFT_ORACLE, + "Gift of Immortality", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + assert_eq!(parsed.triggers.len(), 1); + assert!( + !effect_is_unimplemented(&parsed.triggers[0].execute.as_ref().unwrap().effect), + "Gift dies trigger must be supported" + ); + assert_eq!( + gift_delayed_attach_host(&parsed), + &TargetFilter::ParentTarget, + "Gift delayed Attach host must be ParentTarget (that creature)" + ); +} + +#[test] +fn next_of_kin_delayed_reattach_shape() { + let parsed = parse_oracle_text( + NEXT_OF_KIN_ORACLE, + "Next of Kin", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + let trigger = parsed.triggers.first().expect("dies trigger"); + let execute = trigger.execute.as_ref().expect("execute"); + assert!( + !effect_is_unimplemented(&execute.effect), + "Next of Kin must parse supported: {:?}", + execute.effect + ); + let delayed_link = execute + .sub_ability + .as_ref() + .expect("delayed / if-you-do sibling"); + let Effect::CreateDelayedTrigger { + condition, effect, .. + } = delayed_link.effect.as_ref() + else { + panic!( + "Next of Kin 'next end step' must wrap CreateDelayedTrigger (short-name \ + 'next' must not rewrite temporal text); got {:?}", + delayed_link.effect + ); + }; + assert_eq!( + condition, + &DelayedTriggerCondition::AtNextPhase { phase: Phase::End } + ); + let inner = effect.as_ref(); + assert!(inner.forward_result); + // "If you do" gates CreateDelayedTrigger installation, not the delayed body + // at end-step fire time (OptionalEffectPerformed is a creation-time signal). + assert_eq!( + delayed_link.condition.as_ref(), + Some(&AbilityCondition::effect_performed()), + "OptionalEffectPerformed must lift onto the CreateDelayedTrigger wrapper" + ); + assert!( + inner.condition.is_none(), + "delayed payload must not retain OptionalEffectPerformed; got {:?}", + inner.condition + ); + let attach = inner.sub_ability.as_ref().expect("Attach nest"); + match attach.effect.as_ref() { + Effect::Attach { + attachment, + target: TargetFilter::ParentTarget, + } => { + assert_eq!( + attachment, + &TargetFilter::SelfRef, + "Next of Kin Attach attachment must be SelfRef, got {attachment:?}" + ); + } + other => panic!("Next of Kin Attach host must be ParentTarget, got {other:?}"), + } + assert_eq!( + count_cdts(execute), + 1, + "nest-before-wrap must yield a single CreateDelayedTrigger, not two" + ); +} + +#[test] +fn lynde_delayed_reattach_shape() { + let parsed = parse_oracle_text( + LYNDE_ORACLE, + "Lynde, Cheerful Tormentor", + &[], + &["Creature".to_string()], + &["Human".to_string(), "Warlock".to_string()], + ); + let curse_ltb = parsed + .triggers + .iter() + .find(|t| { + matches!( + t.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::CreateDelayedTrigger { .. }) + ) + }) + .expect("Curse LTB delayed trigger"); + let execute = curse_ltb.execute.as_ref().unwrap(); + let Effect::CreateDelayedTrigger { + effect, condition, .. + } = execute.effect.as_ref() + else { + unreachable!() + }; + assert_eq!( + condition, + &DelayedTriggerCondition::AtNextPhase { phase: Phase::End } + ); + let inner = effect.as_ref(); + assert!(inner.forward_result); + let attach = inner.sub_ability.as_ref().expect("Attach nest"); + match (inner.effect.as_ref(), attach.effect.as_ref()) { + ( + Effect::ChangeZone { + destination: Zone::Battlefield, + target: TargetFilter::TriggeringSource, + .. + }, + Effect::Attach { + attachment: TargetFilter::SelfRef, + target: TargetFilter::Controller, + }, + ) => {} + other => panic!("Lynde delayed body shape wrong: {other:?}"), + } +} + +#[test] +fn gift_of_immortality_reattaches_without_aura_choice() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let gift = scenario + .add_creature(P0, "Gift of Immortality", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(GIFT_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + // `attach_to` returns the prior host (`None` on first attach), not success. + attach_to(runner.state_mut(), gift, host); + assert_eq!( + runner.state().objects[&gift].attached_to, + Some(AttachTarget::Object(host)), + "Gift must start attached to the host" + ); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), host, Zone::Graveyard, &mut events); + process_triggers(runner.state_mut(), &events); + drain_priority(&mut runner); + + assert_eq!( + runner.state().objects[&host].zone, + Zone::Battlefield, + "dies trigger returns the enchanted creature" + ); + assert_eq!( + runner.state().objects[&gift].zone, + Zone::Graveyard, + "Gift is in the graveyard awaiting end-step return" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "exactly one AtNextEnd delayed reattach must be installed" + ); + assert_eq!( + runner.state().delayed_triggers[0].ability.targets, + vec![engine::types::ability::TargetRef::Object(host)], + "delayed trigger must snapshot the returned creature for ParentTarget Attach; \ + targets={:?} source={:?} effect={:?}", + runner.state().delayed_triggers[0].ability.targets, + runner.state().delayed_triggers[0].ability.source_id, + runner.state().delayed_triggers[0].ability.effect, + ); + assert_eq!( + runner.state().delayed_triggers[0].ability.source_id, + gift, + "delayed SelfRef source must remain Gift, not the returned creature" + ); + assert!( + matches!( + &runner.state().delayed_triggers[0].ability.effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + target: TargetFilter::SelfRef, + origin: None, + .. + } + ), + "delayed ChangeZone must be SelfRef→BF with no origin guard; got {:?}", + runner.state().delayed_triggers[0].ability.effect + ); + assert!( + matches!( + runner.state().delayed_triggers[0] + .ability + .sub_ability + .as_ref() + .map(|s| &s.effect), + Some(Effect::Attach { + target: TargetFilter::ParentTarget, + .. + }) + ), + "delayed body must nest Attach→ParentTarget; sub={:?}", + runner.state().delayed_triggers[0].ability.sub_ability + ); + runner.advance_to_end_step(); + advance_through_delayed_end(&mut runner); + + assert!( + runner.state().delayed_triggers.is_empty(), + "delayed reattach must have fired; phase={:?} dt={:?} stack={} wf={:?}", + runner.state().phase, + runner.state().delayed_triggers.len(), + runner.state().stack.len(), + runner.state().waiting_for, + ); + let gift_obj = &runner.state().objects[&gift]; + assert_eq!( + gift_obj.zone, + Zone::Battlefield, + "Gift returns at the next end step; attached={:?} core={:?} subtypes={:?} kw={:?} host_zone={:?} wf={:?}", + gift_obj.attached_to, + gift_obj.card_types.core_types, + gift_obj.card_types.subtypes, + gift_obj.keywords, + runner.state().objects[&host].zone, + runner.state().waiting_for, + ); + assert_eq!( + runner.state().objects[&gift].attached_to, + Some(AttachTarget::Object(host)), + "Gift auto-attaches to that creature — no CR 303.4f prompt" + ); +} + +#[test] +fn gift_of_immortality_stays_in_graveyard_when_host_gone() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let gift = scenario + .add_creature(P0, "Gift of Immortality", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(GIFT_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), gift, host); + assert_eq!( + runner.state().objects[&gift].attached_to, + Some(AttachTarget::Object(host)) + ); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), host, Zone::Graveyard, &mut events); + process_triggers(runner.state_mut(), &events); + drain_priority(&mut runner); + + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "hostile path must install the delayed reattach before the host is exiled; \ + otherwise the negative assertion can pass without exercising CR 303.4i" + ); + assert_eq!( + runner.state().objects[&host].zone, + Zone::Battlefield, + "dies trigger must return the host before the hostile exile" + ); + + // CR 303.4i + Gatherer: exile the returned host before end step → Gift remains in GY. + let mut exile_events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), host, Zone::Exile, &mut exile_events); + + runner.advance_to_end_step(); + advance_through_delayed_end(&mut runner); + + assert_eq!( + runner.state().objects[&gift].zone, + Zone::Graveyard, + "Gift remains in the graveyard when the specified host is undefined/illegal" + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::ReturnAsAuraTarget { .. } + ), + "must not open Aura host choice when the specified host is gone" + ); +} + +#[test] +fn next_of_kin_attaches_to_put_creature_not_dying_host() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Dying host A (MV 3); put creature B from hand (MV 2, lesser). + let host_a = scenario + .add_creature(P0, "Hill Giant", 3, 3) + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 3, + }) + .id(); + let put_b = scenario + .add_creature_to_hand(P0, "Grizzly Bears", 2, 2) + .with_mana_cost(ManaCost::Cost { + shards: vec![], + generic: 2, + }) + .id(); + let aura = scenario + .add_creature(P0, "Next of Kin", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(NEXT_OF_KIN_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), aura, host_a); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(host_a)) + ); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), host_a, Zone::Graveyard, &mut events); + process_triggers(runner.state_mut(), &events); + drain_priority(&mut runner); + + assert_eq!( + runner.state().objects[&put_b].zone, + Zone::Battlefield, + "Next of Kin puts the lesser-MV creature" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "if-you-do delayed reattach installed" + ); + assert!( + runner.state().delayed_triggers[0] + .ability + .condition + .is_none(), + "installed delayed body must not carry OptionalEffectPerformed; got {:?}", + runner.state().delayed_triggers[0].ability.condition + ); + + runner.advance_to_end_step(); + advance_through_delayed_end(&mut runner); + + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Battlefield, + "Next of Kin returns at end step" + ); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(put_b)), + "hostile: attach to put creature B, not dying host A" + ); +} + +#[test] +fn lynde_returns_curse_attached_to_controller() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let _lynde = scenario + .add_creature(P0, "Lynde, Cheerful Tormentor", 2, 4) + .from_oracle_text(LYNDE_ORACLE) + .id(); + let curse = scenario + .add_creature(P0, "Curse of Thirst", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura", "Curse"]) + .with_keyword(Keyword::Enchant(TargetFilter::Player)) + .id(); + + let mut runner = scenario.build(); + attach_to_player(runner.state_mut(), curse, P0); + assert_eq!( + runner.state().objects[&curse].attached_to, + Some(AttachTarget::Player(P0)) + ); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), curse, Zone::Graveyard, &mut events); + process_triggers(runner.state_mut(), &events); + drain_priority(&mut runner); + + assert_eq!(runner.state().delayed_triggers.len(), 1); + + runner.advance_to_end_step(); + advance_through_delayed_end(&mut runner); + + assert_eq!(runner.state().objects[&curse].zone, Zone::Battlefield); + assert_eq!( + runner.state().objects[&curse].attached_to, + Some(AttachTarget::Player(P0)), + "Lynde returns the Curse attached to you" + ); +} + +#[test] +fn smoke_shroud_and_dragon_breath_attach_host_is_parent_target() { + for (name, oracle) in [ + ("Smoke Shroud", SMOKE_SHROUD_ORACLE), + ("Dragon Breath", DRAGON_BREATH_ORACLE), + ] { + let parsed = parse_oracle_text( + oracle, + name, + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + assert_eq!( + event_subject_return_attach_host(&parsed), + &TargetFilter::ParentTarget, + "{name}: GY return Attach host must be ParentTarget (that creature)" + ); + } +} + +#[test] +fn cass_preserves_equipment_reattach_continuation() { + let parsed = parse_oracle_text( + CASS_ORACLE, + "Cass, Hand of Vengeance", + &[], + &["Creature".to_string()], + &["Human".to_string(), "Assassin".to_string()], + ); + let execute = parsed + .triggers + .first() + .and_then(|t| t.execute.as_ref()) + .expect("Cass dies trigger"); + assert!( + ability_chain_contains_equipment_attach(execute), + "Cass must preserve Equipment reattach continuation; execute={:?}", + execute.effect + ); + fn find_equipment_attach( + def: &engine::types::ability::AbilityDefinition, + ) -> Option<(TargetFilter, TargetFilter)> { + match def.effect.as_ref() { + Effect::Attach { + attachment: TargetFilter::Typed(tf), + target, + } if tf.type_filters.iter().any( + |f| matches!(f, engine::types::ability::TypeFilter::Subtype(s) if s == "Equipment"), + ) => + { + Some((TargetFilter::Typed(tf.clone()), target.clone())) + } + _ => def + .sub_ability + .as_deref() + .and_then(find_equipment_attach) + .or_else(|| def.else_ability.as_deref().and_then(find_equipment_attach)), + } + } + let (attachment, target) = + find_equipment_attach(execute).expect("Equipment Attach in Cass chain"); + match &attachment { + TargetFilter::Typed(tf) => { + assert!( + tf.properties + .contains(&engine::types::ability::FilterProp::AttachedToSource), + "Equipment must look back via AttachedToSource LKI, got {tf:?}" + ); + } + other => panic!("expected Typed Equipment, got {other:?}"), + } + assert!( + matches!(target, TargetFilter::ParentTarget), + "Cass 'to that creature' must be ParentTarget (chosen Aura host), got {target:?}" + ); +} + +#[test] +fn storm_herald_preserves_delayed_exile_continuation() { + let parsed = parse_oracle_text( + STORM_HERALD_ORACLE, + "Storm Herald", + &[], + &["Creature".to_string()], + &["Human".to_string(), "Shaman".to_string()], + ); + let execute = parsed + .triggers + .first() + .and_then(|t| t.execute.as_ref()) + .expect("Storm Herald ETB"); + assert!( + ability_chain_contains_delayed_exile(execute), + "Storm Herald must preserve delayed exile continuation; execute={:?}", + execute.effect + ); + fn find_cdt( + def: &engine::types::ability::AbilityDefinition, + ) -> Option<&engine::types::ability::AbilityDefinition> { + if matches!(def.effect.as_ref(), Effect::CreateDelayedTrigger { .. }) { + return Some(def); + } + def.sub_ability + .as_deref() + .and_then(find_cdt) + .or_else(|| def.else_ability.as_deref().and_then(find_cdt)) + } + let cdt = find_cdt(execute).expect("CreateDelayedTrigger"); + let Effect::CreateDelayedTrigger { + uses_tracked_set, + effect, + .. + } = cdt.effect.as_ref() + else { + unreachable!() + }; + assert!( + *uses_tracked_set, + "Storm Herald 'those Auras' delayed exile must set uses_tracked_set" + ); + assert!( + matches!( + effect.effect.as_ref(), + Effect::ChangeZone { + destination: Zone::Exile, + target: TargetFilter::TrackedSet { .. }, + .. + } + ), + "delayed body must exile TrackedSet, got {:?}", + effect.effect + ); + // Leave-battlefield rider must be AddTargetReplacement (TrackedSet), not a + // fake immediate ChangeZone Exile ParentTarget claiming support. + fn find_leave_rider(def: &engine::types::ability::AbilityDefinition) -> Option<&Effect> { + match def.effect.as_ref() { + e @ Effect::AddTargetReplacement { .. } => Some(e), + e @ Effect::Unimplemented { .. } => Some(e), + e @ Effect::ChangeZone { + destination: Zone::Exile, + target: TargetFilter::ParentTarget, + .. + } => Some(e), + _ => def + .sub_ability + .as_deref() + .and_then(find_leave_rider) + .or_else(|| def.else_ability.as_deref().and_then(find_leave_rider)), + } + } + match find_leave_rider(execute) { + Some(Effect::AddTargetReplacement { + target: TargetFilter::TrackedSet { .. }, + .. + }) => {} + Some(Effect::Unimplemented { .. }) => {} + Some(other) => panic!( + "leave-battlefield rider must be AddTargetReplacement{{TrackedSet}} or \ + Unimplemented, got {other:?}" + ), + None => panic!("expected leave-battlefield rider in Storm Herald chain"), + } +} + +#[test] +fn cass_reattaches_equipment_to_chosen_host_via_pipeline() { + // CR 400.7j + CR 608.2c + CR 701.3a: drive the printed Cass dies trigger + // through process_triggers → TriggerTargetSelection → resolution. ParentTarget + // for the Equipment attach must bind the chosen host without a test-side + // stamp_host helper. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let bearer = scenario.add_creature(P0, "Bearer", 2, 2).id(); + let cass = scenario + .add_creature(P0, "Cass, Hand of Vengeance", 2, 2) + .from_oracle_text(CASS_ORACLE) + .id(); + let equipment = scenario + .add_creature(P0, "Bonesplitter", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), equipment, cass); + + let mut death_events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), cass, Zone::Graveyard, &mut death_events); + process_triggers(runner.state_mut(), &death_events); + assert!( + !runner.state().stack.is_empty() + || matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::OrderTriggers { .. } + ), + "Cass dies trigger must be pending after equipped Cass dies; \ + stack={} waiting={:?}", + runner.state().stack.len(), + runner.state().waiting_for + ); + drain_priority_preferring(&mut runner, &[bearer, equipment]); + + assert_eq!( + runner.state().objects[&equipment].attached_to, + Some(AttachTarget::Object(bearer)), + "Equipment that was attached to dying Cass must reattach to chosen bearer; \ + attached_to={:?}", + runner.state().objects[&equipment].attached_to + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::ReturnAsAuraTarget { .. } + ), + "must not open CR 303.4f Aura host choice for Equipment reattach" + ); +} + +#[test] +fn storm_herald_exiles_returned_auras_at_end_step_via_pipeline() { + // CR 603.7 + CR 303.4f: return Aura attached to a creature you control, then + // delayed TrackedSet exile at next end step. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = scenario + .add_creature_to_graveyard(P0, "Pacifism", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + let herald = scenario + .add_creature_to_hand(P0, "Storm Herald", 3, 2) + .from_oracle_text(STORM_HERALD_ORACLE) + .id(); + + let mut runner = scenario.build(); + let mut etb_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + herald, + Zone::Battlefield, + &mut etb_events, + ); + process_triggers(runner.state_mut(), &etb_events); + drain_priority_preferring(&mut runner, &[aura, host]); + + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Battlefield, + "Storm Herald must return the Aura" + ); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(host)), + "returned Aura must attach to a creature you control" + ); + assert_eq!( + runner.state().delayed_triggers.len(), + 1, + "delayed exile of those Auras must be installed; delayed={:?}", + runner.state().delayed_triggers + ); + match &runner.state().delayed_triggers[0].ability.effect { + Effect::ChangeZone { + destination: Zone::Exile, + target: TargetFilter::TrackedSet { id }, + .. + } + | Effect::ChangeZoneAll { + destination: Zone::Exile, + target: TargetFilter::TrackedSet { id }, + .. + } => { + assert_ne!( + id.0, 0, + "TrackedSet sentinel must be rebound at CDT creation; id={id:?}" + ); + assert!( + runner + .state() + .tracked_object_sets + .get(id) + .is_some_and(|set| set.contains(&aura)), + "rebound TrackedSet must contain the returned Aura; set={:?}", + runner.state().tracked_object_sets.get(id) + ); + } + other => panic!("delayed body must be exile TrackedSet, got {other:?}"), + } + + // CR 603.7: Resolve the installed delayed body through the production + // effect pipeline (same path end-step firing uses). Turn-gate timing for + // AtNextPhaseForPlayer is covered by the delayed-trigger suite; here we + // prove the TrackedSet bind + exile semantics for Storm Herald's rem. + let delayed = runner.state().delayed_triggers[0].ability.clone(); + runner.state_mut().delayed_triggers.clear(); + let mut events = Vec::new(); + engine::game::effects::resolve_ability_chain(runner.state_mut(), &delayed, &mut events, 0) + .expect("delayed exile resolves"); + drain_priority_preferring(&mut runner, &[host]); + + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Exile, + "those Auras must be exiled via the delayed TrackedSet body" + ); +} + +#[test] +fn smoke_shroud_attaches_to_entering_ninja_among_multiple_hosts() { + // CR 303.4f + CR 608.2c: with another legal Aura host on the battlefield, + // the event-subject return must bind ParentTarget to the entering Ninja — + // no CR 303.4f prompt, and not the distractor creature. + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let distractor = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let ninja = scenario + .add_creature_to_hand(P0, "Ninja of the Deep Hours", 2, 2) + .with_subtypes(vec!["Human", "Ninja"]) + .id(); + let aura = scenario + .add_creature(P0, "Smoke Shroud", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(SMOKE_SHROUD_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + // Prior host so AttachedTo fallback would prefer the distractor if the + // event referent is not hydrated onto the nested Attach. + attach_to(runner.state_mut(), aura, distractor); + let mut gy_events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), aura, Zone::Graveyard, &mut gy_events); + process_triggers(runner.state_mut(), &gy_events); + drain_priority(&mut runner); + assert_eq!(runner.state().objects[&aura].zone, Zone::Graveyard); + + let mut etb_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + ninja, + Zone::Battlefield, + &mut etb_events, + ); + process_triggers(runner.state_mut(), &etb_events); + drain_priority(&mut runner); + + assert_eq!( + runner.state().objects[&aura].zone, + Zone::Battlefield, + "Smoke Shroud returns from GY on Ninja ETB" + ); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(ninja)), + "must attach to the entering Ninja, not the distractor ({distractor:?}); \ + attached_to={:?}", + runner.state().objects[&aura].attached_to + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::ReturnAsAuraTarget { .. } + ), + "must not open CR 303.4f Aura host choice among multiple legal hosts" + ); +} + +#[test] +fn necrotic_plague_attaches_to_chosen_creature_not_dying_host() { + // CR 608.2c + CR 303.4f: Necrotic Plague nests Attach→ParentTarget under a + // TargetOnly→ChangeZone chain. Drive the printed dies trigger through + // process_triggers → TriggerTargetSelection → resolution with distinct dying + // and chosen creatures — no stamp_host / hand-built ResolvedAbility. + let parsed = parse_oracle_text( + NECROTIC_PLAGUE_ORACLE, + "Necrotic Plague", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + let dies = parsed + .triggers + .iter() + .find(|t| { + matches!( + t.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::TargetOnly { .. }) + ) + }) + .expect("dies trigger"); + let execute = dies.execute.as_ref().expect("execute"); + assert!( + execute.forward_result + || execute + .sub_ability + .as_ref() + .is_some_and(|s| s.forward_result), + "Necrotic Plague return must forward_result into Attach; execute={:?}", + execute.effect + ); + fn change_zone_has_forward_result(def: &engine::types::ability::AbilityDefinition) -> bool { + let here = matches!( + def.effect.as_ref(), + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } + ) && def.forward_result; + here || def + .sub_ability + .as_deref() + .is_some_and(change_zone_has_forward_result) + || def + .else_ability + .as_deref() + .is_some_and(change_zone_has_forward_result) + } + assert!( + change_zone_has_forward_result(execute), + "TargetOnly→ChangeZone[+Attach] must stamp forward_result on ChangeZone; execute={:?}", + execute.effect + ); + fn find_attach_parent(def: &engine::types::ability::AbilityDefinition) -> bool { + matches!( + def.effect.as_ref(), + Effect::Attach { + target: TargetFilter::ParentTarget, + .. + } + ) || def.sub_ability.as_deref().is_some_and(find_attach_parent) + || def.else_ability.as_deref().is_some_and(find_attach_parent) + } + assert!( + find_attach_parent(execute), + "Necrotic Plague must nest Attach→ParentTarget; execute={:?}", + execute.effect + ); + assert!( + dies.trigger_zones.contains(&Zone::Battlefield), + "AttachedTo dies trigger must fire from the battlefield (Gift-shaped), not only GY; \ + trigger_zones={:?}", + dies.trigger_zones + ); + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let dying = scenario.add_creature(P0, "Dying Host", 2, 2).id(); + let chosen = scenario.add_creature(P1, "Chosen Host", 2, 2).id(); + let other_opp = scenario.add_creature(P1, "Other Opp Creature", 2, 2).id(); + let plague = scenario + .add_creature(P0, "Necrotic Plague", 0, 0) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .from_oracle_text(NECROTIC_PLAGUE_ORACLE) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature(), + ))) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), plague, dying); + + // Mirror Gift: collect dies triggers while the Aura is still on the battlefield + // (CR 603.6d LKI); SBAs during drain move it to the GY before resolution. + let mut death_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + dying, + Zone::Graveyard, + &mut death_events, + ); + process_triggers(runner.state_mut(), &death_events); + assert!( + !runner.state().stack.is_empty() + || matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } | WaitingFor::OrderTriggers { .. } + ), + "Necrotic dies trigger must be pending after enchanted creature dies; \ + stack={} waiting={:?}", + runner.state().stack.len(), + runner.state().waiting_for + ); + // CR 704.5m + CR 704.3: Aura with illegal/dead host goes to GY before the + // pending trigger resolves. The printed return is "from its owner's + // graveyard", so ChangeZone's origin guard needs the Aura in GY first. + engine::game::sba::check_state_based_actions(runner.state_mut(), &mut death_events); + assert_eq!( + runner.state().objects[&plague].zone, + Zone::Graveyard, + "SBA must put Necrotic Plague into GY before its return resolves" + ); + drain_priority_preferring(&mut runner, &[chosen]); + + assert_eq!( + runner.state().objects[&plague].zone, + Zone::Battlefield, + "Necrotic Plague returns from GY" + ); + assert_eq!( + runner.state().objects[&plague].attached_to, + Some(AttachTarget::Object(chosen)), + "must attach to the chosen opponent creature ({chosen:?}), not the dying host \ + ({dying:?}) or distractor ({other_opp:?}); attached_to={:?}", + runner.state().objects[&plague].attached_to + ); + assert!( + !matches!( + runner.state().waiting_for, + WaitingFor::ReturnAsAuraTarget { .. } + ), + "must not open CR 303.4f Aura host choice when ParentTarget is the chosen creature" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 446cbd2097..a8abc5d802 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -540,6 +540,7 @@ mod issue_4835_intimidation_tactics; mod issue_4836_mindskinner; mod issue_4921_skullscorch_unless_deal_damage; mod issue_4955_greenbelt_rampager; +mod issue_4956_gift_of_immortality_reattach; mod issue_4960_nova_flame; mod issue_4962_volo_guide_to_monsters; mod issue_4966_waterbenders_ascension;