diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 7e631d65dd..8c6c7f8ee8 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -6102,10 +6102,17 @@ fn parse_neuter_attach_self_recipient(input: &str) -> OracleResult<'_, ()> { /// /// Every other chain keeps its pre-existing `parse_target` binding, unchanged: /// -/// * **A chain with an earlier typed object referent** — "it" names that -/// referent and `ParentTarget` is correct (Aura Graft "gain control of target -/// Aura … attach **it** to another permanent"; Ogre Geargrabber; Auriok -/// Survivors). +/// * **A chain with an earlier typed object referent and NO public-zone move** — +/// "it" names that referent and `ParentTarget` is correct (Aura Graft "gain +/// control of target Aura … attach **it** to another permanent"; Ogre +/// Geargrabber). Their parent is a `GainControl`, so CR 400.7j never applies +/// and there is no moved card for "it" to name. +/// * **A chain whose parent MOVES the card to the battlefield** (Sword of the +/// Meek, Auriok Survivors) — "it" names the moved card under CR 400.7j, so +/// `ParentTarget` is NOT correct there. It is rewritten to `SelfRef` after +/// lowering by `lower::rebind_attach_attachment_to_forwarded_source_if_anaphor_names_moved_card`, +/// because only that pass can see the parent effect. Nothing changes in this +/// function. /// * **The Equipment-ETB class** (Embercleave — "When this Equipment enters, /// attach **it** to target creature you control") — intentionally left at /// `ParentTarget`. It is resolved at RUNTIME by diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 912ab9920b..4b5f5ae126 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -2209,11 +2209,13 @@ pub(super) fn rewire_result_anchored_subchain(def: &mut AbilityDefinition) { .. } ); - let attach_uses_moved_card_as_attachment_to_last_created = parent_moves_to_battlefield - && rebind_attach_attachment_to_forwarded_source_if_last_created_target(&mut sub.effect); + let attach_anaphor_names_moved_card = parent_moves_to_battlefield + && rebind_attach_attachment_to_forwarded_source_if_anaphor_names_moved_card( + &mut sub.effect, + ); if parent_moves_to_battlefield && (sub_is_attach_with_zone_changed_cond - || attach_uses_moved_card_as_attachment_to_last_created + || attach_anaphor_names_moved_card || sub_targets_moved_card(sub)) { def.forward_result = true; @@ -2227,18 +2229,59 @@ pub(super) fn rewire_result_anchored_subchain(def: &mut AbilityDefinition) { } } -fn rebind_attach_attachment_to_forwarded_source_if_last_created_target( +/// CR 400.7j + CR 608.2c + CR 701.3a: in ", then +/// attach it to ", the bare-"it" attachment operand names the card the +/// parent instruction just moved — CR 400.7j lets the rest of that effect find the +/// object it put into a public zone. Encode it as `SelfRef`: the runtime +/// `forward_result` branch rebinds the sub-ability's `source_id` to the moved +/// object, and `change_zone::resolve_forward_result_search_attach_host` gates the +/// pre-entry host stamp on exactly that `SelfRef` encoding. +/// +/// Two recipient encodings prove the attachment anaphor is the moved card: +/// +/// * `LastCreated` — the recipient is a token this chain created (Ratonhnhaké꞉ton, +/// Forum Filibuster), so the attachment cannot be it. +/// * the SAME filter node as the attachment — the sentence's two referents +/// collapsed onto one anaphor (Sword of the Meek). Attaching an object to +/// itself is a guaranteed no-op whatever the operand resolves to — CR 301.5c +/// ("An Equipment can't equip itself"), CR 301.6 (the same for Fortifications), +/// CR 303.4d ("An Aura can't enchant itself"), and CR 701.3b for anything else +/// — so rebinding can only turn a dead node live; it can never take working +/// behavior away. +/// +/// Note the delivery this rebind selects: `SelfRef` also satisfies +/// `change_zone::resolve_forward_result_search_attach_host`'s attachment gate, so +/// the host is stamped as `enter_attached_to` and the card enters already +/// attached. The trailing `Attach` sub still resolves and still emits its +/// `EffectKind::Attach`, so `TriggerMode::Attached` observers are unaffected. +/// It is a CR 301.5c self-attach — the sub's `ParentTarget` host falls back to +/// the source — which `attach::attachment_illegality_projected` rejects before +/// any edit: no state change, no timestamp bump. +/// +/// Caller-gated on the parent moving a card to the battlefield. That gate is why +/// this cannot live in `parse_attachment_anaphor` (`imperative.rs`): the parent +/// effect is unknown at parse time, and rebinding there would regress Stonehewer +/// Giant / Quest for the Holy Relic / Armored Skyhunter / Adaptive Armorer, whose +/// "it" names a searched Equipment rather than the source. The same gate keeps the +/// equal-operand pairs under `GainControl` (Ogre Geargrabber, Thieving Skydiver) +/// and under `CopyTokenOf` untouched: no public-zone move, so no CR 400.7j +/// referent to rebind to. +pub(super) fn rebind_attach_attachment_to_forwarded_source_if_anaphor_names_moved_card( effect: &mut Effect, ) -> bool { let Effect::Attach { attachment, target } = effect else { return false; }; - if matches!(target, TargetFilter::LastCreated) - && matches!( - attachment, - TargetFilter::ParentTarget | TargetFilter::TriggeringSource - ) - { + // Hoisted so the operand-identity test below can never fire for a + // `SelfRef`/`SelfRef` pair (Nim Deathmantle, Boonweaver Giant, Hakim, + // Light-Paws, Magnetic Snuffler, Runed Crown). + if !matches!( + attachment, + TargetFilter::ParentTarget | TargetFilter::TriggeringSource + ) { + return false; + } + if matches!(target, TargetFilter::LastCreated) || *target == *attachment { *attachment = TargetFilter::SelfRef; return true; } diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index c5d2876e59..cadb699759 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -47376,6 +47376,380 @@ fn attach_just_moved_negative_no_attach_sub_no_rewire() { ); } +/// Oracle text is verbatim from `client/public/card-data.json`. +const SWORD_OF_THE_MEEK_ORACLE: &str = "Equipped creature gets +1/+2.\n\ +Equip {2}\n\ +Whenever a 1/1 creature you control enters, you may return this card from your \ +graveyard to the battlefield, then attach it to that creature."; + +const AURIOK_SURVIVORS_ORACLE: &str = "When this creature enters, you may return \ +target Equipment card from your graveyard to the battlefield. If you do, you may \ +attach it to this creature."; + +const STONEHEWER_GIANT_ORACLE: &str = "Vigilance\n\ +{1}{W}, {T}: Search your library for an Equipment card, put it onto the \ +battlefield, attach it to a creature you control, then shuffle."; + +const PRE_WAR_FORMALWEAR_ORACLE: &str = "When this Equipment enters, return target \ +creature card with mana value 3 or less from your graveyard to the battlefield and \ +attach this Equipment to it.\n\ +Equipped creature gets +2/+2 and has vigilance.\n\ +Equip {3}"; + +const OGRE_GEARGRABBER_ORACLE: &str = "Whenever this creature attacks, gain control \ +of target Equipment an opponent controls until end of turn. Attach it to this \ +creature. When you lose control of that Equipment, unattach it."; + +const AURA_GRAFT_ORACLE: &str = + "Gain control of target Aura that's attached to a permanent. Attach it to \ +another permanent it can enchant."; + +fn find_attach_under( + def: &AbilityDefinition, + mut parent_matches: impl FnMut(&Effect) -> bool, +) -> Option<(&AbilityDefinition, &AbilityDefinition)> { + fn walk<'a>( + def: &'a AbilityDefinition, + parent_matches: &mut dyn FnMut(&Effect) -> bool, + ) -> Option<(&'a AbilityDefinition, &'a AbilityDefinition)> { + if let Some(sub) = def.sub_ability.as_deref() { + if matches!(*sub.effect, Effect::Attach { .. }) && parent_matches(&def.effect) { + return Some((def, sub)); + } + if let Some(found) = walk(sub, parent_matches) { + return Some(found); + } + } + if let Some(else_branch) = def.else_ability.as_deref() { + if let Some(found) = walk(else_branch, parent_matches) { + return Some(found); + } + } + None + } + walk(def, &mut parent_matches) +} + +/// Scoped to `abilities` + `triggers` — the two chains these tests walk. +/// `statics` and `replacements` carry different definition types. +fn chain_unimplemented_count(def: &AbilityDefinition) -> usize { + usize::from(matches!(*def.effect, Effect::Unimplemented { .. })) + + def + .sub_ability + .as_deref() + .map_or(0, chain_unimplemented_count) + + def + .else_ability + .as_deref() + .map_or(0, chain_unimplemented_count) +} + +fn ability_or_trigger_has_unimplemented(parsed: &crate::parser::oracle::ParsedAbilities) -> bool { + parsed.abilities.iter().any(chain_has_unimplemented) + || parsed + .triggers + .iter() + .filter_map(|t| t.execute.as_deref()) + .any(chain_has_unimplemented) +} + +fn is_battlefield_move(effect: &Effect) -> bool { + matches!( + effect, + Effect::ChangeZone { + destination: Zone::Battlefield, + .. + } | Effect::Dig { + destination: Some(Zone::Battlefield), + .. + } | Effect::Conjure { + destination: Zone::Battlefield, + .. + } + ) +} + +/// CR 400.7j + CR 301.5c: "return this card from your graveyard to the +/// battlefield, then attach it to that creature" collapsed BOTH operands of the +/// nested `Attach` onto the trigger-event anaphor, making the attach a +/// guaranteed self-attach no-op — Sword of the Meek returned but never equipped +/// the entering 1/1. The attachment operand must name the just-moved card +/// (`SelfRef`, rebound to the forwarded object at resolution), leaving +/// `ParentTarget` to mean only the host — the same shape Dragon Breath and +/// Smoke Shroud already produce. +#[test] +fn attach_just_moved_collapsed_recipient_anaphor_rebinds_to_forwarded_source() { + let parsed = parse_oracle_text( + SWORD_OF_THE_MEEK_ORACLE, + "Sword of the Meek", + &[], + &["Artifact".to_string()], + &["Equipment".to_string()], + ); + assert!( + !ability_or_trigger_has_unimplemented(&parsed), + "Sword of the Meek must parse with zero Unimplemented in its abilities/triggers" + ); + let execute = parsed + .triggers + .iter() + .find_map(|t| t.execute.as_deref()) + .expect("graveyard return trigger"); + // Positive reach-guard: the return itself must still be the parent effect. + assert!( + matches!( + &*execute.effect, + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Battlefield, + target: TargetFilter::SelfRef, + .. + } + ), + "expected the graveyard→battlefield self return, got {:?}", + execute.effect + ); + assert!( + execute.forward_result, + "the return must forward the moved card to the Attach sub" + ); + let attach = execute.sub_ability.as_deref().expect("Attach sub-ability"); + assert_eq!( + &*attach.effect, + &Effect::Attach { + attachment: TargetFilter::SelfRef, + target: TargetFilter::ParentTarget, + }, + "the returned Sword is the attachment; 'that creature' is the host" + ); +} + +/// Shape-only sibling of the test above — **no behavioral claim**. Auriok +/// Survivors' recipient operand ("attach it to *this creature*") is a separate, +/// unfixed misparse: it lowers to `ParentTarget` where `SelfRef` is correct, so +/// the card self-attaches (a CR 301.5c no-op) both before and after this +/// rebind. Only the attachment operand is asserted here. +#[test] +fn attach_just_moved_collapsed_recipient_anaphor_auriok_survivors_shape_only() { + let parsed = parse_oracle_text( + AURIOK_SURVIVORS_ORACLE, + "Auriok Survivors", + &[], + &["Creature".to_string()], + &["Human".to_string(), "Soldier".to_string()], + ); + assert!( + !ability_or_trigger_has_unimplemented(&parsed), + "Auriok Survivors must parse with zero Unimplemented in its abilities/triggers" + ); + let execute = parsed + .triggers + .iter() + .find_map(|t| t.execute.as_deref()) + .expect("ETB trigger"); + let (parent, attach) = + find_attach_under(execute, is_battlefield_move).expect("Attach under the return"); + assert!( + parent.forward_result, + "the return must forward the moved Equipment to the Attach sub" + ); + let Effect::Attach { attachment, .. } = &*attach.effect else { + unreachable!("find_attach_under only returns Attach nodes"); + }; + assert_eq!( + *attachment, + TargetFilter::SelfRef, + "'it' names the returned Equipment (CR 400.7j), not the host slot" + ); +} + +/// Negative sibling: distinct operands must NOT be rebound. Stonehewer Giant's +/// "it" names the *searched* Equipment and is resolved at runtime out of the +/// trigger context; rebinding it to `SelfRef` would try to equip the Giant. +/// +/// The reach-guard deliberately does not assert `forward_result` — Stonehewer +/// Giant carries `forward_result: false` at every node of its chain, because +/// `sub_targets_moved_card` reads the `Attach`'s `target` field, which here is +/// `Typed(Creature)`. +#[test] +fn attach_just_moved_negative_distinct_anaphors_are_not_rebound() { + let parsed = parse_oracle_text( + STONEHEWER_GIANT_ORACLE, + "Stonehewer Giant", + &["Vigilance".to_string()], + &["Creature".to_string()], + &["Giant".to_string(), "Warrior".to_string()], + ); + assert!( + !ability_or_trigger_has_unimplemented(&parsed), + "Stonehewer Giant must parse with zero Unimplemented in its abilities/triggers" + ); + let (_, attach) = parsed + .abilities + .iter() + .find_map(|def| find_attach_under(def, is_battlefield_move)) + .expect("Attach under the library→battlefield put"); + let Effect::Attach { attachment, target } = &*attach.effect else { + unreachable!("find_attach_under only returns Attach nodes"); + }; + assert_eq!( + *attachment, + TargetFilter::ParentTarget, + "distinct operands must keep the runtime-rescued ParentTarget attachment" + ); + assert!( + matches!(target, TargetFilter::Typed(_)), + "expected a typed creature host, got {target:?}" + ); +} + +/// Negative sibling for the mirrored family: an explicit "attach **this +/// Equipment** to it" already lowers the attachment to `SelfRef`, so the parse +/// must survive the rebind untouched. This documents the shape the hoisted +/// allow-list protects; it does not discriminate the hoist itself, because +/// every `SelfRef`/`SelfRef` parent in today's corpus already carries +/// `forward_result` — `rebind_rejects_self_ref_attachment_operand` below is the +/// test that pins the hoist. +#[test] +fn attach_just_moved_negative_self_ref_attachment_is_not_rebound() { + let parsed = parse_oracle_text( + PRE_WAR_FORMALWEAR_ORACLE, + "Pre-War Formalwear", + &[], + &["Artifact".to_string()], + &["Equipment".to_string()], + ); + assert!( + !ability_or_trigger_has_unimplemented(&parsed), + "Pre-War Formalwear must parse with zero Unimplemented in its abilities/triggers" + ); + let execute = parsed + .triggers + .iter() + .find_map(|t| t.execute.as_deref()) + .expect("ETB trigger"); + let (parent, attach) = + find_attach_under(execute, is_battlefield_move).expect("Attach under the return"); + assert!(parent.forward_result); + assert_eq!( + &*attach.effect, + &Effect::Attach { + attachment: TargetFilter::SelfRef, + target: TargetFilter::ParentTarget, + }, + "an explicit self attachment must survive the rebind unchanged" + ); +} + +/// Direct guard on the hoisted allow-list: a `SelfRef`/`SelfRef` `Attach` must +/// not be rewritten by the operand-identity arm. Corpus cards with this shape +/// (Nim Deathmantle, Boonweaver Giant, Hakim, Light-Paws, Magnetic Snuffler, +/// Runed Crown) already carry `forward_result`, so only a direct call can +/// observe the hoist. +#[test] +fn rebind_rejects_self_ref_attachment_operand() { + let mut effect = Effect::Attach { + attachment: TargetFilter::SelfRef, + target: TargetFilter::SelfRef, + }; + assert!( + !super::lower::rebind_attach_attachment_to_forwarded_source_if_anaphor_names_moved_card( + &mut effect + ) + ); + assert_eq!( + effect, + Effect::Attach { + attachment: TargetFilter::SelfRef, + target: TargetFilter::SelfRef, + } + ); +} + +/// The caller gate, not the predicate, is what protects the equal-operand pairs +/// whose parent moves nothing to a public zone. Ogre Geargrabber has the SAME +/// `Attach { ParentTarget, ParentTarget }` shape as Sword of the Meek, but under +/// a `GainControl` parent — CR 400.7j never applies, so there is no moved card +/// for "it" to name and the rebind must not fire. +/// +/// The reach-guard is path-scoped: the card carries one `Effect::Unimplemented` +/// on the sibling "when you lose control" clause, so a card-wide zero assertion +/// would be unsatisfiable. +#[test] +fn attach_just_moved_negative_equal_operands_under_gain_control_are_not_rebound() { + let parsed = parse_oracle_text( + OGRE_GEARGRABBER_ORACLE, + "Ogre Geargrabber", + &[], + &["Creature".to_string()], + &["Ogre".to_string(), "Warrior".to_string()], + ); + let execute = parsed + .triggers + .iter() + .find_map(|t| t.execute.as_deref()) + .expect("attack trigger"); + let (parent, attach) = find_attach_under(execute, |effect| { + matches!(effect, Effect::GainControl { .. }) + }) + .expect("Attach under the GainControl"); + // Reach-guard: the card carries exactly one `Unimplemented` — the sibling + // "when you lose control of that Equipment" clause hanging off the Attach — + // so a parse failure that swallowed the GainControl→Attach path would move + // this count and fail here before the negative below could pass vacuously. + assert_eq!( + chain_unimplemented_count(execute), + 1, + "expected only the trailing lose-control clause to be unimplemented" + ); + assert_eq!( + &*attach.effect, + &Effect::Attach { + attachment: TargetFilter::ParentTarget, + target: TargetFilter::ParentTarget, + }, + "no public-zone move ⇒ no CR 400.7j referent ⇒ no rebind" + ); + assert!( + !parent.forward_result, + "a GainControl parent must not be stamped forward_result" + ); +} + +/// Aura Graft is the other `GainControl` attach whose "it" must stay +/// `ParentTarget`. Its operands are NOT equal (the host is a typed "another +/// permanent"), so it is protected twice over — by the caller gate and by the +/// operand-identity test. +#[test] +fn attach_just_moved_negative_aura_graft_attachment_stays_parent_target() { + let parsed = parse_oracle_text( + AURA_GRAFT_ORACLE, + "Aura Graft", + &[], + &["Instant".to_string()], + &[], + ); + assert!( + !ability_or_trigger_has_unimplemented(&parsed), + "Aura Graft must parse with zero Unimplemented in its abilities/triggers" + ); + let (_, attach) = parsed + .abilities + .iter() + .find_map(|def| { + find_attach_under(def, |effect| matches!(effect, Effect::GainControl { .. })) + }) + .expect("Attach under the GainControl"); + let Effect::Attach { attachment, target } = &*attach.effect else { + unreachable!("find_attach_under only returns Attach nodes"); + }; + assert_eq!(*attachment, TargetFilter::ParentTarget); + assert!( + matches!(target, TargetFilter::Typed(_)), + "expected the typed 'another permanent' host, got {target:?}" + ); +} + /// CR 608.2c: Emperor of Bones class — a ChangeZone-to-Battlefield /// followed by sibling clauses that anaphorically reference the just- /// moved card ("it gains haste. sacrifice it ...") must mark diff --git a/crates/engine/tests/fixtures/integration_cards.json.gz b/crates/engine/tests/fixtures/integration_cards.json.gz index d26eeac8cf..81f01c376d 100644 Binary files a/crates/engine/tests/fixtures/integration_cards.json.gz and b/crates/engine/tests/fixtures/integration_cards.json.gz differ 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 index a0945b6869..5a9251208d 100644 --- a/crates/engine/tests/integration/issue_4956_gift_of_immortality_reattach.rs +++ b/crates/engine/tests/integration/issue_4956_gift_of_immortality_reattach.rs @@ -196,8 +196,8 @@ fn gift_delayed_attach_host(parsed: &engine::parser::oracle::ParsedAbilities) -> } } -fn drain_priority(runner: &mut GameRunner) { - drain_priority_preferring(runner, &[]); +fn drain_priority(runner: &mut GameRunner) -> bool { + drain_priority_preferring(runner, &[]) } /// Drain priority/resolution prompts, preferring `preferred` object ids when @@ -205,10 +205,13 @@ fn drain_priority(runner: &mut GameRunner) { fn drain_priority_preferring( runner: &mut GameRunner, preferred: &[engine::types::identifiers::ObjectId], -) { +) -> bool { + let mut consumed_effect_zone_choice = false; for _ in 0..256 { match &runner.state().waiting_for { - WaitingFor::Priority { .. } if runner.state().stack.is_empty() => return, + WaitingFor::Priority { .. } if runner.state().stack.is_empty() => { + return consumed_effect_zone_choice; + } WaitingFor::ReturnAsAuraTarget { legal_targets, returned_id, @@ -247,6 +250,7 @@ fn drain_priority_preferring( .expect("accept optional"); } WaitingFor::EffectZoneChoice { cards, .. } => { + consumed_effect_zone_choice = true; let pick = preferred .iter() .copied() @@ -318,7 +322,7 @@ fn drain_priority_preferring( } _ => { if runner.act(GameAction::PassPriority).is_err() { - return; + return consumed_effect_zone_choice; } } } @@ -1354,3 +1358,208 @@ fn necrotic_plague_attaches_to_chosen_creature_not_dying_host() { "must not open CR 303.4f Aura host choice when ParentTarget is the chosen creature" ); } + +/// Oracle text verbatim from `client/public/card-data.json`. +const SWORD_OF_THE_MEEK_ORACLE: &str = "Equipped creature gets +1/+2.\n\ +Equip {2}\n\ +Whenever a 1/1 creature you control enters, you may return this card from your \ +graveyard to the battlefield, then attach it to that creature."; + +const AURIOK_SURVIVORS_ORACLE: &str = "When this creature enters, you may return \ +target Equipment card from your graveyard to the battlefield. If you do, you may \ +attach it to this creature."; + +/// CR 400.7j + CR 301.5: "return this card from your graveyard to the +/// battlefield, then attach it to that creature" must equip the entering 1/1. +/// The bare-"it" attachment names the card the same effect just returned +/// (CR 400.7j), not the trigger-event referent; before the parser rebind both +/// operands collapsed onto the entering creature and CR 301.5c's self-attach +/// guard silently swallowed the whole attach. +/// +/// Hostile fixture: the Sword has a *prior host* and there is a *second 1/1* +/// already on the battlefield, so an `AttachedTo` LKI fallback or a battlefield +/// scan binds the wrong permanent and fails. +#[test] +fn sword_of_the_meek_attaches_to_entering_one_one_not_the_prior_host() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let prior_host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let distractor = scenario.add_creature(P0, "Memnite", 1, 1).id(); + let one_one = scenario.add_creature_to_hand(P0, "Ornithopter", 1, 1).id(); + let sword = scenario + .add_creature(P0, "Sword of the Meek", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .from_oracle_text(SWORD_OF_THE_MEEK_ORACLE) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), sword, prior_host); + + let mut gy_events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), sword, Zone::Graveyard, &mut gy_events); + process_triggers(runner.state_mut(), &gy_events); + drain_priority(&mut runner); + assert_eq!(runner.state().objects[&sword].zone, Zone::Graveyard); + + let mut etb_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + one_one, + Zone::Battlefield, + &mut etb_events, + ); + process_triggers(runner.state_mut(), &etb_events); + let consumed_effect_zone_choice = drain_priority(&mut runner); + + // Positive reach-guard: the return ran, so execution reached the Attach. + assert_eq!( + runner.state().objects[&sword].zone, + Zone::Battlefield, + "Sword of the Meek returns from the graveyard on the 1/1's ETB" + ); + assert_eq!( + runner.state().objects[&sword].attached_to, + Some(AttachTarget::Object(one_one)), + "must equip the entering 1/1 ({one_one:?}), not the prior host \ + ({prior_host:?}) or the distractor 1/1 ({distractor:?}); attached_to={:?}", + runner.state().objects[&sword].attached_to + ); + assert!( + runner.state().objects[&one_one] + .attachments + .contains(&sword), + "the entering 1/1 must list the Sword as attached" + ); + assert!( + !consumed_effect_zone_choice, + "a SelfRef attachment must not consume a resolution-time attachment choice" + ); +} + +/// CR 301.5e + CR 608.2c: "return …, **then** attach it" performs the return +/// unconditionally — only the attach can fail. This is the observable that +/// distinguishes it from the Aura "return … **attached to** that creature" +/// family, whose CR 303.4i/CR 704.5m denial keeps the card in the graveyard +/// (`gift_of_immortality_stays_in_graveyard_when_host_gone`, above). The rebind +/// routes the Sword through the same `enter_attached_to` delivery slot those +/// Auras use, so this guard pins that the slot stays a delivery mechanism for an +/// Equipment rather than becoming an entry gate. +/// +/// Not revert-failing (reverted, the Sword also enters unattached) — Claim 1a +/// above carries that burden. The prior host is retained so the `AttachedTo` +/// fallback would bind it if the primary referent ever stopped resolving. +#[test] +fn sword_of_the_meek_returns_unattached_when_the_entering_creature_leaves() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let prior_host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let one_one = scenario.add_creature_to_hand(P0, "Ornithopter", 1, 1).id(); + let sword = scenario + .add_creature(P0, "Sword of the Meek", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .from_oracle_text(SWORD_OF_THE_MEEK_ORACLE) + .id(); + + let mut runner = scenario.build(); + attach_to(runner.state_mut(), sword, prior_host); + + let mut gy_events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), sword, Zone::Graveyard, &mut gy_events); + process_triggers(runner.state_mut(), &gy_events); + drain_priority(&mut runner); + + let mut etb_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + one_one, + Zone::Battlefield, + &mut etb_events, + ); + process_triggers(runner.state_mut(), &etb_events); + // Reach-guard: the trigger is actually pending before the host is removed, + // otherwise "the Sword entered" below could pass without the trigger firing. + assert!( + !runner.state().stack.is_empty() + || matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } | WaitingFor::OrderTriggers { .. } + ), + "the return trigger must be pending; stack={} waiting={:?}", + runner.state().stack.len(), + runner.state().waiting_for + ); + + let mut death_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + one_one, + Zone::Graveyard, + &mut death_events, + ); + process_triggers(runner.state_mut(), &death_events); + assert_eq!(runner.state().objects[&one_one].zone, Zone::Graveyard); + drain_priority(&mut runner); + + assert_eq!( + runner.state().objects[&sword].zone, + Zone::Battlefield, + "CR 608.2c: the return is not gated on the attach — unlike an Aura's \ + CR 303.4i return, the Equipment enters even with no legal host" + ); + assert_eq!( + runner.state().objects[&sword].attached_to, + None, + "CR 301.5e: with an undefined host the Equipment enters unattached — \ + not re-bound to the stale prior host ({prior_host:?})" + ); +} + +/// Auriok Survivors shares Sword of the Meek's collapsed-anaphor shape, so the +/// rebind changes its AST too. Its *recipient* operand ("attach it to this +/// creature") is a separate, unfixed misparse, so the card still asks to attach +/// the returned Equipment to itself — a CR 301.5c no-op. This guard pins that +/// the newly-stamped self-host is neutralized rather than producing a corrupt +/// self-attached state. +#[test] +fn auriok_survivors_returned_equipment_enters_unattached() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let equipment = scenario + .add_creature_to_graveyard(P0, "Bonesplitter", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .id(); + let survivors = scenario + .add_creature_to_hand(P0, "Auriok Survivors", 3, 5) + .from_oracle_text(AURIOK_SURVIVORS_ORACLE) + .id(); + + let mut runner = scenario.build(); + let mut etb_events = Vec::new(); + engine::game::zones::move_to_zone( + runner.state_mut(), + survivors, + Zone::Battlefield, + &mut etb_events, + ); + process_triggers(runner.state_mut(), &etb_events); + drain_priority_preferring(&mut runner, &[equipment]); + + assert_eq!( + runner.state().objects[&equipment].zone, + Zone::Battlefield, + "the targeted Equipment returns from the graveyard" + ); + assert_eq!( + runner.state().objects[&equipment].attached_to, + None, + "CR 301.5c + CR 301.5e: an Equipment can't equip itself, so the \ + unfixed recipient anaphor leaves it entering unattached rather than \ + producing a self-attached state" + ); +}