From bded60aca2ee8064d26a87fcfcf78d2407a8c8e0 Mon Sep 17 00:00:00 2001 From: jaso0n0818 Date: Fri, 3 Jul 2026 06:10:42 +0000 Subject: [PATCH] fix(parser): recognize "enter(s) the battlefield untapped" long-form external entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vigorous Farming ("Lands you control enter the battlefield untapped.") fell through to Unimplemented for the same reason Frozen Aether did before the previous fix: CR 614.1c external-entry replacement effects are templated by WotC in both a short form ("enter untapped") and the fully-spelled long form ("enter the battlefield untapped"), and only the short form was recognized. Mirrors the tapped-side fix in the same two gates: - `oracle_classifier::is_replacement_pattern` — added the long-form untapped suffix alongside the existing short-form and tapped-long-form checks. - `oracle_replacement::parse_external_entry_suffix` — added long-form "enter(s) the battlefield untapped" arms alongside the existing short-form and tapped-long-form arms. Added parser tests for both the real card (Vigorous Farming, single-type subject) and the controller-scoped Or-filter subject shape. clippy -D warnings clean; full engine test green (14714 lib + 1721 integration, 0 failures). Model: claude-sonnet-5 (Claude Code) --- crates/engine/src/parser/oracle_classifier.rs | 10 +++ .../engine/src/parser/oracle_replacement.rs | 78 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/crates/engine/src/parser/oracle_classifier.rs b/crates/engine/src/parser/oracle_classifier.rs index 9c917db4ba..1c0707f3f0 100644 --- a/crates/engine/src/parser/oracle_classifier.rs +++ b/crates/engine/src/parser/oracle_classifier.rs @@ -724,6 +724,16 @@ pub(crate) fn is_replacement_pattern(lower: &str) -> bool { return true; } + // CR 614.1c: the untapped-entry counterpart is templated the same short-vs- + // long way as the tapped form above (Vigorous Farming: "Lands you control + // enter the battlefield untapped."). + if lower + .trim_end_matches('.') + .ends_with(" enter the battlefield untapped") + { + return true; + } + // CR 614.1e + CR 708.11: "As ~ is turned face up, [effect]" // is a replacement effect. The "When ~ is turned face up" form is a trigger // and stays out of this path, so the lead is required to be "As". diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 65f6af3838..291341ab0e 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -3499,6 +3499,35 @@ fn parse_external_entry_suffix(stripped: &str) -> Option<(&str, ExternalEntryKin ) }) }) + .or_else(|| { + // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject + // CR 614.1c: the untapped-entry counterpart is templated the same + // short-vs-long way as the tapped form above (Vigorous Farming: + // "Lands you control enter the battlefield untapped."). + stripped + .strip_suffix(" enter the battlefield untapped") + .map(|subject| { + ( + subject, + ExternalEntryKind::Plain { + enters_tapped: false, + }, + ) + }) + }) + .or_else(|| { + // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject + stripped + .strip_suffix(" enters the battlefield untapped") + .map(|subject| { + ( + subject, + ExternalEntryKind::Plain { + enters_tapped: false, + }, + ) + }) + }) .or_else(|| { // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject stripped.strip_suffix(" enter untapped").map(|subject| { @@ -12208,6 +12237,55 @@ mod tests { } } + // CR 614.1c: the untapped-entry counterpart of the tapped-entry short-vs-long + // templating gap (see `frozen_aether_enters_the_battlefield_tapped_long_form` + // above) — Vigorous Farming prints "Lands you control enter the battlefield + // untapped." where the `spelunking_lands_you_control_enter_untapped` test + // above uses the short "enter untapped" simplification. + #[test] + fn vigorous_farming_enters_the_battlefield_untapped_long_form() { + let def = parse_replacement_line( + "Lands you control enter the battlefield untapped.", + "Vigorous Farming", + ) + .expect("long-form 'enter the battlefield untapped' must parse"); + assert_eq!(def.event, ReplacementEvent::ChangeZone); + assert_eq!(def.destination_zone, Some(Zone::Battlefield)); + assert!(matches!( + *def.execute.as_ref().unwrap().effect, + Effect::SetTapState { + target: TargetFilter::SelfRef, + scope: EffectScope::Single, + state: TapStateChange::Untap, + } + )); + match &def.valid_card { + Some(TargetFilter::Typed(tf)) => { + assert!(tf.type_filters.contains(&TypeFilter::Land)); + assert_eq!(tf.controller, Some(ControllerRef::You)); + } + other => panic!("Expected Typed filter, got {other:?}"), + } + } + + // The controller-scoped Or-filter shape (mirroring + // `frozen_aether_enters_the_battlefield_tapped_long_form`) must also parse + // for the untapped long form. + #[test] + fn opponents_control_enters_the_battlefield_untapped_long_form() { + let def = parse_replacement_line( + "Artifacts, creatures, and lands your opponents control enter the battlefield untapped.", + "Untapped Aether", + ) + .expect("long-form 'enter the battlefield untapped' with Or-filter subject must parse"); + assert_eq!(def.event, ReplacementEvent::ChangeZone); + assert_eq!(def.destination_zone, Some(Zone::Battlefield)); + match &def.valid_card { + Some(TargetFilter::Or { filters }) => assert_eq!(filters.len(), 3), + other => panic!("Expected Or filter with 3 elements, got {other:?}"), + } + } + #[test] fn archelos_untapped_other_permanents_enter_untapped() { let def = parse_replacement_line(