diff --git a/crates/engine/src/parser/oracle_effect/search.rs b/crates/engine/src/parser/oracle_effect/search.rs index 2a05ff9ef7..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) }) } @@ -5484,4 +5500,263 @@ 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:?}" + ); + } + } + + /// 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 a8af054692..70e7904f62 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,64 @@ 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 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 +/// 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 +4374,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 +4444,589 @@ 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) +} + +/// 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 +/// "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 — 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_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 `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 +/// "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 +5042,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 +5071,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 +5083,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 +5682,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 +5700,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 +13440,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 +13526,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 +14093,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 +14777,878 @@ 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)); + } + + /// 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"); 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:?}" + ); +}