Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions crates/engine/src/parser/oracle_static/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<TypedFilter> {
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 "<supertype>
/// <subtype>" subject descriptor ("Legendary Human", "Snow Elf"), returning the
/// supertype and the original-case remainder. Returns `None` for a bare
Expand Down
63 changes: 63 additions & 0 deletions crates/engine/src/parser/oracle_static/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading