From 38346720f7c5740fd73f9517f6c6b4993c1a89d9 Mon Sep 17 00:00:00 2001 From: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:53:32 -0400 Subject: [PATCH 1/2] fix(parser): recognize damage-substitution replacements with an impossibility rider + counted graveyard exile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nefarious Lich (#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 " instead" body, bailing on any trailing text. Accept an optional "If you can't, " 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 #5649 --- .../src/parser/oracle_effect/imperative.rs | 83 +++++++++++++ crates/engine/src/parser/oracle_effect/mod.rs | 3 + crates/engine/src/parser/oracle_ir/ast.rs | 7 ++ .../engine/src/parser/oracle_replacement.rs | 113 +++++++++++++++++- 4 files changed, 201 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index e7d83a6bb2..0f7a05446a 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -7407,6 +7407,32 @@ fn filter_targets_stack(filter: &TargetFilter) -> bool { } } +/// CR 700.4 (#5649): match "<quantity> cards from your graveyard" and return the +/// exile count. `"that many"` → the dynamic replacement amount +/// (`QuantityRef::EventContextAmount`, Nefarious Lich); a number word → `Fixed`. +/// Anchored on the possessive "your graveyard" so it never overlaps the +/// top-of-library impulse idiom guarded by `parse_dynamic_count_phrase`. +fn parse_exile_count_from_your_graveyard(lower: &str) -> Option { + let trimmed = lower.trim().trim_end_matches('.').trim(); + let (rest, qty) = alt(( + value( + QuantityExpr::Ref { + qty: crate::types::ability::QuantityRef::EventContextAmount, + }, + tag::<_, _, OracleError<'_>>("that many"), + ), + map(crate::parser::oracle_nom::primitives::parse_number, |n| { + QuantityExpr::Fixed { value: n as i32 } + }), + )) + .parse(trimmed) + .ok()?; + let (rest, _) = tag::<_, _, OracleError<'_>>(" cards from your graveyard") + .parse(rest) + .ok()?; + rest.trim().is_empty().then_some(qty) +} + pub(super) fn parse_exile_ast( text: &str, lower: &str, @@ -7527,6 +7553,7 @@ pub(super) fn parse_exile_ast( ), all: true, enter_with_counters: vec![], + multi_target: None, }); } // CR 205.2a + CR 205.3a + CR 608.2c: parse the full target as a @@ -7554,6 +7581,7 @@ pub(super) fn parse_exile_ast( target, all: true, enter_with_counters: vec![], + multi_target: None, }); } @@ -7597,6 +7625,7 @@ pub(super) fn parse_exile_ast( target, all: true, enter_with_counters: vec![], + multi_target: None, }); } @@ -7633,6 +7662,7 @@ pub(super) fn parse_exile_ast( target, all: false, enter_with_counters, + multi_target: None, }); } } @@ -7664,10 +7694,33 @@ pub(super) fn parse_exile_ast( }, all: false, enter_with_counters: vec![], + multi_target: None, }); } } + // CR 700.4 (#5649): "exile cards from your graveyard" is a COUNTED + // graveyard exile — Nefarious Lich's damage substitution ("exile that many + // cards from your graveyard instead"). `Effect::ChangeZone` has no count + // slot, so capture the quantity here and thread it onto the clause's + // `MultiTargetSpec` at lowering (Forage precedent). The generic tail below + // would otherwise drop the count and bind a bare `ParentTarget`. + if let Some(count) = parse_exile_count_from_your_graveyard(&rest_text.to_ascii_lowercase()) { + let mut filter = crate::types::ability::TypedFilter::default() + .controller(ControllerRef::You) + .properties(vec![crate::types::ability::FilterProp::InZone { + zone: crate::types::zones::Zone::Graveyard, + }]); + filter.type_filters = vec![crate::types::ability::TypeFilter::Card]; + return Some(ZoneCounterImperativeAst::Exile { + origin: Some(crate::types::zones::Zone::Graveyard), + target: TargetFilter::Typed(filter), + all: false, + enter_with_counters: vec![], + multi_target: Some(crate::types::ability::MultiTargetSpec::exact(count)), + }); + } + // CR 608.2k: thread `ctx` through so bare "it"/"them" anaphors in trigger // bodies ("Whenever an Elf you control dies, exile it") bind to the // triggering subject via `resolve_pronoun_target`, not the ability source. @@ -7723,6 +7776,7 @@ pub(super) fn parse_exile_ast( target, all: false, enter_with_counters, + multi_target: None, }) } @@ -11035,6 +11089,26 @@ pub(super) fn lower_imperative_family_ast(ast: ImperativeFamilyAst) -> ParsedEff ))); clause } + // CR 700.4 (#5649): a COUNTED graveyard exile ("exile that many cards + // from your graveyard", Nefarious Lich) captured a `multi_target`; the + // bare-Effect lowering below produces a `ChangeZone` that cannot carry + // the count, so thread it onto the clause here (mirroring the Tap/Untap + // arm). The runtime resolves the card selection via the shared + // multi-target picker (Forage precedent). + 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 + } // All other arms produce a bare Effect with no sub_ability chain. other => parsed_clause(lower_imperative_family_effect(other)), } @@ -11611,6 +11685,10 @@ pub(super) fn lower_zone_counter_ast(ast: ZoneCounterImperativeAst) -> Effect { target, all, enter_with_counters, + // The count (for "exile N cards from your graveyard") rides the + // clause's `MultiTargetSpec`, threaded by `lower_imperative_family_ast` + // (#5649); the bare-Effect lowering here ignores it. + multi_target: _, } => { if all { // `ChangeZoneAll` has no counter slot; mass exile never carries @@ -13396,6 +13474,7 @@ mod tests { target: TargetFilter::Typed(filter), all: false, enter_with_counters, + multi_target: _, }) = result else { panic!("{input}: expected hand-origin typed exile, got {result:?}"); @@ -13425,6 +13504,7 @@ mod tests { target: TargetFilter::Or { filters }, all: true, enter_with_counters, + multi_target: _, }) = result else { panic!("{input}: expected mass exile type union, got {result:?}"); @@ -13449,6 +13529,7 @@ mod tests { target: TargetFilter::Typed(filter), all: true, enter_with_counters, + multi_target: _, }) = result else { panic!("{input}: expected mass exile parity filter, got {result:?}"); @@ -13474,6 +13555,7 @@ mod tests { target: TargetFilter::Typed(filter), all: false, enter_with_counters: _, + multi_target: _, }) = result else { panic!("{input}: expected hand-origin typed exile, got {result:?}"); @@ -13502,6 +13584,7 @@ mod tests { target: TargetFilter::Typed(filter), all: false, enter_with_counters, + multi_target: _, }) = result else { panic!("{input}: expected hand-origin typed exile, got {result:?}"); diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 1a4361fdd3..1a7d15975e 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -13893,6 +13893,7 @@ fn try_parse_verb_and_target<'a>( ), all: true, enter_with_counters: vec![], + multi_target: None, }, )), // The matcher only claims a fully-consumed clause (trailing text @@ -13914,6 +13915,7 @@ fn try_parse_verb_and_target<'a>( target, all: true, enter_with_counters: vec![], + multi_target: None, })), rem, )); @@ -13935,6 +13937,7 @@ fn try_parse_verb_and_target<'a>( target, all: false, enter_with_counters: vec![], + multi_target: None, })), rem, )); diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index afe7380a9c..11ce1c8853 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -1488,6 +1488,13 @@ pub(crate) enum ZoneCounterImperativeAst { /// ("exile a card … with N counters on it"). Empty for the /// common no-counter case. Mirrors `Effect::ChangeZone.enter_with_counters`. enter_with_counters: Vec<(CounterType, QuantityExpr)>, + /// CR 700.4 (#5649): a counted graveyard exile — "exile cards from + /// your graveyard" (Nefarious Lich: "exile that many cards … instead"). + /// `Effect::ChangeZone` carries no count, so the quantity rides the + /// clause's `MultiTargetSpec` (mirroring Forage), threaded at lowering by + /// `lower_imperative_family_ast`. `None` for the ordinary single-object + /// exile. + multi_target: Option, }, ExileTop { player: TargetFilter, diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 0168c98ec7..3e56d23691 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -8365,17 +8365,40 @@ fn parse_damage_to_self_instead_followup( let (i, _) = opt(char('.')).parse(i)?; Ok((i, (effect_start, effect.len()))) })?; - if !rest.trim().is_empty() { - return None; - } - let effect_text = normalized.get(effect_start..effect_start + effect_len)?; + + // CR 614.1a impossibility rider: "... instead. If you can't, + // ." (Nefarious Lich — "exile that many cards from your + // graveyard instead. If you can't, you lose the game."). `nom_on_lower` + // returns `rest` in the original (mixed-case) text, so match it via a + // lowercased copy. Fold the rider back onto the substituted effect so the + // shared "if you can't" lowering (→ `Not { ZoneChangedThisWay }`) threads it + // as a conditional continuation of the substituted effect, rather than + // bailing on the non-empty remainder. Any OTHER trailing text still bails, + // preserving the recognizer's single-clause scope. + let rider = rest.trim(); + let followup_text = if rider.is_empty() { + effect_text.to_string() + } else { + let rider_lower = rider.to_lowercase(); + if alt(( + tag::<_, _, OracleError<'_>>("if you can't,"), + tag("if you cannot,"), + )) + .parse(rider_lower.as_str()) + .is_err() + { + return None; + } + format!("{effect_text}. {rider}") + }; + let mut ctx = ParseContext { subject: Some(TargetFilter::SelfRef), in_replacement: true, ..ParseContext::default() }; - let followup = parse_effect_chain_with_context(effect_text, AbilityKind::Spell, &mut ctx); + let followup = parse_effect_chain_with_context(&followup_text, AbilityKind::Spell, &mut ctx); Some( ReplacementDefinition::new(ReplacementEvent::DealtDamage) @@ -9676,6 +9699,86 @@ mod tests { use crate::types::card_type::{CoreType, Supertype}; use crate::types::keywords::Keyword; + /// #5649: Nefarious Lich's damage clause is a CR 614.1a substitution + /// replacement — "If damage would be dealt to you, exile that many cards from + /// your graveyard instead. If you can't, you lose the game." Previously it + /// dropped to an inert standalone ability with the impossibility rider lost + /// (the recognizer bailed on the non-empty remainder). It must now parse as a + /// `DealtDamage` replacement whose execute exiles `EventContextAmount` cards + /// from the controller's graveyard — the count riding the clause's + /// `MultiTargetSpec` (CR 700.4, Forage precedent) — with the "if you can't" + /// rider threaded as a conditional `LoseTheGame`. + #[test] + fn nefarious_lich_damage_substitution_exiles_that_many_with_impossibility_rider() { + let parsed = parse_oracle_text( + "If damage would be dealt to you, exile that many cards from your graveyard instead. \ + If you can't, you lose the game.", + "Nefarious Lich", + &[], + &["Enchantment".to_string()], + &[], + ); + let rep = parsed + .replacements + .iter() + .find(|r| matches!(r.event, ReplacementEvent::DealtDamage)) + .expect("damage-substitution replacement must exist (was inert on main)"); + assert!( + matches!(rep.shield_kind, ShieldKind::Prevention { .. }), + "the substituted damage must be prevented, got {:?}", + rep.shield_kind + ); + let execute = rep + .execute + .as_ref() + .expect("replacement must carry an execute"); + assert!( + matches!( + &*execute.effect, + Effect::ChangeZone { + origin: Some(Zone::Graveyard), + destination: Zone::Exile, + .. + } + ), + "execute must exile from the graveyard, got {:?}", + execute.effect + ); + // CR 700.4: the "that many" count rides the clause `MultiTargetSpec`. + let mt = execute + .multi_target + .as_ref() + .expect("counted graveyard exile must carry a MultiTargetSpec"); + assert!( + matches!( + &mt.min, + QuantityExpr::Ref { + qty: QuantityRef::EventContextAmount + } + ), + "exile count must be the dynamic damage amount, got {:?}", + mt.min + ); + // CR 614.1a: "if you can't, you lose the game" → conditional `LoseTheGame`. + let rider = execute + .sub_ability + .as_ref() + .expect("impossibility rider must be threaded onto the exile"); + assert!( + matches!(&*rider.effect, Effect::LoseTheGame { .. }), + "rider effect must be LoseTheGame, got {:?}", + rider.effect + ); + assert!( + matches!( + rider.condition, + Some(crate::types::ability::AbilityCondition::Not { .. }) + ), + "rider must be gated on the impossibility condition, got {:?}", + rider.condition + ); + } + /// CR 614.1c + CR 614.12 + CR 700.6 + CR 205.1b: "As a [historic permanent /// you control] enters, it becomes a 7/7 Dinosaur creature in addition to its /// other types" (Displaced Dinosaurs) lowers to a single Mandatory `Moved`/ From 001fdfbf05f9af9083ba14481fc85fe4a0f03eae Mon Sep 17 00:00:00 2001 From: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:41:46 -0400 Subject: [PATCH 2/2] test(parser): pin the Fixed-count arm of the graveyard-exile recognizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up (#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 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). --- .../src/parser/oracle_effect/imperative.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index 0f7a05446a..0146b1031c 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -19580,4 +19580,64 @@ mod tests { other => panic!("expected AssembleContraptions, got {other:?}"), } } + + /// #5649 review (matthewevans): the `Fixed`-count 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 cards from your graveyard"), while the + /// "that many" arm covers Nefarious Lich. Pin both arms AND the negative that + /// the possessive "your graveyard" anchor keeps the top-of-library impulse + /// idiom (owned by `parse_dynamic_count_phrase`) untouched. + #[test] + fn exile_count_from_your_graveyard_binds_fixed_and_dynamic_counts() { + // Fixed-count arm — the eight numeric cards. + assert_eq!( + parse_exile_count_from_your_graveyard("two cards from your graveyard"), + Some(QuantityExpr::Fixed { value: 2 }) + ); + assert_eq!( + parse_exile_count_from_your_graveyard("eight cards from your graveyard"), + Some(QuantityExpr::Fixed { value: 8 }) + ); + // Dynamic arm — Nefarious Lich. + assert_eq!( + parse_exile_count_from_your_graveyard("that many cards from your graveyard"), + Some(QuantityExpr::Ref { + qty: QuantityRef::EventContextAmount + }) + ); + // Negatives: the possessive "your graveyard" anchor must never capture the + // top-of-library impulse idiom. + assert_eq!( + parse_exile_count_from_your_graveyard("the top two cards of your library"), + None + ); + assert_eq!( + parse_exile_count_from_your_graveyard("two cards from the top of your library"), + None + ); + + // End-to-end: a fixed graveyard exile rides the clause's `MultiTargetSpec`. + let def = crate::parser::oracle_effect::parse_effect_chain( + "exile two cards from your graveyard", + AbilityKind::Spell, + ); + assert!( + matches!( + &*def.effect, + Effect::ChangeZone { + origin: Some(crate::types::zones::Zone::Graveyard), + destination: crate::types::zones::Zone::Exile, + .. + } + ), + "expected a graveyard->exile ChangeZone, got {:?}", + def.effect + ); + assert_eq!( + def.multi_target, + Some(MultiTargetSpec::exact(QuantityExpr::Fixed { value: 2 })), + "the count must ride the clause MultiTargetSpec as exact(Fixed {{ 2 }})" + ); + } }