fix(engine): return the exiled "rest" to the library bottom for exile-until chains (Jodah #5277, #5292)#5710
Merged
matthewevans merged 3 commits intoJul 13, 2026
Conversation
…-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>
Contributor
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
Resolve parser chain assembly against current role-based normalization.\n\nCo-authored-by: Shin <shin-core@users.noreply.github.com>
Parse changes introduced by this PR · 3 card(s), 3 signature(s) (baseline: main
|
matthewevans
approved these changes
Jul 13, 2026
matthewevans
left a comment
Member
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:exile_from_top_until.rsran the sub-chain onlyif let Some(hit). Exhausting the library skipped the bottom-cleanup entirely; repeated triggers shrank the real library until an engine-caused deck-out.TrackedSet(0)is never published by the exile-until resolver, andput_on_top.rs's tracked-set collection scanszone == Libraryonly — never Exile. So even when the cleanup ran, it collected zero cards.ExiledBySource(which does scan Exile) vianormalize_linked_exile_cast_bottom_cleanup, which also wires the cleanup as the cast'selse_ability— but its gate requires the cast/cleanup target to already referenceExiledBySource, which Jodah'sParentTargetcast +TrackedSetcleanup never satisfies.The fix, per layer
lower.rs, wired at both assembly call sites): a head-aware companion gate,is_exile_until_cast_bottom_cleanup. When the chain head isExileFromTopUntil { 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 toAnd { ExiledBySource, Typed(DistinctFrom { ParentTarget }) }and wired as the optional cast'selse_ability(the proven Chaos Wand shape). TheDistinctFrom { 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: theExiledBySourcebranch entries (exile-zone scan gate, random-order placement, exile-link retirement) generalize from an exact match toreferences_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 firstExiledBySource-referencing cleanup link and resolves it with cleared targets — the whole pile returns to the library bottom.effects/mod.rs:filter_refs_parent_targetnow recognizesDistinctFrom { reference: ParentTarget }inside aTypedfilter's properties, so the decline branch inheritsability.targetsand 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_misses— the 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 BOTHsub_abilityandelse_ability, zeroUnimplemented(positive reach-guard).Verification
cargo fmt: clean. Parser combinator gate: exit 0.docs/MagicCompRules.txt.🤖 Generated with Claude Code