From ab119a1d2e41de0fd0e4ae4da853d58286d7346f Mon Sep 17 00:00:00 2001 From: jaso0n0818 Date: Fri, 3 Jul 2026 05:33:43 +0000 Subject: [PATCH] fix(parser): recognize "enter(s) the battlefield tapped" long-form external entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 614.1c ETB-tapped replacement effects that key off a controller-scoped type-phrase subject ("Artifacts, creatures, and lands your opponents control ...") are templated by WotC in two equivalent surface forms: the short "enter tapped" (Kismet, Authority of the Consuls, Imposing Sovereign) and the fully-spelled "enter the battlefield tapped" (Frozen Aether). Only the short form was recognized, so Frozen Aether's Oracle text fell through to Unimplemented. Two gates needed the long form added, mirroring the short-form arm already present at each: - `oracle_classifier::is_replacement_pattern` — the plural-subject `ends_with(" enter tapped")` check has no `" enter the battlefield tapped"` counterpart, so the line never reaches the replacement parser. - `oracle_replacement::parse_external_entry_suffix` — the suffix peeler that recovers the type-phrase subject only strips " enter(s) tapped", not " enter(s) the battlefield tapped". The "played by your opponents" and "unless"/"if you control" conditional variants already accepted both forms; this closes the last unconditional gap. Added two parser tests exercising the long form for both the multi-type Or-filter shape (Frozen Aether) and the single-type shape (Imposing Sovereign), alongside the existing short-form regression tests. clippy -D warnings clean; full engine test green (14678 lib + 1721 integration, 0 failures). Model: claude-sonnet-5 (Claude Code) --- crates/engine/src/parser/oracle_classifier.rs | 12 +++ .../engine/src/parser/oracle_replacement.rs | 100 ++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/crates/engine/src/parser/oracle_classifier.rs b/crates/engine/src/parser/oracle_classifier.rs index 9c917db4ba..d47f8b4d3c 100644 --- a/crates/engine/src/parser/oracle_classifier.rs +++ b/crates/engine/src/parser/oracle_classifier.rs @@ -720,6 +720,18 @@ pub(crate) fn is_replacement_pattern(lower: &str) -> bool { return true; } + // CR 614.1c: the plural-subject entry event is templated both as the short + // "enter tapped" and the fully-spelled "enter the battlefield tapped" + // (Kismet vs Frozen Aether). The `ends_with(" enter tapped")` check above + // misses the long form because "…the battlefield tapped" is not a suffix of + // "…enter tapped", so classify it explicitly. + if lower + .trim_end_matches('.') + .ends_with(" enter the battlefield tapped") + { + return true; + } + if lower.trim_end_matches('.').ends_with(" enter untapped") { return true; } diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 65f6af3838..37d781e66d 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -3477,6 +3477,37 @@ 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: WotC templates the entry event as both "enter tapped" + // and the fully-spelled "enter the battlefield tapped" (Frozen Aether + // vs Kismet). Peel the long form too so the type-phrase subject + // ("Artifacts, creatures, and lands your opponents control") reaches + // the same control-scoped replacement the short form already builds. + stripped + .strip_suffix(" enter the battlefield tapped") + .map(|subject| { + ( + subject, + ExternalEntryKind::Plain { + enters_tapped: true, + }, + ) + }) + }) + .or_else(|| { + // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject + stripped + .strip_suffix(" enters the battlefield tapped") + .map(|subject| { + ( + subject, + ExternalEntryKind::Plain { + enters_tapped: true, + }, + ) + }) + }) .or_else(|| { // allow-noncombinator: fixed external-entry suffix peel after type-phrase subject stripped.strip_suffix(" enter tapped").map(|subject| { @@ -12185,6 +12216,75 @@ mod tests { } } + // CR 614.1c: the printed Frozen Aether Oracle spells the entry event in full + // ("enter the battlefield tapped"); the `frozen_aether_comma_list` case above + // uses the short "enter tapped" simplification. Both must lower to the same + // control-scoped Or filter — the long form previously fell through to an + // `Unimplemented` effect because neither the classifier nor the suffix peeler + // accepted the fully-spelled plural verb. + #[test] + fn frozen_aether_enters_the_battlefield_tapped_long_form() { + let def = parse_replacement_line( + "Artifacts, creatures, and lands your opponents control enter the battlefield tapped.", + "Frozen Aether", + ) + .expect("long-form 'enter the battlefield tapped' 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::Tap, + } + )); + match &def.valid_card { + Some(TargetFilter::Or { filters }) => { + assert_eq!(filters.len(), 3); + assert_eq!( + filters[0], + TargetFilter::Typed( + TypedFilter::new(TypeFilter::Artifact).controller(ControllerRef::Opponent) + ) + ); + assert_eq!( + filters[1], + TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::Opponent)) + ); + assert_eq!( + filters[2], + TargetFilter::Typed( + TypedFilter::new(TypeFilter::Land).controller(ControllerRef::Opponent) + ) + ); + } + other => panic!("Expected Or filter with 3 elements, got {other:?}"), + } + } + + // The single-type long form ("Creatures your opponents control enter the + // battlefield tapped.") must reach the same control-scoped replacement the + // short "enter tapped" form builds for Imposing Sovereign / Authority of the + // Consuls. + #[test] + fn single_type_enters_the_battlefield_tapped_long_form() { + let def = parse_replacement_line( + "Creatures your opponents control enter the battlefield tapped.", + "Imposing Sovereign", + ) + .expect("single-type long-form 'enter the battlefield tapped' must parse"); + assert_eq!(def.event, ReplacementEvent::ChangeZone); + assert_eq!(def.destination_zone, Some(Zone::Battlefield)); + match &def.valid_card { + Some(TargetFilter::Typed(tf)) => { + assert!(tf.type_filters.contains(&TypeFilter::Creature)); + assert_eq!(tf.controller, Some(ControllerRef::Opponent)); + } + other => panic!("Expected Typed filter, got {other:?}"), + } + } + #[test] fn spelunking_lands_you_control_enter_untapped() { let def =