From f55f0bed8cd2f5733ec0e49441cb796631a6f88e Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Wed, 29 Jul 2026 03:25:09 -0500 Subject: [PATCH 1/2] fix(engine): Fight Rigging exiles the targeted creature instead of countering it (#6437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the default sequential-sibling target-propagation arm in resolve_chain_body (has_independent_target_slot) treats any effect whose target filter reports no dedicated slot as "inherit the parent's chosen object". TargetFilter::ExiledBySource is a context-ref with no slot of its own (it resolves independently via exile_links), so a chain like "put a +1/+1 counter on target creature you control. Then ... you may play the exiled card" copied the counter's chosen creature into the CastFromZone sub's targets — treating the targeted creature as the card to license and exiling it instead of countering it. Same fix applied to should_propagate_parent_targets (the sibling authority used by the declined-optional-branch dispatcher), refined so a composed filter that ALSO references ParentTarget (Jodah's "put the rest ... except the hit" cleanup) still propagates as before. A second, sibling bug blocked the fix from being observable on Fight Rigging specifically: capture_linked_exile_snapshot (the leaves-the- battlefield ExiledBySource lookup used whenever a TRIGGERED ability resolves ExiledBySource) filtered to ExileLinkKind::TrackedBySource only, dropping Hideaway's own ExileLinkKind::HideawayLookable links even though Hideaway's own doc comment says the kind is meant to be found by the kind-agnostic ExiledBySource lookup, same as the live (non-snapshot) path. Also affects Collector's Cage, which carries the identical counter-then-exiled-card shape on an activated ability. Adds add_enchantment_from_oracle to the scenario test harness (mirrors add_land_from_oracle) so a permanent's own triggered ability can be driven without going through its cast/ETB pipeline. --- crates/engine/src/game/effects/mod.rs | 62 ++++- crates/engine/src/game/scenario.rs | 37 +++ crates/engine/src/game/zones.rs | 20 +- ...e_6437_fight_rigging_exiled_card_target.rs | 229 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 5 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 607e8a05bb..c1f269c912 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2450,12 +2450,41 @@ fn apply_parent_chain_context( /// NOT inherit an earlier instruction's already-chosen recipient, or every /// replicated instruction in a chain collapses onto whichever single object /// the first one picked (Kathril, Aspect Warper, issue #6321 / PR #6533). +/// +/// CR 406.6 + CR 607.2a (issue #6437, Fight Rigging / Collector's Cage): a sub +/// whose own effect targets a BARE `TargetFilter::ExiledBySource` resolves +/// that reference itself, at its own resolution, against this source's +/// durable `exile_links` (`cast_from_zone::resolve`'s no-target +/// `ExiledBySource` branch) — it never needs a pre-chosen object. "Put a +/// +1/+1 counter on target creature you control. Then ... you may play the +/// exiled card" chains a targeted clause before the linked-exile clause in +/// the SAME resolution; without this guard the counter's targeted creature +/// propagates into the exiled-card sub as if IT were the card to play, and +/// `CastFromZone` proceeds to grant a cast permission (and, for a +/// battlefield object, an exile-delivery move) on the targeted creature +/// instead of the hidden card. +/// +/// Gated on `!effect_refs_parent_target`: a COMPOSED filter like Jodah's +/// cleanup rider (`And { ExiledBySource, Typed(DistinctFrom { ParentTarget }) +/// }`, sweeping the "misses" while excluding the cast hit) also references +/// `ExiledBySource`, but STILL needs the propagated parent target so +/// `ParentTarget` can resolve to the hit and exclude it from the sweep — the +/// declined-branch dispatcher (this function's `Chaos-Wand cleanup` caller) +/// already ANDs this predicate with its own `effect_refs_parent_target` +/// check for exactly that reason, so excluding the composed case here too +/// would strand the exclusion and sweep the hit itself onto the library +/// bottom alongside the misses. /// Every other sub keeps today's behavior: parent targets propagate when the /// sub declares none of its own. 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 + .effect + .target_filter() + .is_some_and(TargetFilter::references_exiled_by_source) + && !effect_refs_parent_target(&sub.effect)) } fn waits_for_resolution_choice(waiting_for: &WaitingFor) -> bool { @@ -10425,10 +10454,39 @@ fn resolve_chain_body( // gated subs ("When you discard a card this way, put a counter on target // Faerie") keep inheriting their selected target through the parent // chain; their condition decides whether the sub fires. + // + // CR 406.6 + CR 607.2a (issue #6437, Fight Rigging / Collector's + // Cage): a sub targeting a BARE `TargetFilter::ExiledBySource` + // ("the exiled card") is ALSO independent — it resolves its own + // object at its own resolution against this source's durable + // `exile_links` (`cast_from_zone::resolve`'s no-target + // `ExiledBySource` branch), never from an inherited object target. + // `extract_target_filter_from_effect` returns `None` for it (its + // `is_context_ref` guard), so without this arm it fell through to + // the default "no independent slot" case and inherited whatever + // object the PARENT clause targeted ("put a +1/+1 counter on + // target creature you control. Then ... you may play the exiled + // card") — treating the targeted creature as the card to license, + // which `grant_lingering_permissions` then routed through the + // exile-delivery batch instead of the hidden card. + // + // Gated on `!effect_refs_parent_target`: a COMPOSED filter like + // Jodah's cleanup rider (`And { ExiledBySource, DistinctFrom { + // ParentTarget } }`, sweeping the "misses" while excluding the + // cast hit) also references `ExiledBySource` but STILL needs the + // propagated parent target so `ParentTarget` can resolve to the + // hit and exclude it — treating it as independent here stranded + // that exclusion, sweeping the hit itself to the library bottom + // alongside the misses. let has_independent_target_slot = - crate::game::triggers::extract_target_filter_from_effect(&sub.effect).is_some() + (crate::game::triggers::extract_target_filter_from_effect(&sub.effect).is_some() && !effect_refs_parent_target(&sub.effect) - && !sub_ability_target_belongs_to_reflexive_context(sub); + && !sub_ability_target_belongs_to_reflexive_context(sub)) + || (sub + .effect + .target_filter() + .is_some_and(TargetFilter::references_exiled_by_source) + && !effect_refs_parent_target(&sub.effect)); sub_with_targets.targets = ability .targets .iter() diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 676747b78d..819c29200b 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -644,6 +644,43 @@ impl GameScenario { builder } + /// Add a nonland, noncreature permanent (e.g. an enchantment) to the + /// battlefield with abilities parsed from Oracle text. Mirrors + /// `add_land_from_oracle`; needed for permanents whose own triggered/ + /// static abilities (not a cast) are under test — e.g. a Hideaway + /// enchantment's beginning-of-combat trigger. + pub fn add_enchantment_from_oracle( + &mut self, + player: PlayerId, + name: &str, + oracle_text: &str, + ) -> CardBuilder<'_> { + let card_id = CardId(self.state.next_object_id); + let id = create_object( + &mut self.state, + card_id, + player, + name.to_string(), + Zone::Battlefield, + ); + let ts = self.state.next_timestamp(); + let obj = self.state.objects.get_mut(&id).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.base_card_types = obj.card_types.clone(); + obj.timestamp = ts; + // CR 302.6 note: summoning sickness only gates creatures, but the + // builder models a pre-existing permanent (entered on a prior turn), + // matching `add_land_from_oracle`'s override. + obj.summoning_sick = false; + + let mut builder = CardBuilder { + state: &mut self.state, + id, + }; + builder.from_oracle_text(oracle_text); + builder + } + /// Add a creature to hand with abilities parsed from Oracle text. pub fn add_creature_to_hand_from_oracle( &mut self, diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 1ea1e3fa11..ca14353ef4 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -1421,6 +1421,18 @@ pub fn stamp_simultaneous_from_slice(state: &GameState, slice: &mut [GameEvent]) mark_simultaneous_departures(slice, &departed); } +/// CR 406.6 + CR 607.2a (issue #6437): Snapshot `source_id`'s linked exiles at +/// the moment it leaves the battlefield, for a leaves-the-battlefield +/// trigger's later `ExiledBySource` lookup (`filter.rs`'s `trigger_source. +/// is_some()` branch). Every `ExileLinkKind` is kind-agnostically readable via +/// `ExiledBySource` (`HideawayLookable`'s and `CraftMaterial`'s own doc +/// comments say so explicitly) and the LIVE lookup +/// (`players::linked_exile_cards_for_source`) does not filter by kind either — +/// this snapshot must match that surface exactly, or a card whose "play the +/// exiled card" clause resolves via a TRIGGERED ability (Fight Rigging's +/// begin-of-combat trigger, as opposed to Windbrisk Heights' activated +/// ability) silently finds nothing: Hideaway's link is `HideawayLookable`, and +/// a `TrackedBySource`-only filter here dropped it before the previous fix. pub(crate) fn capture_linked_exile_snapshot( state: &GameState, source_id: ObjectId, @@ -1433,13 +1445,7 @@ pub(crate) fn capture_linked_exile_snapshot( state .exile_links .iter() - .filter(|link| { - link.source_id == source_id - && matches!( - link.kind, - crate::types::game_state::ExileLinkKind::TrackedBySource - ) - }) + .filter(|link| link.source_id == source_id) .filter_map(|link| { state.objects.get(&link.exiled_id).and_then(|obj| { (obj.zone == Zone::Exile).then(|| crate::types::game_state::LinkedExileSnapshot { diff --git a/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs b/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs new file mode 100644 index 0000000000..b1b3ab5633 --- /dev/null +++ b/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs @@ -0,0 +1,229 @@ +//! Issue #6437 — Fight Rigging: "Exiles the targeted creature instead of +//! putting a counter on it." +//! +//! Fight Rigging: "At the beginning of combat on your turn, put a +1/+1 +//! counter on target creature you control. Then if you control a creature +//! with power 7 or greater, you may play the exiled card without paying its +//! mana cost." Collector's Cage carries the identical shape on an activated +//! ability instead of a trigger. Both chain a TARGETED clause (`PutCounter`) +//! before a linked-exile clause (`CastFromZone { ExiledBySource }`) in the +//! SAME resolution. +//! +//! Root cause: the default target-propagation arm in `resolve_chain_body` +//! (`game/effects/mod.rs`, guarded by a `has_independent_target_slot` check) +//! copies a parent's already-chosen object targets into a following +//! sequential sibling whenever the sibling's own effect carries no +//! recognized "independent" target slot. `TargetFilter::ExiledBySource` is a +//! context-ref (`is_context_ref()`), so `extract_target_filter_from_effect` +//! reports no slot for it, and pre-fix that read as "not independent" — +//! copying the counter's chosen creature into the `CastFromZone` sub's empty +//! `targets`. `cast_from_zone::resolve` then found a non-empty `target_ids` +//! and never reached its own `ExiledBySource` → `exile_links` fallback, +//! instead processing the TARGETED CREATURE as the card to license: since the +//! creature sits on the battlefield (not Hand/Graveyard/Exile), +//! `grant_lingering_permissions` routed it through the exile-delivery batch, +//! moving the targeted creature into exile instead of giving it a counter. +//! `should_propagate_parent_targets` (the sibling authority used by the +//! declined-optional-branch dispatcher and others) carries the matching fix, +//! refined to keep composed filters that ALSO reference `ParentTarget` +//! (Jodah's "put the rest ... except the hit" cleanup) propagating as before. +//! +//! Oracle text is quoted verbatim from Scryfall (`client/public/card-data.json`). + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::types::ability::{CastingPermission, TargetRef}; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::{ExileLink, ExileLinkKind, WaitingFor}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const FIGHT_RIGGING: &str = "Hideaway 5 (When this enchantment enters, look at the top five cards of your library, exile one face down, then put the rest on the bottom in a random order.)\nAt the beginning of combat on your turn, put a +1/+1 counter on target creature you control. Then if you control a creature with power 7 or greater, you may play the exiled card without paying its mana cost."; + +/// Drive the begin-combat trigger to completion: answer the counter's target +/// selection (when more than one legal creature forces an explicit prompt — +/// a lone legal creature auto-binds with no `TriggerTargetSelection` pause), +/// then (if the power-7 condition held) accept the optional free play. Loops +/// `advance_until_stack_empty` (which drains ordinary priority, +/// `OrderTriggers`, and library-bottom `EffectZoneChoice` prompts) against a +/// manual `OptionalEffectChoice` answer, since the driver has no generic +/// "accept every optional offer" policy. +fn resolve_begin_combat_trigger( + runner: &mut GameRunner, + counter_target: engine::types::identifiers::ObjectId, +) { + if matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } + ) { + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(counter_target)], + }) + .expect("select the +1/+1 counter's target creature"); + } + + for _ in 0..10 { + if matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ) { + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accept the optional free play of the hidden card"); + continue; + } + if runner.state().stack.is_empty() { + break; + } + runner.advance_until_stack_empty(); + } +} + +fn p1p1_counters(runner: &GameRunner, id: engine::types::identifiers::ObjectId) -> u32 { + runner + .state() + .objects + .get(&id) + .expect("object still present") + .counters + .get(&CounterType::Plus1Plus1) + .copied() + .unwrap_or(0) +} + +/// DISCRIMINATOR (condition met): a controlled 7-power creature satisfies +/// "you control a creature with power 7 or greater", so the granting +/// sub-ability offers the hidden card. Pre-fix this offer landed on the +/// TARGETED creature (Squire) instead of the linked hidden card, and Squire +/// was moved to exile rather than receiving its counter. +#[test] +fn fight_rigging_counters_the_target_and_offers_only_the_hidden_card() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let fight_rigging = scenario + .add_enchantment_from_oracle(P0, "Fight Rigging", FIGHT_RIGGING) + .id(); + // Satisfies "you control a creature with power 7 or greater" without + // being eligible as the counter's own target choice ambiguity: Squire is + // deliberately the ONLY creature selected below, proving the resolver + // doesn't just get lucky by counting the same object twice. + let big_beater = scenario.add_creature(P0, "Big Beater", 7, 7).id(); + let squire = scenario.add_creature(P0, "Squire", 1, 1).id(); + // Models an earlier turn's already-resolved Hideaway ETB: a card durably + // linked to Fight Rigging in exile (CR 406.6 + CR 607.2a), independent of + // this resolution's chosen targets. + let hidden = scenario.add_creature_to_exile(P0, "Hidden Card", 0, 0).id(); + + let mut runner = scenario.build(); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: hidden, + source_id: fight_rigging, + kind: ExileLinkKind::HideawayLookable, + }); + + runner.pass_both_players(); + assert_eq!(runner.state().phase, Phase::BeginCombat); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::TriggerTargetSelection { .. } + ), + "reach-guard: begin-combat trigger must prompt for the counter's target, got {:?}", + runner.state().waiting_for + ); + + resolve_begin_combat_trigger(&mut runner, squire); + + assert_eq!( + p1p1_counters(&runner, squire), + 1, + "the TARGETED creature must receive the +1/+1 counter" + ); + assert_eq!( + runner.state().objects.get(&squire).map(|o| o.zone), + Some(Zone::Battlefield), + "the targeted creature must remain on the battlefield — it must never \ + be routed through the exiled-card's free-cast/exile-delivery path" + ); + assert!( + runner.state().objects[&squire] + .casting_permissions + .is_empty(), + "the targeted creature must not receive a casting permission meant for \ + the hidden card, got {:?}", + runner.state().objects[&squire].casting_permissions + ); + + let hidden_has_permission = runner.state().objects[&hidden] + .casting_permissions + .iter() + .any(|p| matches!(p, CastingPermission::ExileWithAltCost { granted_to: Some(p), .. } if *p == P0)); + assert!( + hidden_has_permission, + "the HIDDEN card (linked via exile_links) must receive the free-cast \ + permission, got {:?}", + runner.state().objects[&hidden].casting_permissions + ); + assert_eq!( + runner.state().objects.get(&hidden).map(|o| o.zone), + Some(Zone::Exile), + "the hidden card stays in exile — granting the permission is not a zone move" + ); + assert_eq!( + runner.state().objects.get(&big_beater).map(|o| o.zone), + Some(Zone::Battlefield), + "the power-7 creature that satisfied the condition is not itself a \ + target or subject of either clause and must be untouched" + ); +} + +/// CONTROL (condition NOT met): with no power-7 creature, only the counter +/// clause resolves — "nothing else happens" below the threshold. Guards +/// against a fix that accidentally engages the exiled-card path unconditionally. +#[test] +fn fight_rigging_below_power_threshold_only_places_the_counter() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let fight_rigging = scenario + .add_enchantment_from_oracle(P0, "Fight Rigging", FIGHT_RIGGING) + .id(); + let squire = scenario.add_creature(P0, "Squire", 1, 1).id(); + let hidden = scenario.add_creature_to_exile(P0, "Hidden Card", 0, 0).id(); + + let mut runner = scenario.build(); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: hidden, + source_id: fight_rigging, + kind: ExileLinkKind::HideawayLookable, + }); + + runner.pass_both_players(); + // Squire is the only creature P0 controls, so "target creature you + // control" auto-binds to it with no `TriggerTargetSelection` pause + // (unlike the discriminator test above, which has two legal creatures). + resolve_begin_combat_trigger(&mut runner, squire); + + assert_eq!( + p1p1_counters(&runner, squire), + 1, + "the counter must still be placed with no power-7 creature in play" + ); + assert_eq!( + runner.state().objects.get(&squire).map(|o| o.zone), + Some(Zone::Battlefield) + ); + assert!( + runner.state().objects[&hidden] + .casting_permissions + .is_empty(), + "below the power threshold the hidden card must receive no permission, got {:?}", + runner.state().objects[&hidden].casting_permissions + ); + assert_eq!( + runner.state().objects.get(&hidden).map(|o| o.zone), + Some(Zone::Exile) + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 7e430108e7..b56c21f952 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -617,6 +617,7 @@ mod issue_6403_moonmist_mass_transform; mod issue_6405_aang_multicolor_cost_reduction; mod issue_6416_extra_turn_resume_order; mod issue_6431_lava_dart_flashback_control_turn; +mod issue_6437_fight_rigging_exiled_card_target; mod issue_6440_mockingbird_uncast_copy_ceiling; mod issue_6498_portent_of_calamity; mod issue_6499_flickering_ward_protection_exemption; From 9f567dc235aa19f8734528ace62a22ea82c9cdbe Mon Sep 17 00:00:00 2001 From: jsdevninja Date: Wed, 29 Jul 2026 05:01:50 -0500 Subject: [PATCH 2/2] test(engine): witness capture_linked_exile_snapshot on the real LTB path (#6437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: the kind-widening fix in capture_linked_exile_snapshot (TrackedBySource-only -> every ExileLinkKind) had no test that actually drives a source through the real leaves-the-battlefield zone pipeline — the two existing Fight Rigging tests keep the source on the battlefield throughout, exercising a different (still-present) call path. Adds a regression test for Watcher for Tomorrow's real LTB trigger ("When this creature leaves the battlefield, put the exiled card into its owner's hand", Effect::ChangeZoneAll targeting ExiledBySource): casts a real removal spell to destroy it through the actual casting/zone pipeline (zones::move_to_zone), then asserts the HideawayLookable-linked hidden card reaches its owner's hand. Verified as a genuine discriminator by temporarily reverting the kind filter back to TrackedBySource-only and confirming the new test fails (hidden card stranded in exile) before re-applying the fix. --- ...e_6437_fight_rigging_exiled_card_target.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs b/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs index b1b3ab5633..a6186ace32 100644 --- a/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs +++ b/crates/engine/tests/integration/issue_6437_fight_rigging_exiled_card_target.rs @@ -227,3 +227,61 @@ fn fight_rigging_below_power_threshold_only_places_the_counter() { Some(Zone::Exile) ); } + +/// Issue #6437 (second root cause) — `capture_linked_exile_snapshot` +/// (`game/zones.rs`) must be kind-agnostic on the REAL leaves-the-battlefield +/// path, not only on the still-on-battlefield path the two tests above +/// exercise. Watcher for Tomorrow: "Hideaway 4 (...) This creature enters +/// tapped. When this creature leaves the battlefield, put the exiled card +/// into its owner's hand." — a pure LTB + `ExiledBySource` shape +/// (`Effect::ChangeZoneAll`, per the parser's +/// `hideaway_ltb_put_exiled_card_binds_exiled_by_source` test), unrelated to +/// the PutCounter target-propagation fix above and untouched by it. Casting a +/// real removal spell destroys Watcher through the actual casting/zone +/// pipeline (`zones::move_to_zone`), which is exactly the leaves-the- +/// battlefield departure `capture_linked_exile_snapshot` snapshots for the +/// trigger's later `ExiledBySource` lookup (`filter.rs`'s `trigger_source. +/// is_some()` branch). Pre-fix, that snapshot kept only `TrackedBySource` +/// links, silently dropping Hideaway's own `HideawayLookable` link and +/// leaving the hidden card stranded in exile instead of reaching the hand. +const WATCHER_FOR_TOMORROW: &str = "Hideaway 4 (When this creature enters, look at the top four cards of your library, exile one face down, then put the rest on the bottom in a random order.)\nThis creature enters tapped.\nWhen this creature leaves the battlefield, put the exiled card into its owner's hand."; + +#[test] +fn watcher_for_tomorrow_leaving_the_battlefield_finds_the_hideaway_linked_card() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let watcher = scenario + .add_creature_from_oracle(P0, "Watcher for Tomorrow", 2, 1, WATCHER_FOR_TOMORROW) + .id(); + // Models an earlier turn's already-resolved Hideaway ETB: a card durably + // linked to Watcher in exile (CR 406.6 + CR 607.2a) via the SAME + // `HideawayLookable` kind Fight Rigging uses. + let hidden = scenario.add_creature_to_exile(P0, "Hidden Card", 0, 0).id(); + let removal = scenario + .add_spell_to_hand_from_oracle(P0, "Destroy Spell", true, "Destroy target creature.") + .id(); + + let mut runner = scenario.build(); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: hidden, + source_id: watcher, + kind: ExileLinkKind::HideawayLookable, + }); + + // Destroy Watcher through the REAL casting/resolution/zone pipeline — + // the battlefield departure this drives is exactly the + // `zones::move_to_zone` leaves-the-battlefield path under test, and the + // resulting LTB trigger is Watcher's own PARSED printed ability, not a + // hand-built stand-in. + let outcome = runner.cast(removal).target_objects(&[watcher]).resolve(); + + outcome.assert_zone(&[watcher], Zone::Graveyard); + assert_eq!( + outcome.state().objects.get(&hidden).map(|o| o.zone), + Some(Zone::Hand), + "the Hideaway-linked hidden card must reach its owner's hand via the \ + leaves-the-battlefield ExiledBySource lookup, got {:?}", + outcome.state().objects.get(&hidden).map(|o| o.zone) + ); +}