From 26918790746fdcb88970df8f77f65cce6f9ff92c Mon Sep 17 00:00:00 2001 From: minion1227 Date: Sat, 11 Jul 2026 13:53:15 -0700 Subject: [PATCH 1/3] feat(parser): resolve bare "Untapped/Tapped" creature subjects to a status filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 302.6 + CR 508.4a: A subject phrase like "Untapped creatures you control" or "Tapped creatures you control" names a battlefield status the creature HAS, not a creature subtype. But once the subject splitter consumes "creatures you control", the descriptor is the bare word "Untapped"/"Tapped" — and `parse_combat_status_prefix` requires a trailing space (its prefix-boundary rule), so the bare word failed to match and fell through to `typed_filter_for_subtype`, which fabricated a zero-match `Subtype("Untapped")`. The anthem therefore applied to no creature at all: - Builder's Blessing / Castle — "Untapped creatures you control get +0/+2." - Augusta, Dean of Order; Saryth, the Viper's Fang; Sword of the Paruns; The Wandering Rescuer; Adept Watershaper; Oak Street Innkeeper; Lost in the Maze. Fix at the single subtype-filter authority: `typed_filter_for_subtype` now recognizes a bare battlefield-status adjective via a new `bare_status_creature_filter` helper (reusing the existing `parse_combat_status_prefix` allowlist — appending a space to satisfy its boundary rule and requiring full consumption) and returns a creature filter carrying the matching `FilterProp` instead of a fabricated subtype. Because every capitalized-subtype fallback routes through this one function, both the plain ("Untapped creatures you control") and the "Other tapped creatures you control" forms inherit the fix and compose the status prop with the controller scope and `Another` exclusion. A real subtype whose name is not a status word is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/parser/oracle_static/grammar.rs | 21 +++++++ .../engine/src/parser/oracle_static/tests.rs | 62 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/crates/engine/src/parser/oracle_static/grammar.rs b/crates/engine/src/parser/oracle_static/grammar.rs index fbdc73d749..eadd828609 100644 --- a/crates/engine/src/parser/oracle_static/grammar.rs +++ b/crates/engine/src/parser/oracle_static/grammar.rs @@ -298,6 +298,14 @@ pub(crate) fn typed_filter_for_subtype(subtype: &str) -> TypedFilter { .push(FilterProp::HasSupertype { value: supertype }); return filter; } + // CR 302.6 + CR 508.4a: a bare battlefield-status adjective ("Untapped", + // "Tapped", "Attacking", …) used as a whole creature descriptor names a status + // the creature HAS, 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 +319,19 @@ pub(crate) fn typed_filter_for_subtype(subtype: &str) -> TypedFilter { } } +/// CR 302.6 + CR 508.4a: Recognize a bare battlefield-status adjective +/// ("untapped", "tapped", "attacking", "blocking", "transformed", "suspected") +/// used as a whole creature descriptor and resolve it to a creature filter +/// carrying the matching `FilterProp`. 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..4ac37cec35 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -13473,6 +13473,68 @@ fn typed_filter_for_subtype_peels_leading_supertype() { ); } +// CR 302.6 + CR 508.4a: a bare battlefield-status adjective ("Untapped"/"Tapped") +// used as a whole creature descriptor names a status the creature HAS, 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 From 7915f52ecc63a537d8a5d9b03052f0e90a6f7e57 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Sat, 11 Jul 2026 16:14:46 -0700 Subject: [PATCH 2/3] fix(parser): cite CR 110.5a/506.3 for bare status-subject filter Swap the fabricated CR 302.6 (summoning sickness) and CR 508.4a (put-onto-battlefield-attacking) annotations for the rules that actually govern a bare battlefield descriptor resolving to a status filter instead of a subtype: CR 110.5a ("status is not a characteristic", covering tapped/untapped and face up/face down) and CR 506.3 (combat roles attacking/blocking, which are not statuses). Code is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../engine/src/parser/oracle_static/grammar.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/parser/oracle_static/grammar.rs b/crates/engine/src/parser/oracle_static/grammar.rs index eadd828609..3f14f42738 100644 --- a/crates/engine/src/parser/oracle_static/grammar.rs +++ b/crates/engine/src/parser/oracle_static/grammar.rs @@ -298,9 +298,10 @@ pub(crate) fn typed_filter_for_subtype(subtype: &str) -> TypedFilter { .push(FilterProp::HasSupertype { value: supertype }); return filter; } - // CR 302.6 + CR 508.4a: a bare battlefield-status adjective ("Untapped", - // "Tapped", "Attacking", …) used as a whole creature descriptor names a status - // the creature HAS, not a creature subtype — resolve it to a typed FilterProp + // 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) { @@ -319,10 +320,12 @@ pub(crate) fn typed_filter_for_subtype(subtype: &str) -> TypedFilter { } } -/// CR 302.6 + CR 508.4a: Recognize a bare battlefield-status adjective -/// ("untapped", "tapped", "attacking", "blocking", "transformed", "suspected") -/// used as a whole creature descriptor and resolve it to a creature filter -/// carrying the matching `FilterProp`. Reuses the `parse_combat_status_prefix` +/// 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")`. From 19732f762effb8aff8372ec00c4b4164f7dd2589 Mon Sep 17 00:00:00 2001 From: minion1227 Date: Sat, 11 Jul 2026 16:27:31 -0700 Subject: [PATCH 3/3] test(parser): cite CR 110.5a/506.3 in bare-status-subject test doc comment Match the annotation the test exercises: the fabricated CR 302.6 (summoning sickness) / CR 508.4a (put-onto-battlefield-attacking) comment above typed_filter_for_subtype_resolves_bare_status_adjective now cites CR 110.5a ("status is not a characteristic") and CR 506.3 (attacking/blocking combat roles), matching grammar.rs. Test unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/engine/src/parser/oracle_static/tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 4ac37cec35..c5783b3ad6 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -13473,8 +13473,9 @@ fn typed_filter_for_subtype_peels_leading_supertype() { ); } -// CR 302.6 + CR 508.4a: a bare battlefield-status adjective ("Untapped"/"Tapped") -// used as a whole creature descriptor names a status the creature HAS, not a +// 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]