diff --git a/crates/engine/src/parser/oracle_classifier.rs b/crates/engine/src/parser/oracle_classifier.rs index aaf3107ec3..a78bf69951 100644 --- a/crates/engine/src/parser/oracle_classifier.rs +++ b/crates/engine/src/parser/oracle_classifier.rs @@ -1,8 +1,8 @@ use crate::parser::oracle_nom::error::OracleError; use nom::branch::alt; use nom::bytes::complete::tag; -use nom::combinator::verify; -use nom::sequence::terminated; +use nom::combinator::{opt, verify}; +use nom::sequence::{preceded, terminated}; use nom::Parser; use super::oracle_nom::primitives as nom_primitives; @@ -464,10 +464,21 @@ fn is_static_compound_pattern(lower: &str) -> bool { { return false; } - if alt(( - tag::<_, _, OracleError<'_>>("you may play"), - tag("you may cast"), - )) + // CR 604.2 + CR 601.2a: head-anchor the "you may play"/"you may cast" + // permission lead, allowing an optional leading once-per-turn frequency + // phrase ("Once during each of your turns, " / "Once each turn, ") to be + // stripped first. This classifies the disjunctive once-per-turn play/cast- + // from-zone permission (The Eighth Doctor, Serra Paragon) as static so it + // routes ahead of the Priority 8 "would" replacement fallback — the granted + // rider's "would leave the battlefield" text would otherwise misclassify the + // whole line as a replacement. Class-level anchor, not a per-card branch. + if preceded( + opt(alt(( + tag::<_, _, OracleError<'_>>("once during each of your turns, "), + tag("once each turn, "), + ))), + alt((tag("you may play"), tag("you may cast"))), + ) .parse(lower) .is_ok() && (scan_contains(lower, "from your graveyard") diff --git a/crates/engine/src/parser/oracle_static/restriction.rs b/crates/engine/src/parser/oracle_static/restriction.rs index c8a398e7c6..b8c4864a32 100644 --- a/crates/engine/src/parser/oracle_static/restriction.rs +++ b/crates/engine/src/parser/oracle_static/restriction.rs @@ -1448,6 +1448,26 @@ pub(crate) fn try_parse_graveyard_cast_permission( ); } + // CR 305.1 + CR 601.2a + CR 700.6: Disjunctive once-per-turn permission — + // "Once during each of your turns, you may play a or cast a + // from your graveyard." (The Eighth Doctor, Serra Paragon). + // `Play` mode covers both the land-play branch (CR 305.1) and the + // spell-cast branch (CR 601.2a); the two branch filters are merged so the + // permission's `affected` admits either class of card. Parsed before the + // single-verb dispatch below because the disjunctive lead shares the + // "once during each of your turns, you may" prefix with the cast-only form. + // + // The granted leave-battlefield rider ("If you do, it gains \"…exile…\"") + // that may follow is a CR 614.1a Moved replacement on the *resolved* + // permanent (origin Battlefield → Exile), NOT the stack-exit + // `graveyard_destination_replacement` (which is structurally unreachable for + // permanent spells). Attaching it requires a resolution-grant carrier on the + // permission — deferred per the plan's STOP gate — so it is left `None` here + // and the trailing rider text is tolerated rather than modeled. + if let Some(def) = try_parse_disjunctive_graveyard_cast_permission(text, lower) { + return Some(def); + } + // Determine pattern and extract the rest after the prefix let (rest, frequency, play_mode) = if let Some(r) = nom_tag_lower( lower, @@ -1518,6 +1538,125 @@ pub(crate) fn try_parse_graveyard_cast_permission( Some(def) } +/// CR 305.1 + CR 601.2a + CR 700.6: Parse the disjunctive once-per-turn +/// graveyard play/cast permission — "Once during each of your turns, you may +/// play a or cast a from your graveyard." — into a +/// single `GraveyardCastPermission { frequency: OncePerTurn, play_mode: Play }` +/// whose `affected` filter is the union of the two branch filters. +/// +/// Two zone-placement variants are accepted (both observed in printed cards): +/// - tail-zone: "play a or cast a from your graveyard" +/// (The Eighth Doctor — "from your graveyard" once, at the end). +/// - per-branch-zone: "play a from your graveyard or cast a from +/// your graveyard" (Serra Paragon — "from your graveyard" on each branch). +/// +/// The parser parses whatever filter each branch carries (it does NOT assume +/// "historic"): Serra Paragon's spell branch carries "mana value 3 or less", +/// proving the filter axis is general. When both branches resolve to the same +/// filter (The Eighth Doctor: both "historic permanent"), the union collapses +/// to that single filter; otherwise it emits `TargetFilter::Or`. +/// +/// Any trailing rider ("If you do, it gains \"…\"") is tolerated and ignored — +/// the granted leave-battlefield exile rider is a CR 614.1a Moved replacement on +/// the resolved permanent that requires resolution-grant plumbing (deferred). +fn try_parse_disjunctive_graveyard_cast_permission( + text: &str, + lower: &str, +) -> Option { + // CR 601.2a: Frequency prefix. Only the once-per-turn lead is a real printed + // shape for this disjunctive form today; accept both the canonical wording + // and the shorter "once each turn" synonym via the file-wide `or_else` chain. + let rest = nom_tag_lower( + lower, + lower, + "once during each of your turns, you may play ", + ) + .or_else(|| nom_tag_lower(lower, lower, "once each turn, you may play "))?; + + // CR 305.1 + CR 601.2a: The disjunction connector " or cast " splits the + // land-play branch from the spell-cast branch. `split_once_on` is the + // structural "everything up to delimiter" combinator. + let (land_branch, spell_branch) = nom_primitives::split_once_on(rest, " or cast ") + .ok() + .map(|(_, pair)| pair)?; + + // The spell branch must end with the source-zone anchor. Strip a per-branch + // " from your graveyard" if present (Serra form); otherwise the tail-zone + // anchor (Eighth form) lives on the spell branch alone. + let spell_branch = strip_graveyard_zone_anchor(spell_branch)?; + + // The land branch optionally carries its own zone anchor (Serra form); strip + // it when present so the bare filter phrase reaches the filter parser. + let land_branch = strip_graveyard_zone_anchor(land_branch).unwrap_or(land_branch); + + let land_filter = parse_graveyard_branch_filter(land_branch)?; + let spell_filter = parse_graveyard_branch_filter(spell_branch)?; + + // CR 700.6: a land is itself a permanent, so when both branches resolve to + // the same typed filter (historic land ⊆ historic permanent), collapse the + // union to that single filter rather than emitting a redundant `Or`. + let affected = if land_filter == spell_filter { + land_filter + } else { + TargetFilter::Or { + filters: vec![land_filter, spell_filter], + } + }; + + Some( + StaticDefinition::new(StaticMode::GraveyardCastPermission { + frequency: CastFrequency::OncePerTurn, + // CR 305.1: `Play` covers both the land-play and spell-cast branches. + play_mode: CardPlayMode::Play, + // Stack-exit redirect is wrong for the granted leave-battlefield + // rider (see doc comment); leave it unset. + graveyard_destination_replacement: None, + }) + .affected(affected) + .description(text.to_string()), + ) +} + +/// Strip the trailing " from your graveyard" source-zone anchor (plus any +/// leading whitespace) from a branch phrase, returning the bare filter text. +/// Returns `None` when the anchor is absent. +fn strip_graveyard_zone_anchor(branch: &str) -> Option<&str> { + nom_primitives::split_once_on(branch, " from your graveyard") + .ok() + .map(|(_, (before, _))| before.trim()) +} + +/// Strip the leading article and trailing " spell"/" spells" from a single +/// disjunctive-permission branch, then resolve it through +/// `parse_graveyard_permission_filter`. Mirrors the cleanup the single-verb +/// graveyard parser performs, so both paths share one filter grammar. Returns +/// `None` if the branch does not resolve to a usable typed filter. +fn parse_graveyard_branch_filter(branch: &str) -> Option { + let branch = branch.trim(); + // Strip the leading article ("a "/"an "). + let branch = nom_tag_lower(branch, branch, "a ") + .or_else(|| nom_tag_lower(branch, branch, "an ")) + .unwrap_or(branch); + + // Drop " spell"/" spells" so `parse_type_phrase` sees the bare type word; + // "land"/"lands" is already a valid type phrase and needs no stripping. + let cleaned: Cow = if nom_primitives::scan_contains(branch, "spells") { + Cow::Owned(branch.replacen(" spells", "", 1)) + } else if nom_primitives::scan_contains(branch, "spell") { + Cow::Owned(branch.replacen(" spell", "", 1)) + } else { + Cow::Borrowed(branch) + }; + + let (filter, _self_ref) = parse_graveyard_permission_filter(&cleaned); + // Reject the unparseable fallbacks so a branch we cannot model declines the + // whole disjunctive parse rather than silently admitting everything. + match &filter { + TargetFilter::Typed(tf) if !tf.type_filters.is_empty() => Some(filter), + _ => None, + } +} + /// CR 122.2 + CR 113.6b: Parse the counter-persistence static — "Counters /// remain on ~ as it moves to any zone other than [zone list]." Overrides the /// default CR 122.2 rule that counters cease to exist on a zone change, except diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 00363ce798..bb89280cc7 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -7540,6 +7540,144 @@ fn graveyard_cast_permission_muldrotha_legacy_and() { )); } +/// CR 305.1 + CR 601.2a + CR 700.6: The Eighth Doctor's disjunctive permission +/// — "Once during each of your turns, you may play a historic land or cast a +/// historic permanent spell from your graveyard." — lowers to a single +/// `GraveyardCastPermission { frequency: OncePerTurn, play_mode: Play, +/// graveyard_destination_replacement: None }`. The two branches resolve to +/// distinct typed filters (historic land vs. historic permanent), so the merged +/// `affected` is a `TargetFilter::Or` over both — each branch carries the +/// `Historic` property (CR 700.6). The trailing granted leave-battlefield rider +/// is tolerated and does not block the permission. +#[test] +fn graveyard_cast_permission_disjunctive_eighth_doctor_tail_zone() { + let text = "Once during each of your turns, you may play a historic land or cast a historic permanent spell from your graveyard. If you do, it gains \"If ~ would leave the battlefield, exile it instead of putting it anywhere else.\""; + let def = parse_static_line(text).expect("should parse The Eighth Doctor disjunctive line"); + assert!( + matches!( + def.mode, + StaticMode::GraveyardCastPermission { + frequency: CastFrequency::OncePerTurn, + play_mode: CardPlayMode::Play, + graveyard_destination_replacement: None, + } + ), + "expected OncePerTurn + Play + no stack-exit redirect, got {:?}", + def.mode + ); + let filter = def.affected.expect("should have affected filter"); + let TargetFilter::Or { filters } = filter else { + panic!("expected Or over the historic land / historic permanent branches, got {filter:?}"); + }; + assert_eq!( + filters.len(), + 2, + "expected two branch filters, got {filters:?}" + ); + // Land branch: historic land. + assert!( + matches!( + &filters[0], + TargetFilter::Typed(tf) + if tf.type_filters.contains(&TypeFilter::Land) + && tf.properties.contains(&FilterProp::Historic) + ), + "expected first branch to be a historic Land filter, got {:?}", + filters[0] + ); + // Spell branch: historic permanent. + assert!( + matches!( + &filters[1], + TargetFilter::Typed(tf) + if tf.type_filters.contains(&TypeFilter::Permanent) + && tf.properties.contains(&FilterProp::Historic) + ), + "expected second branch to be a historic Permanent filter, got {:?}", + filters[1] + ); +} + +/// CR 305.1 + CR 601.2a: Serra Paragon's per-branch-zone form — "play a land +/// from your graveyard or cast a permanent spell with mana value 3 or less from +/// your graveyard" — proves the disjunctive parser's filter axis is general: the +/// two branches differ (bare land vs. permanent with a mana-value bound), so the +/// merged `affected` is a `TargetFilter::Or` over both branch filters, NOT a +/// hard-coded "historic" assumption. This tests the building block (any two +/// graveyard branch filters), not the Doctor string. +#[test] +fn graveyard_cast_permission_disjunctive_serra_paragon_per_branch_zone() { + let text = "Once during each of your turns, you may play a land from your graveyard or cast a permanent spell with mana value 3 or less from your graveyard."; + let def = parse_static_line(text).expect("should parse Serra Paragon disjunctive line"); + assert!( + matches!( + def.mode, + StaticMode::GraveyardCastPermission { + frequency: CastFrequency::OncePerTurn, + play_mode: CardPlayMode::Play, + graveyard_destination_replacement: None, + } + ), + "expected OncePerTurn + Play, got {:?}", + def.mode + ); + let filter = def.affected.expect("should have affected filter"); + let TargetFilter::Or { filters } = filter else { + panic!("expected divergent branches to produce Or, got {filter:?}"); + }; + assert_eq!( + filters.len(), + 2, + "expected two branch filters, got {filters:?}" + ); + // Land branch: bare Land filter (no properties). + assert!( + matches!( + &filters[0], + TargetFilter::Typed(tf) + if tf.type_filters.contains(&TypeFilter::Land) && tf.properties.is_empty() + ), + "expected first branch to be a bare Land filter, got {:?}", + filters[0] + ); + // Spell branch: Permanent with a mana-value (Cmc) bound. + let TargetFilter::Typed(spell_tf) = &filters[1] else { + panic!("expected second branch Typed, got {:?}", filters[1]); + }; + assert!( + spell_tf.type_filters.contains(&TypeFilter::Permanent), + "expected Permanent type filter on spell branch, got {:?}", + spell_tf.type_filters + ); + assert!( + spell_tf.properties.iter().any(|p| matches!( + p, + FilterProp::Cmc { + comparator: Comparator::LE, + .. + } + )), + "expected CmcLE bound on spell branch, got {:?}", + spell_tf.properties + ); +} + +/// CR 604.2 + CR 614.1a: the disjunctive once-per-turn permission line carries +/// the granted rider's "would leave the battlefield" text, which would otherwise +/// classify the whole line as a replacement (`would ` is the first replacement +/// contains-pattern). The frequency-prefixed permission anchor must make +/// `is_static_pattern` win so the line routes to static dispatch (Priority 7) +/// ahead of the Priority 8 replacement gate. Guards the dispatch ordering for +/// the whole once-per-turn play/cast-from-zone permission class. +#[test] +fn disjunctive_graveyard_permission_classifies_static_not_replacement() { + let lower = "once during each of your turns, you may play a historic land or cast a historic permanent spell from your graveyard. if you do, it gains \"if ~ would leave the battlefield, exile it instead of putting it anywhere else.\""; + assert!( + crate::parser::oracle_classifier::is_static_pattern(lower), + "disjunctive once-per-turn permission must classify as static" + ); +} + // --- Alt-cost rider tests (Ninja Teen et al., CR 118.9 / CR 702.190a) --- #[test]