fix(parser): Nefarious Lich damage-substitution replacement + counted graveyard exile#5664
Conversation
…sibility rider + counted graveyard exile Nefarious Lich (phase-rs#5649): "If damage would be dealt to you, exile that many cards from your graveyard instead. If you can't, you lose the game." — a textbook CR 614.1a substitution replacement — dropped to an inert standalone ability, its defining mechanic never firing and the impossibility rider lost. Two parts: 1. parse_damage_to_self_instead_followup (oracle_replacement.rs) accepted only a bare "<effect> instead" body, bailing on any trailing text. Accept an optional "If you can't, <consequence>" rider and fold it back onto the substituted effect so the shared "if you can't" lowering threads it as a conditional continuation (Not { ZoneChangedThisWay }). returns the remainder in original case, so match the rider via a lowercased copy. 2. "exile that many cards from your graveyard" dropped its count (bound a bare ParentTarget). Effect::ChangeZone carries no count, so — following Forage — the quantity rides the clause's MultiTargetSpec: added multi_target to ZoneCounterImperativeAst::Exile, a recognizer anchored on "cards from your graveyard" (that many -> EventContextAmount, numbers -> Fixed), and a lowering arm mirroring Tap/Untap. Tightly anchored, so the top-of-library impulse idiom guarded by parse_dynamic_count_phrase is untouched. Nefarious Lich now parses as a DealtDamage replacement (Prevention shield) whose execute exiles EventContextAmount cards from the controller's graveyard, with the lose-the-game rider gated on the exile being impossible. Full parser lib suite 7978 pass/0 fail. Fixes phase-rs#5649
There was a problem hiding this comment.
Code Review
This pull request implements support for counted graveyard exile effects and impossibility riders, specifically targeting the damage substitution clause of Nefarious Lich. It introduces parsing for graveyard exile counts, threads the count onto the clause's MultiTargetSpec during lowering, and updates the replacement parser to handle "if you can't" impossibility riders. Feedback on the changes suggests refactoring a redundant match pattern in lower_imperative_family_ast to use a direct reference binding instead of an unreachable wildcard fallback, aligning with the codebase's idiomatic Rust guidelines.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| ImperativeFamilyAst::ZoneCounter( | ||
| ast @ ZoneCounterImperativeAst::Exile { | ||
| multi_target: Some(_), | ||
| .. | ||
| }, | ||
| ) => { | ||
| let multi_target = match &ast { | ||
| ZoneCounterImperativeAst::Exile { multi_target, .. } => multi_target.clone(), | ||
| _ => None, | ||
| }; | ||
| let mut clause = parsed_clause(lower_zone_counter_ast(ast)); | ||
| clause.multi_target = multi_target; | ||
| clause | ||
| } |
There was a problem hiding this comment.
[MEDIUM] Avoid redundant matching with wildcard fallbacks when the variant is already guaranteed by the outer pattern.
Why it matters: The inner match &ast uses a wildcard _ fallback which is technically unreachable and violates the exhaustive matching principle (L5). We can bind multi_target directly in the outer pattern using a reference binding, making the code cleaner and more idiomatic Rust.
ImperativeFamilyAst::ZoneCounter(
ast @ ZoneCounterImperativeAst::Exile {
multi_target: Some(ref multi_target),
..
},
) => {
let mut clause = parsed_clause(lower_zone_counter_ast(ast));
clause.multi_target = Some(multi_target.clone());
clause
}References
- Rule L5: Wildcard
_match arms that should be exhaustive — let the compiler catch missing variants. (link)
Parse changes introduced by this PRBaseline pending for |
matthewevans
left a comment
There was a problem hiding this comment.
The fix is correct and it repairs eight more cards than you claimed — but the branch that drives those eight cards has no test, and the body understates the scope. Both are small.
I verified all nine affected cards against Scryfall before reading the diff. Nefarious Lich's Oracle text is exactly as quoted, and CR 614.1a / CR 700.4 / CR 608.2c are the right citations for a substitution replacement with an impossibility rider.
The parse-diff is wider than the body — and that is good news
Your body scopes this to Nefarious Lich. The sticky reports 9 cards. I checked every one, and each is the same defect class your recognizer fixes:
| Card | Oracle | Before | After |
|---|---|---|---|
| Aegis Sculptor | "exile two cards from your graveyard" | targets: ∅ |
2-2 ✅ |
| Bloodcurdler | "exile two cards from your graveyard" | targets: ∅ |
2-2 ✅ |
| Egon, God of Death | "exile two cards from your graveyard" | targets: ∅ |
2-2 ✅ |
| Insatiable Frugivore | "exile three cards from your graveyard" | targets: ∅ |
3-3 ✅ |
| Kroxa and Kunoros | "exile five cards from your graveyard" | targets: ∅ |
5-5 ✅ |
| Emeritus of Ideation | "exile eight cards from your graveyard" | any target |
8-8, graveyard filter ✅ |
| Ultimecia, Time Sorceress | "exile eight cards from your graveyard" | any target |
8-8, graveyard filter ✅ |
Every one of those was silently dropping its count on main, and Emeritus/Ultimecia were binding a nonsensical any target for a graveyard exile. Each count in the sticky matches the printed Oracle number exactly. This is "build for the class, not the card" working as intended — the collateral is eight additional correct repairs.
But it means the claim and the measurement disagree, which is the one thing that has to hold. Please update the body to state the real population.
🔴 Blocker — the branch that fixes those eight cards is untested
parse_exile_count_from_your_graveyard has two arms:
alt((
value(QuantityExpr::Ref { qty: EventContextAmount }, tag("that many")), // ← tested
map(parse_number, |n| QuantityExpr::Fixed { value: n as i32 }), // ← NOT tested
))Your test covers only the "that many" arm (Nefarious Lich). The Fixed arm is what fires for eight of the nine cards above, and nothing exercises it. Delete map(parse_number, …) today and nefarious_lich_damage_substitution_exiles_that_many_with_impossibility_rider still passes green.
Fix: add a parser test over the building block itself — a couple of number cases ("exile two cards from your graveyard" → Fixed { value: 2 }, "exile eight cards from your graveyard" → Fixed { value: 8 }) plus a negative asserting the impulse-draw idiom ("exile the top two cards of your library") is still untouched by the new anchor. That last one matters: your comment claims the possessive anchor keeps the recognizer clear of parse_dynamic_count_phrase's guard, and the test should hold you to it.
🟡 Non-blocking
exact(N)is the right count semantics and worth stating:MultiTargetSpec::exactmeans min == max, so a graveyard with fewer than N cards genuinely can't satisfy it — which is what makes Nefarious Lich's "if you can't, you lose the game" and Egon's "if you can't, sacrifice" fire correctly. Good.tag(" cards from your graveyard")hardcodes the plural. "exile a card from your graveyard" falls through to the generic tail, same as today, so this is not a regression — just noting the axis is half-covered if you want the singular later.rest_text.to_ascii_lowercase()allocates aStringper call inside the exile parse path.parse_exile_astalready receives alower: &str; theTextPairoffset idiom would avoid the allocation.- Rider over-acceptance. The rider check only tags the prefix
"if you can't,"but then folds the entire remainder intofollowup_text. Trailing text past the rider would ride along and have to parse. Harmless today; tighten if it bites.
✅ Clean
- Right seam.
Effect::ChangeZonegenuinely has no count slot, and riding the clause'sMultiTargetSpecfollows the existing Forage precedent instead of adding a new effect variant. That is the correct call. - The test drives
parse_oracle_text— the real card pipeline — rather thanparse_effect_chainon a freshParseContext. That distinction matters in this repo, and you got it right: a test on the standalone entry point would not have exercised whatdatabase::synthesisactually runs. It is strongly discriminating; theDealtDamagereplacement does not exist onmain. - CR citations all grep-verified, and the assertion set (
ShieldKind::Prevention,Graveyard → Exile,EventContextAmount,Not { .. }-gatedLoseTheGame) pins the whole shape rather than a single field.
Recommendation: request-changes. Add the Fixed-arm parser test (plus the impulse-draw negative), and correct the body's scope to the nine cards it actually fixes. The implementation itself I would take as-is.
Review follow-up (phase-rs#5649, matthewevans): the Fixed arm of parse_exile_count_from_your_graveyard fixes 8 of the 9 affected cards (Aegis Sculptor / Bloodcurdler / Egon / Insatiable Frugivore / Kroxa / Emeritus / Ultimecia — 'exile <N> cards from your graveyard') but was untested. Add exile_count_from_your_graveyard_binds_fixed_and_dynamic_counts: - Fixed arm: 'two/eight cards from your graveyard' -> Fixed{2}/Fixed{8} - dynamic arm: 'that many …' -> EventContextAmount - end-to-end: 'exile two cards from your graveyard' rides ChangeZone{Gy->Exile} + exact(Fixed{2}) MultiTargetSpec - negatives: the top-of-library impulse idiom stays untouched by the possessive 'your graveyard' anchor (owned by parse_dynamic_count_phrase).
|
Both addressed — thanks for verifying all nine against Scryfall and catching the untested arm. Blocker —
Body updated to the real 9-card population (the table you posted) with the "build for the class" framing — no longer scoped to Nefarious Lich alone. On the non-blocking notes, all fair:
|
matthewevans
left a comment
There was a problem hiding this comment.
Verdict: approved
Both blockers from the prior head are cleared, and I verified the fix is live rather than taking the diff's word for it.
Blocker 1 — untested Fixed arm: fixed
exile_count_from_your_graveyard_binds_fixed_and_dynamic_counts now pins both arms plus the guard:
- Fixed arm (
two→Fixed{2},eight→Fixed{8}) — the eight numeric cards. Deletingmap(parse_number, Fixed)now fails the suite; on the prior head it did not. - Dynamic arm (
that many→EventContextAmount) — Nefarious Lich. - Two negatives pinning the top-of-library impulse idiom (
the top two cards of your library,two cards from the top of your library→None). These are load-bearing: the combinator'srest.trim().is_empty()anchor is the only thing keeping the possessiveyour graveyardtail from capturing them, and nothing else in the suite would notice if it did. - End-to-end asserting the count rides
MultiTargetSpec::exact(Fixed{2})— the exact seam I flagged.
Blocker 2 — body population: fixed
The body now states the real 9-card population card-by-card. I independently confirmed all nine against Scryfall last round: every one is a genuine "exile N cards from your graveyard" text, each count matching the printed Oracle number, and every one was silently dropping its count on main. The wide diff is eight additional correct repairs, not scope creep.
Measurement
The parse-diff sticky is baseline-pending (main moved to bd2971dd mid-review), so I did not review against it. Head-authoritative numbers from the Card data job on 001fdfbf:
- REGRESSED (engine): 0 · REGRESSED (coverage honesty): 0 · ORACLE CHANGED: 0
- GAINED: 2 — Emeritus of Ideation, Ultimecia, Time Sorceress
- Diagnostic improvement: target-fallback 57 → 55
The +2 is not in tension with the 9-card claim: only those two flipped unsupported → supported. The other seven already counted as supported while emitting a ChangeZone with the count silently dropped — a misparse is invisible to the coverage counter, which is precisely why this class of defect accumulates. The regression rows are the instrument that matters here, and they are clean.
Parser mandate
Manually grepped the diff for the shapes check-parser-combinators.sh cannot see (matches! alternation, .rfind(, .split(, char-literal trim_*_matches). Clean: every matches! is on a typed enum variant in a test assertion, and .trim_end_matches('.') is punctuation stripping, which CLAUDE.md explicitly permits as a structural use. The combinator is proper nom — alt over the two count forms, tag anchor, is_empty terminator.
Nice work on the negatives in particular — they are the part most contributors skip.
Fixes #5649.
Tier: Frontier
Model: claude-opus-4-8
Summary
Nefarious Lich's defining mechanic — "If damage would be dealt to you, exile that many cards from your graveyard instead. If you can't, you lose the game." — silently dropped to an inert standalone
ChangeZoneability: the substitution never fired as a replacement, the impossibility rider was lost, and the count was unbound. This makes it parse as a properDealtDamagesubstitution replacement that exiles that many (= the damage amount) cards from the graveyard, with the lose-the-game rider gated on the exile being impossible.Scope — this is "build for the class, not the card". The graveyard-exile count fix (part 2) repairs the same defect on 9 cards total, all silently dropping their count on
main; the parse-diff sticky reports exactly these:Fixed{2}Fixed{3}Fixed{5}Fixed{8}(were binding a nonsensicalany target)EventContextAmount(Emeritus and Ultimecia were binding
any targetfor a graveyard exile; the other six dropped their count totargets: ∅. All nine now bind the correct filtered graveyard-card multi-target.)Implementation method (required)
/engine-implementerpipeline/engine-implementerRoot cause
Two independent gaps, both on the damage clause:
The recognizer bailed on the rider.
parse_damage_to_self_instead_followup(oracle_replacement.rs) matched "If damage would be dealt to you,<effect>instead" but required the remainder after "instead." to be empty. Nefarious Lich's trailing "If you can't, you lose the game." made the remainder non-empty →None→ the whole clause fell through to an inertabilities: [ChangeZone]entry.The exile dropped its count. "exile that many cards from your graveyard" lowered to
ChangeZone { target: ParentTarget }— the count and the graveyard filter were lost. The shared count-phrase guard (parse_dynamic_count_phrase) deliberately rejects "that many cards from …" to protect the top-of-library impulse idiom, and the generic exile tail carries no quantity.Fix
Accept the impossibility rider (oracle_replacement.rs). Recognize an optional "If you can't,
<consequence>" tail and fold it back onto the substituted effect, so the shared "if you can't" lowering threads it as a conditional continuation (Not { ZoneChangedThisWay }).nom_on_lowerreturns the remainder in original case, so the rider is matched via a lowercased copy. Any other trailing text still bails, preserving the recognizer's single-clause scope.Bind the graveyard-exile count via
MultiTargetSpec(CR 700.4, following Forage).Effect::ChangeZonehas no count slot, so the quantity rides the clause'sMultiTargetSpec: amulti_targetfield onZoneCounterImperativeAst::Exile, a recognizer anchored on "cards from your graveyard" (that many→EventContextAmount, number words →Fixed), and a lowering arm mirroring the Tap/Untapmulti_targetseam. The anchor never touches the impulse-draw guard, so that idiom is unaffected.Result:
replacements: 2, abilities: 0(was1 / 1inert).CR references
CR 614.1a (substitution replacement + impossibility rider), CR 700.4 (numeric counts), CR 608.2c (the "if you can't" prior-instruction condition).
Verification
nefarious_lich_damage_substitution_exiles_that_many_with_impossibility_rider— asserts theDealtDamagereplacement, theGraveyard → Exileexecute, theEventContextAmountMultiTargetSpec, and the conditionalLoseTheGamerider (the "that many" arm).exile_count_from_your_graveyard_binds_fixed_and_dynamic_counts— pins theFixedarm (the eight numeric cards:"two cards from your graveyard"→Fixed{2},"eight …"→Fixed{8}), the dynamic arm, the end-to-endChangeZone+exact(Fixed{2})MultiTargetSpec, and a negative that the top-of-library impulse idiom stays untouched by the possessive-graveyard anchor.<count>cards from your graveyard".