From c5432dd6b4ccff412f4a08f930f4a0be004db528 Mon Sep 17 00:00:00 2001 From: Jacob Woodson Date: Sat, 15 Aug 2026 22:37:32 -0500 Subject: [PATCH 1/4] Fix Make Your Move --- .../engine/src/parser/oracle_effect/search.rs | 121 ++ crates/engine/src/parser/oracle_target.rs | 1327 ++++++++++++++++- crates/engine/tests/integration/main.rs | 1 + ..._your_move_pt_suffix_binds_creature_leg.rs | 332 +++++ 4 files changed, 1756 insertions(+), 25 deletions(-) create mode 100644 crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs diff --git a/crates/engine/src/parser/oracle_effect/search.rs b/crates/engine/src/parser/oracle_effect/search.rs index 2a05ff9ef7..8f204b417e 100644 --- a/crates/engine/src/parser/oracle_effect/search.rs +++ b/crates/engine/src/parser/oracle_effect/search.rs @@ -5484,4 +5484,125 @@ mod tests { "Cmc<=N must distribute to the earlier (Creature) leg, got {first:?}" ); } + + /// Assert the CR 208.3 binding on a filter produced by the REAL search + /// grammar: every leg is named, the P/T-bearing leg is identified by its + /// type filter, and every other leg must be P/T-free. + fn assert_pt_binds_to_creature_leg_only(filter: &TargetFilter, label: &str) { + let TargetFilter::Or { filters } = filter else { + panic!("{label}: expected an Or filter, got {filter:?}"); + }; + for leg in filters { + let TargetFilter::Typed(typed) = leg else { + panic!("{label}: expected every leg Typed, got {leg:?}"); + }; + let has_pt = typed + .properties + .iter() + .any(|p| matches!(p, FilterProp::PtComparison { .. })); + let is_creature_leg = typed.type_filters.contains(&TypeFilter::Creature); + assert_eq!( + has_pt, is_creature_leg, + "{label}: CR 208.3 — only the creature leg may carry the power \ + restriction, got {typed:?}" + ); + } + assert!( + filters.iter().any(|leg| matches!( + leg, + TargetFilter::Typed(typed) if typed.type_filters.contains(&TypeFilter::Creature) + )), + "{label}: reach-guard — a creature leg must exist, else the \ + assertion above is vacuous: {filters:?}" + ); + } + + /// CR 208.1 + CR 208.3 + CR 701.23a: the search-filter disjunction grammar + /// inherits the type-conditional power/toughness gate with no code of its + /// own, because `parse_search_filter_disjunction` finishes every multi- + /// segment filter through the shared `distribute_properties_to_or`. + /// + /// Driven end to end from Oracle-shaped text through `parse_search_filter` + /// and `parse_search_library_details` — NOT by calling the shared + /// distributor on a hand-built filter, which would only re-test + /// `oracle_target`'s own unit rows and would leave this grammar's segment + /// splitting (`split_filter_disjunctions`) unexercised. + /// + /// Both control axes are present so the test cannot pass on a grammar that + /// simply stopped distributing: + /// * CR 202.3 (every object has a mana value): the `Cmc` shape must still + /// reach every leg. + /// * CR 205.3d/205.3g: the `Vehicle` leg is a noncreature subtype leg, which + /// the search grammar resolves to `[Artifact, Subtype("Vehicle")]`; it must + /// be gated like the spelled-out artifact leg. + /// + /// The one search path that pushes props onto every `Or` branch WITHOUT the + /// gate — `apply_search_suffix_constraints` via + /// `apply_shared_leading_search_properties` — cannot carry a P/T prop: + /// its props come from `parse_search_leading_filter_property`, whose whole + /// output set is `HasSupertype`/`NotSupertype`/`HasColor`, and it is reached + /// only when `search_filter_all_land_subtype_branches` holds. + #[test] + fn search_disjunction_binds_pt_suffix_to_creature_leg_only() { + let mut ctx = ParseContext::default(); + let filter = parse_search_filter( + "an artifact, enchantment, or creature card with power 4 or greater", + &mut ctx, + ); + assert_pt_binds_to_creature_leg_only(&filter, "three-segment comma list"); + + // Same grammar reached through the full effect entry point, so the + // binding is proven where card text actually enters the parser. + let mut ctx = ParseContext::default(); + let details = parse_search_library_details( + "search your library for an artifact, enchantment, or creature card with power 4 \ + or greater, put it onto the battlefield, then shuffle", + &mut ctx, + ); + assert_pt_binds_to_creature_leg_only(&details.filter, "parse_search_library_details"); + + // CR 205.3d + CR 205.3g: a leg named by an artifact subtype is gated too. + let mut ctx = ParseContext::default(); + let filter = parse_search_filter( + "a creature or Vehicle card with power 4 or greater", + &mut ctx, + ); + assert_pt_binds_to_creature_leg_only(&filter, "Vehicle subtype leg"); + let TargetFilter::Or { filters } = &filter else { + panic!("expected an Or filter, got {filter:?}"); + }; + assert!( + filters.iter().any(|leg| matches!( + leg, + TargetFilter::Typed(typed) + if typed.type_filters.contains(&TypeFilter::Subtype("Vehicle".to_string())) + )), + "reach-guard: the Vehicle leg must actually be present: {filters:?}" + ); + + // CR 202.3 positive control, same grammar and same text shape. + let mut ctx = ParseContext::default(); + let filter = parse_search_filter( + "an artifact, enchantment, or creature card with mana value 3 or less", + &mut ctx, + ); + let TargetFilter::Or { filters } = &filter else { + panic!("expected an Or filter, got {filter:?}"); + }; + for leg in filters { + let TargetFilter::Typed(typed) = leg else { + panic!("expected every leg Typed, got {leg:?}"); + }; + assert!( + typed.properties.iter().any(|p| matches!( + p, + FilterProp::Cmc { + comparator: Comparator::LE, + .. + } + )), + "CR 202.3: mana value must distribute to EVERY leg, got {typed:?}" + ); + } + } } diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index a8af054692..978cd6ac94 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -15,7 +15,7 @@ use crate::types::ability::{ SeatDirection, SharedQuality, SharedQualityRelation, TargetFilter, TargetSelectionMode, ThisWayCause, TypeFilter, TypedFilter, }; -use crate::types::card_type::Supertype; +use crate::types::card_type::{noncreature_subtype_set, SubtypeSet, Supertype}; use crate::types::counter::{CounterMatch, CounterType}; use crate::types::identifiers::TrackedSetId; use crate::types::keywords::{Keyword, KeywordKind}; @@ -2376,7 +2376,7 @@ pub fn parse_type_phrase_with_ctx<'a>( } } - // CR 700.4 + CR 700.9: "modified" adjective prefix. A permanent is modified + // CR 700.9: "modified" adjective prefix. A permanent is modified // if it has counters on it, is equipped, or is enchanted by an Aura its // controller controls. Emits FilterProp::Modified (a first-class typed // predicate — see `FilterProp::Modified` in types/ability.rs). Mirrors the @@ -2582,7 +2582,7 @@ pub fn parse_type_phrase_with_ctx<'a>( } } - // CR 700.4 + CR 700.9: A "modified" adjective can also appear AFTER a + // CR 700.9: A "modified" adjective can also appear AFTER a // `non-` token-identity/type negation prefix (e.g. "nontoken modified // creature" in Akki Ember-Keeper / issue #3677 class). The pre-negation // arm above only fires when "modified" leads the phrase. Mirrors the @@ -2970,11 +2970,7 @@ pub fn parse_type_phrase_with_ctx<'a>( } else { properties.clone() }; - let combined = distribute_shared_properties(combined, &shared_props); - let combined = distribute_controller_to_or(combined); - let combined = distribute_core_type_to_or(combined); - let combined = distribute_neg_type_filters_to_or(combined); - return (distribute_properties_to_or(combined), final_rest); + return (finalize_or_disjunction(combined, &shared_props), final_rest); } } } @@ -4044,7 +4040,7 @@ pub(crate) fn starts_with_type_word(text: &str) -> bool { } } } - // CR 700.4 + CR 700.9: "modified " adjective phrase leads a type + // CR 700.9: "modified " adjective phrase leads a type // phrase (e.g., "modified creatures you control"). Consume the adjective // and verify a type word follows so the comma/and-list recursion can // continue across the "modified" leg. @@ -4274,14 +4270,58 @@ fn stack_spell_filter(mut typed: TypedFilter) -> TargetFilter { } } +/// Single authority for finishing a freshly merged type disjunction: the fixed +/// order in which the controller/type backfills and the two property +/// distributors must run. +/// +/// ORDER IS LOAD-BEARING, and it is stated here once so the two property +/// distributors cannot disagree about it. Both `distribute_shared_properties` +/// (the left-to-right path) and `distribute_properties_to_or` (the +/// trailing-suffix path) consult the CR 208.3 gate `prop_distributes_to_leg`, +/// which reads each leg's `type_filters`. A leg assembled as `[TypeFilter::Any]` +/// (its type noun appeared only in a later disjunct) or one that has not yet +/// inherited a leading `Non(Creature)` does not yet know its own card type, so +/// BOTH backfills must complete first — otherwise the "with power N" binding +/// silently goes wrong on exactly those legs. +/// +/// The backfills only add `TypeFilter`s and never read `properties`, so ordering +/// the shared-prop push after them is inert for every non-P/T prop and strictly +/// better informed for P/T props. +fn finalize_or_disjunction(combined: TargetFilter, shared_props: &[FilterProp]) -> TargetFilter { + let combined = distribute_controller_to_or(combined); + let combined = distribute_core_type_to_or(combined); + let combined = distribute_neg_type_filters_to_or(combined); + let combined = distribute_shared_properties(combined, shared_props); + distribute_properties_to_or(combined) +} + +/// Push a caller-supplied set of shared props onto every `Typed` leg reachable +/// from `filter`. This is the left-to-right distribution path (a suffix parsed +/// on the LEFT leg before the connector). +/// +/// CR 208.3 gate: shares `prop_distributes_to_leg` with +/// `distribute_properties_to_or` so a power/toughness restriction can never land +/// on a leg pinned to a noncreature core type. No printed card routes a P/T prop +/// through this path today — the gate exists so the two distributors cannot +/// diverge, not because it fixes a card. +/// +/// No relocation sweep here: this function receives `shared_props` from its +/// caller and never harvests them off a leg, so there is no origin leg to +/// relocate away from. +/// +/// Call it only through `finalize_or_disjunction`, which guarantees the type +/// backfills have already run — this function's gate reads `type_filters`, so +/// invoking it on legs still holding `[TypeFilter::Any]` would consult an +/// unfinished type set. fn distribute_shared_properties(filter: TargetFilter, shared_props: &[FilterProp]) -> TargetFilter { match filter { TargetFilter::Typed(mut typed) => { for prop in shared_props { - if !typed - .properties - .iter() - .any(|existing| prop.same_kind(existing)) + if prop_distributes_to_leg(prop, &typed) + && !typed + .properties + .iter() + .any(|existing| prop.same_kind(existing)) { typed.properties.push(prop.clone()); } @@ -4328,10 +4368,20 @@ fn distribute_shared_properties(filter: TargetFilter, shared_props: &[FilterProp /// wrongly distributed across earlier `Or` legs and silently break the affected /// cards (e.g. #2892). When adding a new leg-local search/target prop, add it to /// this match. +/// +/// COUNTERPART: `prop_distributes_to_leg` is the *type-conditional* leg-locality +/// authority (a prop that distributes to some legs but not others, depending on +/// the receiving leg's card types). The two are NOT mergeable: this predicate is +/// consulted inside `distribute_properties_to_or`'s harvest `find_map` closure, +/// where returning `None` for an all-adjective leg makes `find_map` fall through +/// to an *earlier* leg — so it participates in harvest-*source selection*, not +/// only in filtering. Folding a type-conditional test in here would silently +/// change which leg is harvested for unrelated cards. See that function's doc +/// comment for the full argument. pub(crate) fn is_adjective_prefix_prop(prop: &FilterProp) -> bool { matches!( prop, - // CR 700.4 + CR 700.9: "modified [type]" adjective prefix. + // CR 700.9: "modified [type]" adjective prefix. FilterProp::Modified // CR 702.112b: "renowned [type]" adjective prefix. | FilterProp::Renowned @@ -4388,18 +4438,477 @@ pub(crate) fn is_adjective_prefix_prop(prop: &FilterProp) -> bool { ) } +/// Returns true when `prop` reads a creature's power and/or toughness. +/// +/// CR 208.1: power and toughness are the two numbers printed on a CREATURE +/// card; they are the creature-scoped characteristics this prop family reads. +/// CR 208.3: a noncreature permanent has no power or toughness, even if it's a +/// card with a power and toughness printed on it (such as a Vehicle). CR 208.5's +/// 0-default applies to CREATURES with no value, not to noncreatures — so a +/// power/toughness restriction is not a meaningful narrowing of a noncreature +/// disjunct, and the postnominal modifier binds to the creature disjunct. +/// +/// NOTE: `PowerGTSource` is cited elsewhere as CR 509.1b because every printed +/// card carrying it is a blocking restriction. That is the CONTEXT rule, not the +/// authority for reading power — 509.1b is about the legality of the blocker +/// declaration and says nothing about power being a characteristic. The +/// authority here is CR 208.1 + CR 208.3 only. +/// +/// EXHAUSTIVE BY CONSTRUCTION — no wildcard arm. This function is a registry, +/// and an unenforced registry drifts (the same failure `is_adjective_prefix_prop` +/// warns about). Every `FilterProp` variant is named, so adding a new +/// power/toughness-reading prop fails to compile until it is classified here +/// instead of silently reintroducing the vacuous-noncreature-leg bug. Its two +/// sibling authorities (`type_filter_guarantees_creature`, +/// `is_noncreature_core_type_pin`) are exhaustive for the same reason. +fn prop_reads_creature_pt(prop: &FilterProp) -> bool { + match prop { + // CR 208.1 + CR 208.4: numeric/quantity power or toughness threshold, + // including the superlative `EQ` form built by + // `superlative_property_filter_prop` and the "base power"/"base + // toughness" scope of CR 208.4. + FilterProp::PtComparison { .. } + // CR 208.1: source-relative power comparison ("with greater power"). + | FilterProp::PowerGTSource + // CR 208.1: same-object toughness-vs-power comparison. + | FilterProp::ToughnessGTPower + // CR 208.1 + CR 613.4a: current power vs. base power. + | FilterProp::PowerExceedsBase => true, + // "power or toughness N or less" decomposes to an `AnyOf` of two + // `PtComparison`s. ALL, not ANY: if even one disjunct is satisfiable by + // a noncreature, the whole disjunction is, and distribution stays + // correct. An empty `AnyOf` matches nothing and is not P/T-reading. + FilterProp::AnyOf { props } => !props.is_empty() && props.iter().all(prop_reads_creature_pt), + // CR 208.1: "shares a power / toughness / total power and toughness + // with" reads the same two characteristics, so it belongs to this family + // even though `parse_power_suffix` is not what produces it. Left + // unclassified it would be strictly worse than a threshold prop: a + // noncreature leg evaluates its missing power as 0 + // (`game::filter::pt_value_from_pair`), so the leg would falsely MATCH + // whenever the reference object has power 0, instead of merely matching + // nothing. Inner match is exhaustive so a new `SharedQuality` is + // classified here too. + FilterProp::SharesQuality { quality, .. } => match quality { + SharedQuality::Power + | SharedQuality::Toughness + | SharedQuality::TotalPowerToughness => true, + SharedQuality::Name + | SharedQuality::ManaValue + | SharedQuality::CreatureType + | SharedQuality::Color + | SharedQuality::CardType + | SharedQuality::LandType + | SharedQuality::PermanentType => false, + }, + // `Not` is deliberately NOT in the family. A NEGATED power predicate IS + // satisfiable by a noncreature (CR 208.3 gives it no power, so "power 4 + // or greater" is false and its negation true), so blocking its + // distribution would be unjustified. + FilterProp::Not { .. } => false, + // Everything else reads a characteristic that is not power or toughness + // (CR 205/CR 202.3/CR 105.1 …) or a game-state predicate, and stays + // eligible for distribution onto a noncreature leg. Listed in enum order + // so the next variant added to `FilterProp` cannot slip through. + FilterProp::Token + | FilterProp::NonToken + | FilterProp::RepresentedByCard + | FilterProp::ControllerChoseLabel { .. } + | FilterProp::ControllerMatches { .. } + | FilterProp::WasPlayed + | FilterProp::Attacking { .. } + | FilterProp::Blocking + | FilterProp::BlockingSource + | FilterProp::CombatRelation { .. } + | FilterProp::Unblocked + | FilterProp::AttackingAlone + | FilterProp::BlockingAlone + | FilterProp::Tapped + | FilterProp::Untapped + | FilterProp::IsSaddled + | FilterProp::SaddledSource + | FilterProp::ConvokedSource + | FilterProp::ProtectorMatches { .. } + | FilterProp::HasHasteOrControlledSinceTurnBegan + | FilterProp::WithKeyword { .. } + | FilterProp::HasKeywordKind { .. } + | FilterProp::WithoutKeyword { .. } + | FilterProp::WithoutKeywordKind { .. } + | FilterProp::CanEnchant { .. } + | FilterProp::Counters { .. } + | FilterProp::Cmc { .. } + | FilterProp::ManaValueParity { .. } + | FilterProp::ManaCostIn { .. } + | FilterProp::InZone { .. } + | FilterProp::Owned { .. } + | FilterProp::Foretold + | FilterProp::HasAdventure + | FilterProp::EnchantedBy + | FilterProp::EquippedBy + | FilterProp::AttachedToSource + | FilterProp::AttachedToRecipient + | FilterProp::HasAttachment { .. } + | FilterProp::HasAnyAttachmentOf { .. } + | FilterProp::Another + | FilterProp::Unpaired + | FilterProp::OtherThanTriggerObject + | FilterProp::HasColor { .. } + | FilterProp::ColorCount { .. } + | FilterProp::ManaSymbolCount { .. } + | FilterProp::HasSupertype { .. } + | FilterProp::IsChosenCreatureType + | FilterProp::MostPrevalentCreatureTypeIn { .. } + | FilterProp::IsChosenColor + | FilterProp::IsChosenCardType + | FilterProp::MatchesLastChosenCardPredicate + | FilterProp::HasSingleTarget + | FilterProp::Modal + | FilterProp::NotColor { .. } + | FilterProp::NotSupertype { .. } + | FilterProp::Suspected + | FilterProp::Renowned + | FilterProp::Goaded + | FilterProp::InTrackedSet { .. } + | FilterProp::Modified + | FilterProp::Historic + | FilterProp::NotHistoric + | FilterProp::DifferentNameFrom { .. } + | FilterProp::DistinctFrom { .. } + | FilterProp::InAnyZone { .. } + | FilterProp::WasDealtDamageThisTurn + | FilterProp::DealtDamageThisTurn + | FilterProp::EnteredThisTurn + | FilterProp::ControlledContinuouslySinceTurnBegan + | FilterProp::ZoneChangedThisTurn { .. } + | FilterProp::AttackedThisTurn { .. } + | FilterProp::BlockedThisTurn + | FilterProp::AttackedOrBlockedThisTurn + | FilterProp::CountersPutOnThisTurn { .. } + | FilterProp::FaceDown + | FilterProp::Transformed + | FilterProp::TargetsOnly { .. } + | FilterProp::Targets { .. } + | FilterProp::CouldBeTargetedByTriggeringSpell + | FilterProp::HasXInManaCost + | FilterProp::HasXInActivationCost + | FilterProp::WasKicked + | FilterProp::HasManaAbility + | FilterProp::HasNoAbilities + | FilterProp::Named { .. } + | FilterProp::SameName + | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource + | FilterProp::NameMatchesAnyPermanent { .. } + | FilterProp::IsCommander + | FilterProp::SharesCreatureTypeWithCommander + | FilterProp::Other { .. } => false, + } +} + +/// Returns true when this single `TypeFilter` guarantees the matched object is +/// a creature (CR 205.2a: creature is a card type). +/// +/// Used ONLY as the short-circuit inside `leg_pins_noncreature_core_type` +/// (CR 205.2b: an object can have more than one card type, so an "artifact +/// creature" leg must keep a P/T restriction). It is deliberately NOT the +/// rehoming-witness predicate — see `pt_hosting_leg_props`. +fn type_filter_guarantees_creature(tf: &TypeFilter) -> bool { + match tf { + TypeFilter::Creature => true, + // A type disjunction guarantees creature only if EVERY alternative does + // (e.g. `AnyOf[Creature, Subtype("Vehicle")]` does not). Plain logic on + // the AST, not a rules decision — no CR annotation applies. + TypeFilter::AnyOf(inner) => { + !inner.is_empty() && inner.iter().all(type_filter_guarantees_creature) + } + // A creature subtype does NOT guarantee the creature card type: CR + // 205.3m says creatures and KINDREDS share their list of subtypes, and + // CR 308.1 says each kindred card has another card type — a "Kindred + // Enchantment — Aura Demon" is a Demon that is not a creature. So + // `Subtype(_)` stays false here even for creature types. (The + // consequence — a `[Subtype("Goblin")]` leg is still a legal HOST for a + // relocated P/T restriction — is handled by `pt_hosting_leg_props`, + // which keys on the distribution gate itself rather than on this + // stricter predicate.) + TypeFilter::Land + | TypeFilter::Artifact + | TypeFilter::Enchantment + | TypeFilter::Instant + | TypeFilter::Sorcery + | TypeFilter::Planeswalker + | TypeFilter::Battle + | TypeFilter::Kindred + | TypeFilter::Permanent + | TypeFilter::Card + | TypeFilter::Any + | TypeFilter::Non(_) + | TypeFilter::Subtype(_) => false, + } +} + +/// Returns true when this single `TypeFilter` pins a core card type that is not +/// creature — i.e. it constrains the object to a card type for which CR 208.3 +/// says no power or toughness exists. +/// +/// CR 205.2a: "The card types are artifact, battle, conspiracy, creature, +/// dungeon, enchantment, instant, kindred, land, phenomenon, plane, +/// planeswalker, scheme, sorcery, and vanguard." Battle is a card type distinct +/// from creature, which is the whole proposition this predicate needs — no +/// per-type rule pointer is required. (Do NOT cite CR 310.1 for the battle arm: +/// that rule is about *casting* a battle card during a main phase and +/// establishes nothing about battle's relationship to creature. Do NOT cite bare +/// CR 205.2 either — that line is the section heading "Card Types" with no +/// substantive text.) +fn is_noncreature_core_type_pin(tf: &TypeFilter) -> bool { + match tf { + // CR 205.2a: each of these pins a core card type that does not itself + // make an object a creature. + TypeFilter::Artifact + | TypeFilter::Enchantment + | TypeFilter::Land + | TypeFilter::Instant + | TypeFilter::Sorcery + | TypeFilter::Planeswalker + | TypeFilter::Battle => true, + // CR 208.3 names "noncreature" directly: a NONCREATURE permanent has no + // power or toughness. A `Non(Creature)` pin is exactly that subject. + TypeFilter::Non(inner) => **inner == TypeFilter::Creature, + // A type disjunction pins a noncreature core type only when EVERY + // alternative does. Plain logic on the AST, not a rules decision. + TypeFilter::AnyOf(inner) => { + !inner.is_empty() && inner.iter().all(is_noncreature_core_type_pin) + } + // Named for exhaustiveness; `leg_pins_noncreature_core_type` + // short-circuits on a creature-guaranteeing leg before reaching here. + TypeFilter::Creature => false, + // CR 308.1: each kindred card has ANOTHER card type, so `Kindred` alone + // pins nothing — a kindred creature card is a creature. Conservative + // no-op. + TypeFilter::Kindred => false, + // No specific core type is pinned; preserves prior distribution. + TypeFilter::Permanent | TypeFilter::Card | TypeFilter::Any => false, + // CR 205.3d: "An object can't gain a subtype that doesn't correspond to + // one of that object's types." Each noncreature subtype pool is owned by + // exactly one card type — CR 205.3g (artifact types: Equipment, + // Vehicle, Spacecraft, Treasure …), CR 205.3h (enchantment types: Aura, + // Saga, Class …), CR 205.3i (land types), CR 205.3j (planeswalker + // types), CR 205.3k (spell types), CR 205.3q (battle types) — so naming + // one of those subtypes pins that noncreature card type exactly as the + // card-type word does. "Target creature or Vehicle with power N or + // greater" (the `Suit Up` leg shape) must therefore bind the restriction + // to the creature disjunct: CR 301.7a gives a Vehicle its printed power + // only while it is also a creature, so restricting the Vehicle leg would + // make it either dead (CR 208.3 + `pt_value_from_pair`'s `unwrap_or(0)`) + // or redundant with the creature leg. + // + // `noncreature_subtype_set` is the engine's existing CR 205.3 mapping + // and returns `None` for creature types (the runtime card database owns + // that list) and for unrecognized strings, so "target Goblin" keeps + // receiving the restriction and an unknown subtype preserves prior + // distribution. + TypeFilter::Subtype(subtype) => match noncreature_subtype_set(subtype) { + Some( + SubtypeSet::Artifact + | SubtypeSet::Enchantment + | SubtypeSet::Land + | SubtypeSet::Planeswalker + | SubtypeSet::Spell + | SubtypeSet::Battle, + ) => true, + // CR 205.3m: creature and kindred subtypes. Never returned by + // `noncreature_subtype_set`; named so a future mapping change is a + // compile error rather than a silent reclassification. + Some(SubtypeSet::Creature) | None => false, + }, + } +} + +/// Returns true when this `Or` leg's conjunction of type filters pins a +/// noncreature core type AND does not also guarantee creature. +/// +/// CR 205.2b: an object can have more than one card type, so "artifact +/// creature" satisfies any effect applying to either — a leg that pins BOTH +/// `Artifact` and `Creature` is creature-guaranteeing and must keep a P/T +/// restriction. +fn leg_pins_noncreature_core_type(type_filters: &[TypeFilter]) -> bool { + if type_filters.iter().any(type_filter_guarantees_creature) { + return false; + } + type_filters.iter().any(is_noncreature_core_type_pin) +} + +/// Type-conditional leg-locality gate: may `prop` be distributed onto `typed`? +/// +/// CR 208.3: a noncreature permanent has no power or toughness. A postnominal +/// "with power N or greater" in a coordinated card-type list therefore binds to +/// the creature disjunct only — "Destroy target artifact, enchantment, or +/// creature with power 4 or greater" (Make Your Move, Exorcise) must leave the +/// artifact and enchantment legs unrestricted, exactly as +/// `WithKeyword(Flying)` does for Broken Wings / Vivien Reid (#2941). +/// +/// Relationship to `is_adjective_prefix_prop` — the two authorities are +/// orthogonal and CANNOT be merged: +/// * `is_adjective_prefix_prop` is **prop-absolute**: a registered prop is +/// leg-local for every leg, no matter its types. It runs inside the harvest +/// `find_map` closure of `distribute_properties_to_or`, where returning `None` +/// for an all-adjective leg makes `find_map` fall through to an earlier leg — +/// so it participates in harvest-*source selection*. Registering a P/T prop +/// there would both block legitimate distribution across creature-typed legs +/// ("Goblin or Dwarf with power 4 or greater") and silently change which leg +/// is harvested for unrelated cards. +/// * `prop_distributes_to_leg` is **type-conditional** and runs at the *push* +/// site, where the receiving leg's `type_filters` are available — information +/// the harvest closure does not have. +/// +/// Scope: this gate governs *distribution* only. Cleaning a mis-placed prop off +/// the leg that syntactically parsed it is the separate, differently-gated +/// `strip_misplaced_pt_props_from_or_legs` sweep. +/// +/// Shared by both `distribute_properties_to_or` and +/// `distribute_shared_properties`, so the search-filter disjunction grammar +/// (`oracle_effect::search`, CR 701.23a) inherits it with no extra code. +/// +/// ORDERING DEPENDENCY: `parse_type_phrase_with_ctx` calls +/// `distribute_core_type_to_or` and `distribute_neg_type_filters_to_or` BEFORE +/// BOTH distributors that consult this gate, so a leg that receives its core +/// type (or an inherited `Non(Creature)`) by backfill already carries it when +/// this gate inspects `type_filters`. Reordering those calls would break this +/// gate. `distribute_shared_properties` was originally sequenced ahead of the +/// backfills, where it inspected legs still holding `[TypeFilter::Any]`; it is +/// now sequenced with `distribute_properties_to_or` so this invariant holds at +/// BOTH call sites rather than only one. +fn prop_distributes_to_leg(prop: &FilterProp, typed: &TypedFilter) -> bool { + !(prop_reads_creature_pt(prop) && leg_pins_noncreature_core_type(&typed.type_filters)) +} + +/// Collect the P/T-family props that depth-1 `Typed` legs the CR 208.3 gate +/// ACCEPTS actually carry. This is the rehoming witness set consumed by +/// `strip_misplaced_pt_props_from_or_legs`. +/// +/// The host predicate is the exact complement of `prop_distributes_to_leg`'s +/// type test, not the stricter `type_filter_guarantees_creature`. That makes the +/// two sides of the relocation structurally inseparable: a prop may be stripped +/// off a rejected leg only when a leg the same gate ACCEPTED is carrying it, so +/// "never delete a printed restriction" (invariant 5) cannot drift apart from +/// the gate. +/// +/// Using `type_filter_guarantees_creature` here instead would silently fail the +/// class the gate exists for: CR 205.3m creature subtypes name no card type, so +/// "target Goblin, artifact, or enchantment with power 4 or greater" has an +/// accepting `[Subtype("Goblin")]` leg that the stricter predicate does not +/// recognize — the enchantment leg would keep a vacuous, untargetable +/// restriction (CR 208.3), reproducing the exact Make Your Move defect. +fn pt_hosting_leg_props(filters: &[TargetFilter]) -> Vec { + filters + .iter() + .filter_map(|f| match f { + TargetFilter::Typed(typed) if !leg_pins_noncreature_core_type(&typed.type_filters) => { + Some(typed.properties.iter()) + } + _ => None, + }) + .flatten() + .filter(|p| prop_reads_creature_pt(p)) + .cloned() + .collect() +} + +/// Relocate (never delete) a mis-placed power/toughness restriction off an `Or` +/// leg that pins a noncreature core type. +/// +/// 1. WHY RELOCATION IS NEEDED AT ALL. `parse_type_phrase_with_ctx` recurses +/// right-to-left over `TYPE_SEPARATORS`, so the LAST noun in the list is the +/// leg that parses the trailing suffix and becomes the harvest source. For +/// "artifact, creature, or enchantment with power 4 or greater" that is the +/// *enchantment* leg. Gating distribution alone would leave +/// `Or[Artifact{}, Creature{Pt}, Enchantment{Pt}]` — a vacuous restriction +/// that `game::filter::pt_value_from_pair`'s `power.unwrap_or(0)` turns into +/// "no enchantment is ever a legal target". CR 208.1 + CR 208.3: power and +/// toughness are creature characteristics; a noncreature has none, so the +/// restriction belongs on the creature disjunct wherever it sits in the list. +/// This ordering is not hypothetical — March of Otherworldly Light +/// ("artifact, creature, or enchantment with mana value X or less") shows +/// WotC writes `creature` mid-list. +/// +/// 2. WHY THE WITNESS IS `==` AND NOT `same_kind`. `FilterProp::same_kind` is +/// discriminant-only, so the push loop's dedupe suppresses a *different +/// payload* prop of the same variant. For +/// `Or[Creature{Pt(Toughness,GE,2)}, Enchantment{Pt(Power,GE,4)}]` the +/// harvested `Pt(Power,GE,4)` is never pushed onto the creature leg. A sweep +/// conditioned merely on "some gate-accepted leg exists" would then DELETE a +/// printed restriction that was never rehomed. So the sweep witnesses per +/// prop on exact equality: strip `P` only when a gate-ACCEPTED sibling leg +/// actually carries a `Q == P` (see `pt_hosting_leg_props` for why the host +/// predicate is the gate's complement and not +/// `type_filter_guarantees_creature`). +/// +/// 3. FLATTENING PRECONDITION. This sweep runs from +/// `distribute_properties_to_or`, which `parse_type_phrase_with_ctx` invokes +/// on EVERY separator merge, not once at the top. For +/// "creature, artifact, or enchantment with power 4 or greater" the inner +/// merge yields `Or[Artifact{}, Enchantment{Pt}]` — every leg is gate- +/// rejected, so the witness set is empty and the sweep correctly no-ops +/// there; the relocation happens on the OUTER merge, +/// and only because `oracle_util::merge_or_filters` splices a nested `Or`'s +/// legs into the parent, keeping the leg list flat so the enchantment leg is +/// still visible at depth 1. If `merge_or_filters` ever stopped flattening, +/// this sweep would silently stop relocating (a no-op, never a deletion). +/// +/// 4. DEPTH-1-ONLY SCOPE, BENIGN FAILURE DIRECTION. Both the harvest `find_map` +/// and this sweep match only `TargetFilter::Typed` at depth 1; a non-`Typed` +/// leg (e.g. the `And[StackSpell, Typed]` shape from `stack_spell_filter`) is +/// invisible to both. Such a leg is neither pushed to nor stripped, so a +/// restriction can never be deleted by a shape the sweep cannot see. +/// +/// 5. INVARIANT. Every strip is conditioned on a live, EQUAL witness in the same +/// `Or`. The three non-rehomed cases — no gate-accepted leg, a `same_kind` +/// payload collision, or a non-`Typed` host — all resolve to "keep the prop". +fn strip_misplaced_pt_props_from_or_legs(filters: &mut [TargetFilter]) { + let rehomed = pt_hosting_leg_props(filters); + if rehomed.is_empty() { + // Nothing to relocate onto — never strip (invariant 5). + return; + } + for f in filters.iter_mut() { + let TargetFilter::Typed(typed) = f else { + continue; + }; + if !leg_pins_noncreature_core_type(&typed.type_filters) { + continue; + } + typed + .properties + .retain(|p| !(prop_reads_creature_pt(p) && rehomed.iter().any(|q| q == p))); + } +} + /// Distribute trailing filter properties (Cmc, PtComparison, etc.) /// from the last `Typed` element in an `Or` filter to all preceding `Typed` /// elements that lack a property of the same kind. /// Handles "artifacts and creatures with mana value 2 or less" where only the /// final type parses the "with mana value N or less/greater" suffix. /// -/// CR 700.4: Only distributes props produced by trailing-suffix parsers. Props +/// CR 700.9: Only distributes props produced by trailing-suffix parsers. Props /// produced by adjective prefixes (e.g. FilterProp::Modified from "modified /// creatures", FilterProp::EnchantedBy from "enchanted creature") are /// leg-local and retained only on their originating leg. See /// `is_adjective_prefix_prop`. /// +/// CR 208.1 + CR 208.3: a power/toughness restriction is additionally gated +/// per-leg by `prop_distributes_to_leg`, because a noncreature permanent has no +/// power or toughness. "Destroy target artifact, enchantment, or creature with +/// power 4 or greater" (Make Your Move; Exorcise) binds the restriction to the +/// creature disjunct only — the same binding CR-agnostic keyword suffixes +/// already get via `is_adjective_prefix_prop` (#2941). Because right-recursion +/// makes the LAST noun the harvest source, gating alone is not enough when +/// `creature` is not last, so `strip_misplaced_pt_props_from_or_legs` relocates +/// the restriction off the noncreature origin leg — per prop, and only against +/// an exactly-equal witness on a creature-guaranteeing sibling leg, so a printed +/// restriction is never silently deleted. +/// +/// NOTE: `parse_type_phrase_with_ctx` calls this on EVERY separator merge, not +/// once at the top; both the gate and the sweep are therefore written to be +/// idempotent and to no-op harmlessly at intermediate recursion levels. +/// /// Exposed `pub(crate)` so disjunctive grammars that compose their own `Or` from /// independently-parsed disjuncts can reuse this shared trailing-suffix /// distribution instead of duplicating it. In particular the search-filter @@ -4415,7 +4924,10 @@ pub(crate) fn distribute_properties_to_or(filter: TargetFilter) -> TargetFilter }; // Collect trailing-suffix properties from the last Typed element. Filter - // out adjective-prefix props (CR 700.4, etc.) that are leg-local. + // out adjective-prefix props (CR 700.9, etc.) that are leg-local. + // Deliberately NOT filtered by `prop_distributes_to_leg`: this closure + // selects the harvest SOURCE (returning `None` falls through to an earlier + // leg), and the receiving leg's types are not known here. let trailing_props: Vec = filters .iter() .rev() @@ -4441,7 +4953,11 @@ pub(crate) fn distribute_properties_to_or(filter: TargetFilter) -> TargetFilter for f in &mut filters { if let TargetFilter::Typed(ref mut typed) = f { for prop in &trailing_props { - if !typed.properties.iter().any(|p| prop.same_kind(p)) { + // CR 208.3: never push a power/toughness restriction onto a + // leg pinned to a noncreature core type. + if prop_distributes_to_leg(prop, typed) + && !typed.properties.iter().any(|p| prop.same_kind(p)) + { typed.properties.push(prop.clone()); } } @@ -4449,6 +4965,11 @@ pub(crate) fn distribute_properties_to_or(filter: TargetFilter) -> TargetFilter } } + // Runs unconditionally, OUTSIDE the `trailing_props` guard: a mis-placed + // origin prop must still be relocated when the harvest found nothing to + // distribute (e.g. every candidate prop was adjective-prefix). + strip_misplaced_pt_props_from_or_legs(&mut filters); + TargetFilter::Or { filters } } @@ -5043,8 +5564,12 @@ fn parse_attacking_status_clause_boundary(input: &str) -> OracleResult<'_, ()> { /// Parse "with power [or toughness] N or less/greater", "with toughness N or /// less/greater", and "with greater power" suffixes. Returns `(FilterProp, -/// bytes consumed from the original text)`. CR 208 governs P/T comparisons; -/// CR 509.1b covers the source-relative "greater power" form. +/// bytes consumed from the original text)`. CR 208.1 + CR 208.3: power and +/// toughness are creature characteristics, which is why every prop this +/// function emits is registered in `prop_reads_creature_pt` and does not +/// distribute onto a noncreature `Or` leg. CR 509.1b is the *context* rule for +/// the source-relative "greater power" form (every printed card carrying it is +/// a blocking restriction) — not the authority for reading power. /// /// The P/T-comparison grammar (including the disjunctive "power or toughness" /// form and the optional "base " scope marker per CR 208.4b) is delegated in @@ -5057,9 +5582,12 @@ fn parse_attacking_status_clause_boundary(input: &str) -> OracleResult<'_, ()> { fn parse_power_suffix(text: &str, ctx: &mut ParseContext) -> Option<(FilterProp, usize)> { let trimmed = text.trim_start(); - // CR 509.1b: "with greater power" — relative to the source object. This is - // source-relative (not a numeric threshold) and is not part of the shared - // P/T-comparison combinator, so it is handled here. + // CR 208.1 + CR 509.1b: "with greater power" — relative to the source + // object. CR 208.1 is the authority for power being the characteristic + // compared; CR 509.1b is the blocking-restriction context every printed + // card carrying this form lives in. Source-relative (not a numeric + // threshold) and not part of the shared P/T-comparison combinator, so it is + // handled here. if let Ok((after, _)) = tag::<_, _, OracleError<'_>>("with greater power").parse(trimmed) { return Some((FilterProp::PowerGTSource, text.len() - after.len())); } @@ -12794,7 +13322,7 @@ mod tests { #[test] fn modified_adjective_creates_filter_prop() { - // CR 700.4 + CR 700.9: "modified creature" is a first-class adjective + // CR 700.9: "modified creature" is a first-class adjective // attaching FilterProp::Modified to a typed creature filter. let (f, rest) = parse_type_phrase("modified creature you control"); assert_eq!( @@ -12880,7 +13408,7 @@ mod tests { #[test] fn modified_adjective_in_comma_list_silkguard() { - // CR 700.4 + CR 700.9: Silkguard — "Auras, Equipment, and modified + // CR 700.9: Silkguard — "Auras, Equipment, and modified // creatures you control gain hexproof". The subject is a three-way OR // of Aura (subtype), Equipment (subtype), and creature-with-Modified. // The trailing "you control" controller scope distributes across all @@ -13447,7 +13975,7 @@ mod tests { #[test] fn historic_adjective_does_not_propagate_to_or_legs() { - // CR 700.6 + CR 700.4: `FilterProp::Historic` is leg-local — in a + // CR 700.6: `FilterProp::Historic` is leg-local — in a // comma OR list it must NOT distribute back to earlier legs. Mirrors // the Modified adjective handling for Silkguard. let (f, _rest) = parse_type_phrase("artifacts and historic creatures you control"); @@ -14131,6 +14659,755 @@ mod tests { ); } + // --------------------------------------------------------------------- + // CR 208.1 + CR 208.3: a postnominal power/toughness restriction on a + // coordinated card-type list binds to the CREATURE disjunct only, because a + // noncreature permanent has no power or toughness. Cards: Make Your Move + // ("Destroy target artifact, enchantment, or creature with power 4 or + // greater."), Exorcise (same shape, Exile). + // --------------------------------------------------------------------- + + fn power_ge_4() -> FilterProp { + FilterProp::PtComparison { + stat: PtStat::Power, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 4 }, + } + } + + fn typed_or_leg(filters: &[TargetFilter], idx: usize) -> &TypedFilter { + match &filters[idx] { + TargetFilter::Typed(tf) => tf, + other => panic!("leg {idx} should be Typed, got {other:?}"), + } + } + + fn has_pt_prop(tf: &TypedFilter) -> bool { + tf.properties.iter().any(prop_reads_creature_pt) + } + + /// Matrix row 1 — Make Your Move / Exorcise, `creature` final. + /// CR 208.3: artifact and enchantment legs must carry no P/T restriction. + #[test] + fn comma_or_pt_suffix_stays_on_final_disjunct_only() { + let (f, rest) = + parse_target("target artifact, enchantment, or creature with power 4 or greater"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert_eq!( + filters.len(), + 3, + "expected three disjuncts, got {filters:?}" + ); + + let artifact = typed_or_leg(filters, 0); + let enchantment = typed_or_leg(filters, 1); + let creature = typed_or_leg(filters, 2); + + assert!(has_type(artifact, TypeFilter::Artifact)); + assert!( + !has_pt_prop(artifact), + "power restriction must not distribute onto artifact leg: {artifact:?}" + ); + assert!(has_type(enchantment, TypeFilter::Enchantment)); + assert!( + !has_pt_prop(enchantment), + "power restriction must not distribute onto enchantment leg: {enchantment:?}" + ); + // Reach-guard: the suffix really parsed and really reached the + // distributor, so the two absence assertions above are not vacuous. + assert!(has_type(creature, TypeFilter::Creature)); + assert!( + has_prop(creature, power_ge_4()), + "creature leg must retain the power restriction: {creature:?}" + ); + } + + /// Matrix row 1 (hostile) — Atraxa's Fall skeleton with a `Battle` leg. + /// CR 205.2a: battle is a card type distinct from creature. + #[test] + fn comma_or_pt_suffix_skips_battle_leg_too() { + let (f, rest) = parse_target( + "target artifact, battle, enchantment, or creature with power 4 or greater", + ); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert_eq!(filters.len(), 4, "expected four disjuncts, got {filters:?}"); + + for idx in 0..3 { + let leg = typed_or_leg(filters, idx); + assert!( + !has_pt_prop(leg), + "noncreature leg {idx} must not carry the power restriction: {leg:?}" + ); + } + let creature = typed_or_leg(filters, 3); + assert!(has_type(creature, TypeFilter::Creature)); + assert!( + has_prop(creature, power_ge_4()), + "creature leg must retain the power restriction: {creature:?}" + ); + } + + /// Matrix row 2 — the `AnyOf` ("power or toughness N or greater") form is + /// gated by the same recursion in `prop_reads_creature_pt`. + #[test] + fn comma_or_any_of_pt_suffix_stays_on_final_disjunct_only() { + let (f, rest) = parse_target( + "target artifact, enchantment, or creature with power or toughness 4 or greater", + ); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert_eq!( + filters.len(), + 3, + "expected three disjuncts, got {filters:?}" + ); + + for idx in 0..2 { + let leg = typed_or_leg(filters, idx); + assert!( + !leg.properties + .iter() + .any(|p| matches!(p, FilterProp::AnyOf { .. })), + "noncreature leg {idx} must not carry the AnyOf P/T restriction: {leg:?}" + ); + } + let creature = typed_or_leg(filters, 2); + let anyof = creature + .properties + .iter() + .find(|p| matches!(p, FilterProp::AnyOf { .. })) + .unwrap_or_else(|| panic!("creature leg must retain the AnyOf: {creature:?}")); + assert!( + prop_reads_creature_pt(anyof), + "the retained AnyOf must be an all-P/T disjunction: {anyof:?}" + ); + } + + /// Matrix row 3 — the gate is prop-scoped, not a blanket block. CR 202.3: + /// every object has a mana value, so `Cmc` legitimately distributes. + /// This is also the ordering control for row 5: same creature-mid-list + /// shape (March of Otherworldly Light), opposite outcome. + #[test] + fn comma_or_cmc_suffix_still_distributes_to_every_leg() { + let (f, rest) = + parse_target("target artifact, creature, or enchantment with mana value 3 or less"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert_eq!( + filters.len(), + 3, + "expected three disjuncts, got {filters:?}" + ); + for idx in 0..3 { + let leg = typed_or_leg(filters, idx); + assert!( + leg.properties + .iter() + .any(|p| matches!(p, FilterProp::Cmc { .. })), + "leg {idx} must keep the mana-value restriction: {leg:?}" + ); + } + } + + /// Matrix row 3 (sibling) — Eliminate: `Planeswalker` must not be + /// over-blocked; CR 202.3 mana value distributes to it. + #[test] + fn creature_or_planeswalker_cmc_suffix_distributes_to_both_legs() { + let (f, rest) = parse_target("target creature or planeswalker with mana value 3 or less"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert_eq!(filters.len(), 2, "expected two disjuncts, got {filters:?}"); + for idx in 0..2 { + let leg = typed_or_leg(filters, idx); + assert!( + leg.properties + .iter() + .any(|p| matches!(p, FilterProp::Cmc { .. })), + "leg {idx} must keep the mana-value restriction: {leg:?}" + ); + } + } + + /// Matrix row 4 — the gate keys on a noncreature CORE-TYPE pin, not on leg + /// position and not on "a type word is present". CR 205.3a/205.3c: a bare + /// subtype pins no card type, so a creature-subtype leg still receives the + /// restriction. No printed card — structural guard, hand-built input. + #[test] + fn pt_distribution_keys_on_core_type_pin_not_leg_position() { + let input = TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Subtype("Goblin".to_string())], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![ + TypeFilter::Creature, + TypeFilter::Subtype("Dwarf".to_string()), + ], + properties: vec![power_ge_4()], + ..Default::default() + }), + ], + }; + let TargetFilter::Or { filters } = distribute_properties_to_or(input) else { + panic!("expected Or"); + }; + assert!( + has_prop(typed_or_leg(&filters, 0), power_ge_4()), + "a bare-subtype leg pins no core card type and must receive the restriction" + ); + assert!( + has_prop(typed_or_leg(&filters, 1), power_ge_4()), + "the originating creature leg must keep its own restriction" + ); + } + + /// Matrix row 4 (multi-authority hostile) — CR 205.2b: an object can have + /// more than one card type. BOTH legs pin a noncreature core type AND + /// `Creature`, so both must receive the restriction. Same `Artifact` word as + /// row 1, opposite outcome, decided solely by the co-present `Creature` pin. + #[test] + fn pt_distribution_reaches_legs_that_also_pin_creature() { + let input = TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact, TypeFilter::Creature], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Enchantment, TypeFilter::Creature], + properties: vec![power_ge_4()], + ..Default::default() + }), + ], + }; + let TargetFilter::Or { filters } = distribute_properties_to_or(input) else { + panic!("expected Or"); + }; + assert!( + has_prop(typed_or_leg(&filters, 0), power_ge_4()), + "artifact CREATURE leg must receive the restriction (CR 205.2b)" + ); + assert!( + has_prop(typed_or_leg(&filters, 1), power_ge_4()), + "enchantment CREATURE leg must keep the restriction (CR 205.2b)" + ); + } + + /// Matrix row 5 — creature NOT last. Right-recursion makes the enchantment + /// leg parse the suffix, so gating alone would leave a vacuous + /// `Enchantment{Pt}`. The relocation sweep must move it, not merely block + /// distribution. No printed card has this ordering with a P/T suffix today; + /// March of Otherworldly Light proves WotC does write `creature` mid-list. + #[test] + fn pt_suffix_relocates_to_creature_leg_when_creature_is_not_final() { + let (f, rest) = + parse_target("target artifact, creature, or enchantment with power 4 or greater"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert_eq!( + filters.len(), + 3, + "expected three disjuncts, got {filters:?}" + ); + // Flatness precondition: `merge_or_filters` splices nested `Or` legs + // into the parent, which is what lets the OUTER merge see the + // enchantment leg at depth 1 and relocate off it. + assert!( + !filters + .iter() + .any(|leg| matches!(leg, TargetFilter::Or { .. })), + "leg list must be flat: {filters:?}" + ); + + let artifact = typed_or_leg(filters, 0); + let creature = typed_or_leg(filters, 1); + let enchantment = typed_or_leg(filters, 2); + + assert!(has_type(artifact, TypeFilter::Artifact)); + assert!(has_type(creature, TypeFilter::Creature)); + assert!(has_type(enchantment, TypeFilter::Enchantment)); + + assert!( + !has_pt_prop(artifact), + "artifact leg must carry no P/T restriction: {artifact:?}" + ); + assert!( + !has_pt_prop(enchantment), + "the ORIGIN enchantment leg must be relocated off, not merely gated: {enchantment:?}" + ); + assert!( + has_prop(creature, power_ge_4()), + "creature leg must host the relocated restriction: {creature:?}" + ); + + // The heal is the OUTER merge, by design. The shape the INNER merge + // produces has no creature leg, so the sweep must no-op there. + let intermediate = TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Enchantment], + properties: vec![power_ge_4()], + ..Default::default() + }), + ], + }; + assert_eq!( + distribute_properties_to_or(intermediate.clone()), + intermediate, + "intermediate recursion level must be a no-op" + ); + } + + /// Matrix row 5 (hostile ordering) — creature FIRST. + #[test] + fn pt_suffix_relocates_to_creature_leg_when_creature_is_first() { + let (f, rest) = + parse_target("target creature, artifact, or enchantment with power 4 or greater"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert_eq!( + filters.len(), + 3, + "expected three disjuncts, got {filters:?}" + ); + let creature = typed_or_leg(filters, 0); + assert!(has_type(creature, TypeFilter::Creature)); + assert!( + has_prop(creature, power_ge_4()), + "creature leg must host the relocated restriction: {creature:?}" + ); + for idx in 1..3 { + let leg = typed_or_leg(filters, idx); + assert!( + !has_pt_prop(leg), + "noncreature leg {idx} must carry no P/T restriction: {leg:?}" + ); + } + } + + /// Matrix row 6 — no creature-guaranteeing leg exists, so the witness set is + /// empty and the sweep must NOT fire. Relocation invariant: never delete. + /// No printed card — structural guard, hand-built input. + #[test] + fn pt_suffix_survives_on_origin_leg_when_no_creature_disjunct_exists() { + let input = TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Enchantment], + properties: vec![power_ge_4()], + ..Default::default() + }), + ], + }; + let TargetFilter::Or { filters } = distribute_properties_to_or(input) else { + panic!("expected Or"); + }; + assert!( + has_prop(typed_or_leg(&filters, 1), power_ge_4()), + "with no creature leg to rehome onto, the origin leg must keep its restriction" + ); + // The push-site gate is independently load-bearing: it must still have + // blocked the artifact leg even though the sweep did nothing. + assert!( + !has_pt_prop(typed_or_leg(&filters, 0)), + "the push-site gate must block the artifact leg regardless of the sweep" + ); + } + + /// Matrix row 7 — `FilterProp::same_kind` is discriminant-only, so a + /// different-payload sibling prop suppresses the push. Without a per-prop + /// `==` witness the sweep would DELETE a printed restriction that was never + /// rehomed. No printed card produces this shape — structural guard. The + /// retained AST is deliberately imperfect-but-faithful (a vacuous P/T + /// restriction on an enchantment leg) rather than lossy. Rejected + /// alternative: making `same_kind` payload-sensitive — it is the dedupe + /// authority for every `distribute_*` function, so that has unbounded blast + /// radius. + #[test] + fn pt_suffix_survives_when_same_kind_dedupe_blocks_rehoming() { + let toughness_ge_2 = FilterProp::PtComparison { + stat: PtStat::Toughness, + scope: PtValueScope::Current, + comparator: Comparator::GE, + value: QuantityExpr::Fixed { value: 2 }, + }; + let input = TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![toughness_ge_2.clone()], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Enchantment], + properties: vec![power_ge_4()], + ..Default::default() + }), + ], + }; + let TargetFilter::Or { filters } = distribute_properties_to_or(input) else { + panic!("expected Or"); + }; + assert!( + has_prop(typed_or_leg(&filters, 1), power_ge_4()), + "restriction was never rehomed, so it must not be stripped" + ); + // Reach-guard: the `same_kind` suppression really fired, so the + // assertion above cannot pass because the collision never happened. + assert_eq!( + typed_or_leg(&filters, 0).properties, + vec![toughness_ge_2], + "creature leg must be unchanged — same_kind suppressed the push" + ); + } + + /// Matrix row 8 — `distribute_shared_properties` (the left-to-right path, + /// called unconditionally on every type-disjunction merge) carries the + /// identical CR 208.3 gate. Both polarities in one test so it cannot pass on + /// a distributor that simply stopped distributing. Nested one level to + /// exercise the recursive arm. + #[test] + fn shared_property_distribution_skips_noncreature_pinned_legs() { + let inner = || TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + ..Default::default() + }), + ], + }; + let nested = || TargetFilter::Or { + filters: vec![inner()], + }; + + let TargetFilter::Or { filters: outer } = + distribute_shared_properties(nested(), &[power_ge_4()]) + else { + panic!("expected Or"); + }; + let TargetFilter::Or { filters } = &outer[0] else { + panic!("expected nested Or"); + }; + assert!( + !has_pt_prop(typed_or_leg(filters, 0)), + "artifact leg must not receive the P/T restriction (CR 208.3)" + ); + assert!( + has_prop(typed_or_leg(filters, 1), power_ge_4()), + "creature leg must receive the P/T restriction" + ); + + // CR 202.3 paired positive: mana value is universal and still reaches + // both legs through the same function. + let cmc = FilterProp::Cmc { + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 3 }, + }; + let TargetFilter::Or { filters: outer } = + distribute_shared_properties(nested(), std::slice::from_ref(&cmc)) + else { + panic!("expected Or"); + }; + let TargetFilter::Or { filters } = &outer[0] else { + panic!("expected nested Or"); + }; + assert!(has_prop(typed_or_leg(filters, 0), cmc.clone())); + assert!(has_prop(typed_or_leg(filters, 1), cmc)); + } + + /// Matrix row 9 — a leg named ONLY by a noncreature subtype is gated exactly + /// like the spelled-out card-type word. CR 205.3d: an object can't have a + /// subtype that doesn't correspond to one of its types, and CR 205.3g/205.3h + /// put Vehicle/Equipment in the artifact pool and Aura in the enchantment + /// pool — so those legs pin a noncreature card type. CR 301.7a: a Vehicle has + /// its printed power only while it is also a creature, which the creature + /// disjunct already covers. + /// + /// `Suit Up` shows the engine really does produce a bare + /// `Typed{[Subtype("Vehicle")]}` leg beside a `Creature` leg, so this is the + /// live shape, not a hypothetical one. + #[test] + fn pt_suffix_skips_legs_named_by_a_noncreature_subtype() { + for (text, subtype) in [ + ( + "target creature or Vehicle with power 4 or greater", + "Vehicle", + ), + ( + "target creature or Equipment with power 4 or greater", + "Equipment", + ), + ] { + let (f, rest) = parse_target(text); + assert!(rest.trim().is_empty(), "{text}: remainder '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("{text}: expected Or filter, got {f:?}"); + }; + assert_eq!(filters.len(), 2, "{text}: {filters:?}"); + let creature = typed_or_leg(filters, 0); + let subtype_leg = typed_or_leg(filters, 1); + assert!( + has_type(subtype_leg, TypeFilter::Subtype(subtype.to_string())), + "{text}: second leg should be the bare subtype leg: {subtype_leg:?}" + ); + assert!( + !has_pt_prop(subtype_leg), + "{text}: CR 208.3 — the {subtype} leg must carry no P/T restriction: \ + {subtype_leg:?}" + ); + assert!( + has_prop(creature, power_ge_4()), + "{text}: the creature leg must keep the restriction: {creature:?}" + ); + } + } + + /// Matrix row 9 (hostile, enchantment pool + creature-subtype control) — + /// CR 205.3h puts Aura in the enchantment pool, so an `Aura` leg is gated; + /// CR 205.3m creature types are NOT (a Goblin leg keeps the restriction, + /// because a Goblin permanent can be a creature with real power). + #[test] + fn pt_suffix_gates_enchantment_subtype_but_not_creature_subtype() { + let (f, rest) = parse_target("target Aura, artifact, or creature with power 4 or greater"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert_eq!(filters.len(), 3, "{filters:?}"); + assert!(has_type( + typed_or_leg(filters, 0), + TypeFilter::Subtype("Aura".to_string()) + )); + assert!( + !has_pt_prop(typed_or_leg(filters, 0)), + "CR 205.3h + CR 208.3: the Aura leg must carry no P/T restriction: {filters:?}" + ); + assert!( + !has_pt_prop(typed_or_leg(filters, 1)), + "artifact leg must carry no P/T restriction: {filters:?}" + ); + assert!( + has_prop(typed_or_leg(filters, 2), power_ge_4()), + "creature leg must keep the restriction: {filters:?}" + ); + + // Creature-subtype control on the identical grammar: the restriction + // stays, because a Goblin permanent can be a creature (CR 205.3m). + let (f, rest) = + parse_target("target artifact, enchantment, or Goblin with power 4 or greater"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert!( + has_prop(typed_or_leg(filters, 2), power_ge_4()), + "CR 205.3m: a creature-subtype leg must still receive the restriction: {filters:?}" + ); + } + + /// Matrix row 10 — the relocation sweep must be able to rehome onto a leg + /// that is a creature only by CREATURE SUBTYPE. Right-recursion parses the + /// suffix on the LAST noun (the enchantment leg), so with a witness scan + /// keyed on `type_filter_guarantees_creature` the Goblin leg would not count + /// as a host and the enchantment leg would keep a vacuous restriction — the + /// exact Make Your Move defect, reproduced for a subtype-headed list. + /// `pt_hosting_leg_props` keys on the gate's complement instead, so the + /// Goblin leg is the witness and the enchantment leg is swept clean. + #[test] + fn pt_suffix_relocates_onto_a_creature_subtype_leg() { + let (f, rest) = + parse_target("target Goblin, artifact, or enchantment with power 4 or greater"); + assert!(rest.trim().is_empty(), "remainder: '{rest}'"); + let TargetFilter::Or { filters } = &f else { + panic!("expected Or filter, got {f:?}"); + }; + assert_eq!(filters.len(), 3, "{filters:?}"); + + let goblin = typed_or_leg(filters, 0); + let artifact = typed_or_leg(filters, 1); + let enchantment = typed_or_leg(filters, 2); + assert!(has_type(goblin, TypeFilter::Subtype("Goblin".to_string()))); + assert!(has_type(artifact, TypeFilter::Artifact)); + assert!(has_type(enchantment, TypeFilter::Enchantment)); + + assert!( + has_prop(goblin, power_ge_4()), + "the Goblin leg must host the relocated restriction: {goblin:?}" + ); + assert!( + !has_pt_prop(enchantment), + "the ORIGIN enchantment leg must be relocated off, not left vacuous: {enchantment:?}" + ); + assert!( + !has_pt_prop(artifact), + "artifact leg must carry no P/T restriction: {artifact:?}" + ); + } + + /// Matrix row 11 — `prop_reads_creature_pt` is an exhaustive registry, not a + /// wildcard. `SharesQuality{Power}` reads the same CR 208.1 characteristic as + /// `PtComparison`, and on a noncreature leg it is worse than a dead + /// restriction: `game::filter::pt_value_from_pair` reads the missing power as + /// 0, so an ungated enchantment leg would MATCH whenever the reference object + /// has power 0. No printed card produces this shape (`Wild Pair` is the only + /// `SharesQuality{TotalPowerToughness}` card and it is a single `Typed`, not + /// an `Or`) — structural guard, hand-built input. + #[test] + fn shares_quality_power_is_registered_as_a_pt_reading_prop() { + let shares_power = FilterProp::SharesQuality { + quality: SharedQuality::Power, + relation: SharedQualityRelation::Shares, + reference: None, + }; + let input = TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Enchantment], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![shares_power.clone()], + ..Default::default() + }), + ], + }; + let TargetFilter::Or { filters } = distribute_properties_to_or(input) else { + panic!("expected Or"); + }; + assert!( + !has_prop(typed_or_leg(&filters, 0), shares_power.clone()), + "CR 208.3: 'shares a power with' must not reach the enchantment leg: {filters:?}" + ); + assert!( + has_prop(typed_or_leg(&filters, 1), shares_power), + "the creature leg must keep it: {filters:?}" + ); + + // Same variant, non-P/T quality: CR 201.2a defines shared names for any + // two objects regardless of card type, so this one still distributes. + // Without this control the test would pass on a gate that blocked + // `SharesQuality` wholesale. + let shares_name = FilterProp::SharesQuality { + quality: SharedQuality::Name, + relation: SharedQualityRelation::Shares, + reference: None, + }; + let input = TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Enchantment], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![shares_name.clone()], + ..Default::default() + }), + ], + }; + let TargetFilter::Or { filters } = distribute_properties_to_or(input) else { + panic!("expected Or"); + }; + assert!( + has_prop(typed_or_leg(&filters, 0), shares_name), + "CR 201.2a: a shared-NAME predicate must still distribute: {filters:?}" + ); + } + + /// Matrix row 12 — `finalize_or_disjunction` is the single authority for the + /// order in which a merged disjunction is finished, and BOTH type backfills + /// must precede BOTH property distributors. Before this ordering was fixed, + /// `distribute_shared_properties` ran first and inspected legs still holding + /// `[TypeFilter::Any]`, so the CR 208.3 gate could not see that the leg was + /// about to become an enchantment leg. + /// + /// The `[Any]` leg here is the shape `distribute_core_type_to_or` backfills + /// ("… or white enchantment": the bare-adjective leg is built before the type + /// noun is parsed). Reverting the ordering makes the first assertion fail. + #[test] + fn finalize_or_disjunction_backfills_types_before_distributing_props() { + let merged = || TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Any], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Enchantment], + ..Default::default() + }), + ], + }; + + let pt = power_ge_4(); + let TargetFilter::Or { filters } = + finalize_or_disjunction(merged(), std::slice::from_ref(&pt)) + else { + panic!("expected Or"); + }; + assert!( + has_type(typed_or_leg(&filters, 0), TypeFilter::Enchantment), + "precondition: the `Any` leg must be backfilled to the enchantment \ + type set: {filters:?}" + ); + assert!( + !has_pt_prop(typed_or_leg(&filters, 0)), + "CR 208.3: the backfilled enchantment leg must not receive the shared \ + P/T prop: {filters:?}" + ); + assert!( + !has_pt_prop(typed_or_leg(&filters, 1)), + "the spelled-out enchantment leg must not receive it either: {filters:?}" + ); + + // CR 202.3 control: a non-P/T shared prop is unaffected by the reorder + // and still reaches both legs, so the assertions above are not passing + // merely because shared distribution stopped working. + let cmc = FilterProp::Cmc { + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 3 }, + }; + let TargetFilter::Or { filters } = + finalize_or_disjunction(merged(), std::slice::from_ref(&cmc)) + else { + panic!("expected Or"); + }; + assert!(has_prop(typed_or_leg(&filters, 0), cmc.clone())); + assert!(has_prop(typed_or_leg(&filters, 1), cmc)); + } + #[test] fn comma_or_without_keyword_suffix_stays_on_final_disjunct_only() { let (f, rest) = parse_target("target artifact or creature without flying"); diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index aa8d567f5a..fd55a1258f 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -822,6 +822,7 @@ mod magmatic_scorchwing_intervening_if; mod magnetic_mountain_choose_and_pay; mod magus_of_the_abyss_scoped_chooser; mod make_an_example_pile_separation; +mod make_your_move_pt_suffix_binds_creature_leg; mod mana_autotap_preference; mod mana_cost_reducers_issue_141; mod mana_drain_refund; diff --git a/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs b/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs new file mode 100644 index 0000000000..338d59ad74 --- /dev/null +++ b/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs @@ -0,0 +1,332 @@ +//! Make Your Move ({2}{W} Instant): "Destroy target artifact, enchantment, or +//! creature with power 4 or greater." Exorcise is the same shape with Exile. +//! +//! CR 208.1: power and toughness are the two numbers printed on a CREATURE +//! card. CR 208.3: a noncreature permanent has NO power or toughness, even if +//! it's a card with a power and toughness printed on it (such as a Vehicle). +//! The postnominal "with power 4 or greater" therefore restricts only the +//! creature disjunct — an artifact or enchantment is a legal target regardless +//! of power. CR 115.1a: "target [something]" identifies the objects the spell +//! may affect; CR 601.2c: those targets are chosen as the spell is cast. +//! +//! Buggy parse (before this fix): +//! Or[ Typed{[Artifact], [PtComparison{Power,GE,4}]}, +//! Typed{[Enchantment], [PtComparison{Power,GE,4}]}, +//! Typed{[Creature], [PtComparison{Power,GE,4}]} ] +//! Because `game::filter::pt_value_from_pair` reads `power.unwrap_or(0)` for a +//! noncreature, the artifact and enchantment legs matched nothing at all — the +//! Disenchant half of the card was dead. +//! +//! Fixed parse (matches the already-correct Broken Wings / Vivien Reid shape): +//! Or[ Typed{[Artifact]}, Typed{[Enchantment]}, +//! Typed{[Creature], [PtComparison{Power,GE,4}]} ] +//! +//! The spell is built from verbatim Oracle text rather than the card database: +//! `data/card-data.json` is gitignored and the integration fixture is a +//! committed snapshot, so a DB-backed test would read a stale pre-fix parse and +//! go green while the card stayed broken. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::targeting::find_legal_targets; +use engine::game::zones::create_object; +use engine::types::ability::{Effect, TargetFilter, TargetRef}; +use engine::types::card_type::CoreType; +use engine::types::game_state::{GameState, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +/// Verbatim Scryfall Oracle text (oracle_id 8226f31d-6f51-49c3-87f7-0c68f7f4f9ce). +const MAKE_YOUR_MOVE: &str = + "Destroy target artifact, enchantment, or creature with power 4 or greater."; + +/// `{2}{W}` floating so no `ManaPayment` window surfaces during the cast. +fn make_your_move_mana() -> Vec { + let mut pool = vec![ManaUnit::new(ManaType::White, ObjectId(0), false, vec![])]; + for _ in 0..2 { + pool.push(ManaUnit::new( + ManaType::Colorless, + ObjectId(0), + false, + vec![], + )); + } + pool +} + +/// `GameScenario` has no artifact builder, so mirror the +/// `issue_2941_vivien_reid.rs` idiom: create the object, then push the core +/// type. Power/toughness stay `None` — CR 208.3, a noncreature has none. +fn add_noncreature_permanent( + state: &mut GameState, + card_id: u64, + player: PlayerId, + name: &str, + core_type: CoreType, +) -> ObjectId { + let oid = create_object( + state, + CardId(card_id), + player, + name.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&oid).expect("just created"); + obj.card_types.core_types.push(core_type); + obj.base_card_types = obj.card_types.clone(); + oid +} + +/// Build a runner with Make Your Move in P0's hand and enough floating mana. +fn setup() -> (GameScenario, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Make Your Move", true, MAKE_YOUR_MOVE) + .id(); + scenario.with_mana_pool(P0, make_your_move_mana()); + (scenario, spell) +} + +/// Pull the parsed `Destroy` target filter straight off the spell object, so the +/// runtime rows below and the AST row assert against the same value. +fn destroy_target_filter(state: &GameState, spell: ObjectId) -> TargetFilter { + state + .objects + .get(&spell) + .expect("spell object") + .abilities + .iter() + .find_map(|ability| match &*ability.effect { + Effect::Destroy { target, .. } => Some(target.clone()), + _ => None, + }) + .expect("Make Your Move should parse to a targeted Destroy") +} + +/// Row 11: a powerless noncreature artifact is a legal target and is destroyed. +/// This is the primary claim; on revert the artifact is not a legal target +/// (CR 208.3 gives it no power, so `PtComparison{Power,GE,4}` reads 0) and the +/// cast cannot complete. +#[test] +fn powerless_artifact_is_a_legal_target_and_is_destroyed() { + let (scenario, spell) = setup(); + let mut runner: GameRunner = scenario.build(); + let artifact = add_noncreature_permanent( + runner.state_mut(), + 9001, + P1, + "Opp Artifact", + CoreType::Artifact, + ); + + let outcome = runner.cast(spell).target_objects(&[artifact]).resolve(); + outcome.assert_zone(&[artifact], Zone::Graveyard); + assert!( + matches!(outcome.final_waiting_for(), WaitingFor::Priority { .. }), + "spell must resolve fully, not halt: {:?}", + outcome.final_waiting_for() + ); +} + +/// Row 15: the enchantment leg is genuinely reachable at runtime, not just +/// clean in the AST. A real 0-power enchantment (no Aura attachment). +#[test] +fn powerless_enchantment_is_a_legal_target_and_is_destroyed() { + let (scenario, spell) = setup(); + let mut runner = scenario.build(); + let enchantment = add_noncreature_permanent( + runner.state_mut(), + 9002, + P1, + "Opp Enchantment", + CoreType::Enchantment, + ); + + let outcome = runner.cast(spell).target_objects(&[enchantment]).resolve(); + outcome.assert_zone(&[enchantment], Zone::Graveyard); +} + +/// Row 13: the creature leg's restriction still works in the positive +/// direction — a 4-power creature is destroyed. Paired in-file with row 12 so +/// that negative cannot pass because the spell is simply broken. +#[test] +fn four_power_creature_is_a_legal_target_and_is_destroyed() { + let (mut scenario, spell) = setup(); + let creature = scenario.add_creature(P1, "Big Creature", 4, 4).id(); + let mut runner = scenario.build(); + + let outcome = runner.cast(spell).target_objects(&[creature]).resolve(); + outcome.assert_zone(&[creature], Zone::Graveyard); +} + +/// Row 12: the creature leg keeps its restriction — the fix did not simply drop +/// it. A 2-power creature is NOT among the legal targets. Asserted through +/// `find_legal_targets` rather than a driver panic so the failure mode is an +/// assertion, and paired with a positive reach-guard in the same test proving +/// the filter is live (the 4-power creature IS legal). +#[test] +fn two_power_creature_is_not_a_legal_target_but_four_power_is() { + let (mut scenario, spell) = setup(); + let small = scenario.add_creature(P1, "Small Creature", 2, 2).id(); + let big = scenario.add_creature(P1, "Big Creature", 4, 4).id(); + let runner = scenario.build(); + + let filter = destroy_target_filter(runner.state(), spell); + let legal = find_legal_targets(runner.state(), &filter, P0, spell); + + assert!( + !legal.contains(&TargetRef::Object(small)), + "2-power creature must not satisfy 'power 4 or greater': {legal:?}" + ); + // Positive reach-guard: the creature leg is live, so the negative above is + // not vacuous. + assert!( + legal.contains(&TargetRef::Object(big)), + "4-power creature must satisfy 'power 4 or greater': {legal:?}" + ); +} + +/// Row 14: the single case that separates the two readings. A 2/2 ARTIFACT +/// creature has power 2, so it fails the creature disjunct — but CR 205.2b (an +/// object with more than one card type satisfies any effect applying to any of +/// them) plus CR 115.1a mean it satisfies the bare `artifact` disjunct. Illegal +/// under the buggy distributed parse, legal under the leg-local parse. +#[test] +fn two_power_artifact_creature_is_legal_via_the_bare_artifact_leg() { + let (mut scenario, spell) = setup(); + let artifact_creature = scenario.add_creature(P1, "Small Servo", 2, 2).id(); + let mut runner = scenario.build(); + { + let obj = runner + .state_mut() + .objects + .get_mut(&artifact_creature) + .expect("artifact creature"); + obj.card_types.core_types.push(CoreType::Artifact); + obj.base_card_types = obj.card_types.clone(); + } + + let outcome = runner + .cast(spell) + .target_objects(&[artifact_creature]) + .resolve(); + outcome.assert_zone(&[artifact_creature], Zone::Graveyard); +} + +/// Row 16: the same binding for a leg named ONLY by a noncreature SUBTYPE. +/// CR 205.3d + CR 205.3g: Vehicle is an artifact type, so "creature or Vehicle" +/// pins the artifact card type on the second disjunct even though no card-type +/// word is printed there. CR 301.7a: a Vehicle has its printed power only while +/// it's also a creature, so an uncrewed Vehicle has no power (CR 208.3) — the +/// restriction must bind to the creature disjunct, leaving the Vehicle leg +/// targetable. Under the buggy distribution the Vehicle leg carries +/// `PtComparison{Power,GE,4}` and `pt_value_from_pair`'s `power.unwrap_or(0)` +/// makes it match nothing, so this assertion flips on revert. +/// +/// No printed card prints this exact wording today, so the Oracle text here is a +/// structural fixture for the class (`Suit Up` prints the same "creature or +/// Vehicle" disjunct without the power clause), not a card reproduction. +#[test] +fn uncrewed_vehicle_leg_is_targetable_but_small_creature_is_not() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Vehicle Class Guard", + true, + "Destroy target creature or Vehicle with power 4 or greater.", + ) + .id(); + scenario.with_mana_pool(P0, make_your_move_mana()); + let small = scenario.add_creature(P1, "Small Creature", 2, 2).id(); + let mut runner: GameRunner = scenario.build(); + + // An uncrewed Vehicle: an artifact with the Vehicle subtype and NO live + // power/toughness (CR 301.7a + CR 208.3). + let vehicle = create_object( + runner.state_mut(), + CardId(9003), + P1, + "Opp Vehicle".to_string(), + Zone::Battlefield, + ); + { + let obj = runner + .state_mut() + .objects + .get_mut(&vehicle) + .expect("just created"); + obj.card_types.core_types.push(CoreType::Artifact); + obj.card_types.subtypes.push("Vehicle".to_string()); + obj.base_card_types = obj.card_types.clone(); + } + + // Positive reach-guard on the other half, taken before the cast: the + // creature disjunct still enforces the restriction, so "the Vehicle is + // legal" below is not "the filter matches everything". + let filter = destroy_target_filter(runner.state(), spell); + let legal = find_legal_targets(runner.state(), &filter, P0, spell); + assert!( + legal.contains(&TargetRef::Object(vehicle)), + "CR 208.3: an uncrewed Vehicle has no power, so the Vehicle disjunct must \ + be unrestricted and the Vehicle legal: {legal:?}" + ); + assert!( + !legal.contains(&TargetRef::Object(small)), + "the creature leg must still reject a 2-power creature: {legal:?}" + ); + + let outcome = runner.cast(spell).target_objects(&[vehicle]).resolve(); + outcome.assert_zone(&[vehicle], Zone::Graveyard); +} + +/// AST-shape guard mirroring `issue_2941_vivien_reid.rs`: the parsed filter must +/// match the already-correct Broken Wings shape. Complements the runtime rows — +/// they prove the legs match objects, this pins exactly which leg carries the +/// restriction. +#[test] +fn parser_binds_power_restriction_to_creature_leg_only() { + let (scenario, spell) = setup(); + let runner = scenario.build(); + let filter = destroy_target_filter(runner.state(), spell); + + let TargetFilter::Or { filters } = &filter else { + panic!("expected an Or target filter, got {filter:?}"); + }; + assert_eq!(filters.len(), 3, "expected three disjuncts: {filters:?}"); + + for (idx, expected) in [ + (0usize, engine::types::ability::TypeFilter::Artifact), + (1, engine::types::ability::TypeFilter::Enchantment), + ] { + let TargetFilter::Typed(typed) = &filters[idx] else { + panic!("leg {idx} should be Typed: {:?}", filters[idx]); + }; + assert!(typed.type_filters.contains(&expected)); + assert!( + !typed + .properties + .iter() + .any(|p| matches!(p, engine::types::ability::FilterProp::PtComparison { .. })), + "CR 208.3: leg {idx} must carry no power restriction: {typed:?}" + ); + } + + let TargetFilter::Typed(creature) = &filters[2] else { + panic!("creature leg should be Typed: {:?}", filters[2]); + }; + assert!(creature + .type_filters + .contains(&engine::types::ability::TypeFilter::Creature)); + assert!( + creature + .properties + .iter() + .any(|p| matches!(p, engine::types::ability::FilterProp::PtComparison { .. })), + "creature leg must retain the power restriction: {creature:?}" + ); +} From 78aba56008f25e3c400d9a87956d7d838de79475 Mon Sep 17 00:00:00 2001 From: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:03:36 -0500 Subject: [PATCH 2/4] fix(parser): bind a P/T suffix only to legs that name a creature-capable type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #7472. The CR 208.3 gate this PR added answers "is a power/toughness restriction VACUOUS on this leg?", which is only half the binding question. A leg that names no card type at all is not vacuous, so it was accepted — and a restriction printed on a sibling `creature` noun silently narrowed a disjunct whose own text never mentioned creatures. Measured on the pre-fix head, through the real search grammar: a green card or a creature card with power 4 or greater -> [Card] leg wrongly inherits power >= 4 a permanent card or a creature card with power 4 or greater -> [Permanent] leg wrongly inherits it a card named Llanowar Elves or a creature card with power 4 or greater -> the name-only leg (no type filters) wrongly inherits it CR 208.1: a postnominal modifier binds to the noun it follows. None of those legs is that noun. `prop_distributes_to_leg` now consults a three-valued verdict, `leg_admits_creature_pt`: 1. CR 205.2b - the leg guarantees creature ("artifact creature") -> accept 2. CR 208.3 - the leg pins a noncreature card type -> reject 3. the leg names no card type scope at all -> reject Case 3 is decided by a new exhaustive sibling of the two predicates this PR already added, `type_filter_names_a_card_type_scope`. `Permanent`, `Card` and `Any` are "whatever its type" quantifiers naming no card type (CR 205.2a enumerates the card types; CR 110.1, CR 108.2); every other variant names a card type or a subtype pool (CR 205.3), so `Subtype("Goblin")` and `Non(_)` keep distributing and the CR 205.3m creature-subtype class is unaffected. The gate now FAILS CLOSED. A leg still naming no type - because a backfill has not run, or could not resolve it - is left unrestricted rather than wrongly restricted. Ordering is therefore load-bearing for precision, not for safety: `finalize_or_disjunction` still backfills first so resolvable `[Any]` legs are decided on their real type, but `oracle_effect::search`, which composes its own `Or` from independently parsed segments and runs no backfill, is now merely coarser rather than wrong. That is why the search grammar is deliberately NOT routed through `finalize_or_disjunction`: `distribute_core_type_to_or` rewrites only an exactly-`[Any]` leg, so it is a no-op on every leaking shape above, while it CAN project one segment's core type onto a standalone article-led segment that never named it. `pt_hosting_leg_props` / `strip_misplaced_pt_props_from_or_legs` stay keyed on `leg_pins_noncreature_core_type` and NOT on the new gate; the "exact complement" doc is corrected to say why. That sweep relocates a restriction off a leg where it is vacuous, while the gate refuses to place one where it does not belong. A type-open leg is ineligible under the gate but is not vacuous, so widening the sweep to it would delete a live predicate from the leg that syntactically parsed it - exactly what the PR's invariant 5 forbids. Tests: * `search_disjunction_leaves_type_open_legs_unbound_by_pt_suffix` - end to end through `parse_search_filter`, five rows covering `[Card]`, `[Permanent]`, no-type-filters, and a type-open leg sitting between a pinned leg and the creature leg. Each row asserts the creature leg RETAINS the predicate, the type-open leg does not acquire it, and the type-open leg's `type_filters` still equal exactly what its own text named (so a future backfill that narrows it fails here instead of passing silently). A CR 202.3 mana-value row is the discriminator against a blanket "never distribute to a generic leg". * `leg_admits_creature_pt_rejects_type_open_legs_but_keeps_creature_scopes` and `distribute_skips_pt_on_a_type_open_leg_but_still_distributes_cmc` - the same claims at the building-block level, with `Subtype("Goblin")` and CR 205.2b accept controls that a naive "reject unless provably creature" gate fails. Co-Authored-By: Claude Opus 5 --- .../engine/src/parser/oracle_effect/search.rs | 154 +++++++++ crates/engine/src/parser/oracle_target.rs | 291 ++++++++++++++++-- 2 files changed, 420 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/search.rs b/crates/engine/src/parser/oracle_effect/search.rs index 8f204b417e..c7db1a3975 100644 --- a/crates/engine/src/parser/oracle_effect/search.rs +++ b/crates/engine/src/parser/oracle_effect/search.rs @@ -1100,6 +1100,22 @@ fn parse_search_filter_disjunction(text: &str, ctx: &mut ParseContext) -> Option // Distribute that trailing predicate back onto the earlier `Typed` // legs via the shared leg-locality authority, which keeps inherently // leg-local props (keyword/name/adjective) on their originating leg. + // + // Deliberately NOT `finalize_or_disjunction`. Every segment here is + // parsed as a standalone filter phrase, so a type-open segment surfaces + // as `[TypeFilter::Card]` ("a green card", "a legendary card"), + // `[TypeFilter::Permanent]` ("a permanent card"), or no type filters at + // all ("a card named X") — see `parse_search_specialized_type_word`. + // `distribute_core_type_to_or` rewrites only an exactly-`[TypeFilter:: + // Any]` leg, so on those shapes it is a no-op. What it CAN do is project + // one segment's core type onto another's, narrowing a standalone + // article-led segment to a type its own text never named. + // + // CR 208.1 + CR 208.3 correctness for those type-open legs comes from + // the gate itself: `leg_admits_creature_pt` fails closed on a leg that + // names no card type, so "a green card or a creature card with power 4 + // or greater" leaves the green-card leg unrestricted without this + // grammar needing scope repair of its own. distribute_properties_to_or(filter) }) } @@ -5605,4 +5621,142 @@ mod tests { ); } } + + /// The type-OPEN half of the CR 208.3 binding, which the explicit-type rows + /// above cannot reach. + /// + /// `parse_search_filter_disjunction` composes its `Or` from independently + /// parsed segments and runs no type backfill, so a segment whose text names + /// no card type keeps a type-open scope: "a green card" and "a legendary + /// card" resolve to `[TypeFilter::Card]`, "a permanent card" to + /// `[TypeFilter::Permanent]`, and "a card named X" to no type filters at all + /// (`parse_search_specialized_type_word`'s `"card"` arm returns + /// `TypedFilter::default()`). Under a gate keyed only on "pins a NONCREATURE + /// core type" every one of those legs is accepted, so the trailing "with + /// power 4 or greater" — printed on the *creature* noun — silently narrowed + /// a disjunct whose own text never mentioned creatures (CR 208.1: the + /// postnominal modifier binds to the noun it follows). + /// + /// Both halves of the reviewer-requested claim are asserted per row: + /// 1. the creature-scoped leg RETAINS the power predicate, and + /// 2. the generic leg acquires NEITHER the power predicate NOR a type + /// restriction it did not print — the second half is what would break if + /// this grammar were "fixed" by routing through `finalize_or_disjunction`, + /// whose `distribute_core_type_to_or` projects one segment's core type + /// onto type-open siblings. + /// + /// The `Cmc` row at the end is the discriminator in the other direction: a + /// gate that simply refused to distribute anything to a type-open leg would + /// pass rows 1-2 and fail it (CR 202.3 — every object has a mana value, so a + /// generic leg MUST inherit that suffix). + #[test] + fn search_disjunction_leaves_type_open_legs_unbound_by_pt_suffix() { + /// Locate the one leg whose `type_filters` contain `Creature`, and the + /// one leg that names no card type at all. + fn split_legs<'a>( + filter: &'a TargetFilter, + label: &str, + ) -> (&'a TypedFilter, &'a TypedFilter) { + let TargetFilter::Or { filters } = filter else { + panic!("{label}: expected an Or filter, got {filter:?}"); + }; + let mut creature = None; + let mut generic = None; + for leg in filters { + let TargetFilter::Typed(typed) = leg else { + panic!("{label}: expected every leg Typed, got {leg:?}"); + }; + if typed.type_filters.contains(&TypeFilter::Creature) { + creature = Some(typed); + } else { + generic = Some(typed); + } + } + ( + creature.unwrap_or_else(|| panic!("{label}: no creature leg: {filter:?}")), + generic.unwrap_or_else(|| panic!("{label}: no type-open leg: {filter:?}")), + ) + } + + let has_pt = |typed: &TypedFilter| { + typed + .properties + .iter() + .any(|p| matches!(p, FilterProp::PtComparison { .. })) + }; + + // Each row pairs a type-open segment with the creature segment that + // actually carries the printed restriction. The expected type scope is + // spelled out so a future backfill that narrows the generic leg fails + // here rather than passing silently. + for (text, expected_open_scope) in [ + ( + "a green card or a creature card with power 4 or greater", + &[TypeFilter::Card][..], + ), + ( + "a legendary card or a creature card with power 4 or greater", + &[TypeFilter::Card][..], + ), + ( + "a permanent card or a creature card with power 4 or greater", + &[TypeFilter::Permanent][..], + ), + ( + "a card named Llanowar Elves or a creature card with power 4 or greater", + &[][..], + ), + // Three segments, so the type-open leg sits between a pinned + // noncreature leg and the creature leg rather than first. + ( + "an artifact, a green card, or a creature card with power 4 or greater", + &[TypeFilter::Card][..], + ), + ] { + let mut ctx = ParseContext::default(); + let filter = parse_search_filter(text, &mut ctx); + let (creature, generic) = split_legs(&filter, text); + + // (1) The creature leg keeps the restriction — without this the row + // would pass on a grammar that dropped the suffix entirely. + assert!( + has_pt(creature), + "{text}: the creature leg must retain the power restriction: {creature:?}" + ); + // (2a) The type-open leg did not acquire it. + assert!( + !has_pt(generic), + "{text}: CR 208.1 — 'with power 4 or greater' binds to the creature \ + noun, so the type-open leg must stay unrestricted: {generic:?}" + ); + // (2b) ...and did not acquire a type restriction either. + assert_eq!( + generic.type_filters, expected_open_scope, + "{text}: the type-open leg must keep exactly the scope its own \ + text named: {generic:?}" + ); + } + + // CR 202.3 discriminator: a type-open leg MUST still inherit a mana + // value suffix, so the gate above is P/T-specific and not a blanket + // "never distribute to a generic leg". + let mut ctx = ParseContext::default(); + let filter = parse_search_filter( + "a green card or a creature card with mana value 3 or less", + &mut ctx, + ); + let (creature, generic) = split_legs(&filter, "cmc control"); + for (label, typed) in [("creature", creature), ("type-open", generic)] { + assert!( + typed.properties.iter().any(|p| matches!( + p, + FilterProp::Cmc { + comparator: Comparator::LE, + .. + } + )), + "CR 202.3: the {label} leg must inherit the mana value suffix: {typed:?}" + ); + } + } } diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index 978cd6ac94..70e7904f62 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -4274,15 +4274,21 @@ fn stack_spell_filter(mut typed: TypedFilter) -> TargetFilter { /// order in which the controller/type backfills and the two property /// distributors must run. /// -/// ORDER IS LOAD-BEARING, and it is stated here once so the two property -/// distributors cannot disagree about it. Both `distribute_shared_properties` -/// (the left-to-right path) and `distribute_properties_to_or` (the -/// trailing-suffix path) consult the CR 208.3 gate `prop_distributes_to_leg`, -/// which reads each leg's `type_filters`. A leg assembled as `[TypeFilter::Any]` -/// (its type noun appeared only in a later disjunct) or one that has not yet -/// inherited a leading `Non(Creature)` does not yet know its own card type, so -/// BOTH backfills must complete first — otherwise the "with power N" binding -/// silently goes wrong on exactly those legs. +/// ORDER IS LOAD-BEARING FOR PRECISION, and it is stated here once so the two +/// property distributors cannot disagree about it. Both +/// `distribute_shared_properties` (the left-to-right path) and +/// `distribute_properties_to_or` (the trailing-suffix path) consult the CR 208.3 +/// gate `prop_distributes_to_leg`, which reads each leg's `type_filters`. A leg +/// assembled as `[TypeFilter::Any]` (its type noun appeared only in a later +/// disjunct) or one that has not yet inherited a leading `Non(Creature)` does +/// not yet know its own card type, so both backfills run first — otherwise the +/// "with power N" binding is decided on a leg that cannot yet answer. +/// +/// Running them first is what makes the binding PRECISE, not what makes it SAFE: +/// `leg_admits_creature_pt` fails closed on a leg that names no card type, so a +/// caller that skips the backfills gets a leg left unrestricted rather than one +/// wrongly restricted. Every caller should still use this function; the +/// fail-closed behavior is the floor, not the target. /// /// The backfills only add `TypeFilter`s and never read `properties`, so ordering /// the shared-prop push after them is inert for every non-P/T prop and strictly @@ -4736,6 +4742,101 @@ fn leg_pins_noncreature_core_type(type_filters: &[TypeFilter]) -> bool { type_filters.iter().any(is_noncreature_core_type_pin) } +/// Returns true when this single `TypeFilter` names a card-type SCOPE at all, as +/// opposed to one of the three type-open universals. +/// +/// CR 205.2a enumerates the card types; `Permanent` (CR 110.1: a permanent is a +/// card or token on the battlefield, whatever its type), `Card` (CR 108.2: a +/// reference to a "card" means only a Magic card or an object represented by +/// one, again whatever its type), and `Any` are not among them — each is a +/// "whatever its type" quantifier that names no card type. Every other variant +/// names a card type (CR 205.2a) or a subtype pool (CR 205.3) and therefore +/// scopes the leg to something. +/// +/// EXHAUSTIVE BY CONSTRUCTION, for the same reason as its two sibling +/// authorities `type_filter_guarantees_creature` and +/// `is_noncreature_core_type_pin`: a new `TypeFilter` variant must be classified +/// here rather than defaulting into a silent behavior. +fn type_filter_names_a_card_type_scope(tf: &TypeFilter) -> bool { + match tf { + // CR 110.1 / CR 108.2: "a permanent" / "a card" name no card type — they + // are the quantifiers a type noun would otherwise narrow. `Any` is the + // parser's own not-yet-known placeholder and is likewise type-open. + TypeFilter::Permanent | TypeFilter::Card | TypeFilter::Any => false, + // A type disjunction scopes the leg only when EVERY alternative does: a + // single type-open alternative reopens the whole disjunction. Plain + // logic on the AST, not a rules decision. + TypeFilter::AnyOf(inner) => { + !inner.is_empty() && inner.iter().all(type_filter_names_a_card_type_scope) + } + // CR 205.2a card types, CR 205.3 subtype pools, and CR 205.4b negations + // all name a scope. `Non(_)` scopes by exclusion, which is still a scope. + TypeFilter::Creature + | TypeFilter::Land + | TypeFilter::Artifact + | TypeFilter::Enchantment + | TypeFilter::Instant + | TypeFilter::Sorcery + | TypeFilter::Planeswalker + | TypeFilter::Battle + | TypeFilter::Kindred + | TypeFilter::Non(_) + | TypeFilter::Subtype(_) => true, + } +} + +/// Three-valued CR 208.3 verdict for an `Or` leg: may a power/toughness +/// restriction be DISTRIBUTED onto it from a sibling disjunct? +/// +/// 1. CR 205.2b — a leg that guarantees creature ("artifact creature") accepts, +/// even though it also pins a noncreature type. +/// 2. CR 208.3 — a leg pinned to a noncreature card type has no power or +/// toughness, so the restriction would be vacuous. Reject. +/// 3. TYPE-OPEN — a leg naming no card type at all ("a card named X", "a green +/// card", "a permanent card", or an `[Any]` leg whose type noun was never +/// backfilled). Reject. +/// +/// Case 3 is the CR 208.1 postnominal-binding reading, and it is the half that +/// `leg_pins_noncreature_core_type` alone cannot decide. "Search your library +/// for a green card or a creature card with power 4 or greater" prints the +/// restriction on the *creature* noun; the green-card disjunct is unrestricted, +/// exactly as the artifact disjunct of Make Your Move is. A `[Card]` leg is not +/// vacuous the way an `[Artifact]` leg is — `game::filter::pt_value_from_pair` +/// would still match creature cards through it — so the defect is quieter, but +/// it is the same defect: a restriction bound to the wrong disjunct. +/// +/// FAILS CLOSED, WHICH IS WHY ORDERING IS NO LONGER LOAD-BEARING FOR SAFETY. A +/// leg still holding `[TypeFilter::Any]` because `distribute_core_type_to_or` +/// could not resolve it now lands in case 3 and is left unrestricted. That is +/// the same "preserve the looser behavior" policy that function already applies +/// to an ambiguous disjunction, and it means a caller that distributes before +/// backfilling gets a LOOSER leg, never a vacuous one. `finalize_or_disjunction` +/// still backfills first so resolvable `[Any]` legs are decided on their real +/// type rather than falling into case 3; grammars that compose their own `Or` +/// without the backfills (`oracle_effect::search`) are merely less precise, not +/// wrong. +/// +/// Note the deliberate asymmetry with `pt_hosting_leg_props`, which keys on +/// `leg_pins_noncreature_core_type` and NOT on this function. That sweep +/// relocates a restriction off a leg where it is VACUOUS; this gate refuses to +/// place one on a leg where it does not BELONG. A type-open leg is ineligible +/// under this gate but is not vacuous, so it is neither stripped nor used as a +/// relocation witness — see `pt_hosting_leg_props` for why the two predicates +/// are now intentionally different. +fn leg_admits_creature_pt(type_filters: &[TypeFilter]) -> bool { + // CR 205.2b: "artifact creature" satisfies both, and keeps the restriction. + if type_filters.iter().any(type_filter_guarantees_creature) { + return true; + } + // CR 208.3: pinned to a card type that has no power or toughness. + if type_filters.iter().any(is_noncreature_core_type_pin) { + return false; + } + // CR 208.1: the restriction binds to a type noun. A leg that names no card + // type scope was never the noun it was printed on. + type_filters.iter().any(type_filter_names_a_card_type_scope) +} + /// Type-conditional leg-locality gate: may `prop` be distributed onto `typed`? /// /// CR 208.3: a noncreature permanent has no power or toughness. A postnominal @@ -4767,29 +4868,46 @@ fn leg_pins_noncreature_core_type(type_filters: &[TypeFilter]) -> bool { /// `distribute_shared_properties`, so the search-filter disjunction grammar /// (`oracle_effect::search`, CR 701.23a) inherits it with no extra code. /// -/// ORDERING DEPENDENCY: `parse_type_phrase_with_ctx` calls -/// `distribute_core_type_to_or` and `distribute_neg_type_filters_to_or` BEFORE -/// BOTH distributors that consult this gate, so a leg that receives its core -/// type (or an inherited `Non(Creature)`) by backfill already carries it when -/// this gate inspects `type_filters`. Reordering those calls would break this -/// gate. `distribute_shared_properties` was originally sequenced ahead of the -/// backfills, where it inspected legs still holding `[TypeFilter::Any]`; it is -/// now sequenced with `distribute_properties_to_or` so this invariant holds at -/// BOTH call sites rather than only one. +/// ORDERING DEPENDENCY — PRECISION, NOT SAFETY. `parse_type_phrase_with_ctx` +/// calls `distribute_core_type_to_or` and `distribute_neg_type_filters_to_or` +/// BEFORE both distributors that consult this gate, so a leg that receives its +/// core type (or an inherited `Non(Creature)`) by backfill already carries it +/// when this gate inspects `type_filters`. `distribute_shared_properties` was +/// originally sequenced ahead of the backfills, where it inspected legs still +/// holding `[TypeFilter::Any]`; it is now sequenced with +/// `distribute_properties_to_or` so this holds at BOTH call sites. +/// +/// Reordering those calls no longer produces a WRONG binding, only a coarser +/// one: `leg_admits_creature_pt` fails closed on a leg that still names no card +/// type, so an un-backfilled leg is left unrestricted rather than silently +/// acquiring a restriction printed on a different disjunct. That is what lets +/// `oracle_effect::search` — which composes its own `Or` from independently +/// parsed segments and runs no backfill — share this gate safely. fn prop_distributes_to_leg(prop: &FilterProp, typed: &TypedFilter) -> bool { - !(prop_reads_creature_pt(prop) && leg_pins_noncreature_core_type(&typed.type_filters)) + !prop_reads_creature_pt(prop) || leg_admits_creature_pt(&typed.type_filters) } /// Collect the P/T-family props that depth-1 `Typed` legs the CR 208.3 gate /// ACCEPTS actually carry. This is the rehoming witness set consumed by /// `strip_misplaced_pt_props_from_or_legs`. /// -/// The host predicate is the exact complement of `prop_distributes_to_leg`'s -/// type test, not the stricter `type_filter_guarantees_creature`. That makes the -/// two sides of the relocation structurally inseparable: a prop may be stripped -/// off a rejected leg only when a leg the same gate ACCEPTED is carrying it, so -/// "never delete a printed restriction" (invariant 5) cannot drift apart from -/// the gate. +/// The host predicate is the exact complement of `leg_pins_noncreature_core_type` +/// — the VACUITY test — not the stricter `type_filter_guarantees_creature` and +/// deliberately not the full `leg_admits_creature_pt` distribution gate. The two +/// answer different questions and must not be unified: +/// * This sweep relocates a restriction off a leg where CR 208.3 makes it +/// VACUOUS (an `[Artifact]` leg can never have power). Vacuity is exactly +/// `leg_pins_noncreature_core_type`. +/// * `leg_admits_creature_pt` additionally rejects TYPE-OPEN legs (`[Card]`, +/// `[Permanent]`, `[Any]`, no types at all). Those legs are ineligible to +/// RECEIVE a restriction printed on a sibling noun, but a restriction sitting +/// on one is not vacuous — `[Card]` with power ≥ 4 still matches creature +/// cards. Widening the sweep to them would DELETE a live predicate from the +/// leg that syntactically parsed it, which is precisely what invariant 5 +/// forbids. +/// +/// The relocation therefore stays paired with vacuity: a prop is stripped off a +/// vacuous leg only when a non-vacuous leg carries an exactly-equal one. /// /// Using `type_filter_guarantees_creature` here instead would silently fail the /// class the gate exists for: CR 205.3m creature subtypes name no card type, so @@ -15408,6 +15526,129 @@ mod tests { assert!(has_prop(typed_or_leg(&filters, 1), cmc)); } + /// Matrix row 13 — the THIRD value of the CR 208.3 verdict, asserted on the + /// gate itself rather than through any one grammar. + /// + /// `leg_pins_noncreature_core_type` answers "is a P/T restriction VACUOUS + /// here?", which is only half the binding question. A leg that names no card + /// type at all — `[Card]` ("a green card"), `[Permanent]` ("a permanent + /// card"), `[Any]` (a type noun that was never backfilled), or no filters at + /// all ("a card named X") — is not vacuous, but it is not the noun the + /// restriction was printed on either (CR 208.1). `leg_admits_creature_pt` + /// rejects all four, which is what lets `oracle_effect::search` compose an + /// `Or` with no type backfill and still bind correctly. + /// + /// The accept rows are the discriminator: a gate that simply rejected + /// everything it could not prove to be a creature would fail the + /// `Subtype("Goblin")` row (CR 205.3m creature subtypes name no card type, + /// so `type_filter_guarantees_creature` is false there) and the CR 205.2b + /// artifact-creature row. + #[test] + fn leg_admits_creature_pt_rejects_type_open_legs_but_keeps_creature_scopes() { + for types in [ + vec![TypeFilter::Card], + vec![TypeFilter::Permanent], + vec![TypeFilter::Any], + vec![], + // A disjunction is only as scoped as its loosest alternative. + vec![TypeFilter::AnyOf(vec![ + TypeFilter::Creature, + TypeFilter::Card, + ])], + ] { + assert!( + !leg_admits_creature_pt(&types), + "CR 208.1: a leg naming no card type must not receive a P/T \ + restriction printed on a sibling noun: {types:?}" + ); + } + + for types in [ + vec![TypeFilter::Creature], + // CR 205.2b: an object with more than one card type satisfies either. + vec![TypeFilter::Artifact, TypeFilter::Creature], + // CR 205.3m: a creature subtype names a scope that can be a creature. + vec![TypeFilter::Subtype("Goblin".to_string())], + // CR 205.4b: scoping by exclusion is still scoping. + vec![TypeFilter::Non(Box::new(TypeFilter::Artifact))], + // CR 308.1: a kindred card has another card type, possibly creature. + vec![TypeFilter::Kindred], + ] { + assert!( + leg_admits_creature_pt(&types), + "a creature-compatible scope must keep receiving the restriction: \ + {types:?}" + ); + } + + for types in [ + vec![TypeFilter::Artifact], + vec![TypeFilter::Enchantment], + // CR 205.3g: an artifact subtype pins the artifact card type. + vec![ + TypeFilter::Artifact, + TypeFilter::Subtype("Vehicle".to_string()), + ], + vec![TypeFilter::Non(Box::new(TypeFilter::Creature))], + ] { + assert!( + !leg_admits_creature_pt(&types), + "CR 208.3: a leg pinned to a noncreature card type has no power: \ + {types:?}" + ); + } + } + + /// Matrix row 14 — the gate composed with a real distributor, proving the + /// type-open rejection is P/T-SPECIFIC. A `[Card]` leg must lose the power + /// suffix (CR 208.1) while still inheriting the mana-value suffix (CR 202.3: + /// every object has a mana value, so nothing about a type-open leg blocks + /// it). Without the second half, a blanket "never distribute to a typeless + /// leg" rule would pass the first assertion and silently regress #2892. + #[test] + fn distribute_skips_pt_on_a_type_open_leg_but_still_distributes_cmc() { + let merged = |trailing: FilterProp| TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Card], + ..Default::default() + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + properties: vec![trailing], + ..Default::default() + }), + ], + }; + + let TargetFilter::Or { filters } = distribute_properties_to_or(merged(power_ge_4())) else { + panic!("expected Or"); + }; + assert!( + !has_pt_prop(typed_or_leg(&filters, 0)), + "CR 208.1: the type-open `Card` leg must not acquire the power \ + restriction: {filters:?}" + ); + assert!( + has_pt_prop(typed_or_leg(&filters, 1)), + "the creature leg must keep its own printed restriction — otherwise \ + the assertion above passes vacuously: {filters:?}" + ); + + let cmc = FilterProp::Cmc { + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 3 }, + }; + let TargetFilter::Or { filters } = distribute_properties_to_or(merged(cmc.clone())) else { + panic!("expected Or"); + }; + assert!( + has_prop(typed_or_leg(&filters, 0), cmc), + "CR 202.3: a mana value suffix must still reach the type-open leg: \ + {filters:?}" + ); + } + #[test] fn comma_or_without_keyword_suffix_stays_on_final_disjunct_only() { let (f, rest) = parse_target("target artifact or creature without flying"); From bf38294a1bb9f5830bb6a7a2b5d0e5e51b6a58cf Mon Sep 17 00:00:00 2001 From: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:24:24 -0500 Subject: [PATCH 3/4] fix(parser): require a positive creature anchor before distributing a P/T suffix Review follow-up on #7472. The previous gate asked whether a leg was SCOPED at all, which let an exclusion-only leg through. CR 205.4b: `Non(Artifact)` narrows a leg to nonartifacts, but every noncreature nonartifact permanent still satisfies it, and CR 208.3 gives those no power - so distributing the creature leg's restriction there deletes the whole first disjunct. Measured on the pre-fix head via parse_target: target nonartifact or creature with power 4 or greater -> [Any, Non(Artifact)] wrongly inherits power >= 4 target nonartifact permanent or creature with power 4 or greater -> [Permanent, Non(Artifact)] wrongly inherits it target nonland permanent or creature with power 4 or greater -> [Permanent, Non(Land)] wrongly inherits it target non-Human or creature with power 4 or greater -> [Any, Non(Subtype(Human))] wrongly inherits it target noncreature or creature with power 4 or greater -> [Any, Non(Creature)] correctly gated already `type_filter_names_a_card_type_scope` is REPLACED by `type_filter_anchors_creature`, which asks the CR 208.1 question directly: does this leg name a noun that could be a creature? Only `Creature` (CR 205.2a) and a creature-capable subtype pool (CR 205.3m, delegated to `is_noncreature_core_type_pin` so the subtype split keeps one authority) anchor. This is a replacement rather than a fourth sibling predicate: an unanchored leg covers the type-open shapes too, so the previous rule is subsumed. Two shapes beyond the reported one are fixed by the same rule, for the same reason they would otherwise have been left behind: * `Kindred` alone no longer anchors - CR 308.1, each kindred card has ANOTHER card type, so the word names no creature. * `AnyOf` anchors only when EVERY alternative does - CR 301.7a leaves an uncrewed Vehicle with no power, so AnyOf[Creature, Subtype(Vehicle)] must not receive the restriction. Tests: * `exclusion_only_leg_admits_a_powerless_enchantment_but_still_excludes_artifacts` drives the real cast pipeline: a powerless enchantment becomes a legal target (it was illegal pre-fix), with an artifact as the reach-guard proving the filter still discriminates. The reviewer-suggested "small creature is rejected" pairing is deliberately NOT asserted - a 2/2 creature IS a nonartifact permanent, so once the exclusion leg is correctly unrestricted the small creature is legal THROUGH that leg (CR 115.1a); asserting otherwise would encode a bug. Reasoning recorded in the test doc. * `leg_admits_creature_pt_rejects_unanchored_legs_but_keeps_creature_scopes` covers the exclusion-only, Kindred and AnyOf-Vehicle reject rows, and keeps Subtype(Goblin) / CR 205.2b artifact-creature / creature-plus-negation as accept controls so the gate cannot collapse into rejecting everything. Co-Authored-By: Claude Opus 5 --- crates/engine/src/parser/oracle_target.rs | 180 ++++++++++++------ ..._your_move_pt_suffix_binds_creature_leg.rs | 96 ++++++++++ 2 files changed, 218 insertions(+), 58 deletions(-) diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index 92be90f80e..025cb0dd31 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -4750,36 +4750,63 @@ fn leg_pins_noncreature_core_type(type_filters: &[TypeFilter]) -> bool { type_filters.iter().any(is_noncreature_core_type_pin) } -/// Returns true when this single `TypeFilter` names a card-type SCOPE at all, as -/// opposed to one of the three type-open universals. +/// Returns true when this single `TypeFilter` positively ANCHORS the leg to +/// something that can be a creature — the noun a printed power/toughness +/// restriction could have been written on. /// -/// CR 205.2a enumerates the card types; `Permanent` (CR 110.1: a permanent is a -/// card or token on the battlefield, whatever its type), `Card` (CR 108.2: a -/// reference to a "card" means only a Magic card or an object represented by -/// one, again whatever its type), and `Any` are not among them — each is a -/// "whatever its type" quantifier that names no card type. Every other variant -/// names a card type (CR 205.2a) or a subtype pool (CR 205.3) and therefore -/// scopes the leg to something. +/// CR 208.1: power and toughness are printed on a creature card, so the +/// postnominal "with power N or greater" modifies a creature noun. A leg may +/// receive that restriction by distribution only if it names such a noun +/// itself. Two families qualify: +/// * `Creature` — CR 205.2a, the card type itself. +/// * A subtype from a pool that can belong to a creature — CR 205.3m (creature +/// and kindred subtypes). Delegated to `is_noncreature_core_type_pin` so the +/// creature-vs-noncreature subtype split has exactly one authority: CR 205.3d +/// gives each noncreature subtype pool to one card type (CR 205.3g artifact, +/// 205.3h enchantment, 205.3i land, 205.3j planeswalker, 205.3k spell, +/// 205.3q battle), and everything else is creature-capable. +/// +/// EXCLUSION IS NOT AN ANCHOR — this is the half that `Non(_)` gets wrong if it +/// is treated as merely "scoping" the leg. `Non(Artifact)` restricts the leg to +/// nonartifacts, which still includes every noncreature nonartifact permanent; +/// an enchantment satisfies it and has no power (CR 208.3). So +/// "target nonartifact or creature with power 4 or greater" must leave the +/// `nonartifact` disjunct unrestricted — the restriction was printed on the +/// `creature` noun, and `Non(Artifact)` is not that noun. The one negation that +/// DOES decide the question, `Non(Creature)`, is handled upstream by +/// `is_noncreature_core_type_pin` (it rejects, rather than anchors). +/// +/// The type-open universals `Permanent` (CR 110.1), `Card` (CR 108.2) and `Any` +/// are likewise not anchors: each is a "whatever its type" quantifier that names +/// no creature noun. That subsumes the type-open rejection this predicate +/// replaced — a `[Card]`, `[Permanent]`, `[Any]` or empty leg simply has no +/// anchor. /// /// EXHAUSTIVE BY CONSTRUCTION, for the same reason as its two sibling /// authorities `type_filter_guarantees_creature` and /// `is_noncreature_core_type_pin`: a new `TypeFilter` variant must be classified /// here rather than defaulting into a silent behavior. -fn type_filter_names_a_card_type_scope(tf: &TypeFilter) -> bool { +fn type_filter_anchors_creature(tf: &TypeFilter) -> bool { match tf { - // CR 110.1 / CR 108.2: "a permanent" / "a card" name no card type — they - // are the quantifiers a type noun would otherwise narrow. `Any` is the - // parser's own not-yet-known placeholder and is likewise type-open. - TypeFilter::Permanent | TypeFilter::Card | TypeFilter::Any => false, - // A type disjunction scopes the leg only when EVERY alternative does: a - // single type-open alternative reopens the whole disjunction. Plain - // logic on the AST, not a rules decision. + // CR 205.2a: the creature card type. + TypeFilter::Creature => true, + // CR 205.3m: creature and kindred subtypes anchor; the noncreature + // subtype pools do not. Single authority, see doc above. + TypeFilter::Subtype(_) => !is_noncreature_core_type_pin(tf), + // A type disjunction anchors only when EVERY alternative does: one + // non-anchoring alternative (e.g. `AnyOf[Creature, Subtype("Vehicle")]`, + // where CR 301.7a leaves an uncrewed Vehicle with no power) means the + // leg can match a powerless object. Plain logic on the AST. TypeFilter::AnyOf(inner) => { - !inner.is_empty() && inner.iter().all(type_filter_names_a_card_type_scope) - } - // CR 205.2a card types, CR 205.3 subtype pools, and CR 205.4b negations - // all name a scope. `Non(_)` scopes by exclusion, which is still a scope. - TypeFilter::Creature + !inner.is_empty() && inner.iter().all(type_filter_anchors_creature) + } + // CR 205.4b: a negation scopes by exclusion, which is not a creature + // noun — see EXCLUSION IS NOT AN ANCHOR above. CR 308.1: `Kindred` + // alone names no creature either, since each kindred card has another + // card type. The remaining card types are noncreature, and + // `Permanent`/`Card`/`Any` are type-open quantifiers. + TypeFilter::Non(_) + | TypeFilter::Kindred | TypeFilter::Land | TypeFilter::Artifact | TypeFilter::Enchantment @@ -4787,9 +4814,9 @@ fn type_filter_names_a_card_type_scope(tf: &TypeFilter) -> bool { | TypeFilter::Sorcery | TypeFilter::Planeswalker | TypeFilter::Battle - | TypeFilter::Kindred - | TypeFilter::Non(_) - | TypeFilter::Subtype(_) => true, + | TypeFilter::Permanent + | TypeFilter::Card + | TypeFilter::Any => false, } } @@ -4800,9 +4827,12 @@ fn type_filter_names_a_card_type_scope(tf: &TypeFilter) -> bool { /// even though it also pins a noncreature type. /// 2. CR 208.3 — a leg pinned to a noncreature card type has no power or /// toughness, so the restriction would be vacuous. Reject. -/// 3. TYPE-OPEN — a leg naming no card type at all ("a card named X", "a green -/// card", "a permanent card", or an `[Any]` leg whose type noun was never -/// backfilled). Reject. +/// 3. NOT CREATURE-ANCHORED — the leg names no noun that could be a creature. +/// Reject. This covers the type-open shapes ("a card named X", "a green +/// card", "a permanent card", an `[Any]` leg whose type noun was never +/// backfilled) AND the exclusion-only shapes (`[Any, Non(Artifact)]` from +/// "target nonartifact or …", `[Permanent, Non(Land)]` from "nonland +/// permanent or …"). /// /// Case 3 is the CR 208.1 postnominal-binding reading, and it is the half that /// `leg_pins_noncreature_core_type` alone cannot decide. "Search your library @@ -4813,6 +4843,12 @@ fn type_filter_names_a_card_type_scope(tf: &TypeFilter) -> bool { /// would still match creature cards through it — so the defect is quieter, but /// it is the same defect: a restriction bound to the wrong disjunct. /// +/// A negation leg is the sharpest instance: `[Any, Non(Artifact)]` is satisfied +/// by an enchantment, which CR 208.3 gives no power, so distributing the +/// restriction there silently deletes the whole `nonartifact` half of the +/// disjunction. It is NOT enough to ask whether the leg is *scoped*; it must be +/// scoped to something that can be a creature. See `type_filter_anchors_creature`. +/// /// FAILS CLOSED, WHICH IS WHY ORDERING IS NO LONGER LOAD-BEARING FOR SAFETY. A /// leg still holding `[TypeFilter::Any]` because `distribute_core_type_to_or` /// could not resolve it now lands in case 3 and is left unrestricted. That is @@ -4832,17 +4868,16 @@ fn type_filter_names_a_card_type_scope(tf: &TypeFilter) -> bool { /// relocation witness — see `pt_hosting_leg_props` for why the two predicates /// are now intentionally different. fn leg_admits_creature_pt(type_filters: &[TypeFilter]) -> bool { - // CR 205.2b: "artifact creature" satisfies both, and keeps the restriction. - if type_filters.iter().any(type_filter_guarantees_creature) { - return true; - } - // CR 208.3: pinned to a card type that has no power or toughness. - if type_filters.iter().any(is_noncreature_core_type_pin) { + // CR 208.3: pinned to a card type that has no power or toughness. Keeps the + // CR 205.2b carve-out internally, so an "artifact creature" leg is not + // pinned and survives to the anchor test below. + if leg_pins_noncreature_core_type(type_filters) { return false; } - // CR 208.1: the restriction binds to a type noun. A leg that names no card - // type scope was never the noun it was printed on. - type_filters.iter().any(type_filter_names_a_card_type_scope) + // CR 208.1: the restriction was printed on a creature noun, so the leg must + // name one. Absence of a disqualifying pin is NOT sufficient — an + // exclusion-only or type-open leg has no anchor and is left unrestricted. + type_filters.iter().any(type_filter_anchors_creature) } /// Type-conditional leg-locality gate: may `prop` be distributed onto `typed`? @@ -15538,36 +15573,62 @@ mod tests { /// gate itself rather than through any one grammar. /// /// `leg_pins_noncreature_core_type` answers "is a P/T restriction VACUOUS - /// here?", which is only half the binding question. A leg that names no card - /// type at all — `[Card]` ("a green card"), `[Permanent]` ("a permanent - /// card"), `[Any]` (a type noun that was never backfilled), or no filters at - /// all ("a card named X") — is not vacuous, but it is not the noun the - /// restriction was printed on either (CR 208.1). `leg_admits_creature_pt` - /// rejects all four, which is what lets `oracle_effect::search` compose an - /// `Or` with no type backfill and still bind correctly. + /// here?", which is only half the binding question. The other half is CR + /// 208.1: the restriction was printed on a creature noun, so a leg must NAME + /// one to receive it. Two families fail that test without being vacuous: + /// * type-open — `[Card]` ("a green card"), `[Permanent]` ("a permanent + /// card"), `[Any]` (a type noun never backfilled), or no filters at all + /// ("a card named X"); + /// * exclusion-only — `[Any, Non(Artifact)]` ("target nonartifact or + /// creature with power 4 or greater"), `[Permanent, Non(Land)]`. An + /// exclusion narrows the leg but names no creature: an enchantment + /// satisfies `Non(Artifact)` and CR 208.3 gives it no power, so + /// distributing there would delete the whole `nonartifact` disjunct. /// - /// The accept rows are the discriminator: a gate that simply rejected - /// everything it could not prove to be a creature would fail the - /// `Subtype("Goblin")` row (CR 205.3m creature subtypes name no card type, - /// so `type_filter_guarantees_creature` is false there) and the CR 205.2b + /// The accept rows are the discriminator: a gate that rejected everything it + /// could not prove to be a creature would fail the `Subtype("Goblin")` row + /// (CR 205.3m creature subtypes name no card type, so + /// `type_filter_guarantees_creature` is false there) and the CR 205.2b /// artifact-creature row. #[test] - fn leg_admits_creature_pt_rejects_type_open_legs_but_keeps_creature_scopes() { + fn leg_admits_creature_pt_rejects_unanchored_legs_but_keeps_creature_scopes() { for types in [ vec![TypeFilter::Card], vec![TypeFilter::Permanent], vec![TypeFilter::Any], vec![], - // A disjunction is only as scoped as its loosest alternative. + // A disjunction anchors only if every alternative does. vec![TypeFilter::AnyOf(vec![ TypeFilter::Creature, TypeFilter::Card, ])], + // CR 301.7a: an uncrewed Vehicle has no power, so a leg that may be + // either is not anchored. + vec![TypeFilter::AnyOf(vec![ + TypeFilter::Creature, + TypeFilter::Subtype("Vehicle".to_string()), + ])], + // CR 205.4b + CR 208.3: exclusion-only legs. `Non(Artifact)` is + // satisfied by a powerless enchantment. + vec![ + TypeFilter::Any, + TypeFilter::Non(Box::new(TypeFilter::Artifact)), + ], + vec![ + TypeFilter::Permanent, + TypeFilter::Non(Box::new(TypeFilter::Land)), + ], + vec![TypeFilter::Non(Box::new(TypeFilter::Subtype( + "Human".to_string(), + )))], + // CR 308.1: a kindred card has ANOTHER card type; `Kindred` alone + // names no creature. + vec![TypeFilter::Kindred], ] { assert!( !leg_admits_creature_pt(&types), - "CR 208.1: a leg naming no card type must not receive a P/T \ - restriction printed on a sibling noun: {types:?}" + "CR 208.1: a leg that names no creature noun must not receive a \ + P/T restriction printed on a sibling noun: {types:?}" ); } @@ -15575,16 +15636,19 @@ mod tests { vec![TypeFilter::Creature], // CR 205.2b: an object with more than one card type satisfies either. vec![TypeFilter::Artifact, TypeFilter::Creature], - // CR 205.3m: a creature subtype names a scope that can be a creature. + // CR 205.3m: a creature subtype anchors even though it names no card + // type of its own. vec![TypeFilter::Subtype("Goblin".to_string())], - // CR 205.4b: scoping by exclusion is still scoping. - vec![TypeFilter::Non(Box::new(TypeFilter::Artifact))], - // CR 308.1: a kindred card has another card type, possibly creature. - vec![TypeFilter::Kindred], + // An exclusion RIDING ALONG with a real creature anchor still + // distributes — the anchor is what matters, not the negation. + vec![ + TypeFilter::Creature, + TypeFilter::Non(Box::new(TypeFilter::Artifact)), + ], ] { assert!( leg_admits_creature_pt(&types), - "a creature-compatible scope must keep receiving the restriction: \ + "a creature-anchored leg must keep receiving the restriction: \ {types:?}" ); } diff --git a/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs b/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs index 338d59ad74..b42d507635 100644 --- a/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs +++ b/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs @@ -284,6 +284,102 @@ fn uncrewed_vehicle_leg_is_targetable_but_small_creature_is_not() { outcome.assert_zone(&[vehicle], Zone::Graveyard); } +/// Row 17: the EXCLUSION-ONLY leg shape. CR 205.4b: "nonartifact permanent" +/// scopes the disjunct by exclusion, producing `[Permanent, Non(Artifact)]` — +/// no creature noun anywhere in it. An enchantment satisfies that leg and CR +/// 208.3 gives it no power, so distributing "with power 4 or greater" there +/// makes `pt_value_from_pair`'s `power.unwrap_or(0)` reject every noncreature +/// nonartifact permanent — silently deleting the entire first half of the +/// disjunction, exactly the Make Your Move defect wearing a negation. +/// +/// NOTE ON THE DISCRIMINATOR. The natural pairing — "a small creature must be +/// rejected" — is NOT assertable for this shape, and asserting it would be +/// wrong: a 2/2 creature IS a nonartifact permanent, so once the first leg is +/// correctly unrestricted the small creature becomes legal THROUGH THAT LEG +/// (CR 115.1a). That is the printed meaning of "target nonartifact permanent or +/// creature with power 4 or greater" — the first disjunct carries no power +/// restriction at all. The reach-guard therefore uses an ARTIFACT, which both +/// legs exclude (`distribute_neg_type_filters_to_or` shares `Non(Artifact)` +/// across the disjunction), proving the filter still discriminates rather than +/// matching everything. +/// +/// No printed card prints this wording; like the Vehicle row above it is a +/// structural fixture for the class. +#[test] +fn exclusion_only_leg_admits_a_powerless_enchantment_but_still_excludes_artifacts() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle( + P0, + "Negation Class Guard", + true, + "Destroy target nonartifact permanent or creature with power 4 or greater.", + ) + .id(); + scenario.with_mana_pool(P0, make_your_move_mana()); + let mut runner: GameRunner = scenario.build(); + + // A powerless, noncreature, nonartifact permanent: satisfies the exclusion + // leg and nothing else. + let enchantment = add_noncreature_permanent( + runner.state_mut(), + 9004, + P1, + "Opp Enchantment", + CoreType::Enchantment, + ); + // An artifact: excluded by `Non(Artifact)` on BOTH legs. + let artifact = add_noncreature_permanent( + runner.state_mut(), + 9005, + P1, + "Opp Artifact", + CoreType::Artifact, + ); + + let filter = destroy_target_filter(runner.state(), spell); + let legal = find_legal_targets(runner.state(), &filter, P0, spell); + + // The claim: flips from illegal to legal when the exclusion leg stops + // inheriting the creature leg's power restriction. + assert!( + legal.contains(&TargetRef::Object(enchantment)), + "CR 205.4b + CR 208.3: a nonartifact permanent with no power must be \ + legal through the unrestricted exclusion leg: {legal:?}" + ); + // Reach-guard: the filter is not simply matching everything. + assert!( + !legal.contains(&TargetRef::Object(artifact)), + "an artifact is excluded by Non(Artifact) on both legs: {legal:?}" + ); + + // AST row: the restriction lives on the creature leg only. + let TargetFilter::Or { filters } = &filter else { + panic!("expected an Or target filter, got {filter:?}"); + }; + for leg in filters { + let TargetFilter::Typed(typed) = leg else { + panic!("expected every leg Typed, got {leg:?}"); + }; + let has_pt = typed + .properties + .iter() + .any(|p| matches!(p, engine::types::ability::FilterProp::PtComparison { .. })); + let anchors_creature = typed + .type_filters + .contains(&engine::types::ability::TypeFilter::Creature); + assert_eq!( + has_pt, anchors_creature, + "CR 208.1: only the creature-anchored leg may carry the power \ + restriction, got {typed:?}" + ); + } + + let outcome = runner.cast(spell).target_objects(&[enchantment]).resolve(); + outcome.assert_zone(&[enchantment], Zone::Graveyard); +} + /// AST-shape guard mirroring `issue_2941_vivien_reid.rs`: the parsed filter must /// match the already-correct Broken Wings shape. Complements the runtime rows — /// they prove the legs match objects, this pins exactly which leg carries the From b548e7a696003bbeb48d5148e4438f3a1779ed99 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 23 Aug 2026 15:09:34 -0700 Subject: [PATCH 4/4] fix(PR-7472): correct CR citations --- crates/engine/src/parser/oracle_target.rs | 6 +++--- .../make_your_move_pt_suffix_binds_creature_leg.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/parser/oracle_target.rs b/crates/engine/src/parser/oracle_target.rs index 025cb0dd31..7c327b936b 100644 --- a/crates/engine/src/parser/oracle_target.rs +++ b/crates/engine/src/parser/oracle_target.rs @@ -4800,8 +4800,8 @@ fn type_filter_anchors_creature(tf: &TypeFilter) -> bool { TypeFilter::AnyOf(inner) => { !inner.is_empty() && inner.iter().all(type_filter_anchors_creature) } - // CR 205.4b: a negation scopes by exclusion, which is not a creature - // noun — see EXCLUSION IS NOT AN ANCHOR above. CR 308.1: `Kindred` + // A negation scopes by exclusion, which is not a creature noun — see + // EXCLUSION IS NOT AN ANCHOR above. CR 308.1: `Kindred` // alone names no creature either, since each kindred card has another // card type. The remaining card types are noncreature, and // `Permanent`/`Card`/`Any` are type-open quantifiers. @@ -15608,7 +15608,7 @@ mod tests { TypeFilter::Creature, TypeFilter::Subtype("Vehicle".to_string()), ])], - // CR 205.4b + CR 208.3: exclusion-only legs. `Non(Artifact)` is + // CR 208.3: exclusion-only legs. `Non(Artifact)` is // satisfied by a powerless enchantment. vec![ TypeFilter::Any, diff --git a/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs b/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs index b42d507635..aba11b0bb2 100644 --- a/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs +++ b/crates/engine/tests/integration/make_your_move_pt_suffix_binds_creature_leg.rs @@ -284,8 +284,8 @@ fn uncrewed_vehicle_leg_is_targetable_but_small_creature_is_not() { outcome.assert_zone(&[vehicle], Zone::Graveyard); } -/// Row 17: the EXCLUSION-ONLY leg shape. CR 205.4b: "nonartifact permanent" -/// scopes the disjunct by exclusion, producing `[Permanent, Non(Artifact)]` — +/// Row 17: the EXCLUSION-ONLY leg shape. "nonartifact permanent" scopes the +/// disjunct by exclusion, producing `[Permanent, Non(Artifact)]` — /// no creature noun anywhere in it. An enchantment satisfies that leg and CR /// 208.3 gives it no power, so distributing "with power 4 or greater" there /// makes `pt_value_from_pair`'s `power.unwrap_or(0)` reject every noncreature @@ -345,7 +345,7 @@ fn exclusion_only_leg_admits_a_powerless_enchantment_but_still_excludes_artifact // inheriting the creature leg's power restriction. assert!( legal.contains(&TargetRef::Object(enchantment)), - "CR 205.4b + CR 208.3: a nonartifact permanent with no power must be \ + "CR 208.3: a nonartifact permanent with no power must be \ legal through the unrestricted exclusion leg: {legal:?}" ); // Reach-guard: the filter is not simply matching everything.