Skip to content
Merged
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
8 changes: 8 additions & 0 deletions crates/engine/src/parser/oracle_classifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 61 additions & 31 deletions crates/engine/src/parser/oracle_static/restriction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <condition>, ]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
Expand All @@ -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 <self>, 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
Expand All @@ -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 <self>") 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 <self>") 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
Expand All @@ -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
Expand Down
76 changes: 76 additions & 0 deletions crates/engine/src/parser/oracle_static/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading