diff --git a/crates/engine/src/parser/oracle_static/grammar.rs b/crates/engine/src/parser/oracle_static/grammar.rs index fbdc73d749..3f14f42738 100644 --- a/crates/engine/src/parser/oracle_static/grammar.rs +++ b/crates/engine/src/parser/oracle_static/grammar.rs @@ -298,6 +298,15 @@ pub(crate) fn typed_filter_for_subtype(subtype: &str) -> TypedFilter { .push(FilterProp::HasSupertype { value: supertype }); return filter; } + // CR 110.5a + CR 506.3: a bare battlefield descriptor ("Untapped", "Tapped", + // "Attacking", …) used as a whole creature descriptor names a status the + // creature HAS (CR 110.5a: "status is not a characteristic") or a combat role + // it's in (CR 506.3), not a creature subtype — resolve it to a typed FilterProp + // instead of fabricating a zero-match `Subtype("Untapped")` (Builder's + // Blessing / Castle "Untapped creatures you control get +0/+2"). + if let Some(filter) = bare_status_creature_filter(subtype) { + return filter; + } if let Some(core_type) = infer_core_type_for_subtype(subtype) { let type_filter = match core_type { crate::types::card_type::CoreType::Artifact => TypeFilter::Artifact, @@ -311,6 +320,21 @@ pub(crate) fn typed_filter_for_subtype(subtype: &str) -> TypedFilter { } } +/// CR 110.5a + CR 506.3: Recognize a bare battlefield descriptor ("untapped", +/// "tapped", "attacking", "blocking", "transformed", "suspected") used as a whole +/// creature descriptor and resolve it to a creature filter carrying the matching +/// `FilterProp`. These name a permanent's status (CR 110.5, "not a characteristic" +/// per CR 110.5a) or its combat role (CR 506.3), never a creature subtype. +/// Reuses the `parse_combat_status_prefix` +/// allowlist (appending a space to satisfy its prefix-boundary rule, then +/// requiring the whole word be consumed) so "Untapped creatures you control" +/// filters on `FilterProp::Untapped` rather than a zero-match `Subtype("Untapped")`. +fn bare_status_creature_filter(descriptor: &str) -> Option { + let with_space = format!("{} ", descriptor.to_lowercase()); + let (prop, consumed) = crate::parser::oracle_target::parse_combat_status_prefix(&with_space)?; + (consumed == with_space.len()).then(|| TypedFilter::creature().properties(vec![prop])) +} + /// CR 205.4a: Peel a leading supertype word off a compound " /// " subject descriptor ("Legendary Human", "Snow Elf"), returning the /// supertype and the original-case remainder. Returns `None` for a bare diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 8142bfdd77..c5783b3ad6 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -13473,6 +13473,69 @@ fn typed_filter_for_subtype_peels_leading_supertype() { ); } +// CR 110.5a + CR 506.3: a bare battlefield descriptor ("Untapped"/"Tapped") +// used as a whole creature descriptor names a status the creature HAS (CR 110.5a: +// "status is not a characteristic") or a combat role it's in (CR 506.3), not a +// creature subtype. The single subtype-filter authority must resolve it to a +// typed FilterProp instead of fabricating a zero-match Subtype("Untapped"). +#[test] +fn typed_filter_for_subtype_resolves_bare_status_adjective() { + let untapped = typed_filter_for_subtype("Untapped"); + assert!( + untapped.get_subtype().is_none(), + "must not fabricate a subtype" + ); + assert!(untapped.type_filters.contains(&TypeFilter::Creature)); + assert!(untapped.properties.contains(&FilterProp::Untapped)); + + let tapped = typed_filter_for_subtype("Tapped"); + assert!(tapped.properties.contains(&FilterProp::Tapped)); + + // Regression: a real subtype whose name is not a status word is unchanged. + let goblin = typed_filter_for_subtype("Goblin"); + assert_eq!(goblin.get_subtype(), Some("Goblin")); + assert!( + !goblin + .properties + .iter() + .any(|p| matches!(p, FilterProp::Tapped | FilterProp::Untapped)), + "plain subtype must not gain a status prop, got {:?}", + goblin.properties + ); +} + +#[test] +fn untapped_creatures_you_control_anthem_uses_status_prop() { + // Builder's Blessing / Castle — "Untapped creatures you control get +0/+2." + let def = parse_static_line("Untapped creatures you control get +0/+2.").unwrap(); + let Some(TargetFilter::Typed(tf)) = &def.affected else { + panic!("expected typed affected filter, got {:?}", def.affected); + }; + assert!(tf.type_filters.contains(&TypeFilter::Creature)); + assert!( + tf.get_subtype().is_none(), + "must not fabricate Subtype(\"Untapped\")" + ); + assert!(tf.properties.contains(&FilterProp::Untapped)); + assert_eq!(tf.controller, Some(ControllerRef::You)); + assert!(def + .modifications + .contains(&ContinuousModification::AddToughness { value: 2 })); +} + +// Regression: "Other tapped creatures you control" must carry BOTH the status +// prop and the Other exclusion (Saryth, the Viper's Fang; Augusta). +#[test] +fn other_tapped_creatures_you_control_keeps_status_and_another() { + let def = parse_static_line("Other tapped creatures you control have vigilance.").unwrap(); + let Some(TargetFilter::Typed(tf)) = &def.affected else { + panic!("expected typed affected filter, got {:?}", def.affected); + }; + assert!(tf.properties.contains(&FilterProp::Tapped)); + assert!(tf.properties.contains(&FilterProp::Another)); + assert!(tf.get_subtype().is_none()); +} + #[test] fn static_jodah_anthem_affected_filter_uses_legendary_supertype() { // CR 205.4a + CR 613.4c: Jodah, the Unifier's anthem affects