diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index 292f29bd62..528b9ece54 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -5949,7 +5949,151 @@ pub(super) fn extract_remove_counter_multi_target(text: &str) -> Option( +/// CR 115.4 + CR 115.1a: The noun class of a "to each of ⟨count⟩ ⟨noun⟩" head. +/// +/// Not a `bool`: the two arms are different rules with different downstream +/// handling. CR 115.4 gives a bare plural "two targets" the damage target class +/// (creature, player, planeswalker, or battle) ⇒ `TargetFilter::Any`, while +/// CR 115.1a's "two target ⟨type⟩" defers to `parse_target_with_ctx` for the +/// printed filter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EachOfTargetNoun { + /// Bare plural `targets` — CONSUMED by the combinator; the caller supplies + /// `TargetFilter::Any` itself. + AnyTargets, + /// `[other |another ]target ⟨type⟩` — NOT consumed; `parse_target_with_ctx` + /// needs the full phrase including the `target ` article. + Typed, +} + +/// CR 601.2c: Bounded announced count ("one or two", "one, two, or three"). +/// Composes the trailing noun off `BOUNDED_TARGET_CARDINALITIES` exactly as +/// that constant's doc comment prescribes, so a future cardinality is added in +/// one place. +fn parse_bounded_target_cardinality(input: &str) -> OracleResult<'_, MultiTargetSpec> { + for &(stem, min, max) in BOUNDED_TARGET_CARDINALITIES { + if let Ok((rest, _)) = tag::<_, _, OracleError<'_>>(stem).parse(input) { + return Ok((rest, MultiTargetSpec::fixed(min, max))); + } + } + Err(oracle_err(input)) +} + +/// CR 601.2c: Optional announced count ("up to two", "up to X"). The count +/// vocabulary is delegated to `parse_multi_target_count_expr`, the single +/// authority for digits/English numerals/`x`/dynamic quantities. +fn parse_optional_target_cardinality(input: &str) -> OracleResult<'_, MultiTargetSpec> { + map( + preceded(tag("up to "), parse_multi_target_count_expr), + MultiTargetSpec::up_to, + ) + .parse(input) +} + +/// CR 601.2c: Exact announced count ("two", "X"). "Once the number of targets +/// the spell has is determined, that number doesn't change." +fn parse_exact_target_cardinality(input: &str) -> OracleResult<'_, MultiTargetSpec> { + map(parse_multi_target_count_expr, MultiTargetSpec::exact).parse(input) +} + +/// CR 115.4 + CR 115.1a: The noun following the announced count. +/// +/// The bare-plural arm is fenced with `not(satisfy(char::is_alphanumeric))` so +/// `targets` never matches inside a longer word — the same in-file form as the +/// `" tapped"` fence below. `not` never consumes, so no `peek` wrapper is +/// needed, and `satisfy` errors on empty input, so end-of-input is covered +/// without an `eof` arm. +/// +/// The typed arm is `peek`-only: `parse_target_with_ctx` must still see the +/// `target `/`other target `/`another target ` article. `other`/`another` is a +/// lexical modifier here, and `FilterProp::Another` is applied downstream by +/// `parse_target_with_ctx` — the same grandfathered handling as +/// `strip_optional_target_prefix`. Note the asymmetry: the bare-plural arm does +/// NOT accept "other targets" (Drakuseth's "up to two other targets"), matching +/// the pre-existing behaviour exactly rather than adding new leniency. +fn parse_each_of_target_noun(input: &str) -> OracleResult<'_, EachOfTargetNoun> { + alt(( + value( + EachOfTargetNoun::AnyTargets, + terminated(tag("targets"), not(satisfy(char::is_alphanumeric))), + ), + value( + EachOfTargetNoun::Typed, + peek(alt(( + tag("target "), + tag("other target "), + tag("another target "), + ))), + ), + )) + .parse(input) +} + +/// CR 601.2c + CR 115.4: The parameterized "⟨cardinality⟩ ⟨noun⟩" head that +/// follows "to each of ". Spans the cardinality × noun matrix (bounded / +/// optional / exact × bare-plural / typed) that two single-leaf strippers +/// previously covered one cell each. +/// +/// One printed cell is deliberately excluded: `other`/`another` on the +/// BARE-PLURAL arm ("each of up to two other targets", Drakuseth, Maw of +/// Flames). On the typed arm the modifier survives into `parse_target_with_ctx` +/// as `FilterProp::Another`, but the bare-plural arm consumes the noun and +/// synthesizes `TargetFilter::Any`, so there is nowhere for the CR 115.3 +/// cross-slot distinctness to land — accepting it would silently drop the +/// constraint. Drakuseth's clause is dropped upstream today anyway, so this +/// changes nothing for it; the cell is left to whoever threads that constraint. +/// +/// Each `alt` arm is a COMPLETE `(cardinality, multispace1, noun)` tuple so a +/// noun failure backtracks the whole arm: `"one or two targets"` would +/// otherwise have its leading `"one"` eaten by the exact arm. Bounded runs +/// first as defence in depth; the tuple shape is what makes the ordering +/// non-load-bearing. +/// +/// Returns the ORIGINAL-CASE remainder so `parse_target_with_ctx` still sees +/// printed casing. +fn parse_each_of_target_distribution( + after_each_of: &str, +) -> Option<(MultiTargetSpec, EachOfTargetNoun, &str)> { + let lower = after_each_of.to_ascii_lowercase(); + let ((spec, noun), remainder) = nom_on_lower(after_each_of, lower.as_str(), |input| { + map( + alt(( + ( + parse_bounded_target_cardinality, + multispace1, + parse_each_of_target_noun, + ), + ( + parse_optional_target_cardinality, + multispace1, + parse_each_of_target_noun, + ), + ( + parse_exact_target_cardinality, + multispace1, + parse_each_of_target_noun, + ), + )), + |(spec, _, noun)| (spec, noun), + ) + .parse(input) + })?; + // Return the remainder UNTRIMMED. A leading space is the compound-boundary + // marker every consumer of `try_parse_damage_with_remainder` keys on — + // `try_split_damage_compound` matches `tag(" and ")` and explicitly does not + // trim. Only the typed arm needs a phrase starting at `target `, so it trims + // at its own call site. + Some((spec, noun, remainder)) +} + +/// CR 601.2c + CR 115.4: "⟨source⟩ deals N damage to each of ⟨count⟩ ⟨noun⟩". +/// +/// CR 601.2c fixes the announced target COUNT; CR 115.4 fixes the target CLASS +/// for the bare-plural noun (creature, player, planeswalker, or battle). The +/// count is recorded on `ctx.pending_damage_multi_target` so the filter and the +/// count come from ONE parse rather than from a second text scan that can +/// disagree with it. +pub(super) fn parse_each_of_up_to_damage_target<'a>( target_phrase: &'a str, ctx: &mut ParseContext, ) -> Option<(TargetFilter, &'a str)> { @@ -5959,15 +6103,17 @@ fn parse_each_of_up_to_damage_target<'a>( .ok()?; let consumed = lower.len() - after_each_of_lower.len(); let after_each_of = &target_phrase[consumed..]; - if let Some((remainder, _)) = strip_bounded_targets_placeholder(after_each_of) { - if remainder.is_empty() { - return Some((TargetFilter::Any, "")); + let (spec, noun, remainder) = parse_each_of_target_distribution(after_each_of)?; + ctx.pending_damage_multi_target = Some(spec); + Some(match noun { + EachOfTargetNoun::AnyTargets => (TargetFilter::Any, remainder), + EachOfTargetNoun::Typed => { + // Only this arm trims: `parse_target_with_ctx` must see a phrase + // starting at `target `/`other target `/`another target `. + let (target, rest) = parse_target_with_ctx(remainder.trim_start(), ctx); + refine_damage_target_remainder(target, rest) } - } - let (target_text, multi_target) = strip_optional_target_prefix(after_each_of); - multi_target.as_ref()?; - let (target, remainder) = parse_target_with_ctx(target_text, ctx); - Some(refine_damage_target_remainder(target, remainder)) + }) } /// Verbs where "any number of" / "up to N" modifies the target set (CR 115.1d), @@ -12334,7 +12480,7 @@ mod strip_optional_effect_prefix_tests { mod dq_d_player_set_lift_tests { use super::{for_each_repeatable_repeat_for, strip_for_each_repeat_suffix}; use crate::parser::oracle_nom::quantity::parse_for_each_clause_ref; - use crate::types::ability::{PlayerFilter, QuantityExpr, QuantityRef}; + use crate::types::ability::{MultiTargetSpec, PlayerFilter, QuantityExpr, QuantityRef}; // Matrix #3 — the shared `split_for_each_suffix` refactor is byte-identical: // each input yields the SAME `(Option, String)` as pre-refactor. @@ -12477,4 +12623,249 @@ mod dq_d_player_set_lift_tests { ), } } + + // ───────────────────────────────────────────────────────────────────── + // CR 601.2c + CR 115.4 — the "to each of ⟨cardinality⟩ ⟨noun⟩" matrix. + // ───────────────────────────────────────────────────────────────────── + + fn x_expr() -> QuantityExpr { + QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + } + } + + fn fixed(n: i32) -> QuantityExpr { + QuantityExpr::Fixed { value: n } + } + + /// Claim 1 — all six cells of the cardinality × noun matrix parse, plus the + /// X-count and larger-literal leaves. + /// + /// CR 601.2c (announced count) × CR 115.4 (bare-plural damage target class) + /// / CR 115.1a (typed target). + #[test] + fn parse_each_of_target_distribution_covers_the_full_cardinality_noun_matrix() { + use super::{parse_each_of_target_distribution, EachOfTargetNoun}; + + // Cell 1 — bounded × bare plural. REGRESSION: Prismari Charm, Storm of Steel. + // ORDERING GUARD (load-bearing): this must be `fixed(1, 2)` with an EMPTY + // remainder, NOT `exact(1)` with remainder "or two targets". If the exact + // arm ever wins here, "one or two targets" silently becomes a one-target + // spell. + assert_eq!( + parse_each_of_target_distribution("one or two targets"), + Some(( + MultiTargetSpec::fixed(1, 2), + EachOfTargetNoun::AnyTargets, + "" + )), + "bounded × bare-plural must win over the exact arm's leading \"one\"" + ); + + // Cell 2 — bounded × typed (new leaf). The noun is NOT consumed. + assert_eq!( + parse_each_of_target_distribution("one or two target creatures"), + Some(( + MultiTargetSpec::fixed(1, 2), + EachOfTargetNoun::Typed, + "target creatures" + )) + ); + + // Cells 1b / 2b — the SECOND `BOUNDED_TARGET_CARDINALITIES` member. + // `parse_bounded_target_cardinality` composes its noun off that shared + // const, so both entries must be exercised or the second can regress (or + // be shadowed by the exact arm) without a matrix failure. Same ordering + // guard as cell 1: `fixed(1, 3)`, never `exact(1)` with a stranded + // remainder. + assert_eq!( + parse_each_of_target_distribution("one, two, or three targets"), + Some(( + MultiTargetSpec::fixed(1, 3), + EachOfTargetNoun::AnyTargets, + "" + )), + "the three-target bounded stem must win over the exact arm's leading \"one\"" + ); + assert_eq!( + parse_each_of_target_distribution("one, two, or three target creatures"), + Some(( + MultiTargetSpec::fixed(1, 3), + EachOfTargetNoun::Typed, + "target creatures" + )) + ); + + // Cell 3 — optional × bare plural (new leaf). NEW: Shower of Coals, + // Jaya's Immolating Inferno, Myojin of Roaring Blades. + assert_eq!( + parse_each_of_target_distribution("up to three targets"), + Some(( + MultiTargetSpec::up_to(fixed(3)), + EachOfTargetNoun::AnyTargets, + "" + )) + ); + + // Cell 4 — optional × typed. REGRESSION: Dual Shot, Wrap in Flames. + assert_eq!( + parse_each_of_target_distribution("up to two target creatures"), + Some(( + MultiTargetSpec::up_to(fixed(2)), + EachOfTargetNoun::Typed, + "target creatures" + )) + ); + + // Cell 5 — exact × bare plural (new leaf). NEW: Furious Reprisal, + // Pinnacle of Rage. + assert_eq!( + parse_each_of_target_distribution("two targets"), + Some(( + MultiTargetSpec::exact(fixed(2)), + EachOfTargetNoun::AnyTargets, + "" + )) + ); + + // Cell 5b — exact × bare plural, X count. NEW: Firestorm, Meteor Blast. + assert_eq!( + parse_each_of_target_distribution("x targets"), + Some(( + MultiTargetSpec::exact(x_expr()), + EachOfTargetNoun::AnyTargets, + "" + )) + ); + + // Cell 6 — exact × typed (new leaf). NEW: Jagged Lightning, Swelter, + // Twinstrike. + assert_eq!( + parse_each_of_target_distribution("two target creatures"), + Some(( + MultiTargetSpec::exact(fixed(2)), + EachOfTargetNoun::Typed, + "target creatures" + )) + ); + + // Cell 6b — optional × bare plural, X count. Batroc the Leaper. + assert_eq!( + parse_each_of_target_distribution("up to x targets"), + Some(( + MultiTargetSpec::up_to(x_expr()), + EachOfTargetNoun::AnyTargets, + "" + )) + ); + + // Cell 6c — Chandra, the Firebrand's [−6]. + assert_eq!( + parse_each_of_target_distribution("up to six targets"), + Some(( + MultiTargetSpec::up_to(fixed(6)), + EachOfTargetNoun::AnyTargets, + "" + )) + ); + + // Cell 6d — Fall of the Titans, Chandra Hope's Beacon. + assert_eq!( + parse_each_of_target_distribution("up to two targets"), + Some(( + MultiTargetSpec::up_to(fixed(2)), + EachOfTargetNoun::AnyTargets, + "" + )) + ); + + // Grandfathered lexical `other` on the TYPED arm only (CR 115.3 + // distinctness is applied downstream by `parse_target_with_ctx`, not by + // the cardinality head). + assert_eq!( + parse_each_of_target_distribution("up to two other target creatures"), + Some(( + MultiTargetSpec::up_to(fixed(2)), + EachOfTargetNoun::Typed, + "other target creatures" + )) + ); + + // FENCE GUARD — `targets` must not match inside a longer word. This pins + // `not(satisfy(char::is_alphanumeric))`; dropping the fence makes this + // return `Some(exact(2), AnyTargets, "omething")`. + assert_eq!( + parse_each_of_target_distribution("two targetsomething"), + None, + "the bare-plural arm must be fenced at a word boundary" + ); + + // Original casing is preserved in the remainder handed to + // `parse_target_with_ctx`. + assert_eq!( + parse_each_of_target_distribution("two target Goblins"), + Some(( + MultiTargetSpec::exact(fixed(2)), + EachOfTargetNoun::Typed, + "target Goblins" + )) + ); + } + + /// Claim 2 — hostile negatives, each paired with a positive reach-guard in + /// the SAME test so no `None` assertion is vacuous. + #[test] + fn parse_each_of_target_distribution_rejects_non_cardinality_heads() { + use super::{parse_each_of_target_distribution, EachOfTargetNoun}; + + // REACH-GUARD: the combinator is live in this test. + assert_eq!( + parse_each_of_target_distribution("two targets"), + Some(( + MultiTargetSpec::exact(fixed(2)), + EachOfTargetNoun::AnyTargets, + "" + )), + "reach-guard: the positive path must still parse, so the None \ + assertions below are not vacuous" + ); + + // "~ deals N damage to each of your opponents" — must fall through to + // `parse_damage_each_player_scope` (DamageEachPlayer), not this seam. + assert_eq!( + parse_each_of_target_distribution("your opponents"), + None, + "player-scope heads belong to parse_damage_each_player_scope" + ); + + // No "of": "each creature" never reaches this combinator, but the head + // itself must not parse either. + assert_eq!(parse_each_of_target_distribution("each creature"), None); + + // VERBATIM Shower of Coals Threshold anaphor. It must NOT parse — the + // second sentence stays dropped (an anaphor to the already-chosen + // targets), which is what keeps that card an honest PARTIAL fix. + assert_eq!( + parse_each_of_target_distribution("those permanents and/or players instead"), + None, + "the Threshold anaphor must not be mistaken for a cardinality head" + ); + + assert_eq!(parse_each_of_target_distribution("them"), None); + + // A bare count with no noun at all. + assert_eq!(parse_each_of_target_distribution("two"), None); + + // ASYMMETRY (deliberate, matches pre-existing behaviour): the + // bare-plural arm does NOT accept "other targets". Drakuseth's + // "up to two other targets" clause is dropped upstream by the + // compound-damage splitter, so this returns None exactly as before. + assert_eq!( + parse_each_of_target_distribution("up to two other targets"), + None, + "bare-plural `other targets` is intentionally NOT accepted" + ); + } } diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 0a9a92d403..fbacab49f5 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -14994,6 +14994,18 @@ fn try_parse_for_each_counter_kind_adjust_target(text: &str) -> Option ParsedEffectClause { + // CR 601.2c: the announced target count for a "… to each of ⟨N⟩ …" head is a + // property of THIS clause only. `parse_imperative_effect` is also reached + // from sites that never consume this field (the shared-`ctx` + // `try_parse_reanimate_self_and_target` sub-parse) and from speculative + // sub-parses that mutate `ctx` and then discard their result (the + // radiance / multi-target-chain / general damage-compound splitters). Reset + // here — the very first statement, above every early-return guard — so a + // stale spec can never attach to a later, unrelated DealDamage. Same + // discipline as the `ctx.target_chooser = None;` resets in the damage-chain + // splitter. + ctx.pending_damage_multi_target = None; + if let Some(effect) = try_parse_drawn_this_turn_choice(text) { return parsed_clause(effect); } @@ -15128,6 +15140,11 @@ fn lower_imperative_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectCl let (stripped, duration) = strip_trailing_duration(text); let mut clause = parse_imperative_effect(stripped, ctx); + // CR 601.2c: paired with the reset at the top of this function. The count is + // produced by the same parse that produced the target filter + // (`parse_each_of_target_distribution`), so it is the primary authority for + // the DealDamage fixup below. + let pending_damage_multi_target = ctx.pending_damage_multi_target.take(); if clause.duration.is_none() { clause.duration = duration; } @@ -15148,8 +15165,14 @@ fn lower_imperative_clause(text: &str, ctx: &mut ParseContext) -> ParsedEffectCl .or_else(|| extract_bounded_target_multi_target(text)) .or_else(|| extract_optional_target_multi_target(text)); } + // CR 601.2c + CR 115.4: the announced target count for a + // "⟨source⟩ deals N damage to each of ⟨count⟩ ⟨noun⟩" head comes from the + // SAME parse that produced the target filter, so the ctx value is the + // primary authority here. The text-scanning extractor stays as a fallback + // for the shapes that combinator does not reach. if matches!(clause.effect, Effect::DealDamage { .. }) && clause.multi_target.is_none() { - clause.multi_target = extract_deal_damage_multi_target(text); + clause.multi_target = + pending_damage_multi_target.or_else(|| extract_deal_damage_multi_target(text)); } // CR 115.1d: Post-parse fixup for SwitchPT prepositional form. The // imperative parser strips "any number of" / "each of" to keep `parse_target` @@ -16698,6 +16721,23 @@ fn try_parse_radiance_color_fanout_damage( text: &str, lower: &str, ctx: &mut ParseContext, +) -> Option { + // This recognizer speculatively parses the whole clause and abandons it on + // several branches below. A failed attempt must leave every parser-context + // field untouched — including `pending_damage_multi_target`, which + // `parse_each_of_target_distribution` records before any of those bails are + // reached. Same clone-and-commit shape as + // `try_parse_multi_target_damage_chain`. + let mut tentative_ctx = ctx.clone(); + let clause = try_parse_radiance_color_fanout_damage_inner(text, lower, &mut tentative_ctx)?; + *ctx = tentative_ctx; + Some(clause) +} + +fn try_parse_radiance_color_fanout_damage_inner( + text: &str, + lower: &str, + ctx: &mut ParseContext, ) -> Option { let (primary_effect, remainder) = try_parse_damage_with_remainder(text, lower, ctx)?; @@ -16815,6 +16855,12 @@ fn try_parse_multi_target_damage_chain_inner( // Each segment parses through the shared context, so retain the primary // segment's announcer before a continuation can overwrite it. let primary_target_chooser = ctx.target_chooser.clone(); + // CR 601.2c: same for the primary segment's announced target count. This + // function returns its own `ParsedEffectClause` straight out of + // `lower_imperative_clause`, ahead of the `take()` + DealDamage fixup, so + // the count has to be captured here and attached below or a chain headed by + // "each of ⟨N⟩ target ⟨type⟩" silently degrades to one mandatory target. + let primary_damage_multi_target = ctx.pending_damage_multi_target.take(); let trimmed = remainder.trim_start(); let trimmed_lower = trimmed.to_lowercase(); // A comma-delimited list ("..., M damage to T2, and K ...") or a bare @@ -16835,7 +16881,11 @@ fn try_parse_multi_target_damage_chain_inner( // consumes one bare-damage segment (returning the next AbilityDefinition) // or aborts the entire chain — partial parses are not committed. Each // continuation retains its own target announcer. - let mut segments: Vec<(Effect, Option)> = Vec::new(); + // CR 601.2c: each segment carries its OWN announced target count, not just + // its announcer. A continuation is a fresh instruction ("… and 1 damage to + // each of two target creatures"), so its count belongs to the sub-ability + // built from it, exactly as its `target_chooser` does. + let mut segments: Vec<(Effect, Option, Option)> = Vec::new(); let mut cursor = remainder.trim_start(); while !cursor.is_empty() { let cursor_lower = cursor.to_lowercase(); @@ -16866,8 +16916,15 @@ fn try_parse_multi_target_damage_chain_inner( // is a distinct target phrase. Do not inherit the preceding recipient's // announcing-player override. ctx.target_chooser = None; + // CR 601.2c: likewise clear the pending count, so a segment without an + // "each of ⟨N⟩" head cannot inherit the previous segment's. + ctx.pending_damage_multi_target = None; let (segment_effect, leftover) = parse_bare_damage_continuation(after_separator, ctx)?; - segments.push((segment_effect, ctx.target_chooser.clone())); + segments.push(( + segment_effect, + ctx.target_chooser.clone(), + ctx.pending_damage_multi_target.take(), + )); cursor = leftover.trim_start(); } @@ -16882,10 +16939,13 @@ fn try_parse_multi_target_damage_chain_inner( // damage-source semantics by construction (each is a `DealDamage` with // `damage_source: None`). let mut chain_tail: Option> = None; - for (effect, target_chooser) in segments.into_iter().rev() { + for (effect, target_chooser, multi_target) in segments.into_iter().rev() { let mut def = AbilityDefinition::new(AbilityKind::Spell, effect); def.sub_ability = chain_tail.take(); def.target_chooser = target_chooser; + // CR 601.2c: the segment's own announced count, so a continuation headed + // by "each of ⟨N⟩ …" requires and announces N targets rather than one. + def.multi_target = multi_target; chain_tail = Some(Box::new(def)); } @@ -16894,7 +16954,9 @@ fn try_parse_multi_target_damage_chain_inner( duration: None, sub_ability: chain_tail, distribute: None, - multi_target: None, + // CR 601.2c: the primary segment's announced target count, captured + // before the continuation segments reused the shared context. + multi_target: primary_damage_multi_target, condition: None, optional: false, unless_pay: None, @@ -17300,6 +17362,25 @@ fn parse_bare_damage_continuation<'a>( rem_out, )); } + // CR 601.2c + CR 115.4: an "each of ⟨N⟩ ⟨noun⟩" continuation is a + // distribution head in its own right, not a plain recipient. Route it + // through the same authority the primary head uses so (a) the announced + // count is recorded on `ctx` — the chain loop takes it and attaches it to + // THIS segment's sub-ability — and (b) a bare-plural noun yields + // `TargetFilter::Any` rather than a typed filter. Without this the generic + // fallback below reaches `parse_target_with_ctx`, whose "each of" arm + // discards the count, and the continuation lowers as one mandatory target. + if let Some((target, rem)) = lower::parse_each_of_up_to_damage_target(after_to, ctx) { + return Some(( + Effect::DealDamage { + amount, + target, + damage_source: None, + excess: None, + }, + trim_dangling_target_word(rem), + )); + } let (target, rem) = parse_target_with_ctx(after_to, ctx); let (target, rem) = refine_damage_target_remainder(target, rem); let rem = trim_dangling_target_word(rem); @@ -17450,6 +17531,13 @@ fn try_split_damage_compound(text: &str, ctx: &mut ParseContext) -> Option Option Option AbilityDefinition { + let parsed = parse_oracle_text(oracle, name, &[], &["Sorcery".to_string()], &[]); + parsed + .abilities + .into_iter() + .next() + .unwrap_or_else(|| panic!("{name} must produce at least one ability")) +} + +fn fixed_qty(n: i32) -> QuantityExpr { + QuantityExpr::Fixed { value: n } +} + +fn x_qty() -> QuantityExpr { + QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + } +} + +/// Claim 3 — Jagged Lightning, the headline card. CR 601.2c: "two target +/// creatures" announces exactly two targets; CR 115.1a supplies the creature +/// filter. Revert-fails: the pre-fix parse is +/// `DamageAll { Fixed(3), Typed[Creature] }` with `multi_target: None`. +#[test] +fn jagged_lightning_deals_damage_to_each_of_two_target_creatures() { + let def = each_of_sorcery( + "Jagged Lightning", + "Jagged Lightning deals 3 damage to each of two target creatures.", + ); + assert!( + !matches!(*def.effect, Effect::DamageAll { .. }), + "Jagged Lightning must not lower to mass damage: {:?}", + def.effect + ); + match &*def.effect { + Effect::DealDamage { + amount, + target: TargetFilter::Typed(filter), + .. + } => { + assert_eq!(*amount, fixed_qty(3)); + assert!( + filter + .type_filters + .iter() + .any(|t| matches!(t, TypeFilter::Creature)), + "target filter must be a creature filter: {filter:?}" + ); + } + other => panic!("expected DealDamage to a typed creature filter, got {other:?}"), + } + assert_eq!( + def.multi_target, + Some(MultiTargetSpec::exact(fixed_qty(2))), + "CR 601.2c: exactly two announced targets" + ); +} + +/// Claim 3 — Furious Reprisal. CR 115.4: a BARE plural "two targets" is the +/// damage target class (creature / player / planeswalker / battle), so the +/// filter must be `TargetFilter::Any`, not a creature filter. +#[test] +fn furious_reprisal_deals_damage_to_each_of_two_bare_targets() { + let def = each_of_sorcery( + "Furious Reprisal", + "Furious Reprisal deals 2 damage to each of two targets.", + ); + assert!(!matches!(*def.effect, Effect::DamageAll { .. })); + assert!( + matches!( + &*def.effect, + Effect::DealDamage { + target: TargetFilter::Any, + .. + } + ), + "CR 115.4: bare-plural targets must be TargetFilter::Any, got {:?}", + def.effect + ); + assert_eq!(def.multi_target, Some(MultiTargetSpec::exact(fixed_qty(2)))); +} + +/// Claim 3 — Meteor Blast, the X-COUNT axis. The `X` here is the target COUNT +/// (CR 601.2c), not the damage amount, so it must land in `multi_target`. +#[test] +fn meteor_blast_deals_damage_to_each_of_x_targets() { + let def = each_of_sorcery( + "Meteor Blast", + "Meteor Blast deals 4 damage to each of X targets.", + ); + assert!(!matches!(*def.effect, Effect::DamageAll { .. })); + match &*def.effect { + Effect::DealDamage { + amount, + target: TargetFilter::Any, + .. + } => assert_eq!(*amount, fixed_qty(4), "the AMOUNT is the literal 4"), + other => panic!("expected DealDamage to Any, got {other:?}"), + } + assert_eq!( + def.multi_target, + Some(MultiTargetSpec::exact(x_qty())), + "the X belongs to the target COUNT" + ); +} + +/// Claim 3 — Fall of the Titans: the mirror-image binding. Here `X` is the +/// damage AMOUNT and the count is the fixed literal two, so `multi_target` must +/// be `up_to(2)` with no `Variable("X")` leaf. +#[test] +fn fall_of_the_titans_binds_x_to_the_amount_not_the_count() { + let def = each_of_sorcery( + "Fall of the Titans", + "Surge {X}{R} (You may cast this spell for its surge cost if you or a teammate has \ + cast another spell this turn.)\nFall of the Titans deals X damage to each of up to \ + two targets.", + ); + assert!(!matches!(*def.effect, Effect::DamageAll { .. })); + match &*def.effect { + Effect::DealDamage { + amount, + target: TargetFilter::Any, + .. + } => assert_eq!(*amount, x_qty(), "the X is the damage AMOUNT here"), + other => panic!("expected DealDamage to Any, got {other:?}"), + } + assert_eq!( + def.multi_target, + Some(MultiTargetSpec::up_to(fixed_qty(2))), + "the COUNT is the fixed literal two" + ); +} + +/// Claim 3 — Chandra, the Firebrand `[−6]`: the seam is reached through a +/// LOYALTY ability body (CR 602.2b), not only through a spell's effect chain. +#[test] +fn chandra_the_firebrand_ultimate_deals_damage_to_up_to_six_targets() { + let parsed = parse_oracle_text( + "[+1]: Chandra deals 1 damage to any target.\n[−2]: When you next cast an instant or \ + sorcery spell this turn, copy that spell. You may choose new targets for the copy.\n\ + [−6]: Chandra deals 6 damage to each of up to six targets.", + "Chandra, the Firebrand", + &[], + &["Legendary".to_string(), "Planeswalker".to_string()], + &[], + ); + let ultimate = parsed + .abilities + .iter() + .find(|a| matches!(&*a.effect, Effect::DealDamage { .. }) && a.multi_target.is_some()) + .expect("the [-6] loyalty body must lower to a multi-target DealDamage"); + assert!( + matches!( + &*ultimate.effect, + Effect::DealDamage { + target: TargetFilter::Any, + .. + } + ), + "CR 115.4: bare-plural targets ⇒ Any, got {:?}", + ultimate.effect + ); + assert_eq!( + ultimate.multi_target, + Some(MultiTargetSpec::up_to(fixed_qty(6))) + ); + assert!( + !parsed + .abilities + .iter() + .any(|a| matches!(&*a.effect, Effect::DamageAll { .. })), + "no Chandra ability may lower to mass damage: {:?}", + parsed.abilities + ); +} + +/// Claim 3 — Shower of Coals is a PARTIAL fix, and this test says so out loud. +/// Sentence 1 (`up to three targets`, per the verbatim card-data text) is +/// fixed; sentence 2's Threshold anaphor to the already-chosen targets was +/// dropped before this change and remains dropped. Asserting the partial state +/// keeps the coverage claim honest. +#[test] +fn shower_of_coals_first_sentence_fixed_threshold_anaphor_still_dropped() { + let parsed = parse_oracle_text( + "Shower of Coals deals 2 damage to each of up to three targets.\nThreshold — Shower of \ + Coals deals 4 damage to each of those permanents and/or players instead if there are \ + seven or more cards in your graveyard.", + "Shower of Coals", + &[], + &["Sorcery".to_string()], + &[], + ); + let damage = parsed + .abilities + .iter() + .find(|a| matches!(&*a.effect, Effect::DealDamage { .. })) + .expect("sentence 1 must lower to DealDamage"); + assert_eq!( + damage.multi_target, + Some(MultiTargetSpec::up_to(fixed_qty(3))), + "CR 601.2c: up to THREE targets (verbatim card-data text)" + ); + assert!( + !parsed + .abilities + .iter() + .any(|a| matches!(&*a.effect, Effect::DamageAll { .. })), + "sentence 1 must no longer be mass damage: {:?}", + parsed.abilities + ); +} + +/// Batroc the Leaper — MANDATORY PIN. The regenerated export confirms its +/// root-cause-#15 defect (dropped announced count) is genuinely fixed, so its +/// backlog bullet was retired along with the rest. What is NOT covered here is +/// the runtime path — `KickerCount` resolved through *trigger* target selection +/// — so this pins the observed parser shape instead, and the behaviour change +/// can never be silent. +#[test] +fn batroc_the_leaper_each_of_up_to_x_targets_pinned_shape() { + let parsed = parse_oracle_text( + "Multikicker {2} (You may pay an additional {2} any number of times as you cast this \ + spell.)\nBatroc enters with a +1/+1 counter on him for each time he was kicked.\n\ + When Batroc enters, he deals damage equal to his power to each of up to X targets, \ + where X is the number of times he was kicked.", + "Batroc the Leaper", + &["Multikicker".to_string()], + &["Creature".to_string()], + &[], + ); + let execute = parsed + .triggers + .iter() + .find_map(|t| t.execute.as_deref()) + .expect("Batroc's ETB trigger must carry an execute body"); + assert!( + !matches!(&*execute.effect, Effect::DamageAll { .. }), + "Batroc must no longer lower to mass damage: {:?}", + execute.effect + ); + assert!( + matches!( + &*execute.effect, + Effect::DealDamage { + target: TargetFilter::Any, + .. + } + ), + "expected DealDamage to Any, got {:?}", + execute.effect + ); + assert_eq!( + execute.multi_target, + Some(MultiTargetSpec::up_to(QuantityExpr::Ref { + qty: QuantityRef::KickerCount + })), + "the where-X must bind to KickerCount, leaving no unbound Variable(\"X\")" + ); +} + +/// Claim 3 — Drakuseth non-regression. Its second clause ("and 3 damage to each +/// of up to two other targets") is dropped by the compound-damage splitter +/// BEFORE this seam (root cause #27, out of scope). The primary +/// `DealDamage { 4, Any }` must therefore keep `multi_target: None` — no +/// `up_to(2)` may leak onto it from the ctx side channel. +#[test] +fn drakuseth_primary_damage_clause_gains_no_multi_target() { + let parsed = parse_oracle_text( + "Flying\nWhenever Drakuseth attacks, it deals 4 damage to any target and 3 damage to \ + each of up to two other targets.", + "Drakuseth, Maw of Flames", + &["Flying".to_string()], + &["Legendary".to_string(), "Creature".to_string()], + &[], + ); + let execute = parsed + .triggers + .iter() + .find_map(|t| t.execute.as_deref()) + .expect("Drakuseth's attack trigger must carry an execute body"); + // Reach-guard: the primary clause DID parse to real damage, so the `None` + // below is not a vacuous parse failure. + assert!( + matches!( + &*execute.effect, + Effect::DealDamage { + target: TargetFilter::Any, + .. + } + ), + "reach-guard: the primary clause must still be DealDamage to Any, got {:?}", + execute.effect + ); + assert_eq!( + execute.multi_target, None, + "no announced count may leak onto Drakuseth's single-target primary clause" + ); +} + +/// Claim 4.1 — MULTI-AUTHORITY hostile fixture. Dire-Strain Anarchist reaches +/// the seam AND is reachable by the fallback text extractor, so both authorities +/// can produce a spec. The result must still be exactly `up_to(1)`. +#[test] +fn dire_strain_anarchist_multi_authority_still_yields_up_to_one() { + let parsed = parse_oracle_text( + "Whenever this creature enters or attacks, it deals 3 damage to each of up to one \ + target creature and up to one target player.", + "Dire-Strain Anarchist", + &[], + &["Creature".to_string()], + &[], + ); + let execute = parsed + .triggers + .iter() + .find_map(|t| t.execute.as_deref()) + .expect("the trigger must carry an execute body"); + assert!( + matches!(&*execute.effect, Effect::DealDamage { .. }), + "reach-guard: must still be DealDamage, got {:?}", + execute.effect + ); + assert_eq!( + execute.multi_target, + Some(MultiTargetSpec::up_to(fixed_qty(1))), + "the two authorities must agree on up_to(1)" + ); +} + +/// Claim 4.3 — CROSS-CHUNK ISOLATION of the announced target count. +/// +/// Sentence 1 is a bare-damage CHAIN, so it is consumed by +/// `try_parse_multi_target_damage_chain_inner`. That recognizer takes the +/// primary head's count off `ctx` immediately after parsing it and attaches it +/// to the clause it returns, and clears + takes each continuation segment's +/// count the same way. Sentence 2 must therefore see a clean context. +/// +/// What this pins: (a) sentence 1's "each of two target creatures" head KEEPS +/// its `exact(2)` — a chain head must still announce two targets (CR 601.2c); +/// (b) sentence 2, a plain single-target `DealDamage`, does NOT inherit it. +/// +/// NOTE on what this test does *not* prove. It no longer exercises the reset at +/// the top of `lower_imperative_clause`: the chain parser consumes its pending +/// count at the source, so nothing is left dangling for that reset to clear on +/// this fixture. The isolation here is structural (take-at-source), not +/// reset-dependent. The reset still guards the `parse_imperative_effect` call +/// sites that never consume the field, but this is not the test that covers it — +/// an earlier revision of this comment claimed otherwise and was wrong. +/// +/// Anti-vacuity: the reach-guard below proves the seam DID run in sentence 1 — +/// `parse_each_of_up_to_damage_target` is the only producer of a `DealDamage` to +/// a typed creature filter from an "each of" head — so the `None` on sentence 2 +/// is a real absence, not a parse that never happened. +#[test] +fn each_of_count_head_does_not_leak_onto_a_later_sibling_clause() { + let parsed = parse_oracle_text( + "Leak Probe deals 2 damage to each of two target creatures and 3 damage to each \ + player. Leak Probe deals 1 damage to any target.", + "Leak Probe", + &[], + &["Sorcery".to_string()], + &[], + ); + let mut chain: Vec<&AbilityDefinition> = Vec::new(); + let mut cursor = parsed + .abilities + .first() + .expect("the sorcery must produce an ability"); + loop { + chain.push(cursor); + match cursor.sub_ability.as_deref() { + Some(next) => cursor = next, + None => break, + } + } + + // Reach-guard (a): the "each of two target creatures" head DID parse, so the + // side channel was written. + let each_of_clause = chain + .iter() + .find(|d| { + matches!( + &*d.effect, + Effect::DealDamage { + target: TargetFilter::Typed(_), + .. + } + ) + }) + .expect("reach-guard: sentence 1's `each of two target creatures` head must parse"); + // CR 601.2c — the chain-head regression. This fixture is consumed by the + // bare-damage CHAIN recognizer (`try_parse_multi_target_damage_chain`), + // which returns its own clause out of `lower_imperative_clause` ahead of the + // `take()` + DealDamage fixup. It therefore captures the primary segment's + // announced count immediately after parsing the head and attaches it to that + // clause; without that, a chain headed by "each of ⟨N⟩ target ⟨type⟩" would + // lower as ONE mandatory target, contrary to the announced-target + // requirement. The sibling `try_split_damage_compound` tail does the same. + assert_eq!( + each_of_clause.multi_target, + Some(MultiTargetSpec::exact(fixed_qty(2))), + "the chain's primary head must retain its CR 601.2c announced count \ + across the continuation segments' shared-context parse" + ); + + // The assertion under test: sentence 2 must not inherit the dangling count. + let any_target_clause = chain + .iter() + .find(|d| { + matches!( + &*d.effect, + Effect::DealDamage { + target: TargetFilter::Any, + .. + } + ) + }) + .expect("reach-guard: sentence 2 must lower to a plain any-target DealDamage"); + assert_eq!( + any_target_clause.multi_target, None, + "sentence 1's announced count must NOT leak onto sentence 2" + ); +} + +/// CR 601.2c — CHAIN CONTINUATION announced count. A continuation segment is a +/// fresh instruction, so an "each of ⟨N⟩ ⟨noun⟩" head in the SECOND half of a +/// bare-damage chain announces its own N targets. The primary here is a plain +/// "any target" with no count, which isolates the assertion to the continuation. +/// +/// Regression: `parse_bare_damage_continuation` excludes "each of " from its +/// mass-recipient branch, so before this was wired the clause fell through to +/// the generic `parse_target_with_ctx` fallback — whose "each of" arm discards +/// the count — and the segment lowered as ONE mandatory target. The chain loop +/// also had no slot to carry a per-segment count even if one had been produced. +#[test] +fn chain_continuation_each_of_head_keeps_its_announced_count() { + let parsed = parse_oracle_text( + "Chain Probe deals 3 damage to any target and 1 damage to each of two target creatures.", + "Chain Probe", + &[], + &["Sorcery".to_string()], + &[], + ); + let primary = parsed + .abilities + .first() + .expect("the sorcery must produce an ability"); + // Reach-guard: the primary really is the plain any-target half, so the + // assertion below is about the CONTINUATION and not about the head. + assert!( + matches!( + &*primary.effect, + Effect::DealDamage { + target: TargetFilter::Any, + .. + } + ), + "reach-guard: primary must be the plain any-target half, got {:?}", + primary.effect + ); + assert_eq!( + primary.multi_target, None, + "reach-guard: the primary announces no count, so a count found below \ + belongs to the continuation" + ); + + let continuation = primary + .sub_ability + .as_deref() + .expect("the chain must build a continuation sub-ability"); + assert!( + !matches!(&*continuation.effect, Effect::DamageAll { .. }), + "the continuation must not collapse to mass damage, got {:?}", + continuation.effect + ); + assert_eq!( + continuation.multi_target, + Some(MultiTargetSpec::exact(fixed_qty(2))), + "CR 601.2c: the continuation's \"each of two target creatures\" head must \ + announce exactly two targets on its own sub-ability" + ); +} diff --git a/crates/engine/src/parser/oracle_ir/context.rs b/crates/engine/src/parser/oracle_ir/context.rs index 86c7b2b1ee..24c8bf7b1e 100644 --- a/crates/engine/src/parser/oracle_ir/context.rs +++ b/crates/engine/src/parser/oracle_ir/context.rs @@ -5,7 +5,7 @@ use super::diagnostic::OracleDiagnostic; use crate::types::ability::{ - ControllerRef, PlayerFilter, PtValue, QuantityExpr, QuantityRef, TargetFilter, + ControllerRef, MultiTargetSpec, PlayerFilter, PtValue, QuantityExpr, QuantityRef, TargetFilter, TargetSelectionMode, }; use crate::types::zones::Zone; @@ -76,6 +76,35 @@ pub(crate) struct ParseContext { /// resolver's outermost-repeat driver. Set and consumed within a single /// chunk parse; never serialized. pub pending_repeat_for: Option, + /// CR 601.2c + CR 115.4: The announced target count recovered by + /// `parse_each_of_target_distribution` for a "… damage to each of ⟨N⟩ + /// ⟨noun⟩" head, produced by the SAME parse that produced the target + /// filter. CR 601.2c fixes the count; CR 115.4 fixes the class for the + /// bare-plural `targets` noun. + /// + /// Single authority for that seam, with an explicit set/reset lifecycle: + /// `lower_imperative_clause` clears this as the very first statement of its + /// body and `take()`s it immediately after `parse_imperative_effect`, so it + /// never outlives one clause and can never attach to an unrelated + /// `DealDamage`. The reset is required because `parse_imperative_effect` is + /// also called from sites that never consume this field (the shared-`ctx` + /// `try_parse_reanimate_self_and_target` sub-parse) and from speculative + /// sub-parses that mutate `ctx` and then discard their result. + /// + /// Two seams need more than the reset, because the reset alone is wrong in + /// both directions: + /// - The speculative recognizers that can abandon a parse mid-way + /// (`try_parse_multi_target_damage_chain`, + /// `try_parse_radiance_color_fanout_damage`) run against a cloned + /// `ParseContext` and commit with `*ctx = tentative_ctx` only on success, + /// so an abandoned attempt cannot leak a count forward. + /// - `try_split_damage_compound` re-enters `lower_imperative_clause` for the + /// continuation, and that nested frame clears this field on entry. It + /// therefore saves the primary clause's count before the sub-parse and + /// restores it after, alongside the `target_chooser` save/restore. + /// + /// Set and consumed within a single chunk parse; never serialized. + pub pending_damage_multi_target: Option, /// CR 608.2c + CR 109.4: Count of `Effect::Choose { choice_type: Player }` /// clauses emitted so far in the current effect chain. Each "choose a /// player" / "choose a [second|third] player" clause increments this; the diff --git a/crates/engine/tests/integration/jagged_lightning_each_of_two_targets.rs b/crates/engine/tests/integration/jagged_lightning_each_of_two_targets.rs new file mode 100644 index 0000000000..c4386fb31a --- /dev/null +++ b/crates/engine/tests/integration/jagged_lightning_each_of_two_targets.rs @@ -0,0 +1,275 @@ +//! CR 601.2c + CR 115.4 — "⟨source⟩ deals N damage to each of ⟨count⟩ ⟨noun⟩". +//! +//! Headline card: **Jagged Lightning** ({3}{R}{R} Sorcery, "Jagged Lightning +//! deals 3 damage to each of two target creatures."). Before the parser seam +//! was parameterized across the cardinality × noun matrix, this lowered to +//! `Effect::DamageAll { Fixed(3), Typed[Creature] }` with `multi_target: None` +//! — 3 damage to EVERY creature on the battlefield, in a two-target spell. +//! +//! Every test here drives the real cast pipeline (`GameScenario` + +//! `GameRunner::cast(..).resolve()` + `CastOutcome` deltas) with the card's +//! VERBATIM Oracle text, re-parsed at test time. Reverting the parser change +//! flips T1, T3, T4 and T5 to `DamageAll` and fails them. +//! +//! CR 601.2c: "If the spell has a variable number of targets, the player +//! announces how many targets they will choose… In some cases, the number of +//! targets will be defined by the spell's text." +//! CR 115.4: a bare-plural "two targets" may be creatures, players, +//! planeswalkers, or battles. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::EngineError; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +// Verbatim Oracle text (client/public/card-data.json, verified 2026-07-26). +const JAGGED_LIGHTNING: &str = "Jagged Lightning deals 3 damage to each of two target creatures."; +const PINNACLE_OF_RAGE: &str = "Pinnacle of Rage deals 3 damage to each of two targets."; +const METEOR_BLAST: &str = "Meteor Blast deals 4 damage to each of X targets."; +const FALL_OF_THE_TITANS: &str = + "Surge {X}{R} (You may cast this spell for its surge cost if you or a teammate has cast \ + another spell this turn.)\nFall of the Titans deals X damage to each of up to two targets."; + +fn add_mana(runner: &mut GameRunner, ty: ManaType, count: usize) { + for _ in 0..count { + let unit = ManaUnit::new(ty, ObjectId(0), false, vec![]); + runner.state_mut().players[0].mana_pool.add(unit); + } +} + +fn damage_on(outcome: &engine::game::scenario::CastOutcome, id: ObjectId) -> u32 { + outcome.state().objects[&id].damage_marked +} + +/// T1 — HEADLINE, revert-proof. Three 3/3s on the battlefield; Jagged Lightning +/// targets exactly two of them. The two chosen take 3 each; the third takes +/// NOTHING and survives state-based actions (CR 704.5g). +/// +/// Revert behaviour: the pre-fix `DamageAll { Typed[Creature] }` parse hits all +/// three creatures, so `damage_on(bystander)` reads 3 and the bystander is in +/// the graveyard — both assertions below fail. +#[test] +fn t1_jagged_lightning_damages_only_the_two_chosen_creatures() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let first = scenario.add_creature(P1, "First Victim", 3, 3).id(); + let second = scenario.add_creature(P1, "Second Victim", 3, 3).id(); + let bystander = scenario.add_creature(P1, "Bystander", 3, 3).id(); + let spell = { + let mut b = + scenario.add_spell_to_hand_from_oracle(P0, "Jagged Lightning", false, JAGGED_LIGHTNING); + b.with_mana_cost(ManaCost::generic(0)); + b.id() + }; + + let mut runner = scenario.build(); + let outcome = runner + .cast(spell) + .target_objects(&[first, second]) + .resolve(); + + assert_eq!( + damage_on(&outcome, first), + 3, + "the first chosen target must take 3" + ); + assert_eq!( + damage_on(&outcome, second), + 3, + "the second chosen target must take 3" + ); + assert_eq!( + damage_on(&outcome, bystander), + 0, + "CR 601.2c: an unchosen creature is not a target and takes no damage — \ + a `DamageAll` parse would read 3 here" + ); + // CR 704.5g: 3 damage on a 3-toughness creature is lethal. The bystander + // must still be on the battlefield. + outcome.assert_zone(&[bystander], Zone::Battlefield); + outcome.assert_zone(&[first, second], Zone::Graveyard); +} + +/// T2′ — MIN-BOUND. `exact(2)` with only ONE legal creature must be rejected: +/// CR 601.2c requires the announced number of targets to be legally choosable. +/// `resolve_multi_target_bounds` raises "Not enough legal targets available" +/// when `legal_target_count < min`. +/// +/// This passes trivially on revert (a `DamageAll` parse has no target slots at +/// all), so T1 is its reach-guard: the SAME card, with two creatures available, +/// resolves and marks exactly the two chosen. +#[test] +fn t2_jagged_lightning_rejects_cast_with_only_one_legal_creature() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let only = scenario.add_creature(P1, "Lone Victim", 3, 3).id(); + let spell = { + let mut b = + scenario.add_spell_to_hand_from_oracle(P0, "Jagged Lightning", false, JAGGED_LIGHTNING); + b.with_mana_cost(ManaCost::generic(0)); + b.id() + }; + + let mut runner = scenario.build(); + let result = runner.cast(spell).target_objects(&[only]).try_resolve(); + + match result { + Err(EngineError::ActionNotAllowed(msg)) => assert!( + msg.contains("Not enough legal targets"), + "expected the CR 601.2c minimum-target rejection, got: {msg}" + ), + Err(other) => panic!("expected ActionNotAllowed, got: {other:?}"), + Ok(_) => panic!( + "casting a two-target spell with only one legal creature must be rejected (CR 601.2c)" + ), + } +} + +/// T3 — CR 115.4 PLAYER CLASS. Pinnacle of Rage's noun is the BARE plural "two +/// targets", so the target class is creature / player / planeswalker / battle. +/// One creature and one opponent are targeted; both must take 3. +/// +/// Revert behaviour: `DamageAll { Any }` is an object-only mass effect and +/// cannot damage a player, so the life delta reads 0. +#[test] +fn t3_pinnacle_of_rage_can_target_a_player_and_a_creature() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let creature = scenario.add_creature(P1, "Victim", 3, 5).id(); + let spell = { + let mut b = + scenario.add_spell_to_hand_from_oracle(P0, "Pinnacle of Rage", false, PINNACLE_OF_RAGE); + b.with_mana_cost(ManaCost::generic(0)); + b.id() + }; + + let mut runner = scenario.build(); + let outcome = runner + .cast(spell) + .target_objects(&[creature]) + .target_player(P1) + .resolve(); + + outcome.assert_life_delta(P1, -3); + assert_eq!( + damage_on(&outcome, creature), + 3, + "the creature half of the CR 115.4 target class must also take 3" + ); +} + +/// T4 — X-COUNT AXIS (mandatory). Meteor Blast is `{X}{R}{R}{R}` and its `X` is +/// the TARGET COUNT, not the damage amount: `multi_target = exact(Variable X)`. +/// Cast for X=2 with three creatures available and two targeted, it must deal 4 +/// to each chosen creature, 0 to the third, and complete the cast (not stall at +/// `ChooseXValue` or on the "Target count requires a resolved quantity" guard). +#[test] +fn t4_meteor_blast_x_is_the_target_count() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let first = scenario.add_creature(P1, "First Victim", 4, 4).id(); + let second = scenario.add_creature(P1, "Second Victim", 4, 4).id(); + let bystander = scenario.add_creature(P1, "Bystander", 4, 4).id(); + let spell = { + let mut b = scenario.add_spell_to_hand_from_oracle(P0, "Meteor Blast", false, METEOR_BLAST); + b.with_mana_cost(ManaCost::Cost { + shards: vec![ + ManaCostShard::X, + ManaCostShard::Red, + ManaCostShard::Red, + ManaCostShard::Red, + ], + generic: 0, + }); + b.id() + }; + + let mut runner = scenario.build(); + add_mana(&mut runner, ManaType::Red, 5); + let outcome = runner + .cast(spell) + .x(2) + .target_objects(&[first, second]) + .resolve(); + + assert_eq!(damage_on(&outcome, first), 4); + assert_eq!(damage_on(&outcome, second), 4); + assert_eq!( + damage_on(&outcome, bystander), + 0, + "only the X announced targets are damaged" + ); + outcome.assert_zone(&[bystander], Zone::Battlefield); +} + +/// T5 — DECLINING EVERY OPTIONAL SLOT on `up_to(2)`. Fall of the Titans is +/// `{X}{X}{R}` and its `X` is the damage AMOUNT; the count is the fixed literal +/// two, so `collect_target_slots` marks every slot optional (`min = 0`) and +/// `pick_slot_target` declines each one when no object intent is declared. +/// The spell must resolve and deal damage to nobody. +/// +/// Reach-guard (anti-vacuity): a SECOND Fall of the Titans in the same fixture, +/// cast for X=3 with one declared target, deals 3 — so "no damage anywhere" in +/// the first cast is a genuine decline, not a dead pipeline. +#[test] +fn t5_fall_of_the_titans_declines_all_optional_target_slots() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let victim = scenario.add_creature(P1, "Victim", 5, 9).id(); + let x_cost = ManaCost::Cost { + shards: vec![ManaCostShard::X, ManaCostShard::X, ManaCostShard::Red], + generic: 0, + }; + let declined = { + let mut b = scenario.add_spell_to_hand_from_oracle( + P0, + "Fall of the Titans", + false, + FALL_OF_THE_TITANS, + ); + b.with_mana_cost(x_cost.clone()); + b.id() + }; + let targeted = { + let mut b = scenario.add_spell_to_hand_from_oracle( + P0, + "Fall of the Titans", + false, + FALL_OF_THE_TITANS, + ); + b.with_mana_cost(x_cost); + b.id() + }; + + let mut runner = scenario.build(); + add_mana(&mut runner, ManaType::Red, 20); + + let life_before = runner.state().players[1].life; + let declined_outcome = runner.cast(declined).x(3).resolve(); + assert_eq!( + damage_on(&declined_outcome, victim), + 0, + "CR 601.2c: with `up to two` every slot is optional, so declining both \ + deals damage to nothing" + ); + assert_eq!( + declined_outcome.state().players[1].life, + life_before, + "no player may be damaged either" + ); + + // Reach-guard: the same card, same fixture, with one declared target. + let targeted_outcome = runner + .cast(targeted) + .x(3) + .target_objects(&[victim]) + .resolve(); + assert_eq!( + damage_on(&targeted_outcome, victim), + 3, + "reach-guard: a declared target must take X = 3, proving the declined \ + cast above was a real decline" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 7e430108e7..dea10e4d39 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -660,6 +660,7 @@ mod issue_desperate_gambit_choose_damage_source; mod issue_haze_frog_other_creature_prevention; mod ivory_gargoyle_temporal_and_skip_tail; mod jace_wielder_empty_library_win; +mod jagged_lightning_each_of_two_targets; mod jaws_of_defeat; mod json_smoke_test; mod judgment_bolt_where_x_damage_runtime; diff --git a/docs/parser-misparse-backlog.md b/docs/parser-misparse-backlog.md index b951858543..fc708654b6 100644 --- a/docs/parser-misparse-backlog.md +++ b/docs/parser-misparse-backlog.md @@ -3,8 +3,8 @@ Consolidated from 50 per-batch clustering passes over the whole card database. Synonymous per-batch clusters were merged into canonical root causes, their card lists unioned and deduped, and ranked by total card appearances (largest first). - **Canonical root causes:** 30 -- **Distinct cards implicated:** 4732 -- **Total card appearances across root causes:** 4766 (a card may appear under more than one root cause when it exhibits multiple distinct misparses) +- **Distinct cards implicated:** 4726 +- **Total card appearances across root causes:** 4760 (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. @@ -26,7 +26,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top | 12 | Modal 'choose one/N' parsed as independent abilities | 138 | oracle.rs modal dispatch — detect 'Choose one —' header, wrap modes in Effect::ChooseOneOf | | 13 | State/game-state condition → StaticCondition::Unrecognized | 133 | oracle_nom/condition.rs parse_inner_condition — add typed variant for the predicate class | | 14 | Granted/quoted ability or continuous modification dropped | 95 | oracle_static.rs continuous-modification extraction — emit all conjuncts incl. GrantAbility/GrantKeyword | -| 15 | Multi-target / 'up to N' optionality or count dropped | 89 | oracle_target.rs strip_optional_target_prefix — preserve MultiTargetSpec and optional_targeting | +| 15 | Multi-target / 'up to N' optionality or count dropped | 83 | oracle_target.rs strip_optional_target_prefix — preserve MultiTargetSpec and optional_targeting | | 16 | Keyword payload / multiplicity / mis-tokenization | 84 | game/keywords.rs + oracle keyword parsing — use typed discriminants and guard ability-word labels | | 17 | Copy 'except' / additional-modification clause dropped | 81 | oracle parser copy handling — populate BecomeCopy/CopyTokenOf additional_modifications from the except-list (CR 707.2) | | 18 | Subtype / type-change modification malformed or dropped | 79 | oracle_util.rs SUBTYPES + parse_enchanted_is_type — register subtypes and emit full type-change set | @@ -4208,7 +4208,7 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top -### 15. Multi-target / 'up to N' optionality or count dropped (89 cards) +### 15. Multi-target / 'up to N' optionality or count dropped (83 cards) **Signature.** MultiTargetSpec / 'up to one/two target' optionality dropped to a mandatory single Typed target (or collapsed into DamageAll), losing the multi_target / up_to slot and per-target distinctness. @@ -4217,7 +4217,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top
Cards - A-Incriminate -- Batroc the Leaper - Blue Dragon - Bon... placeholder - Bonfire of the Damned @@ -4232,7 +4231,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Capricious Efreet - Cetavolver - Chandra, Flame's Catalyst -- Chandra, Hope's Beacon - Chandra, Roaring Flame - Chaotic Transformation - Clattering Augur @@ -4254,8 +4252,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Invent - Ioreth of the Healing House - Jace, Ingenious Mind-Mage -- Jagged Lightning -- Jaya's Immolating Inferno - Journey of Discovery - Magus of the Candelabra - March of Reckless Joy @@ -4269,7 +4265,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Nimbleclaw Adept - Nomad Decoy - Perpetual Timepiece -- Pinnacle of Rage - Primal Might - Pull from the Deep - Put Away @@ -4285,7 +4280,6 @@ This is the prioritized "fix N root causes → unlock M cards" backlog: the top - Rimehorn Aurochs - Risky Move - Sex Appeal -- Shower of Coals - Simoon - Soratami Mirror-Mage - Soratami Seer