diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index 9c2f7c4517..164098bed0 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -391,6 +391,59 @@ pub fn resolve( } } + // CR 601.3: The exile-set anaphor ("… from among them", "… + // from among the exiled cards") is a LINKED reference, never a targeted + // one — its object ids reach this effect implicitly, forwarded by the + // chain seam that resolved the exile step (`Effect::ExileTop`'s sub-ability + // hand-off in `effects::resolve_ability_chain`). That seam forwards EVERY + // exiled card, because it cannot know which of them this instruction + // describes. The permission granted below is what "a rule or effect allows + // that player to cast" (CR 601.3), so the clause's own filter — the card + // type gate on "cast instant and sorcery spells" / "up to two sorcery + // spells" / "a Vehicle or artifact creature spell" — must still be applied + // to the forwarded set. Without this, the type leg the parser composes onto + // the `ExiledBySource` anaphor is inert at runtime and every exiled card + // becomes castable (issue #6960; the mana-value axis already survives via + // the `CastPermissionConstraint`). + // + // Placed HERE, immediately after `target_ids` is final and ABOVE every + // downstream router (the private-library one-shot, the per-opponent fanout + // window, and the `driver_free_cast` / `immediate_graveyard_free_cast` + // single-target casts), so the gate is universal rather than partial: those + // routers return early, and `immediate_graveyard_free_cast` in particular + // carries no driver requirement, so a set filtered only below them would + // leave the type gate unapplied on whichever path fires first. + // + // Scoped to `references_exiled_by_source()` — the one filter class whose + // ids are chain-forwarded rather than chosen. Explicitly targeted grants + // (Emry, Bring to Light, Urza) were validated when their target was + // declared and must not be re-filtered here. The no-target fallback above + // populates `target_ids` from the live exile links and has already applied + // the whole filter to them, so re-testing the residual here is idempotent. + if !target_ids.is_empty() && target_filter.references_exiled_by_source() { + // Apply the clause's OWN legs only. `without_exile_anaphor` + // discharges the `ExiledBySource` leg the seam already satisfied and + // returns what is left of the tree, preserving its `And`/`Or` structure + // (Sanwell's `And[Or[Vehicle, artifact creature], ExiledBySource]` + // residualizes to the bare `Or`). Re-evaluating the anaphor here would be + // actively wrong: on a triggered ability `filter::ExiledBySource` reads + // the trigger's `linked_exile_snapshot`, captured before this ability's + // own exile step ran, so every forwarded id would be dropped and the + // grant would become a total no-op. `None` means the filter was nothing + // *but* the anaphor (Hellcarver Demon, Improvisation Capstone, and every + // other bare-`ExiledBySource` row) — those keep the full forwarded set. + if let Some(own_filter) = target_filter.without_exile_anaphor() { + // Bind the residual's object-scope reads to exactly the + // forwarded set, mirroring the no-target fallback's scoped context. + let mut scoped_ability = ability.clone(); + scoped_ability.targets = target_ids.iter().copied().map(TargetRef::Object).collect(); + let ctx = crate::game::filter::FilterContext::from_ability(&scoped_ability); + target_ids.retain(|id| { + crate::game::filter::matches_target_filter(state, *id, &own_filter, &ctx) + }); + } + } + // The usual no-target fallback above observes the raw chain shape. Optional // look-cast frames may instead arrive with the same looked-at cards already // injected as resolved targets; both forms carry exactly the private-library diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index cc925993fe..0250608674 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -71,8 +71,10 @@ use crate::parser::oracle_trigger::parse_trigger_line; use nom::branch::alt; use nom::bytes::complete::{tag, take_until}; use nom::character::complete::{anychar, multispace0, multispace1, space1}; -use nom::combinator::{all_consuming, eof, map, map_opt, not, opt, peek, recognize, rest, value}; -use nom::multi::{many1, many_till, separated_list1}; +use nom::combinator::{ + all_consuming, eof, map, map_opt, not, opt, peek, recognize, rest, value, verify, +}; +use nom::multi::{many0, many1, many_till, separated_list1}; use nom::sequence::{pair, preceded, terminated}; use nom::Parser; @@ -21942,58 +21944,244 @@ fn try_parse_emblem_creation(lower: &str, original: &str) -> Option { } } -/// CR 601.2a + CR 118.9: Parse "cast it/that card [without paying its mana cost]". +/// CR 109.2b: the bare informational head-noun WORD that closes a cast type +/// gate — "spell(s)" / "card(s)". CR 109.2b: "If a spell or ability uses a +/// description of an object that includes the word 'spell,' it means a spell +/// matching that description on the stack." The noun therefore scopes the ZONE, +/// it is not a card-type restriction — which is why `parse_cast_type_gate` +/// separately rejects a gate whose only atoms are `TypeFilter::Card`/`Any`. /// -/// Three branches: -/// CR 205.2 + CR 108.1: Parse a leading core-type disjunction with the -/// "spell" / "card" informational suffix — "an instant or sorcery spell", -/// "target instant or sorcery card". Returns a `TypedFilter` whose type atom -/// is the disjunctive set. +/// The alphabet is NOT re-declared here: `oracle_nom::target::parse_type_filter_word` +/// already maps exactly `{spell, spells, card, cards}` — and nothing else — to +/// `TypeFilter::Card` (CR 112.1: a spell is a card on the stack), behind the same +/// non-alphanumeric word-boundary guard, plural before singular. So the head noun +/// is that shared table `verify`-ed down to its `Card` image: one alphabet, one +/// boundary guard, and the leg parser below and this guard can never drift apart. +/// The boundary is what keeps a subtype that merely *prefixes* the noun +/// ("Spellshaper") from being mistaken for it — it falls through to the subtype +/// table and yields `Subtype`, which `verify` rejects. /// -/// `parse_type_phrase` handles single core types and adjective-conjunction -/// ("artifact creature") but not " or " between bare core-type words. This -/// helper covers the common cast-effect surface that needs the disjunctive -/// form (Jeleva, Past in Flames, Mizzix's Mastery, Wandering Mind, and the -/// wider "cast instant or sorcery from " class). -fn parse_cast_type_disjunction(rest: &str) -> Option { - type E<'a> = OracleError<'a>; - fn parse_core(i: &str) -> nom::IResult<&str, TypeFilter, OracleError<'_>> { +/// This is the bare form used as the leg-internal `not(..)` guard; +/// `parse_cast_head_noun` is the space-leading form that closes the list. +fn parse_cast_head_noun_word(input: &str) -> OracleResult<'_, ()> { + value( + (), + verify( + super::oracle_nom::target::parse_type_filter_word, + |type_filter: &TypeFilter| matches!(type_filter, TypeFilter::Card), + ), + ) + .parse(input) +} + +/// CR 109.2b: the head noun that closes a cast type gate, **including its +/// leading space** — byte-identical to the `" spell"` / `" spells"` / +/// `" card"` / `" cards"` suffix `alt` this replaces. The leg combinator below +/// deliberately consumes no trailing space, so the space belongs here (and on +/// the separator), exactly as in `oracle_nom/enchant.rs`. +fn parse_cast_head_noun(input: &str) -> OracleResult<'_, ()> { + preceded(tag::<_, _, OracleError<'_>>(" "), parse_cast_head_noun_word).parse(input) +} + +/// CR 205.2b: one leg of a cast type gate — a run of ADJACENT type words with +/// no connector between them. CR 205.2b: "Some objects have more than one card +/// type (for example, an artifact creature). Such objects satisfy the criteria +/// for any effect that applies to any of their card types." So adjacent words +/// describe ONE object bearing all of them and lower to a conjunctive +/// `TypedFilter::type_filters` vector (`game/filter.rs:3722` / `:4024` evaluate +/// that vector with `.all()`). +/// +/// The word alphabet is `oracle_nom::target::parse_type_filter_word` — the +/// shared, word-boundary-guarded table of core types (singular AND plural) plus +/// the canonical subtype registry, which is what lets a subtype leg ("Vehicle", +/// CR 205.3g) stand beside a core-type leg. Do not re-declare type words here. +/// +/// `separated_list1` (not `many1(terminated(word, tag(" ")))`): the leg consumes +/// NO trailing space, so the space-leading separator and head noun compose. +/// `separated_list1` backtracks the separator when the following element fails, +/// so "instant and sorcery" stops cleanly after "instant" and leaves +/// " and sorcery…" for `parse_cast_type_list_sep`. +/// +/// The `not(parse_cast_head_noun_word)` guard is what stops the run at the head +/// noun: CR 112.1 makes `parse_type_filter_word` map "spell"/"spells" to +/// `TypeFilter::Card`, so without the guard the noun would be swallowed as a +/// leg word and the mandatory head-noun close would then fail. +fn parse_cast_type_leg(input: &str) -> OracleResult<'_, Vec> { + separated_list1( + tag(" "), + preceded( + not(parse_cast_head_noun_word), + super::oracle_nom::target::parse_type_filter_word, + ), + ) + .parse(input) +} + +/// CR 601.3 + CR 205.2b: the connector between cast type-gate legs. Every +/// spelling enumerates ALTERNATIVE members of the permission's candidate set — +/// CR 601.3 defines the permission by the set of spells it allows to be cast, +/// and "instant and sorcery spells" is a plural over that set, not a +/// conjunction over one object: no object is both an instant and a sorcery, so +/// a literal per-object AND would match nothing and turn a permissive bug into +/// a total no-op. `and`, `or`, and `and/or` are therefore ONE axis with ONE +/// meaning, not three branches. Per-object conjunction is expressed only by +/// ADJACENT type words with no connector (CR 205.2b), i.e. inside a leg. +/// +/// Longest-match-first; the alphabet is kept byte-identical to +/// `oracle_target.rs::match_mass_union_separator` and a superset of +/// `oracle_nom/enchant.rs::parse_enchant_list_sep` so the connector tables +/// cannot drift. +fn parse_cast_type_list_sep(input: &str) -> OracleResult<'_, ()> { + value( + (), alt(( - value(TypeFilter::Instant, tag::<_, _, E>("instant")), - value(TypeFilter::Sorcery, tag("sorcery")), - value(TypeFilter::Creature, tag("creature")), - value(TypeFilter::Artifact, tag("artifact")), - value(TypeFilter::Enchantment, tag("enchantment")), - value(TypeFilter::Planeswalker, tag("planeswalker")), - value(TypeFilter::Land, tag("land")), - value(TypeFilter::Battle, tag("battle")), - )) - .parse(i) - } + tag(", and/or "), + tag(", or "), + tag(", and "), + tag(", "), + tag(" and/or "), + tag(" or "), + tag(" and "), + )), + ) + .parse(input) +} + +/// CR 601.2: a leading quantifier on a cast permission — "up to two", +/// "up to X", "any number of", "one or more". CR 601.2 ("to cast a spell is to +/// take it from where it is…") makes the quantifier a count of CAST EVENTS, not +/// an object quality, so it is consumed and DISCARDED here: it belongs on the +/// cast permission (`Effect::FreeCastFromZones { count }` and +/// `try_parse_counted_free_cast_from_exiled_this_way`), never on the type +/// filter. +/// +/// `nom_primitives::parse_number` (not `parse_number_or_x`) so a literal "X" is +/// never silently resolved to 0; the explicit `"x "` arm consumes the variable +/// spelling instead ("cast up to X instant and/or sorcery spells" — Wand of +/// Wonder). Mirrors `parse_cast_copies_count_prefix` and the "up to N" head of +/// `try_parse_counted_free_cast_from_exiled_this_way`, both in this file. +fn parse_cast_quantifier_prefix(input: &str) -> OracleResult<'_, ()> { + alt(( + value( + (), + ( + tag::<_, _, OracleError<'_>>("up to "), + opt(alt(( + value((), terminated(nom_primitives::parse_number, tag(" "))), + value((), tag("x ")), + ))), + ), + ), + value((), tag("any number of ")), + value((), tag("one or more ")), + )) + .parse(input) +} + +/// CR 601.3 + CR 205.2b + CR 109.2b: the card-type list carried by a cast +/// clause subject — "an instant or sorcery spell", "instant and sorcery +/// spells", "any number of instant and/or sorcery spells", "up to two sorcery +/// spells", "a Vehicle or artifact creature spell", "target instant or sorcery +/// card". +/// +/// Composed per axis, not enumerated — `and` × `or` × `and/or` × serial comma × +/// article × quantifier would be 24+ literal arms; here each axis is one +/// `opt`/`alt`/`many0` call: +/// +/// ```text +/// cast_type_list := opt("target ") opt(quantifier) opt(article) leg (sep leg)* head_noun +/// leg := type_word (" " type_word)* -- CR 205.2b, per-object conjunction +/// sep := ", and/or " | ", or " | ", and " | ", " | " and/or " | " or " | " and " +/// head_noun := " spell" | " spells" | " card" | " cards" -- CR 109.2b, mandatory +/// ``` +/// +/// **Acceptance boundary.** Returns `Some` only when it consumed something +/// `parse_type_phrase` demonstrably cannot: at least two legs (so a connector +/// was consumed) OR a leading quantifier. A single leg with no quantifier is +/// still rejected, exactly as before, so "a creature spell", "an artifact +/// spell", and "an Aura spell" keep falling through to `parse_type_phrase` +/// byte-for-byte. That predicate, together with the mandatory head noun and the +/// `separated_list1` (which yields zero legs when the clause opens on the head +/// noun — "cast a spell from among them"), is the anti-swallow guard that keeps +/// the 25 correctly-bare "from among them" cards bare. +/// +/// Output shape, by exhaustive match on leg arity/width: +/// * one leg (quantifier present) → `Typed { type_filters: leg }` +/// * every leg exactly one atom → `Typed { type_filters: [AnyOf(atoms)] }` — +/// byte-identical to the shape this helper produced before, which is what +/// keeps the Jeleva/Velomachus pins green +/// * some leg wider than one atom → `Or` over one `Typed` per leg, because a +/// per-object conjunction cannot collapse into a single `type_filters` vector +/// alongside a disjunction. No new `TypeFilter` variant is introduced; +/// `TargetFilter::Or` already evaluates through every consumer on this path. +fn parse_cast_type_list(rest: &str) -> Option { + type E<'a> = OracleError<'a>; let (rest, _) = opt(tag::<_, _, E>("target ")).parse(rest).ok()?; - // Strip optional "a "/"an " article. + let (rest, quantifier) = opt(parse_cast_quantifier_prefix).parse(rest).ok()?; + // Strip optional "a "/"an " article (longest-first). let (rest, _) = opt(alt((tag::<_, _, E>("an "), tag("a ")))) .parse(rest) .ok()?; - let (rest, first) = parse_core(rest).ok()?; - let (rest, _) = tag::<_, _, E>(" or ").parse(rest).ok()?; - let (rest, second) = parse_core(rest).ok()?; - // Require the informational "spell"/"card" suffix so we don't over-match - // bare disjunctions ("instant or sorcery" alone falls through to the - // normal parser path). - let (_rest, _) = alt(( - tag::<_, _, E>(" spell"), - tag(" card"), - tag(" spells"), - tag(" cards"), - )) - .parse(rest) - .ok()?; - Some(TypedFilter { - type_filters: vec![TypeFilter::AnyOf(vec![first, second])], - controller: None, - properties: Vec::new(), - }) + + let (rest, first) = parse_cast_type_leg(rest).ok()?; + let (rest, more) = many0(preceded(parse_cast_type_list_sep, parse_cast_type_leg)) + .parse(rest) + .ok()?; + // CR 109.2b: the head noun is mandatory, so a bare type list with no noun + // ("instant or sorcery" alone) still falls through to the normal parser + // path. + let (_rest, ()) = parse_cast_head_noun(rest).ok()?; + + let mut legs = Vec::with_capacity(more.len() + 1); + legs.push(first); + legs.extend(more); + + fn typed(atoms: Vec) -> TargetFilter { + TargetFilter::Typed(TypedFilter { + type_filters: atoms, + controller: None, + properties: Vec::new(), + }) + } + + match legs.as_slice() { + // CR 601.3: one leg with no quantifier is ordinary type-phrase + // territory — `parse_type_phrase` already handles it and can carry + // controller/property legs this helper never builds. Reject, exactly as + // before this helper was composed. + [_single] if quantifier.is_none() => None, + [single] => Some(typed(single.clone())), + many if many.iter().all(|leg| leg.len() == 1) => Some(typed(vec![TypeFilter::AnyOf( + many.iter().map(|leg| leg[0].clone()).collect(), + )])), + many => Some(TargetFilter::Or { + filters: many.iter().map(|leg| typed(leg.clone())).collect(), + }), + } +} + +/// CR 109.2b: does this cast gate name a real card type? +/// +/// The exact predicate the pre-composition gate applied to a flat `TypedFilter` +/// (`type_filters.iter().any(|tf| !matches!(tf, Card | Any))`), lifted to walk +/// the composed `Or`/`And`/`Not` tree. Deliberately NOT +/// `TypedFilter::has_meaningful_type_constraint`, which also returns true for a +/// filter carrying only *properties*: a non-type restriction (Perception +/// Bobblehead's mana-value bound, Meeting of the Five's colour count) must keep +/// yielding `None` here so this helper never invents a type gate the Oracle +/// text did not state. +fn cast_gate_names_a_card_type(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::Typed(tf) => tf + .type_filters + .iter() + .any(|tf| !matches!(tf, TypeFilter::Card | TypeFilter::Any)), + TargetFilter::Or { filters } | TargetFilter::And { filters } => { + filters.iter().any(cast_gate_names_a_card_type) + } + TargetFilter::Not { filter } => cast_gate_names_a_card_type(filter), + _ => false, + } } /// CR 601.3 + CR 109.2b: the card-type gate carried by a @@ -22009,8 +22197,8 @@ fn parse_cast_type_disjunction(rest: &str) -> Option { /// /// Composes the two existing subject parsers in the same order the /// `has_from_among_cards_exiled_with_self` branch already uses: -/// `parse_cast_type_disjunction` first (it handles " or " between bare core -/// types, which `parse_type_phrase` does not), then `parse_type_phrase`. +/// `parse_cast_type_list` first (it owns the multi-leg / quantified / subtype +/// grammar `parse_type_phrase` does not), then `parse_type_phrase`. /// /// Returns `None` when the clause names no card type — "cast a spell from among /// them" (Aetherworks Marvel, Svella, Apex of Power) grants an unrestricted @@ -22020,19 +22208,15 @@ fn parse_cast_type_disjunction(rest: &str) -> Option { /// (Chandra's "red spells", Meeting of the Five's "spells with exactly three /// colors", Perception Bobblehead's mana-value bound) also yield `None` so this /// helper never invents a type gate the Oracle text did not state. -fn parse_cast_type_gate(rest: &str) -> Option { - let typed = - parse_cast_type_disjunction(rest).or_else( - || match super::oracle_target::parse_type_phrase(rest).0 { - TargetFilter::Typed(tf) => Some(tf), +fn parse_cast_type_gate(rest: &str) -> Option { + let gate = + parse_cast_type_list(rest).or_else(|| { + match super::oracle_target::parse_type_phrase(rest).0 { + typed @ TargetFilter::Typed(_) => Some(typed), _ => None, - }, - )?; - typed - .type_filters - .iter() - .any(|tf| !matches!(tf, TypeFilter::Card | TypeFilter::Any)) - .then_some(typed) + } + })?; + cast_gate_names_a_card_type(&gate).then_some(gate) } /// CR 601.3: AND a parsed card-type gate onto an exile-set cast anaphor. @@ -22047,8 +22231,8 @@ fn parse_cast_type_gate(rest: &str) -> Option { /// `Zone::Exile` before applying this filter. fn exiled_cast_target_with_type_gate(rest: &str) -> TargetFilter { match parse_cast_type_gate(rest) { - Some(typed) => TargetFilter::And { - filters: vec![TargetFilter::Typed(typed), TargetFilter::ExiledBySource], + Some(gate) => TargetFilter::And { + filters: vec![gate, TargetFilter::ExiledBySource], }, None => TargetFilter::ExiledBySource, } @@ -22168,11 +22352,11 @@ fn ensure_exile_zone_on_cast_target(filter: &mut TargetFilter) { /// * `None` — anchor or "exiled this way" suffix not present. /// /// Composition mirrors `has_from_among_cards_exiled_with_self` (anchor -/// strip) and `parse_cast_type_disjunction` / `parse_type_phrase` (typed +/// strip) and `parse_cast_type_list` / `parse_type_phrase` (typed /// leg extraction). fn parse_from_among_exiled_this_way(rest: &str) -> Option { type E<'a> = OracleError<'a>; - let (after_anchor, _) = take_until::<_, _, E>("from among ").parse(rest).ok()?; + let (after_anchor, before_anchor) = take_until::<_, _, E>("from among ").parse(rest).ok()?; let (after_anchor, _) = tag::<_, _, E>("from among ").parse(after_anchor).ok()?; let (after_article, _) = opt(alt((tag::<_, _, E>("the "), tag("those ")))) .parse(after_anchor) @@ -22186,10 +22370,20 @@ fn parse_from_among_exiled_this_way(rest: &str) -> Option { return None; } - // Try disjunctive typed leg first ("instant or sorcery cards"); fall - // back to parse_type_phrase ("nonland cards", "creature cards"). - let mut typed_filter = parse_cast_type_disjunction(after_article) - .map(TargetFilter::Typed) + // CR 601.3: WotC puts the type list BEFORE the anchor in the + // counted form ("cast up to X instant and/or sorcery spells from among + // cards exiled this way" — Wand of Wonder) and AFTER it in the article form + // ("from among the nonland cards exiled this way" — Etali). Probe the + // pre-anchor prefix first: when both positions carry text, the prefix is + // the restriction and the suffix is the anaphor's own head noun. The + // untyped members of this family ("any number of spells ", "up to two + // spells ") yield `None` from the prefix probe because the leg list is + // empty once the head noun is guarded out, so they stay bare. + // + // Then the post-article probe ("instant or sorcery cards"), then + // `parse_type_phrase` ("nonland cards", "creature cards"). + let mut typed_filter = parse_cast_type_list(before_anchor) + .or_else(|| parse_cast_type_list(after_article)) .unwrap_or_else(|| super::oracle_target::parse_type_phrase(after_article).0); // "the cards exiled this way" lifts a bare `Typed(Card)` leaf with no @@ -22906,14 +23100,37 @@ fn try_parse_cast_effect(lower: &str, ctx: &ParseContext) -> Option { // type gate exactly as the exile-bound anaphor below did // (issue #6880) — "cast an instant or sorcery spell from among // those cards" would reach any revealed card. - if let Some(typed) = parse_cast_type_gate(rest) { - hand_filter.type_filters = typed.type_filters; - } + let and_leg = match parse_cast_type_gate(rest) { + // Single typed gate: graft its atoms onto the hand binding, + // replacing the bare `Card` head noun. A typed gate with + // a controller or properties must stay whole: copying only + // its type atoms drops predicates such as a mana-value bound. + Some(TargetFilter::Typed(typed)) + if typed.controller.is_none() && typed.properties.is_empty() => + { + hand_filter.type_filters = typed.type_filters; + None + } + Some(TargetFilter::Typed(typed)) => Some(TargetFilter::Typed(typed)), + // CR 205.2b: a gate whose legs are per-object conjunctions + // ("a Vehicle or artifact creature spell") cannot collapse + // into one `type_filters` vector — AND it beside the hand + // binding instead, which `matches_target_filter` evaluates + // as the same conjunction of predicates. + Some(gate) => Some(gate), + None => None, + }; hand_filter .properties .push(FilterProp::InZone { zone: Zone::Hand }); + let hand_target = match and_leg { + Some(gate) => TargetFilter::And { + filters: vec![gate, TargetFilter::Typed(hand_filter)], + }, + None => TargetFilter::Typed(hand_filter), + }; return Some(Effect::CastFromZone { - target: TargetFilter::Typed(hand_filter), + target: hand_target, without_paying_mana_cost: without_paying, mode, cast_transformed: false, @@ -22992,30 +23209,26 @@ fn try_parse_cast_effect(lower: &str, ctx: &ParseContext) -> Option { // AND it with `ExiledBySource` so the cast is restricted both by card // type and by the source-exile-link. if has_from_among_cards_exiled_with_self(rest) { - // First try the disjunctive form ("an instant or sorcery spell ...") - // since `parse_type_phrase` doesn't currently handle " or " between - // bare core-type words. Then fall back to `parse_type_phrase` for - // single-type forms. - let typed_filter = parse_cast_type_disjunction(rest) - .map(TargetFilter::Typed) + // First try the composed type-list form ("an instant or sorcery + // spell ...") since `parse_type_phrase` doesn't handle connectors + // between bare core-type words. Then fall back to `parse_type_phrase` + // for single-type forms. + let mut typed_filter = parse_cast_type_list(rest) .unwrap_or_else(|| super::oracle_target::parse_type_phrase(rest).0); - let target = if let TargetFilter::Typed(mut tf) = typed_filter { - // CR 406.1: source-linked exiled cards live in the Exile zone. - // Make the zone explicit so target legality (CR 601.2c) restricts - // the choice to exile-zone objects even before AND'ing with - // ExiledBySource. - if !tf - .properties - .iter() - .any(|p| matches!(p, FilterProp::InZone { .. })) - { - tf.properties.push(FilterProp::InZone { zone: Zone::Exile }); - } - TargetFilter::And { - filters: vec![TargetFilter::Typed(tf), TargetFilter::ExiledBySource], + let target = match typed_filter { + TargetFilter::Typed(_) | TargetFilter::Or { .. } => { + // CR 406.1: source-linked exiled cards live in the Exile zone. + // Make the zone explicit so target legality (CR 601.2c) + // restricts the choice to exile-zone objects even before + // AND'ing with ExiledBySource. `ensure_exile_zone_on_cast_target` + // is the shared recursing form, so an `Or`-shaped gate gets the + // zone on every leg. + ensure_exile_zone_on_cast_target(&mut typed_filter); + TargetFilter::And { + filters: vec![typed_filter, TargetFilter::ExiledBySource], + } } - } else { - TargetFilter::ExiledBySource + _ => TargetFilter::ExiledBySource, }; return Some(Effect::CastFromZone { target, @@ -23100,8 +23313,7 @@ fn try_parse_cast_effect(lower: &str, ctx: &ParseContext) -> Option { // so for inputs of the form "spell from your hand with mana value ..." // the mana-value clause is past the type-phrase pos when reached. let cast_target_rest = strip_cast_target_prefix(rest); - let mut filter = parse_cast_type_disjunction(rest) - .map(TargetFilter::Typed) + let mut filter = parse_cast_type_list(rest) .unwrap_or_else(|| super::oracle_target::parse_type_phrase(cast_target_rest).0); if cast_filter_has_typed_leaf(&filter) { apply_cast_target_suffixes(&mut filter, rest); diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 086e14d473..587f70633e 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -42106,7 +42106,7 @@ fn cast_from_among_the_cards_exiled_this_way_binds_to_exiled_by_source() { } /// CR 610.3 + CR 608.2c: Disjunctive typed leg via -/// `parse_cast_type_disjunction` — "from among the instant or sorcery +/// `parse_cast_type_list` — "from among the instant or sorcery /// cards exiled this way" must bind to `And { ExiledBySource, Typed(...) }` /// with the disjunctive type set. #[test] diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 8f48c8d841..b644aa86dc 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -14551,6 +14551,73 @@ impl TargetFilter { } } + /// CR 601.3: This filter with the exile-set anaphor + /// (`TargetFilter::ExiledBySource`) removed — i.e. the clause's OWN + /// restrictions, expressed against a set of objects whose membership in the + /// exile link has *already* been established by whoever produced the set. + /// + /// Returns `None` when nothing but the anaphor remains. A bare + /// `ExiledBySource` restricts a pre-established set not at all, so its + /// consumers must filter nothing rather than re-derive the link — re-reading + /// it is not merely redundant, it is wrong whenever the link is younger than + /// the reader's view of it (a trigger's `linked_exile_snapshot` is captured + /// when the trigger is put on the stack, before the ability's own exile step + /// has run, so the anaphor leg would be false for every forwarded id). + /// + /// Structure: `And` legs residualize independently and re-conjoin; an `Or` + /// with a branch that residualizes away imposes nothing on any member and so + /// drops whole. `TrackedSetFiltered` keeps its own set membership and + /// residualizes only the filter nested under it. + /// + /// INVARIANT — `Not`: this helper does not descend into `Not`, and neither + /// does [`TargetFilter::references_exiled_by_source`], the predicate that + /// gates every consumer of this residual. The two rest on the same premise + /// and must be changed in lockstep: no production cast filter puts the + /// anaphor under a negation ("cards NOT exiled this way" describes no + /// printed clause), so a `Not` is always a genuine restriction and is + /// preserved verbatim. If a card ever makes that shape real, both helpers + /// must be taught about it in the SAME change — a residual that still + /// contains `ExiledBySource` would re-evaluate the very link this helper + /// exists to discharge, and (per the paragraph above) that re-read is false + /// for every forwarded id, so the grant would silently become a no-op. + /// `not_over_the_exile_anaphor_is_unreachable_and_pinned_in_lockstep` pins + /// the current agreement between the two. + pub fn without_exile_anaphor(&self) -> Option { + match self { + TargetFilter::ExiledBySource => None, + TargetFilter::And { filters } => { + let mut residual: Vec = filters + .iter() + .filter_map(TargetFilter::without_exile_anaphor) + .collect(); + match residual.len() { + 0 => None, + 1 => residual.pop(), + _ => Some(TargetFilter::And { filters: residual }), + } + } + TargetFilter::Or { filters } => { + let mut residual = Vec::with_capacity(filters.len()); + for filter in filters { + residual.push(filter.without_exile_anaphor()?); + } + Some(TargetFilter::Or { filters: residual }) + } + // The tracked-set membership is its own restriction and survives; only + // the anaphor nested under it is discharged. + TargetFilter::TrackedSetFiltered { + id, + filter, + caused_by, + } => Some(TargetFilter::TrackedSetFiltered { + id: *id, + filter: Box::new(filter.without_exile_anaphor().unwrap_or(TargetFilter::Any)), + caused_by: *caused_by, + }), + other => Some(other.clone()), + } + } + /// CR 400.7d + CR 608.2k: True when this filter tree references the /// cost-paid object (`TargetFilter::CostPaidObject`) at any structural /// position — directly, or nested inside `And`/`Or`/`Not`/ @@ -24156,6 +24223,145 @@ mod tests { use crate::types::mana::ZoneSpendPolarity; use crate::types::zones::Zone; + /// CR 601.3: `without_exile_anaphor` is the residual of a cast + /// filter once the exile-set anaphor is discharged. The three shapes that + /// matter to the chain-forwarded grant path: + /// + /// * a BARE `ExiledBySource` (Hellcarver Demon, Improvisation Capstone, and + /// the ~50 other bare rows) has no residual — its consumers must filter + /// nothing, so those cards are a strict no-change path; + /// * a single-gate `And` residualizes to the gate alone; + /// * a nested `And[Or[..], ExiledBySource]` (Sanwell, Scarlet Witch) keeps + /// the whole `Or` — collapsing it would drop one of its branches. + #[test] + fn without_exile_anaphor_discharges_only_the_anaphor_legs() { + let vehicle = + TargetFilter::Typed(TypedFilter::new(TypeFilter::Subtype("Vehicle".to_string()))); + let artifact_creature = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact, TypeFilter::Creature], + controller: None, + properties: Vec::new(), + }); + + assert_eq!(TargetFilter::ExiledBySource.without_exile_anaphor(), None); + + let single_gate = TargetFilter::And { + filters: vec![vehicle.clone(), TargetFilter::ExiledBySource], + }; + assert_eq!( + single_gate.without_exile_anaphor(), + Some(vehicle.clone()), + "a lone surviving leg unwraps out of the And" + ); + + let or_gate = TargetFilter::Or { + filters: vec![vehicle, artifact_creature], + }; + let sanwell = TargetFilter::And { + filters: vec![or_gate.clone(), TargetFilter::ExiledBySource], + }; + assert_eq!( + sanwell.without_exile_anaphor(), + Some(or_gate), + "both Or branches must survive the discharge" + ); + + // An Or with a bare-anaphor branch restricts a pre-established set not + // at all, so the whole disjunction drops rather than half of it. + assert_eq!( + TargetFilter::Or { + filters: vec![ + TargetFilter::Typed(TypedFilter::new(TypeFilter::Instant)), + TargetFilter::ExiledBySource, + ], + } + .without_exile_anaphor(), + None + ); + } + + /// CR 601.3: the `Not` invariant documented on + /// `without_exile_anaphor`. Neither that helper nor + /// `references_exiled_by_source` descends into `Not`; zero production cast + /// filters put the anaphor under a negation, so the two agree today. These + /// rows pin that agreement: teaching one helper about `Not` without the + /// other reddens this test instead of silently producing a residual that + /// re-reads the anaphor. + #[test] + fn not_over_the_exile_anaphor_is_unreachable_and_pinned_in_lockstep() { + let anaphor_under_not = TargetFilter::Not { + filter: Box::new(TargetFilter::ExiledBySource), + }; + assert!( + !anaphor_under_not.references_exiled_by_source(), + "the gate predicate does not descend into Not, so this shape never \ + reaches the chain-forwarded retain at all" + ); + assert_eq!( + anaphor_under_not.clone().without_exile_anaphor(), + Some(anaphor_under_not), + "and the residualizer agrees: Not is preserved verbatim — if either \ + side learns to descend, BOTH must, in the same change" + ); + + // An anaphor-free negation is a genuine restriction and survives the + // discharge unchanged, which is the shape this arm actually exists for. + let not_a_land = TargetFilter::Not { + filter: Box::new(TargetFilter::Typed(TypedFilter::new(TypeFilter::Land))), + }; + assert_eq!( + TargetFilter::And { + filters: vec![not_a_land.clone(), TargetFilter::ExiledBySource], + } + .without_exile_anaphor(), + Some(not_a_land) + ); + } + + /// CR 608.2c: a tracked-set membership ("cards exiled this way") + /// is its own restriction on a chain-forwarded set, so it survives the + /// discharge; only the filter nested under it residualizes. + #[test] + fn without_exile_anaphor_keeps_tracked_set_membership() { + let instant = TargetFilter::Typed(TypedFilter::new(TypeFilter::Instant)); + let gated = TargetFilter::TrackedSetFiltered { + id: crate::types::identifiers::TrackedSetId(0), + filter: Box::new(TargetFilter::And { + filters: vec![instant.clone(), TargetFilter::ExiledBySource], + }), + caused_by: Some(ThisWayCause::Exiled), + }; + assert!( + gated.references_exiled_by_source(), + "the gate predicate descends into the tracked set, so the residual \ + below is what the retain applies" + ); + assert_eq!( + gated.without_exile_anaphor(), + Some(TargetFilter::TrackedSetFiltered { + id: crate::types::identifiers::TrackedSetId(0), + filter: Box::new(instant), + caused_by: Some(ThisWayCause::Exiled), + }) + ); + + // A tracked set whose only nested leg is the anaphor degrades that leg + // to `Any` — never to `None`, which would discard the membership too. + assert_eq!( + TargetFilter::TrackedSetFiltered { + id: crate::types::identifiers::TrackedSetId(1), + filter: Box::new(TargetFilter::ExiledBySource), + caused_by: Some(ThisWayCause::Exiled), + } + .without_exile_anaphor(), + Some(TargetFilter::TrackedSetFiltered { + id: crate::types::identifiers::TrackedSetId(1), + filter: Box::new(TargetFilter::Any), + caused_by: Some(ThisWayCause::Exiled), + }) + ); + } + #[test] fn put_chosen_counter_quantity_visitor_includes_target_condition_rhs() { let count = QuantityExpr::Fixed { value: 1 }; diff --git a/crates/engine/tests/integration/issue_5240_silent_blade_oni.rs b/crates/engine/tests/integration/issue_5240_silent_blade_oni.rs index 461122f2e8..9746f40faf 100644 --- a/crates/engine/tests/integration/issue_5240_silent_blade_oni.rs +++ b/crates/engine/tests/integration/issue_5240_silent_blade_oni.rs @@ -39,6 +39,7 @@ use engine::parser::oracle::parse_oracle_text; use engine::types::ability::{ControllerRef, Effect, FilterProp, TargetFilter, TypeFilter}; use engine::types::actions::GameAction; use engine::types::game_state::WaitingFor; +use engine::types::mana::ManaCost; use engine::types::phase::Phase; use engine::types::triggers::TriggerMode; use engine::types::zones::Zone; @@ -49,6 +50,7 @@ const SILENT_BLADE_ONI_ORACLE: &str = "Ninjutsu {4}{U}{B} ({4}{U}{B}, Return an attacker you control to hand: Put this card onto the battlefield from your hand tapped and \ attacking.)\nWhenever this creature deals combat damage to a player, look at that player's \ hand. You may cast a spell from among those cards without paying its mana cost."; +const HAND_REVEAL_CMC_GATE_ORACLE: &str = "Whenever this creature deals combat damage to a player, look at that player's hand. You may cast a creature spell with mana value 2 or less from among those cards without paying its mana cost."; /// CR 603.2 + CR 701.20a + CR 118.9: the DamageDone trigger's execute chain /// must be `RevealHand { TriggeringPlayer, reveal: false }` followed by a @@ -237,3 +239,74 @@ fn silent_blade_oni_offers_free_cast_from_damaged_players_hand() { "the cast must be free — no mana spent" ); } + +/// The hand-bound branch must retain property predicates from its typed cast +/// gate. This drives the combat trigger through the production reveal and cast +/// pipeline: all candidates reach the revealed hand, but only the creature at +/// or below the printed mana-value ceiling reaches the cast-choice prompt. +#[test] +fn hand_reveal_cast_respects_the_mana_value_gate() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature(P0, "Hand Reveal CMC Source", 3, 2) + .from_oracle_text(HAND_REVEAL_CMC_GATE_ORACLE) + .id(); + let legal_creature = scenario + .add_creature_to_hand(P1, "Legal Hand Creature", 2, 2) + .with_mana_cost(ManaCost::generic(2)) + .id(); + let over_limit_creature = scenario + .add_creature_to_hand(P1, "Over-Limit Hand Creature", 3, 3) + .with_mana_cost(ManaCost::generic(3)) + .id(); + let noncreature_inside_ceiling = scenario + .add_spell_to_hand(P1, "Noncreature Hand Instant", true) + .with_mana_cost(ManaCost::generic(2)) + .id(); + + let mut runner = scenario.build(); + run_combat(&mut runner, vec![source], vec![]); + + for _ in 0..40 { + match runner.state().waiting_for { + WaitingFor::OptionalEffectChoice { .. } => break, + WaitingFor::Priority { .. } => { + runner + .act(GameAction::PassPriority) + .expect("PassPriority should drain the combat trigger"); + } + ref other => panic!("unexpected waiting state while draining: {other:?}"), + } + } + assert!( + matches!(runner.state().waiting_for, WaitingFor::OptionalEffectChoice { player, .. } if player == P0), + "reach guard: the cast permission must reach P0's optional choice, got {:?}", + runner.state().waiting_for + ); + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accepting the free-cast permission must succeed"); + + let WaitingFor::EffectZoneChoice { cards, zone, .. } = runner.state().waiting_for.clone() + else { + panic!( + "accepting the hand-reveal cast permission must open its candidate choice, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!(zone, Zone::Hand); + assert!( + cards.contains(&legal_creature), + "reach guard: the mana-value-2 creature must be offered; offered = {cards:?}" + ); + assert!( + !cards.contains(&over_limit_creature), + "the mana-value-3 creature must not be offered; offered = {cards:?}" + ); + assert!( + !cards.contains(&noncreature_inside_ceiling), + "the mana-value-2 instant must not be offered by a creature-only permission; \ + offered = {cards:?}" + ); +} diff --git a/crates/engine/tests/integration/kiora_self_library_peek_cast.rs b/crates/engine/tests/integration/kiora_self_library_peek_cast.rs index d2fa8c8698..9c56fc9560 100644 --- a/crates/engine/tests/integration/kiora_self_library_peek_cast.rs +++ b/crates/engine/tests/integration/kiora_self_library_peek_cast.rs @@ -10,10 +10,11 @@ use engine::game::visibility::filter_state_for_viewer; use engine::parser::oracle::parse_oracle_text; use engine::types::ability::{ AbilityDefinition, CastFromZoneDriver, CastPermissionConstraint, Comparator, ControllerRef, - Effect, FilterProp, ObjectScope, QuantityExpr, QuantityRef, TargetFilter, TypeFilter, - TypedFilter, + Effect, FilterProp, ObjectScope, QuantityExpr, QuantityRef, ResolvedAbility, TargetFilter, + TypeFilter, TypedFilter, }; use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; use engine::types::format::FormatConfig; use engine::types::game_state::WaitingFor; use engine::types::identifiers::ObjectId; @@ -801,6 +802,14 @@ fn untyped_from_among_them_cast_stays_a_bare_exile_anaphor() { ("Meeting of the Five", MEETING_OF_THE_FIVE, &["Sorcery"][..]), ("Perception Bobblehead", BOBBLEHEAD, &["Artifact"][..]), ("Kiora, Sovereign of the Deep", KIORA, &["Creature"][..]), + // Issue #6960 rows: the grammar now consumes a leading quantifier, so + // these clauses reach the leg list with `"spells"` in the leg position. + // The head-noun guard yields zero legs there, which is what keeps them + // bare. Without these rows the quantifier axis could swallow the whole + // untyped majority of this family. + ("Hazoret's Undying Fury", HAZORET, &["Sorcery"][..]), + ("Primeval Spawn", PRIMEVAL_SPAWN, &["Creature"][..]), + ("Improvisation Capstone", CAPSTONE, &["Sorcery"][..]), ] { assert_eq!( cast_target_of(oracle, name, types), @@ -1067,3 +1076,872 @@ fn kiora_library_choice_is_private_across_serde_round_trip() { assert!(cards.iter().all(|id| *id == ObjectId(0))); assert_eq!(restored_opponent.objects[&legal].name, "Hidden Card"); } + +// --------------------------------------------------------------------------- +// Issue #6960 — `parse_cast_type_disjunction` missed conjunctive, counted, and +// subtype forms, so seven cards kept a bare (or `Any`) cast target and ANY card +// type could be cast from the exiled set. +// +// The helper is now a per-axis composed grammar +// (`opt(quantifier) opt(article) leg (sep leg)* head_noun`). These rows pin the +// three axes it unfroze, the anti-swallow acceptance boundary that keeps the +// untyped majority bare, and the runtime consequence. +// --------------------------------------------------------------------------- + +const RAL_LEYLINE_PRODIGY: &str = "Ral enters with an additional loyalty counter on him for each instant and sorcery spell you've cast this turn.\n[+1]: Until your next turn, instant and sorcery spells you cast cost {1} less to cast.\n[\u{2212}2]: Ral deals 2 damage divided as you choose among one or two targets. Draw a card if you control a blue permanent other than Ral.\n[\u{2212}8]: Exile the top eight cards of your library. You may cast instant and sorcery spells from among them this turn without paying their mana costs."; +const KYLOX: &str = "Menace, ward {2}, haste\nWhenever Kylox attacks, sacrifice any number of other creatures, then exile the top X cards of your library, where X is their total power. You may cast any number of instant and/or sorcery spells from among the exiled cards without paying their mana costs."; +const SANWELL: &str = "As long as an artifact creature you control is attacking, prevent all damage that would be dealt to Sanwell.\nWhenever Sanwell becomes tapped, exile the top six cards of your library. You may cast a Vehicle or artifact creature spell from among them. Then put the rest on the bottom of your library in a random order."; +/// Sanwell's becomes-tapped trigger body, verbatim from `SANWELL` above — the +/// trigger's own instruction chain, without the card's separate static ability. +const SANWELL_TRIGGER_BODY: &str = "exile the top six cards of your library. You may cast a Vehicle or artifact creature spell from among them. Then put the rest on the bottom of your library in a random order."; +const WAND_OF_WONDER: &str = "{4}, {T}: Roll a d20. Each opponent exiles cards from the top of their library until they exile an instant or sorcery card, then shuffles the rest into their library. You may cast up to X instant and/or sorcery spells from among cards exiled this way without paying their mana costs.\n1\u{2014}9 | X is one.\n10\u{2014}19 | X is two.\n20 | X is three."; +const SCHOLAR_OF_THE_LOST_TROVE: &str = "Flying\nWhen this creature enters, you may cast target instant, sorcery, or artifact card from your graveyard without paying its mana cost. If an instant or sorcery spell cast this way would be put into your graveyard, exile it instead."; +const ETALI_PRIMAL_CONQUEROR: &str = "Trample\nWhen Etali enters, each player exiles cards from the top of their library until they exile a nonland card. You may cast any number of spells from among the nonland cards exiled this way without paying their mana costs.\n{9}{G/P}: Transform Etali. Activate only as a sorcery."; +const HELLCARVER_DEMON: &str = "Flying\nWhenever this creature deals combat damage to a player, sacrifice all other permanents you control and discard your hand. Exile the top six cards of your library. You may cast any number of spells from among cards exiled this way without paying their mana costs."; +/// Synthetic Oracle text: no printed card puts an `Or`-shaped (multi-word-leg) +/// type gate on the hand-bound branch, so the `And` arm of that branch's match +/// has no production card. This fixture drives the real `parse_oracle_text` +/// path to reach it. Called out as synthetic in the PR body. +const SYNTHETIC_HAND_BOUND_VEHICLE: &str = "When this creature enters, target opponent reveals their hand. You may cast a Vehicle or artifact creature spell from among those cards without paying its mana cost."; +const SYNTHETIC_HAND_BOUND_CMC: &str = "When this creature enters, target opponent reveals their hand. You may cast a creature spell with mana value 2 or less from among those cards without paying its mana cost."; + +fn instant_or_sorcery() -> TypeFilter { + TypeFilter::AnyOf(vec![TypeFilter::Instant, TypeFilter::Sorcery]) +} + +/// Reads the exile-set-anaphor composition: `And { [gate, ExiledBySource] }`. +/// Asserts BOTH legs, so a gate that replaced the anaphor rather than AND-ing +/// with it fails just as loudly as a dropped gate. +fn exile_gated_cast_legs(oracle: &str, name: &str, types: &[&str]) -> Vec { + let target = cast_target_of(oracle, name, types); + let TargetFilter::And { filters } = &target else { + panic!("{name}: expected And {{ gate, ExiledBySource }}, got {target:?}"); + }; + assert!( + filters.contains(&TargetFilter::ExiledBySource), + "{name}: the exile-set anaphor leg must survive the composition, got {filters:?}" + ); + filters.clone() +} + +fn typed_leg_of(filters: &[TargetFilter], name: &str) -> TypedFilter { + filters + .iter() + .find_map(|f| match f { + TargetFilter::Typed(tf) => Some(tf.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("{name}: expected a typed gate leg, got {filters:?}")) +} + +fn hand_bound_typed_leg_of(filters: &[TargetFilter], name: &str) -> TypedFilter { + filters + .iter() + .find_map(|filter| match filter { + TargetFilter::Typed(typed) + if typed + .properties + .contains(&FilterProp::InZone { zone: Zone::Hand }) => + { + Some(typed.clone()) + } + _ => None, + }) + .unwrap_or_else(|| panic!("{name}: expected a hand-bound typed leg, got {filters:?}")) +} + +/// R1 — CR 601.3 + CR 205.2b: the connector spelling `" and "` enumerates +/// ALTERNATIVE members of the permission's candidate set, so it lowers to +/// `TypeFilter::AnyOf`, exactly like `" or "`. +/// +/// The `assert_eq!` on the whole `type_filters` vector is the load-bearing +/// assertion: `game/filter.rs` evaluates that vector with `.all()`, so the +/// tempting literal reading — `vec![Instant, Sorcery]` — is a per-object +/// conjunction that matches NOTHING (see `no_card_is_both_instant_and_sorcery`), +/// i.e. strictly worse than the bare filter this replaces. Equality, not +/// `contains`, is what fails on that refactor. +#[test] +fn and_joined_cast_type_gate_is_a_disjunction() { + for (name, oracle, types) in [ + ("Epic Experiment", EPIC_EXPERIMENT, &["Sorcery"][..]), + ( + "Ral, Leyline Prodigy", + RAL_LEYLINE_PRODIGY, + &["Planeswalker"][..], + ), + ] { + let filters = exile_gated_cast_legs(oracle, name, types); + assert_eq!( + typed_leg_of(&filters, name).type_filters, + vec![instant_or_sorcery()], + "{name}: \"instant and sorcery spells\" is a plural over the permitted \ + SET (CR 601.3), not a conjunction over one object" + ); + } +} + +/// R3 — the `" and/or "` spelling is the same axis as `" and "` / `" or "`, and +/// a leading `"any number of "` quantifier is consumed without becoming a type. +#[test] +fn and_or_joined_cast_type_gate_is_a_disjunction() { + let filters = exile_gated_cast_legs(KYLOX, "Kylox, Visionary Inventor", &["Creature"]); + assert_eq!( + typed_leg_of(&filters, "Kylox, Visionary Inventor").type_filters, + vec![instant_or_sorcery()] + ); +} + +/// R4 — CR 601.2: a leading count is a count of CAST EVENTS, not an object +/// quality, so it is consumed and discarded rather than folded into the filter. +/// +/// Two authorities in one test: the type gate (`[Sorcery]`, single leg accepted +/// only because the quantifier was consumed) and the mana-value +/// `CastPermissionConstraint`. A fix that ate the constraint while consuming the +/// count fails the second assertion. +#[test] +fn counted_cast_type_gate_keeps_the_type_leg() { + let parsed = parse(COLLECTED_CONJURING, "Collected Conjuring", &["Sorcery"]); + let Effect::CastFromZone { + target, constraint, .. + } = parsed_cast_from_zone(&parsed) + else { + unreachable!("helper returns CastFromZone") + }; + let TargetFilter::And { filters } = target else { + panic!("Collected Conjuring: expected And {{ gate, ExiledBySource }}, got {target:?}"); + }; + assert!(filters.contains(&TargetFilter::ExiledBySource)); + assert_eq!( + typed_leg_of(filters, "Collected Conjuring").type_filters, + vec![TypeFilter::Sorcery], + "\"up to two sorcery spells\" names exactly one card type — no AnyOf wrapper" + ); + assert_eq!( + constraint, + &Some(CastPermissionConstraint::ManaValue { + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 3 }, + }), + "consuming the leading count must not eat the mana-value bound" + ); +} + +/// R5 — serial-comma lists yield every leg, in source order. Pins the `many0` +/// arity against a regression that hard-codes two legs. +/// +/// Scholar of the Lost Trove is a real printed card with the serial-comma +/// surface (`"target instant, sorcery, or artifact card"`), so this row is not +/// synthetic. Its non-type legs (`you control`, `InZone { Graveyard }`) are +/// asserted too: the composed grammar returns `controller: None, properties: +/// []` and relies on `apply_cast_target_suffixes` to re-add them, so dropping +/// that re-add would silently widen the permission to every graveyard. +#[test] +fn serial_comma_cast_type_gate_yields_all_three_legs() { + let target = cast_target_of( + SCHOLAR_OF_THE_LOST_TROVE, + "Scholar of the Lost Trove", + &["Creature"], + ); + let TargetFilter::Typed(typed) = &target else { + panic!("Scholar of the Lost Trove: expected a single typed filter, got {target:?}"); + }; + assert_eq!( + typed.type_filters, + vec![TypeFilter::AnyOf(vec![ + TypeFilter::Instant, + TypeFilter::Sorcery, + TypeFilter::Artifact, + ])], + "three legs, order-preserving" + ); + assert_eq!(typed.controller, Some(ControllerRef::You)); + assert!(typed.properties.contains(&FilterProp::InZone { + zone: Zone::Graveyard + })); +} + +/// R10 — CR 205.3g + CR 205.2b: a subtype leg (`Vehicle`, an artifact subtype) +/// stands beside a multi-word core-type leg (`artifact creature`). +/// +/// The multi-word leg must be ONE `Typed` carrying TWO atoms, not two legs: +/// CR 205.2b says adjacent type words with no connector describe one object +/// bearing both types. A grammar that split them would permit any artifact. +#[test] +fn subtype_and_multiword_cast_type_gate() { + let filters = exile_gated_cast_legs(SANWELL, "Sanwell, Avenger Ace", &["Creature"]); + let gate = filters + .iter() + .find(|f| matches!(f, TargetFilter::Or { .. })) + .unwrap_or_else(|| panic!("Sanwell: expected an Or-shaped gate leg, got {filters:?}")); + let TargetFilter::Or { filters: legs } = gate else { + unreachable!("matched Or above") + }; + assert_eq!( + legs, + &vec![ + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Subtype("Vehicle".to_string())], + controller: None, + properties: Vec::new(), + }), + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Artifact, TypeFilter::Creature], + controller: None, + properties: Vec::new(), + }), + ], + "CR 205.2b: \"artifact creature\" is one leg with two atoms; the Vehicle \ + subtype is canonicalized, not lowercased" + ); +} + +/// R6 — the trap row. `TargetFilter::references_exiled_by_source` uses `.any()` +/// for `And` but **`.all()` for `Or`**. The composed shape is always +/// `And { [gate, ExiledBySource] }`, so the `And` arm answers and the exile +/// binding survives an `Or`-shaped gate. A future refactor that hoisted the gate +/// to top level (`Or { [legA, legB] }`) would silently return `false` here and +/// the runtime would stop remapping the library-peek set. +#[test] +fn or_shaped_cast_gate_still_references_the_exile_set() { + let target = cast_target_of(SANWELL, "Sanwell, Avenger Ace", &["Creature"]); + assert!( + matches!(&target, TargetFilter::And { filters } + if filters.iter().any(|f| matches!(f, TargetFilter::Or { .. }))), + "reach guard: this row is only meaningful on an Or-shaped gate, got {target:?}" + ); + assert!( + target.references_exiled_by_source(), + "the Or-shaped gate must not break the exile-set binding (Or evaluates \ + `references_exiled_by_source` with .all(), And with .any())" + ); +} + +/// R8 — anti-swallow on the NEW pre-anchor probe. +/// +/// `parse_from_among_exiled_this_way` now probes the text BEFORE the +/// `"from among "` anchor, because WotC puts the type list there in the counted +/// form. The untyped members of that family carry `"any number of spells "` / +/// `"up to two spells "` in exactly that position, and must not gain a gate. +/// +/// Wand of Wonder in the same test is the mandatory paired positive: it proves +/// the prefix probe actually ran, so the negatives below are not vacuous. +#[test] +fn untyped_pre_anchor_prefix_adds_no_type_gate() { + // Positive reach guard: the pre-anchor type list IS consumed. + let filters = exile_gated_cast_legs(WAND_OF_WONDER, "Wand of Wonder", &["Artifact"]); + let typed = typed_leg_of(&filters, "Wand of Wonder"); + assert_eq!(typed.type_filters, vec![instant_or_sorcery()]); + assert!( + typed + .properties + .contains(&FilterProp::InZone { zone: Zone::Exile }), + "the exiled-this-way arm pins the candidate cards to exile, got {:?}", + typed.properties + ); + + // Negatives: same branch, same prefix position, no card type named. + assert_eq!( + cast_target_of(HELLCARVER_DEMON, "Hellcarver Demon", &["Creature"]), + TargetFilter::ExiledBySource, + "\"any number of spells from among cards exiled this way\" names no type" + ); + assert_eq!( + cast_target_of(CAPSTONE, "Improvisation Capstone", &["Sorcery"]), + TargetFilter::ExiledBySource + ); + // Etali has a real POST-anchor typed leg ("the nonland cards exiled this + // way"); the prefix probe must not shadow or duplicate it. + let etali = cast_target_of( + ETALI_PRIMAL_CONQUEROR, + "Etali, Primal Conqueror", + &["Creature"], + ); + let TargetFilter::And { filters } = &etali else { + panic!("Etali: expected And {{ typed, ExiledBySource }}, got {etali:?}"); + }; + assert!(filters.contains(&TargetFilter::ExiledBySource)); + assert_eq!( + typed_leg_of(filters, "Etali, Primal Conqueror").type_filters, + vec![ + TypeFilter::Card, + TypeFilter::Non(Box::new(TypeFilter::Land)) + ], + "Etali's post-anchor nonland leg must be unchanged" + ); +} + +/// R9 — the hand-bound branch's `And` arm. No printed card reaches it, so the +/// fixture Oracle text is synthetic; it still runs through production +/// `parse_oracle_text`. +/// +/// Paired positive: `hand_bound_cast_retains_the_instant_or_sorcery_gate` +/// (Mindclaw Shaman) must stay on the `Typed` graft arm — that test failing +/// would mean the `Typed` arm regressed into the `And` arm. +#[test] +fn hand_bound_or_shaped_gate_ands_rather_than_grafts() { + let target = cast_target_of( + SYNTHETIC_HAND_BOUND_VEHICLE, + "Synthetic Hand Reveal Pilot", + &["Creature"], + ); + let TargetFilter::And { filters } = &target else { + panic!("expected And {{ Or-gate, hand binding }}, got {target:?}"); + }; + assert!( + filters.iter().any(|f| matches!(f, TargetFilter::Or { .. })), + "the Or-shaped gate must be AND-ed beside the hand binding, got {filters:?}" + ); + let hand = hand_bound_typed_leg_of(filters, "Synthetic Hand Reveal Pilot"); + assert_eq!( + hand.type_filters, + vec![TypeFilter::Card], + "the hand binding keeps its bare Card head noun; the type gate rides beside it" + ); + assert_eq!(hand.controller, Some(ControllerRef::Opponent)); + assert!(hand + .properties + .contains(&FilterProp::InZone { zone: Zone::Hand })); +} + +/// A typed cast gate can carry property predicates as well as type atoms. Those +/// predicates cannot be grafted into the hand binding's type vector; the whole +/// gate must remain an `And` leg beside the revealed-hand binding. +#[test] +fn hand_bound_cast_keeps_rich_typed_gate_as_a_complete_predicate() { + let target = cast_target_of( + SYNTHETIC_HAND_BOUND_CMC, + "Synthetic Hand Reveal CMC", + &["Creature"], + ); + let TargetFilter::And { filters } = &target else { + panic!("expected And {{ typed gate, hand binding }}, got {target:?}"); + }; + assert!( + filters.iter().any(|filter| { + matches!(filter, TargetFilter::Typed(typed) + if typed.type_filters == vec![TypeFilter::Creature] + && typed.properties.contains(&FilterProp::Cmc { + comparator: Comparator::LE, + value: QuantityExpr::Fixed { value: 2 }, + })) + }), + "the complete creature + mana-value gate must survive, got {filters:?}" + ); + let hand = hand_bound_typed_leg_of(filters, "Synthetic Hand Reveal CMC"); + assert_eq!(hand.type_filters, vec![TypeFilter::Card]); + assert_eq!(hand.controller, Some(ControllerRef::Opponent)); + assert!( + hand.properties + .contains(&FilterProp::InZone { zone: Zone::Hand }), + "the hand binding must remain alongside the rich gate, got {:?}", + hand.properties + ); +} + +/// R2 — the semantic trap, documented executably rather than in a comment. +/// +/// `game/filter.rs` evaluates `TypedFilter::type_filters` with `.all()`, so a +/// literal `vec![Instant, Sorcery]` demands one object be BOTH. No such object +/// exists, which is why every connector spelling must lower to `AnyOf`. +/// +/// Loaded through `support::shared_card_export_json()` (the sanctioned loader — +/// `scripts/check-test-card-data-load.sh` fails any test that opens +/// `client/public/card-data.json` directly). That loader returns `None` when the +/// gitignored export is absent, so this row SELF-SKIPS in CI and is local +/// documentation only; the real pin is `and_joined_cast_type_gate_is_a_disjunction`'s +/// `assert_eq!`. +#[test] +fn no_card_is_both_instant_and_sorcery() { + let Some(export) = crate::support::shared_card_export_json() else { + return; + }; + assert!( + export.len() >= 30_000, + "reach guard: a truncated export would satisfy the count below vacuously, \ + got {} entries", + export.len() + ); + let both: Vec<&String> = export + .iter() + .filter(|(_, value)| { + let types = value + .get("card_type") + .and_then(|ct| ct.get("core_types")) + .and_then(|t| t.as_array()); + types.is_some_and(|t| { + t.iter().any(|v| v.as_str() == Some("Instant")) + && t.iter().any(|v| v.as_str() == Some("Sorcery")) + }) + }) + .map(|(key, _)| key) + .collect(); + assert!( + both.is_empty(), + "no card carries both Instant and Sorcery, so a literal per-object `And` \ + of the two legs would match nothing; found {both:?}" + ); +} + +// --------------------------------------------------------------------------- +// R11-R13 — RUNTIME coverage for the exile-set ("from among them") site. +// +// The runtime shape of this site is NOT the private-library `EffectZoneChoice` +// used by Kiora/Velomachus/Svella. Those cards LOOK at library cards and pick +// one during resolution (CR 608.2g). The cards below EXILE first, and their +// "you may cast ..." instruction grants a lingering +// `CastingPermission::ExileWithAltCost` (CR 118.9) on the exiled cards, then +// hands the controller priority — so the observable is which exiled cards +// carry a cast permission and appear on the legal-action surface. +// --------------------------------------------------------------------------- + +/// CR 608.2c: "Then put all cards exiled this way that weren't cast into your +/// graveyard" (Epic Experiment), "Put the exiled cards not cast this way on the +/// bottom of your library" (Collected Conjuring) and "Then put the rest on the +/// bottom of your library" (Sanwell) are SEPARATE instructions that follow the +/// cast permission. The engine grants the permission and then resolves that +/// cleanup inside the same resolution; its zone change runs +/// `zones::apply_zone_exit_cleanup`, which strips the grant before the +/// controller ever reaches a priority window. Detach exactly that trailing +/// instruction so the permission set the cast instruction produced is +/// observable. +/// +/// Everything else — including the parse itself — is the card's real, unmodified +/// Oracle text. The detached node's identity is asserted, so no other +/// instruction can be silently dropped, and the cleanup's own behaviour is +/// covered by `issue_3267_sanwell_rest_on_bottom.rs`. +fn exile_then_cast_chain_without_uncast_cleanup(oracle: &str) -> AbilityDefinition { + let mut execute = engine::parser::oracle_effect::parse_effect_chain( + oracle, + engine::types::ability::AbilityKind::Spell, + ); + let cast = execute + .sub_ability + .as_mut() + .expect("the exile step must chain into the \"you may cast\" instruction"); + assert!( + matches!(cast.effect.as_ref(), Effect::CastFromZone { .. }), + "expected the chained cast instruction, got {:?}", + cast.effect + ); + let detached: Vec<_> = cast + .sub_ability + .take() + .into_iter() + .chain(cast.else_ability.take()) + .collect(); + assert!( + !detached.is_empty(), + "reach guard: this card's trailing uncast-cleanup instruction must exist, \ + otherwise this helper is silently doing nothing" + ); + for cleanup in &detached { + assert!( + is_uncast_cleanup(cleanup), + "only the uncast-cleanup instruction may be detached, got {:?}", + cleanup.effect + ); + } + execute +} + +/// True for a chain made only of "put the uncast cards somewhere" instructions +/// (`PutAtLibraryPosition`, or a mass move to the graveyard). +fn is_uncast_cleanup(def: &AbilityDefinition) -> bool { + matches!( + def.effect.as_ref(), + Effect::PutAtLibraryPosition { .. } + | Effect::ChangeZoneAll { + destination: Zone::Graveyard, + .. + } + ) && def.sub_ability.as_deref().is_none_or(is_uncast_cleanup) + && def.else_ability.as_deref().is_none_or(is_uncast_cleanup) +} + +/// Resolve an exile-then-cast chain and accept its "you may cast" offer, leaving +/// the runner at the priority window where the granted permissions are live. +fn accept_exile_set_cast( + runner: &mut GameRunner, + source: ObjectId, + execute: &AbilityDefinition, + chosen_x: Option, +) { + let resolved = exile_set_cast_ability(execute, source, chosen_x); + resolve_and_accept_exile_set_cast(runner, &resolved); +} + +/// The resolved form of an exile-then-cast chain, with X stamped across it. +fn exile_set_cast_ability( + execute: &AbilityDefinition, + source: ObjectId, + chosen_x: Option, +) -> ResolvedAbility { + let mut resolved = engine::game::ability_utils::build_resolved_from_def(execute, source, P0); + // CR 107.3i: every instance of X in a single announcement shares one value, + // so it is stamped on the whole chain — on Epic Experiment X sizes both the + // exile step and the cast permission's mana-value ceiling. + fn stamp_x(ability: &mut ResolvedAbility, chosen_x: Option) { + ability.chosen_x = chosen_x; + if let Some(sub) = ability.sub_ability.as_mut() { + stamp_x(sub, chosen_x); + } + if let Some(alt) = ability.else_ability.as_mut() { + stamp_x(alt, chosen_x); + } + } + stamp_x(&mut resolved, chosen_x); + resolved +} + +fn resolve_and_accept_exile_set_cast(runner: &mut GameRunner, resolved: &ResolvedAbility) { + let mut events = Vec::new(); + engine::game::effects::resolve_ability_chain(runner.state_mut(), resolved, &mut events, 0) + .expect("the exile-then-cast chain must resolve"); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::OptionalEffectChoice { .. } + ), + "CR 608.2d: the \"you may cast\" offer must be presented, parked at {:?}", + runner.state().waiting_for + ); + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("accepting the optional cast must succeed"); + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "CR 118.9: this site grants a lingering permission and hands back \ + priority, parked at {:?}", + runner.state().waiting_for + ); +} + +/// CR 601.3: the cards the granted permission actually authorizes, read off the +/// engine's own legal-action surface rather than off the raw permission list, so +/// a permission the casting pipeline would refuse cannot count as "offered". +fn free_cast_offers(runner: &GameRunner) -> Vec { + engine::ai_support::legal_actions(runner.state()) + .iter() + .filter_map(|action| match action { + GameAction::CastSpell { object_id, .. } + | GameAction::CastSpellForFree { object_id, .. } => Some(*object_id), + _ => None, + }) + .collect() +} + +/// Positive reach guard: take the offer and prove the card reaches the stack. +fn take_offer_onto_the_stack(runner: &mut GameRunner, card: ObjectId) { + let action = engine::ai_support::legal_actions(runner.state()) + .into_iter() + .find(|action| { + matches!( + action, + GameAction::CastSpell { object_id, .. } + | GameAction::CastSpellForFree { object_id, .. } if *object_id == card + ) + }) + .unwrap_or_else(|| panic!("{card:?} must be castable from the granted permission")); + runner.act(action).expect("casting the offered card"); + if matches!(runner.state().waiting_for, WaitingFor::ManaPayment { .. }) { + runner + .act(GameAction::PassPriority) + .expect("finalizing the cast's mana payment"); + } + assert_eq!( + runner.state().objects[&card].zone, + Zone::Stack, + "the offered card must land on the stack" + ); +} + +/// R11 — RUNTIME. Epic Experiment with X = 2 exiles two mana-value-2 cards: a +/// sorcery and a creature. Both are inside the `ManaValue LE X` ceiling, so ONLY +/// the card-type gate (`AnyOf([Instant, Sorcery])`, the `" and "` connector this +/// change learned to read) can exclude the creature. +/// +/// Reach guard: the sorcery must BE offered and must land on the stack, so the +/// negative cannot pass by an empty or short-circuited permission set. +#[test] +fn epic_experiment_does_not_offer_a_creature_inside_its_mana_value_ceiling() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let epic = scenario + .add_spell_to_hand_from_oracle(P0, "Epic Experiment", false, EPIC_EXPERIMENT) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::X], + generic: 0, + }) + .id(); + // The trap: mana value 2 <= X = 2, so only the type gate excludes it. + let creature_inside_ceiling = scenario + .add_spell_to_library_top(P0, "Epic Trap Creature", false) + .with_mana_cost(ManaCost::generic(2)) + .as_creature() + .id(); + let legal_sorcery = scenario + .add_spell_to_library_top(P0, "Epic Legal Sorcery", false) + .with_mana_cost(ManaCost::generic(2)) + .id(); + + let mut runner = scenario.build(); + assert_eq!( + runner.state().objects[&creature_inside_ceiling] + .card_types + .core_types, + vec![CoreType::Creature], + "anti-vacuity: the trap must be a creature and NOTHING else — a fixture \ + that is still also a Sorcery would satisfy the gate legitimately" + ); + + let execute = exile_then_cast_chain_without_uncast_cleanup(EPIC_EXPERIMENT); + accept_exile_set_cast(&mut runner, epic, &execute, Some(2)); + + let offers = free_cast_offers(&runner); + assert!( + offers.contains(&legal_sorcery), + "reach guard: the legal sorcery must be offered, otherwise the negative \ + below is vacuous; offered = {offers:?}" + ); + assert!( + !offers.contains(&creature_inside_ceiling), + "CR 601.3: \"cast instant and sorcery spells\" permits only instants and \ + sorceries — a creature inside the mana-value ceiling must never be \ + offered (issue #6960); offered = {offers:?}" + ); + assert!( + runner.state().objects[&creature_inside_ceiling] + .casting_permissions + .is_empty(), + "the ineligible creature must not receive a casting permission" + ); + + take_offer_onto_the_stack(&mut runner, legal_sorcery); +} + +/// R12 — RUNTIME. Collected Conjuring names ONE type behind a leading count +/// ("up to two sorcery spells"), the form whose quantifier prefix had to be +/// consumed before the type phrase. The mana-value-3 instant is inside the +/// `ManaValue LE 3` ceiling, so only the type gate excludes it; the +/// mana-value-3 sorcery is the paired positive. +#[test] +fn collected_conjuring_does_not_offer_an_instant() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let conjuring = scenario + .add_spell_to_hand_from_oracle(P0, "Collected Conjuring", false, COLLECTED_CONJURING) + .with_mana_cost(ManaCost::generic(4)) + .id(); + // Seeded as an instant outright: `add_spell_to_library_top(.., false)` + // seeds Sorcery, and `CardBuilder::as_instant` only strips Creature — the + // resulting Sorcery-AND-Instant card would satisfy a Sorcery gate honestly + // and make the negative below vacuous. + let instant_inside_ceiling = scenario + .add_spell_to_library_top(P0, "Conjuring Trap Instant", true) + .with_mana_cost(ManaCost::generic(3)) + .id(); + let legal_sorcery = scenario + .add_spell_to_library_top(P0, "Conjuring Legal Sorcery", false) + .with_mana_cost(ManaCost::generic(3)) + .id(); + for index in 0..4 { + scenario + .add_spell_to_library_top(P0, &format!("Conjuring Filler {index}"), false) + .with_mana_cost(ManaCost::generic(6)); + } + + let mut runner = scenario.build(); + assert_eq!( + runner.state().objects[&instant_inside_ceiling] + .card_types + .core_types, + vec![CoreType::Instant], + "anti-vacuity: the trap must be an instant and NOTHING else" + ); + + let execute = exile_then_cast_chain_without_uncast_cleanup(COLLECTED_CONJURING); + accept_exile_set_cast(&mut runner, conjuring, &execute, None); + + let offers = free_cast_offers(&runner); + assert!( + offers.contains(&legal_sorcery), + "reach guard: the legal sorcery must be offered; offered = {offers:?}" + ); + assert!( + !offers.contains(&instant_inside_ceiling), + "CR 601.3: \"up to two sorcery spells\" permits sorceries only — an \ + instant inside the mana-value ceiling must never be offered; \ + offered = {offers:?}" + ); + assert!( + runner.state().objects[&instant_inside_ceiling] + .casting_permissions + .is_empty(), + "the ineligible instant must not receive a casting permission" + ); + + take_offer_onto_the_stack(&mut runner, legal_sorcery); +} + +/// R13 — RUNTIME. Sanwell's `Or`-shaped gate has TWO legs from two different CR +/// sections (CR 205.3g subtype, CR 205.2b core-type conjunction). Both positives +/// are asserted, so a gate that collapsed the `Or` to a single leg fails. +/// +/// Sanwell's clause carries no "without paying its mana cost", so these are paid +/// casts — the mana pool covers every fixture equally and the only axis that can +/// separate them is the type gate. +#[test] +fn sanwell_offers_only_vehicles_and_artifact_creatures() { + let mut fixture = sanwell_fixture(); + let execute = exile_then_cast_chain_without_uncast_cleanup(SANWELL_TRIGGER_BODY); + accept_exile_set_cast(&mut fixture.runner, fixture.sanwell, &execute, None); + fixture.assert_only_the_two_gate_legs_are_offered(); + take_offer_onto_the_stack(&mut fixture.runner, fixture.vehicle); +} + +/// R13b — RUNTIME, under a REAL triggered-ability context. Sanwell's grant is +/// printed on a trigger ("Whenever Sanwell becomes tapped, …"), so in production +/// the resolving ability carries a `TriggerSourceContext`. +/// +/// That context is captured when the trigger is put on the stack — BEFORE the +/// ability's own exile step runs — so its `linked_exile_snapshot` is empty. +/// `filter::ExiledBySource` prefers that snapshot over the live exile links +/// whenever `trigger_source.is_some()`, so a runtime gate that re-evaluated the +/// whole filter (anaphor leg included) against the chain-forwarded ids would +/// match NOTHING and grant NOTHING — turning the fix into a total no-op on +/// exactly the cards it targets. Discharging the anaphor +/// (`TargetFilter::without_exile_anaphor`) and testing only the clause's own +/// legs is what keeps this row green. +/// +/// R13's sibling row above builds the same chain with no trigger context and so +/// cannot see this; that is why this variant exists. +#[test] +fn sanwell_type_gate_holds_under_a_real_trigger_context() { + let mut fixture = sanwell_fixture(); + let execute = exile_then_cast_chain_without_uncast_cleanup(SANWELL_TRIGGER_BODY); + let mut resolved = exile_set_cast_ability(&execute, fixture.sanwell, None); + // CR 603.4: stamp the provenance a real "becomes tapped" trigger would carry. + let (incarnation, card_id) = { + let source = &fixture.runner.state().objects[&fixture.sanwell]; + (source.incarnation, source.card_id) + }; + resolved.set_test_trigger_source_recursive(incarnation, card_id); + assert!( + resolved + .sub_ability + .as_ref() + .is_some_and(|cast| cast.trigger_source.is_some()), + "reach guard: the cast instruction itself must carry the trigger context, \ + otherwise this row degenerates into R13" + ); + + resolve_and_accept_exile_set_cast(&mut fixture.runner, &resolved); + fixture.assert_only_the_two_gate_legs_are_offered(); + take_offer_onto_the_stack(&mut fixture.runner, fixture.artifact_creature); +} + +/// Sanwell plus one card per gate outcome, with the seeded types pinned so the +/// negatives below cannot pass by accident. +struct SanwellFixture { + runner: GameRunner, + sanwell: ObjectId, + vehicle: ObjectId, + artifact_creature: ObjectId, + plain_creature: ObjectId, + instant: ObjectId, +} + +impl SanwellFixture { + /// Two positives and two negatives: a gate that collapsed the `Or` to a + /// single leg fails one positive, and a gate that vanished fails a negative. + fn assert_only_the_two_gate_legs_are_offered(&self) { + let offers = free_cast_offers(&self.runner); + assert!( + offers.contains(&self.vehicle), + "CR 205.3g: the Vehicle subtype leg must be offered; offered = {offers:?}" + ); + assert!( + offers.contains(&self.artifact_creature), + "CR 205.2b: the artifact-creature leg must be offered; offered = {offers:?}" + ); + assert!( + !offers.contains(&self.plain_creature), + "a nonartifact creature satisfies neither leg; offered = {offers:?}" + ); + assert!( + !offers.contains(&self.instant), + "an instant satisfies neither leg; offered = {offers:?}" + ); + } +} + +fn sanwell_fixture() -> SanwellFixture { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let sanwell = scenario + .add_creature(P0, "Sanwell, Avenger Ace", 3, 3) + .from_oracle_text(SANWELL) + .id(); + // CR 205.3g: a Vehicle is "Artifact — Vehicle"; `as_creature` + // first strips the Sorcery seed, `as_artifact` then strips Creature. + let vehicle = scenario + .add_spell_to_library_top(P0, "Sanwell Vehicle", false) + .with_mana_cost(ManaCost::generic(2)) + .as_creature() + .as_artifact() + .with_subtypes(vec!["Vehicle"]) + .id(); + let artifact_creature = scenario + .add_spell_to_library_top(P0, "Sanwell Artifact Creature", false) + .with_mana_cost(ManaCost::generic(2)) + .as_artifact() + .as_creature() + .id(); + let plain_creature = scenario + .add_spell_to_library_top(P0, "Sanwell Plain Creature", false) + .with_mana_cost(ManaCost::generic(2)) + .as_creature() + .id(); + let instant = scenario + .add_spell_to_library_top(P0, "Sanwell Instant", true) + .with_mana_cost(ManaCost::generic(2)) + .id(); + for index in 0..2 { + scenario + .add_spell_to_library_top(P0, &format!("Sanwell Filler {index}"), false) + .with_mana_cost(ManaCost::generic(2)); + } + scenario.with_mana_pool( + P0, + (0..2) + .map(|_| ManaUnit::new(ManaType::Colorless, sanwell, false, vec![])) + .collect(), + ); + + let runner = scenario.build(); + let types = |id: ObjectId| runner.state().objects[&id].card_types.core_types.clone(); + assert_eq!(types(vehicle), vec![CoreType::Artifact]); + assert_eq!( + runner.state().objects[&vehicle].card_types.subtypes, + vec!["Vehicle".to_string()] + ); + assert_eq!( + types(artifact_creature), + vec![CoreType::Artifact, CoreType::Creature] + ); + assert_eq!( + types(plain_creature), + vec![CoreType::Creature], + "anti-vacuity: the nonartifact creature must satisfy neither leg" + ); + assert_eq!(types(instant), vec![CoreType::Instant]); + + SanwellFixture { + runner, + sanwell, + vehicle, + artifact_creature, + plain_creature, + instant, + } +} diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index 81445a33cc..1626f8407e 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -4,7 +4,7 @@ Consolidated from 50 per-batch clustering passes over the whole card database. S - **Canonical root causes:** 30 - **Distinct cards implicated:** 4719 -- **Total card appearances across root causes:** 4753 (a card may appear under more than one root cause when it exhibits multiple distinct misparses) +- **Total card appearances across root causes:** 4752 (a card may appear under more than one root cause when it exhibits multiple distinct misparses) This is the prioritized "fix N root causes → unlock M cards" backlog: the top handful of root causes account for the majority of broken cards. @@ -17,7 +17,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top | 3 | Anaphor bound to wrong referent | 404 | oracle_quantity.rs context-ref resolution + game/ability_utils.rs forward_result wiring | | 4 | Conjoined / chained second effect clause dropped | 387 | oracle.rs effect-chain composition — split on 'and'/'then'/sentence boundaries and build sub_ability chain | | 5 | Dropped 'for each' / dynamic count collapsed to Fixed | 330 | oracle_quantity.rs parse_for_each_clause / parse_quantity_ref — thread ForEach/ObjectCount into the effect count field | -| 6 | Disjunctive (or-list) collapsed to first branch | 239 | oracle_nom/filter.rs + oracle_target.rs — build TargetFilter::Or across all alt() branches | +| 6 | Disjunctive (or-list) collapsed to first branch | 238 | oracle_nom/filter.rs + oracle_target.rs — build TargetFilter::Or across all alt() branches | | 7 | Wrong / dropped zone parameters on zone-change effect | 211 | game/zones.rs + oracle parser zone routing — derive correct origin/destination/owner from Oracle | | 8 | Additional / alternative casting cost dropped | 210 | oracle_cost.rs — parse additional/alternative cost clauses into Spell.cost / AdditionalCost | | 9 | Wrong player/controller scope (You where Opponent/Scoped/Target/Defending needed) | 182 | oracle parser ControllerRef binding — resolve scoped/defending/iterated player refs instead of defaulting to You | @@ -2567,7 +2567,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top -### 6. Disjunctive (or-list) collapsed to first branch (239 cards) +### 6. Disjunctive (or-list) collapsed to first branch (238 cards) **Signature.** An 'A or B (or C)' enumeration in a target/filter/cost/trigger/effect collapses to the first branch (or splits into a dangling Unknown); the OR/AnyOf union is never built. @@ -2750,7 +2750,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Sauron, the Dark Lord - Savai Triome - Sawback Manticore -- Scarlet Witch, Chaotic Avenger - Scarred Puma - Sea Troll - Search the Premises