Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions crates/engine/src/parser/oracle_classifier.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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")
Expand Down
139 changes: 139 additions & 0 deletions crates/engine/src/parser/oracle_static/restriction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <land-filter> or cast a
// <spell-filter> 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,
Expand Down Expand Up @@ -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 <land-filter> or cast a <spell-filter> 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 <land> or cast a <spell> from your graveyard"
/// (The Eighth Doctor — "from your graveyard" once, at the end).
/// - per-branch-zone: "play a <land> from your graveyard or cast a <spell> 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<StaticDefinition> {
// 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<TargetFilter> {
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<str> = 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
Expand Down
138 changes: 138 additions & 0 deletions crates/engine/src/parser/oracle_static/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading