From fdaf7f6d3663a126a11390b1ef37c41810cfa07f Mon Sep 17 00:00:00 2001 From: Nick M <274344962+nickmopen@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:22:55 -0500 Subject: [PATCH] fix(parser): harden trailing if-gate splitter against "as if" riders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote `split_trailing_gate_condition` (and its `split_trailing_as_long_as` building block) from `oracle_static/dispatch.rs` to `oracle_static/shared.rs`, and match the LAST trailing ` if ` while skipping `as if` riders instead of anchoring on the first ` if `. The old `take_until(" if ")` anchored on the first occurrence, so a line like "You can't play lands as if it had flash if you control a Swamp" mis-split inside `as if` — attaching the wrong condition or dropping the real trailing gate. The splitter now walks each ` if ` boundary (combinator-based) and keeps the last one whose preceding word is not "as". Adds regression coverage for an `as if` + trailing `if` line on can't-play-lands and extends the Rock Jockey dispatch test with card-name-normalized text. Closes #4876 --- .../src/parser/oracle_static/dispatch.rs | 37 +----------- .../engine/src/parser/oracle_static/shared.rs | 58 +++++++++++++++++++ .../engine/src/parser/oracle_static/tests.rs | 58 +++++++++++++++++++ 3 files changed, 117 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/parser/oracle_static/dispatch.rs b/crates/engine/src/parser/oracle_static/dispatch.rs index 0f3c07dbb5..79ddf5cb53 100644 --- a/crates/engine/src/parser/oracle_static/dispatch.rs +++ b/crates/engine/src/parser/oracle_static/dispatch.rs @@ -451,21 +451,6 @@ pub(crate) fn parse_damage_not_removed_during_cleanup( ) } -/// Split a trailing " as long as " rider off a static line, returning -/// the condition text when present (combinator form, no string-method dispatch). -fn split_trailing_as_long_as(lower: &str) -> Option<&str> { - opt(preceded( - ( - take_until::<_, _, OracleError<'_>>(" as long as "), - tag(" as long as "), - ), - rest, - )) - .parse(lower) - .ok() - .and_then(|(_, condition)| condition) -} - /// CR 509.1b: "Creatures with power can't /// block this creature." — a can't-be-blocked-by restriction whose blocker /// filter gates on a power threshold that may be DYNAMIC (Kraken of the Straits: @@ -526,26 +511,6 @@ fn filter_prop_has_power_comparison(prop: &FilterProp) -> bool { } } -/// CR 611.3a: A static restriction may carry a trailing gate introduced by -/// either `" as long as "` (continuous) or `" if "` (state -/// gate) — e.g. Rock Jockey: "You can't play lands if this creature was cast -/// this turn." Returns the condition text for `parse_static_condition`. The -/// `as long as` form is tried first so a card carrying both keywords anchors on -/// the continuous form; a bare `if` gate is the fallback. As with -/// `split_trailing_as_long_as`, an unrecognized condition downstream leaves the -/// line unsupported rather than enforcing the restriction unconditionally. -fn split_trailing_gate_condition(lower: &str) -> Option<&str> { - split_trailing_as_long_as(lower).or_else(|| { - opt(preceded( - (take_until::<_, _, OracleError<'_>>(" if "), tag(" if ")), - rest, - )) - .parse(lower) - .ok() - .and_then(|(_, condition)| condition) - }) -} - pub(crate) fn parse_static_line_inner( text: &str, inverted: InvertedAsLongAs, @@ -2834,7 +2799,7 @@ pub(crate) fn parse_static_line_inner( // gates the restriction. If the rider is present but its condition is // NOT recognized, leave the whole line unsupported (return None) rather // than marking it a CantPlayLand enforced unconditionally. - return match split_trailing_gate_condition(tp.lower) { + return match super::shared::split_trailing_gate_condition(tp.lower) { Some(condition_text) => Some(def.condition(parse_static_condition(condition_text)?)), None => Some(def), }; diff --git a/crates/engine/src/parser/oracle_static/shared.rs b/crates/engine/src/parser/oracle_static/shared.rs index 19d047b278..66d1d5a378 100644 --- a/crates/engine/src/parser/oracle_static/shared.rs +++ b/crates/engine/src/parser/oracle_static/shared.rs @@ -181,6 +181,64 @@ pub(crate) fn nom_tag_tp<'a>(tp: &TextPair<'a>, prefix: &str) -> Option"` rider off a static +/// line, returning the condition text when present (combinator form, no +/// string-method dispatch). Shared trailing-gate building block alongside +/// [`split_trailing_gate_condition`]. +pub(crate) fn split_trailing_as_long_as(lower: &str) -> Option<&str> { + opt(preceded( + ( + take_until::<_, _, OracleError<'_>>(" as long as "), + tag(" as long as "), + ), + rest, + )) + .parse(lower) + .ok() + .and_then(|(_, condition)| condition) +} + +/// CR 611.3a: A static restriction may carry a trailing gate introduced by +/// either `" as long as "` (continuous) or `" if "` (state +/// gate) — e.g. Rock Jockey: "You can't play lands if this creature was cast +/// this turn." Returns the condition text for `parse_static_condition`. +/// +/// The `as long as` form is tried first so a card carrying both keywords anchors +/// on the continuous form. For the bare `if` fallback the condition is taken +/// after the LAST trailing `" if "` that is not the tail of an `"as if"` +/// permission rider — e.g. "… cast as if it had flash if you control a Swamp" +/// gates on "you control a Swamp", not "it had flash …". Anchoring on the first +/// `" if "` (as the original inline helper did) would otherwise attach the wrong +/// condition or drop the real trailing gate. +/// +/// As with [`split_trailing_as_long_as`], an unrecognized condition downstream +/// leaves the line unsupported rather than enforcing the restriction +/// unconditionally. +pub(crate) fn split_trailing_gate_condition(lower: &str) -> Option<&str> { + if let Some(condition) = split_trailing_as_long_as(lower) { + return Some(condition); + } + + // Walk every `" if "` boundary via `take_until`/`tag`, keeping the last one + // whose preceding word is not "as" (so an "as if" rider is skipped). The + // scan advances one boundary per iteration and stays combinator-based. + let mut remaining = lower; + let mut consumed = 0usize; + let mut gate_start: Option = None; + while let Ok((after, before)) = + terminated(take_until::<_, _, OracleError<'_>>(" if "), tag(" if ")).parse(remaining) + { + let after_if = consumed + before.len() + " if ".len(); + if before.rsplit(' ').next() != Some("as") { + gate_start = Some(after_if); + } + consumed = after_if; + remaining = after; + } + + gate_start.map(|start| lower[start..].trim()) +} + /// Recognizes the first token/phrase of an effect clause that follows the /// condition-vs-effect comma in an inverted `"As long as , "` line. /// diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index f86da60c32..978e94f262 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -313,6 +313,64 @@ fn rock_jockey_cant_play_land_gated_on_source_cast_this_turn() { "no clause should be swallowed; warnings = {:?}", parsed.parse_warnings ); + + // The real card references itself by name; card-name normalization ("Rock + // Jockey" → "~") must still route through the shared trailing-gate splitter + // and produce the same gated static. + let named = crate::parser::oracle::parse_oracle_text( + "You can't play lands if Rock Jockey was cast this turn.", + "Rock Jockey", + &[], + &["Creature".to_string()], + &[], + ); + assert!( + named.statics.iter().any( + |d| matches!(&d.mode, StaticMode::Other(n) if n == "CantPlayLand") + && matches!(&d.condition, Some(StaticCondition::And { conditions }) + if conditions.contains(&StaticCondition::WasCast { zone: None }) + && conditions.contains(&StaticCondition::SourceEnteredThisTurn)) + ), + "card-name-normalized dispatch must produce the gated CantPlayLand, got {:?}", + named.statics + ); +} + +/// CR 611.3a: `split_trailing_gate_condition` anchors on the LAST ` if ` and +/// skips an `as if` rider. Asserted on the splitter building block directly — +/// the production `can't play lands` arm does not model a middle `as if` rider, +/// so driving it through dispatch would silently erase the rider and prove +/// nothing about the splitter's `as if` handling. +#[test] +fn split_trailing_gate_condition_skips_as_if_rider() { + use super::shared::split_trailing_gate_condition; + + // The real trailing gate is the LAST ` if `, not the ` if ` inside `as if`. + assert_eq!( + split_trailing_gate_condition( + "you can't play lands as if it had flash if you control a swamp" + ), + Some("you control a swamp") + ); + // A plain trailing `if` (no rider) is unchanged. + assert_eq!( + split_trailing_gate_condition("you can't play lands if this creature was cast this turn"), + Some("this creature was cast this turn") + ); + // `as long as` still takes precedence over a later bare `if`. + assert_eq!( + split_trailing_gate_condition( + "you can't play lands as long as ten or more lands are on the battlefield" + ), + Some("ten or more lands are on the battlefield") + ); + // A line whose only ` if ` sits inside `as if` has no real trailing gate. + assert_eq!( + split_trailing_gate_condition("cast this as if it had flash"), + None + ); + // No trailing gate at all. + assert_eq!(split_trailing_gate_condition("you can't play lands"), None); } /// CR 508.1 + CR 611.3a: A trailing "if a[n] is on the battlefield" gate