From 79208ae1847d05ebcd2bb92847c50a359bf26969 Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:23:05 +0900 Subject: [PATCH] fix(parser): parse Evendo exile play permission --- crates/engine/src/parser/oracle_classifier.rs | 8 ++ .../src/parser/oracle_static/restriction.rs | 92 ++++++++++++------- .../engine/src/parser/oracle_static/tests.rs | 76 +++++++++++++++ 3 files changed, 145 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/parser/oracle_classifier.rs b/crates/engine/src/parser/oracle_classifier.rs index aaf3107ec3..5464e13ea1 100644 --- a/crates/engine/src/parser/oracle_classifier.rs +++ b/crates/engine/src/parser/oracle_classifier.rs @@ -500,6 +500,14 @@ fn is_static_compound_pattern(lower: &str) -> bool { ) { return true; } + // CR 117.1c + CR 113.6b: Evendo-class compact persistent exile-play + // permission. Like the Matrix form above, this may be preceded by timing + // and condition qualifiers. + if scan_contains(lower, "you may play cards exiled with") + || scan_contains(lower, "you may play the cards exiled with") + { + return true; + } // CR 601.3f + CR 406.6: The "look-at" variant leads with "you may look at // cards exiled with ~, and you may play lands and cast spells from among // those cards." — the play/cast clause uses "those cards" (a back-reference diff --git a/crates/engine/src/parser/oracle_static/restriction.rs b/crates/engine/src/parser/oracle_static/restriction.rs index c8a398e7c6..81f2a7de6b 100644 --- a/crates/engine/src/parser/oracle_static/restriction.rs +++ b/crates/engine/src/parser/oracle_static/restriction.rs @@ -1701,11 +1701,12 @@ pub(crate) fn try_parse_exile_cast_permission(text: &str, lower: &str) -> Option } /// CR 113.6b + CR 305.1 + CR 406.6 + CR 117.1c: Parse the persistent, -/// name-anchored exile-play permission — "[During your turn, ]you may play -/// lands and cast spells from among cards exiled with ~[.]" (The Matrix of -/// Time) and the "you may look at cards exiled with ~, and you may play lands -/// and cast spells from among those cards." variant (the Prosper/Tibalt -/// impulse-commander class). +/// name-anchored exile-play permission — "[During your turn, ][as long as +/// , ]you may play lands and cast spells from among cards exiled +/// with ~[.]" (The Matrix of Time), the compact "you may play cards exiled +/// with ~" wording (Evendo Brushrazer), and the "you may look at cards exiled +/// with ~, and you may play lands and cast spells from among those cards." +/// variant (the Prosper/Tibalt impulse-commander class). /// /// Distinguished from `try_parse_exile_cast_permission` (Maralen) by: /// - **No "this turn" pool bound** → `pool: ExileCardPool::Persistent` reads the @@ -1731,6 +1732,11 @@ pub(crate) fn try_parse_persistent_exile_play_permission( None => (lower, ExileCastTiming::AnyTime), }; + let (rest, condition) = match strip_leading_permission_condition(rest) { + Some((rest, condition)) => (rest, Some(condition)), + None => (rest, None), + }; + // Optional "you may look at cards exiled with , and " preamble // (CR 601.3f). When present, the play clause uses the "those cards" anaphor // rather than re-naming the source; when absent, the play clause names the @@ -1739,16 +1745,25 @@ pub(crate) fn try_parse_persistent_exile_play_permission( let uses_anaphor = after_look.is_some(); let rest = after_look.unwrap_or(rest); - // Core permission phrase. CR 305.1: "play lands and cast spells" → Play mode. - let rest = nom_tag_lower(rest, rest, "you may play lands and cast spells from among ")?; - - // The play clause either names the source ("cards exiled with ") or - // refers back to the look-at preamble's set ("those cards"). - let after_clause = if uses_anaphor { - nom_tag_lower(rest, rest, "those cards")? + // Core permission phrase. CR 305.1: both "play lands and cast spells" and + // "play cards" lower to Play mode. + let after_clause = if let Some(rest) = + nom_tag_lower(rest, rest, "you may play lands and cast spells from among ") + { + // The play clause either names the source ("cards exiled with ") or + // refers back to the look-at preamble's set ("those cards"). + if uses_anaphor { + nom_tag_lower(rest, rest, "those cards")? + } else { + strip_exile_play_source_reference(rest)? + } } else { - let after_anchor = nom_tag_lower(rest, rest, "cards exiled with ")?; - strip_self_reference(after_anchor)? + let rest = nom_tag_lower(rest, rest, "you may play ")?; + if uses_anaphor { + nom_tag_lower(rest, rest, "those cards")? + } else { + strip_exile_play_source_reference(rest)? + } }; // CR 113.6b: A trailing period is the only permitted remainder; any other @@ -1761,23 +1776,38 @@ pub(crate) fn try_parse_persistent_exile_play_permission( return None; } - Some( - StaticDefinition::new(StaticMode::ExileCastPermission { - // CR 601.2a: No once-per-turn cap on this class. - frequency: CastFrequency::Unlimited, - // CR 305.1: Play covers lands (played) and non-land cards (cast). - play_mode: CardPlayMode::Play, - // CR 305.1 / CR 601.3: Cards are played/cast at their normal cost. - cost: ExileCastCost::PayNormalCost, - // CR 406.6: Lifetime per-source exile-link pool. - pool: ExileCardPool::Persistent, - timing, - }) - // CR 305.1: The permission applies to every card in the source's exile - // pool; the pool itself is the scope, so no type/MV constraint. - .affected(TargetFilter::Any) - .description(text.to_string()), - ) + let mut definition = StaticDefinition::new(StaticMode::ExileCastPermission { + // CR 601.2a: No once-per-turn cap on this class. + frequency: CastFrequency::Unlimited, + // CR 305.1: Play covers lands (played) and non-land cards (cast). + play_mode: CardPlayMode::Play, + // CR 305.1 / CR 601.3: Cards are played/cast at their normal cost. + cost: ExileCastCost::PayNormalCost, + // CR 406.6: Lifetime per-source exile-link pool. + pool: ExileCardPool::Persistent, + timing, + }) + // CR 305.1: The permission applies to every card in the source's exile + // pool; the pool itself is the scope, so no type/MV constraint. + .affected(TargetFilter::Any) + .description(text.to_string()); + if let Some(condition) = condition { + definition = definition.condition(condition); + } + + Some(definition) +} + +fn strip_leading_permission_condition(input: &str) -> Option<(&str, StaticCondition)> { + let (rest, condition) = nom_condition::parse_condition(input).ok()?; + let (rest, _) = tag::<_, _, OracleError<'_>>(", ").parse(rest).ok()?; + Some((rest, condition)) +} + +fn strip_exile_play_source_reference(rest: &str) -> Option<&str> { + let after_anchor = nom_tag_lower(rest, rest, "cards exiled with ") + .or_else(|| nom_tag_lower(rest, rest, "the cards exiled with "))?; + strip_self_reference(after_anchor) } /// CR 601.3f + CR 113.6b: Strip the "you may look at cards exiled with diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index b846f1622d..d2196a18d1 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -7837,6 +7837,82 @@ fn persistent_exile_play_permission_matrix_form() { ); } +/// Issue #717 — Evendo Brushrazer's condition-gated persistent exile-play +/// permission uses the compact "you may play cards exiled with ~" wording, +/// rather than the Matrix-style "play lands and cast spells from among ..." +/// wording. It must still lower to the same persistent Play permission, with +/// the sacrificed-permanent condition attached to the static. +#[test] +fn persistent_exile_play_permission_evendo_sacrificed_permanent_gate() { + let text = "During your turn, as long as you've sacrificed a nontoken permanent this turn, you may play cards exiled with ~."; + let def = parse_static_line(text).expect("Evendo static must parse"); + assert_eq!( + def.mode, + StaticMode::ExileCastPermission { + frequency: CastFrequency::Unlimited, + play_mode: CardPlayMode::Play, + cost: ExileCastCost::PayNormalCost, + pool: ExileCardPool::Persistent, + timing: ExileCastTiming::YourTurnOnly, + }, + "expected persistent your-turn Play permission, got {:?}", + def.mode + ); + assert_eq!( + def.affected, + Some(TargetFilter::Any), + "the persistent pool is the scope; affected must be Any" + ); + + let condition = def + .condition + .as_ref() + .expect("Evendo permission must keep its sacrificed-permanent gate"); + match condition { + StaticCondition::QuantityComparison { + lhs: + QuantityExpr::Ref { + qty: + QuantityRef::SacrificedThisTurn { + player: PlayerScope::Controller, + filter: + TargetFilter::Typed(TypedFilter { + type_filters, + properties, + .. + }), + }, + }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + } => { + assert_eq!(type_filters, &vec![TypeFilter::Permanent]); + assert!( + properties.contains(&FilterProp::NonToken), + "condition filter must preserve nontoken permanent qualifier: {properties:?}" + ); + } + other => panic!("expected SacrificedThisTurn permanent gate, got {other:?}"), + } + + let card_text = "During your turn, as long as you've sacrificed a nontoken permanent this turn, you may play cards exiled with Evendo Brushrazer."; + let parsed = crate::parser::oracle::parse_oracle_text( + card_text, + "Evendo Brushrazer", + &[], + &["Creature".to_string()], + &["Brushwagg".to_string()], + ); + assert!( + parsed + .statics + .iter() + .any(|parsed_def| parsed_def.mode == def.mode && parsed_def.condition == def.condition), + "full Oracle dispatch must route Evendo's line to the same static, got {:?}", + parsed.statics + ); +} + /// CR 601.3f + CR 305.1: The "you may look at cards exiled with ~, and you may /// play lands and cast spells from among those cards." variant lowers to the /// same persistent Play permission, but without the your-turn timing gate.