Skip to content

fix(engine): return the exiled "rest" to the library bottom for exile-until chains (Jodah #5277, #5292)#5710

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
shin-core:fix-jodah-exile-until-rest-5277
Jul 13, 2026
Merged

fix(engine): return the exiled "rest" to the library bottom for exile-until chains (Jodah #5277, #5292)#5710
matthewevans merged 3 commits into
phase-rs:mainfrom
shin-core:fix-jodah-exile-until-rest-5277

Conversation

@shin-core

Copy link
Copy Markdown
Contributor

Fixes #5277 and fixes #5292 (both claimed there first, with the full root-cause breakdown). Verified Jodah's Oracle text and rulings on Scryfall before designing:

  • "If you exile your entire library without exiling a legendary nonland card with lesser mana value, you will randomize the exiled cards and they again become your library, ending the effect."
  • "If you choose not to cast the card, it remains in exile. The rest of the cards will be put on the bottom of your library in a random order."

The bugs (three compounding, one pipeline)

Jodah's trigger parses to ExileFromTopUntil { NextMatches } → optional CastFromZone { ParentTarget } → PutAtLibraryPosition { TrackedSet(0), Bottom }. The exiled pile got stranded in exile in every path:

  1. No match (jodah, the unifer makes you lose the game. — If you chain 4 or 5 times you just lose the game. #5277 — the game loss): exile_from_top_until.rs ran the sub-chain only if let Some(hit). Exhausting the library skipped the bottom-cleanup entirely; repeated triggers shrank the real library until an engine-caused deck-out.
  2. Dead cleanup channel (Jodah the unifier — After the cards are exiled, they do not return to the bottom of the library  #5292): the cleanup's TrackedSet(0) is never published by the exile-until resolver, and put_on_top.rs's tracked-set collection scans zone == Library only — never Exile. So even when the cleanup ran, it collected zero cards.
  3. The working Chaos Wand class routes this through ExiledBySource (which does scan Exile) via normalize_linked_exile_cast_bottom_cleanup, which also wires the cleanup as the cast's else_ability — but its gate requires the cast/cleanup target to already reference ExiledBySource, which Jodah's ParentTarget cast + TrackedSet cleanup never satisfies.

The fix, per layer

  • Parser (lower.rs, wired at both assembly call sites): a head-aware companion gate, is_exile_until_cast_bottom_cleanup. When the chain head is ExileFromTopUntil { NextMatches } — the discriminator that keeps the Dig/Expressive-Iteration class (whose rest-cards never left the library) on its correct TrackedSet library-scan — the bottom-cleanup is normalized to And { ExiledBySource, Typed(DistinctFrom { ParentTarget }) } and wired as the optional cast's else_ability (the proven Chaos Wand shape). The DistinctFrom { ParentTarget } leg (reusing the feat(engine): Radiance color fan-out damage (Cleansing Beam #5244) #5470 building block) encodes the decline ruling: the un-cast hit remains in exile, only "the rest" is swept. It fails open with no bound hit, so the no-match case returns everything — exactly the ruling.
  • put_on_top.rs: the ExiledBySource branch entries (exile-zone scan gate, random-order placement, exile-link retirement) generalize from an exact match to references_exiled_by_source() so the And-composed form reaches the scan; the collected-targets seed is forced empty for such filters so an inherited hit target can't bypass the scan + exclusion.
  • exile_from_top_until.rs: on no hit, the resolver walks the sub-chain for the first ExiledBySource-referencing cleanup link and resolves it with cleared targets — the whole pile returns to the library bottom.
  • effects/mod.rs: filter_refs_parent_target now recognizes DistinctFrom { reference: ParentTarget } inside a Typed filter's properties, so the decline branch inherits ability.targets and the declined hit is excluded by identity.

Tests (each fails on revert of its seam)

  • jodah_no_hit_returns_all_exiled_cards_to_library — the jodah, the unifer makes you lose the game. — If you chain 4 or 5 times you just lose the game. #5277 loss: library exhausted, ALL cards return (revert → all stay in exile).
  • jodah_hit_accept_casts_hit_and_returns_misses — hit on the stack, misses returned (revert of the seed fix → hit swept to library).
  • jodah_hit_decline_leaves_hit_in_exile_returns_missesthe discriminating Jodah-vs-Chaos-Wand ruling: declined hit stays in exile (revert of the predicate extension → hit wrongly returns).
  • jodah_parse_lowers_the_rest_cleanup — verbatim Oracle text lowers to the And-composed cleanup on BOTH sub_ability and else_ability, zero Unimplemented (positive reach-guard).
  • Regressions: both Chaos Wand decline/accept tests and the Dig/Expressive-Iteration class tests stay green unchanged.

Verification

  • Full engine lib suite: 16170 passed, 0 failed. Clippy: clean. cargo fmt: clean. Parser combinator gate: exit 0.
  • CR annotations (109.1, 406.6, 608.2c, 701.13a) all grep-verified against docs/MagicCompRules.txt.

🤖 Generated with Claude Code

…-until chains (Jodah phase-rs#5277, phase-rs#5292)

Jodah, the Unifier's trigger — "exile cards from the top of your library
until you exile a legendary nonland card with lesser mana value. You may
cast that card without paying its mana cost. Put the rest on the bottom of
your library in a random order." — stranded the exiled pile in exile
permanently, in every path:

1. No match found (phase-rs#5277, the game loss): exile_from_top_until ran the
   sub-chain only `if let Some(hit)`, so exhausting the library skipped the
   bottom-cleanup entirely. Repeated triggers shrank the real library until
   an engine-caused deck-out. Per the official ruling the exiled cards are
   randomized and become the library again.
2. The cleanup channel was dead (phase-rs#5292): the parse emitted
   PutAtLibraryPosition { TrackedSet(0), Bottom }, but nothing publishes
   that set and put_on_top's tracked-set collection scans zone == Library
   only — never Exile. The working Chaos Wand class routes this through
   ExiledBySource (which scans Exile) via the linked-exile-cleanup
   normalization, but its gate requires the cast/cleanup target to already
   reference ExiledBySource — Jodah's ParentTarget cast + TrackedSet
   cleanup never qualified.

Fixes, per layer:

- parser (lower.rs + assembly.rs + sequence.rs): a HEAD-AWARE companion
  gate `is_exile_until_cast_bottom_cleanup` — when the chain head is
  ExileFromTopUntil { NextMatches } (cards physically in Exile; the
  discriminator that keeps the Dig/Expressive Iteration class, whose
  rest-cards never left the library, on its correct TrackedSet
  library-scan), the bottom-cleanup is normalized to
  And { ExiledBySource, Typed(DistinctFrom { ParentTarget }) } and wired
  as the optional cast's else_ability (the Chaos Wand shape). The
  DistinctFrom leg encodes Jodah's ruling that a DECLINED hit remains in
  exile — only "the rest" is swept; it fails open when no hit exists, so
  the no-match case returns everything.
- put_on_top.rs: the ExiledBySource branch entries (scan gate,
  random-order placement, exile-link retirement) generalize from an exact
  ExiledBySource match to references_exiled_by_source(), so the
  And-composed form reaches the exile-zone scan; the collected-targets
  seed is forced empty for such filters so an inherited hit target cannot
  bypass the scan + exclusion.
- exile_from_top_until.rs: when NextMatches finds no hit, the resolver now
  walks the sub-chain for the first ExiledBySource-referencing cleanup
  link and resolves it (with cleared targets), returning the whole pile to
  the library bottom (CR 608.2c; bottom-in-random-order into an empty
  library is the ruling's "randomize and they become your library").
- effects/mod.rs: filter_refs_parent_target now recognizes
  DistinctFrom { reference: ParentTarget } inside a Typed filter's
  properties, so the decline branch inherits ability.targets and the
  declined hit is excludable by identity (CR 109.1).

Tests (each fails on revert of its seam): no-hit returns all exiled cards
to the library (the phase-rs#5277 loss); accepted hit is cast while misses return;
DECLINED hit remains in exile while misses return (the discriminating
Jodah-vs-Chaos-Wand ruling); parser shape asserts the And-composed cleanup
on both sub_ability and else_ability with zero Unimplemented. Chaos Wand
and Dig-class regressions stay green.

Full lib suite: 16170 passed, 0 failed. Clippy clean.

Fixes phase-rs#5277. Fixes phase-rs#5292.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@shin-core
shin-core requested a review from matthewevans as a code owner July 13, 2026 01:28
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@matthewevans matthewevans self-assigned this Jul 13, 2026
Resolve parser chain assembly against current role-based normalization.\n\nCo-authored-by: Shin <shin-core@users.noreply.github.com>
@matthewevans matthewevans added the bug Bug fix label Jul 13, 2026
@matthewevans matthewevans removed their assignment Jul 13, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR · 3 card(s), 3 signature(s) (baseline: main 9a156beade96)

3 card(s) · ability/PutAtLibraryPosition · added: PutAtLibraryPosition (count=Fixed { value: 0 }, position=Bottom, target=cards exiled by source + other than target)

Examples: Jodah, the Unifier, Ryan Sinclair, The Day of the Doctor

3 card(s) · ability/PutAtLibraryPosition · field count: Fixed { value: 1 }Fixed { value: 0 }

Examples: Jodah, the Unifier, Ryan Sinclair, The Day of the Doctor

3 card(s) · ability/PutAtLibraryPosition · field target: tracked set #0cards exiled by source + other than target

Examples: Jodah, the Unifier, Ryan Sinclair, The Day of the Doctor

1 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.

@matthewevans matthewevans self-assigned this Jul 13, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve — the Jodah-class exile-until cleanup now preserves the selected-hit provenance across no-hit, cast, and decline resolution paths.

🔴 Blocker

  • None. The maintainer port’s stale import was repaired on 1b4efa2; all required current-head checks are green.

🟡 Non-blocking

  • Gemini is quota-limited this session; the local adversarial review covered the current head and the full CI/parse artifact is available.

✅ Clean

  • The parser reuses ExiledBySource, DistinctFrom { ParentTarget }, and the existing Chaos-Wand cleanup shape rather than inventing a second cleanup channel.
  • Runtime regressions discriminate the library-exhausted, accepted-hit, and declined-hit cases; the declined hit remains exiled while only the rest return.
  • The current parse artifact is limited to Jodah, Ryan Sinclair, and The Day of the Doctor (three expected cleanup signatures).

Recommendation: approve and enqueue as bug.

@matthewevans
matthewevans added this pull request to the merge queue Jul 13, 2026
@matthewevans matthewevans removed their assignment Jul 13, 2026
Merged via the queue into phase-rs:main with commit 9c9b017 Jul 13, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

2 participants