Skip to content

Saruman of Many Colors: CopySpell reads a stale tracked set (same-chain targeted exile never publishes one) #5576

Description

@mike-theDude

Summary

Saruman of Many Colors' "Copy the exiled card" copies the wrong card (or nothing) at runtime. This is a distinct root cause from #4792 (fixed by #5571) and was deliberately scoped out of that PR.

Oracle text (verified from client/public/card-data.json)

Ward—Discard an enchantment, instant, or sorcery card.
Whenever you cast your second spell each turn, each opponent mills two cards. When one or more cards are milled this way, exile target enchantment, instant, or sorcery card with equal or lesser mana value than that spell from an opponent's graveyard. Copy the exiled card. You may cast the copy without paying its mana cost.

Why this is NOT fixed by #5571

#5571 fixed the cross-resolution case, where "the exiled card" refers to an exile made by a different, earlier ability (Isochron Scepter's Imprint, Hideaway lands). It routes those to the durable TargetFilter::ExiledBySource link.

Saruman is same-chain: the exile (exile target … card … from an opponent's graveyard) and the Copy the exiled card live in the same triggered ability. #5571's exile-producer gate therefore (correctly) keeps Saruman's copy target as the chain-local TrackedSet(0) — it must not flip, or it would regress.

Parsed AST (current): CopySpell { target: TrackedSet(0) } — correct for a same-chain reference.

Actual root cause (runtime)

change_zone::resolve() never calls publish_tracked_set for a plain targeted Effect::ChangeZone { destination: Exile } — every write to chain_tracked_set_id / tracked_object_sets in that file is under #[cfg(test)]. So when Saruman's CopySpell { TrackedSet(0) } sub-ability resolves, TrackedSet(0) is empty and copy_source_from_tracked_set (game/effects/copy_spell.rs) falls back to latest_tracked_set_id — a global scan for any non-empty tracked set — which by that point is some unrelated set (or empty). Result: wrong card copied, or silent no-op.

Candidate fixes (from the #5571 plan, §3)

  • (a) Runtime — publish a tracked set for exile-destination ChangeZone in change_zone::resolve(). NOT recommended: blast radius is every targeted ChangeZone{Exile} in the game (removal-exile, flicker, graveyard-exile — hundreds of cards), risking unrelated TrackedSet consumers / delayed-return snapshots.
  • (b) Narrow parser refinement — route same-chain plain-targeted ChangeZone{Exile}-then-copy to ExiledBySource. Lower blast radius; reuses the exile_links path fix(parser): bind "the exiled card" anaphor to ExiledBySource for cross-resolution abilities (#4792, #3246, #3674) #5571 added (records via should_track_exiled_by_source, resolves via copy_source_from_exiled_by_source). But it also flips two other same-chain plain-targeted-exile cards whose runtime correctness under ExiledBySource is currently unverified and needs per-card runtime tests:
    • Evolving Door{1},{T}, Sacrifice a creature: … Exile that card, then shuffle. You may cast the exiled card. (shuffle between exile and cast)
    • Beseech the MirrorSearch your library for a card, exile it face down, then shuffle. … you may cast the exiled card … Put the exiled card into your hand if it wasn't cast this way. (face-down + bargain + "put into hand" fallback)

Recommended: (b), gated behind runtime tests for Evolving Door and Beseech the Mirror to prove no regression.

References

Follow-up to #4792 / #3246 / #3674, split out of #5571. The runtime building block (ExiledBySource + exile_links + copy_source_from_exiled_by_source) already exists after #5571 — this issue is about wiring the same-chain-targeted-exile case to it and verifying the collateral cards.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions