diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index f1dda13f6f..e0421b3ddf 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -36283,6 +36283,227 @@ mod namor_colored_pip_cast_trigger { } } +/// CR 107.4 + CR 202.3 + CR 603.2 (issue #1718): Ovika, Enigma Goliath — +/// runtime cast-pipeline coverage. "Whenever you cast a noncreature spell, +/// create X 1/1 red Phyrexian Goblin creature tokens, where X is the mana value +/// of that spell. They gain haste until end of turn." The count binds the +/// triggering spell's mana value via the prepositional of-form anaphor +/// (`ObjectManaValue { EventSource }`). Before the parser fix the token clause +/// "create X … tokens, where X is the mana value of that spell" dropped to +/// `Unimplemented`, so the trigger fired but created ZERO tokens — the exact +/// reported symptom. +mod ovika_noncreature_spell_token_trigger { + use super::*; + use crate::game::scenario::{GameScenario, P0}; + use crate::types::mana::{ManaCost, ManaUnit}; + + const OVIKA_ORACLE: &str = "Flying\nWard—{3}, Pay 3 life.\nWhenever you cast a noncreature spell, create X 1/1 red Phyrexian Goblin creature tokens, where X is the mana value of that spell. They gain haste until end of turn."; + + /// Count token permanents a player controls on the battlefield. + fn token_count(runner: &crate::game::scenario::GameRunner, player: PlayerId) -> usize { + runner + .state() + .objects + .values() + .filter(|o| o.zone == Zone::Battlefield && o.controller == player && o.is_token) + .count() + } + + /// Cast a benign noncreature spell of the given mana value with Ovika on the + /// battlefield, resolve the whole stack, and report how many tokens P0 ends + /// up controlling. + fn cast_noncreature_spell_of_mana_value(mv: u32) -> crate::game::scenario::GameRunner { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature_from_oracle(P0, "Ovika, Enigma Goliath", 7, 7, OVIKA_ORACLE); + // A noncreature spell whose only mana is generic, so its mana value is + // exactly `mv`. Benign resolution (gain 1 life) keeps the state simple. + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Test Filler", true, "You gain 1 life.") + .with_mana_cost(ManaCost::generic(mv)) + .id(); + // CR 601.2g-h: fund the generic cost from the pool so the driver + // auto-pays (601.2g covers mana abilities/funding, 601.2h the payment; + // 601.2f is total-cost determination). + scenario.with_mana_pool( + P0, + vec![ManaUnit::new(ManaType::Colorless, ObjectId(9_999), false, vec![]); mv as usize], + ); + let mut runner = scenario.build(); + runner.cast(spell).resolve(); + runner + } + + #[test] + fn casting_mana_value_three_spell_creates_three_goblins() { + let runner = cast_noncreature_spell_of_mana_value(3); + assert_eq!( + token_count(&runner, P0), + 3, + "X must bind the triggering spell's mana value (3), got {}", + token_count(&runner, P0) + ); + // Every created token is a red Phyrexian Goblin, not a generic token. + for obj in runner + .state() + .objects + .values() + .filter(|o| o.zone == Zone::Battlefield && o.is_token && o.controller == P0) + { + assert_eq!( + (obj.power, obj.toughness), + (Some(1), Some(1)), + "each token must be exactly 1/1 — the mana value drives the token \ + COUNT, never the P/T, got {:?}/{:?}", + obj.power, + obj.toughness + ); + assert!( + obj.color.contains(&ManaColor::Red), + "token must be red, got colors {:?}", + obj.color + ); + assert!( + obj.card_types.subtypes.iter().any(|s| s == "Phyrexian"), + "token must be a Phyrexian, got subtypes {:?}", + obj.card_types.subtypes + ); + assert!( + obj.card_types.subtypes.iter().any(|s| s == "Goblin"), + "token must be a Goblin, got subtypes {:?}", + obj.card_types.subtypes + ); + assert!( + obj.keywords.contains(&Keyword::Haste), // allow-raw-authority: asserts the literal keyword set stamped on the freshly created token, not an effective-keyword query + "each token must gain haste (\"They gain haste until end of turn\"), \ + got keywords {:?}", + obj.keywords + ); + } + } + + #[test] + fn token_count_tracks_spell_mana_value() { + // Control: a mana-value-2 spell makes exactly two tokens, proving the + // count reads the triggering spell's mana value rather than a fixed + // number or the generic (amount-less) SpellCast event context. + let runner = cast_noncreature_spell_of_mana_value(2); + assert_eq!( + token_count(&runner, P0), + 2, + "X must track the spell's mana value (2), got {}", + token_count(&runner, P0) + ); + } +} + +/// CR 107.4 + CR 202.3 + CR 603.2 (issue #1718): Pure Reflection — runtime +/// cast-pipeline coverage for the P/T axis of the mana-value of-form anaphor. +/// "Whenever a player casts a creature spell, destroy all Reflections. Then +/// that player creates an X/X white Reflection creature token, where X is the +/// mana value of that spell." Ovika binds `ObjectManaValue { EventSource }` to +/// the token COUNT; Pure Reflection binds it to the token's POWER/TOUGHNESS. +/// Pinning P/T here (with count pinned at one) discriminates a count/P-T axis +/// confusion that count-only assertions cannot catch. +mod pure_reflection_mana_value_token_pt { + use super::*; + use crate::game::scenario::{GameScenario, P0}; + use crate::types::mana::{ManaCost, ManaUnit}; + + const PURE_REFLECTION_ORACLE: &str = "Whenever a player casts a creature spell, destroy all Reflections. Then that player creates an X/X white Reflection creature token, where X is the mana value of that spell."; + + /// Put Pure Reflection on P0's battlefield, cast a creature spell with the + /// given mana cost (funded exactly by `pool`), resolve the whole stack, and + /// return the runner for token assertions. + fn cast_creature_spell_with_cost( + cost: ManaCost, + pool: Vec, + ) -> crate::game::scenario::GameRunner { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario + .add_creature(P0, "Pure Reflection", 0, 0) + .as_enchantment() + .from_oracle_text(PURE_REFLECTION_ORACLE); + let spell = scenario + .add_creature_to_hand(P0, "Test Bear", 2, 2) + .with_mana_cost(cost) + .id(); + // CR 601.2g-h: fund the cost from the pool so the driver auto-pays. + scenario.with_mana_pool(P0, pool); + let mut runner = scenario.build(); + runner.cast(spell).resolve(); + runner + } + + /// Exactly one white Reflection token whose P/T both equal the triggering + /// spell's mana value. + fn assert_single_reflection_token( + runner: &crate::game::scenario::GameRunner, + expected_pt: i32, + ) { + let tokens: Vec<_> = runner + .state() + .objects + .values() + .filter(|o| o.zone == Zone::Battlefield && o.controller == P0 && o.is_token) + .collect(); + assert_eq!( + tokens.len(), + 1, + "exactly one Reflection token must be created (the mana value drives \ + P/T, never the count), got {}", + tokens.len() + ); + let token = tokens[0]; + assert_eq!( + (token.power, token.toughness), + (Some(expected_pt), Some(expected_pt)), + "Reflection must be {expected_pt}/{expected_pt} — X binds the \ + triggering spell's mana value — got {:?}/{:?}", + token.power, + token.toughness + ); + assert!( + token.color.contains(&ManaColor::White), + "token must be white, got colors {:?}", + token.color + ); + assert!( + token.card_types.subtypes.iter().any(|s| s == "Reflection"), + "token must be a Reflection, got subtypes {:?}", + token.card_types.subtypes + ); + } + + #[test] + fn reflection_token_pt_binds_spell_mana_value() { + let runner = cast_creature_spell_with_cost( + ManaCost::generic(3), + vec![ManaUnit::new(ManaType::Colorless, ObjectId(9_999), false, vec![]); 3], + ); + assert_single_reflection_token(&runner, 3); + } + + #[test] + fn reflection_token_pt_sums_generic_and_colored_pips() { + // CR 202.3: {1}{R} has mana value 2 — one generic plus one colored pip. + // A mana-value computation that ignored colored pips would yield a 1/1 + // here; the control above cannot catch that (generic-only cost). + let runner = cast_creature_spell_with_cost( + ManaCost::Cost { + shards: vec![ManaCostShard::Red], + generic: 1, + }, + vec![ + ManaUnit::new(ManaType::Red, ObjectId(9_999), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_998), false, vec![]), + ], + ); + assert_single_reflection_token(&runner, 2); + } +} + /// CR 701.43a / CR 701.43b / CR 502.3: Exert cost — Arena of Glory class. mod exert_cost { use super::*; diff --git a/crates/engine/src/parser/oracle_nom/quantity.rs b/crates/engine/src/parser/oracle_nom/quantity.rs index f169e3fa22..78177733d0 100644 --- a/crates/engine/src/parser/oracle_nom/quantity.rs +++ b/crates/engine/src/parser/oracle_nom/quantity.rs @@ -2950,11 +2950,16 @@ fn parse_object_mana_value_ref(input: &str) -> OracleResult<'_, QuantityRef> { // building block. Only fires when the phrase actually used the "target" // keyword; the bare "that creature's mana value" possessive stays // `ObjectManaValue { scope: Target }`. + // Optional leading article for the prepositional "the mana value of ..." + // form (mirrors `parse_cost_paid_object_prepositional_ref`). The possessive + // fallback below re-parses from the ORIGINAL `input`, so consuming the + // article here only affects the "of"-form branch. + let (of_form_input, _) = opt(tag::<_, _, OracleError<'_>>("the ")).parse(input)?; if let Ok((rest, _)) = alt(( tag::<_, _, OracleError<'_>>("mana value of "), tag("converted mana cost of "), )) - .parse(input) + .parse(of_form_input) { // CR 608.2c + CR 701.20b: "the card revealed by the other player" — the // OTHER revealer's revealed card in an exactly-two-target symmetric reveal @@ -2974,13 +2979,28 @@ fn parse_object_mana_value_ref(input: &str) -> OracleResult<'_, QuantityRef> { }, )); } - let (after, filter) = parse_target_with_syntax_target_keyword(rest)?; - return Ok(( - after, - QuantityRef::TargetObjectManaValue { - filter: Box::new(filter), - }, - )); + // CR 202.3 + CR 115.1: targeted of-form ("the mana value of target + // ") reads the ref's own target slot. + if let Ok((after, filter)) = parse_target_with_syntax_target_keyword(rest) { + return Ok(( + after, + QuantityRef::TargetObjectManaValue { + filter: Box::new(filter), + }, + )); + } + // CR 202.3 + CR 608.2c: non-target prepositional anaphor — "the mana + // value of that spell" (Ovika, Enigma Goliath). Delegates to the SHARED + // prepositional object-scope grammar `parse_object_prepositional_scope` + // (the one `parse_color_of_object_for_each` / + // `parse_object_typeline_scope` already use), so the mana-value axis + // inherits its full sibling coverage — `it` / `the enchanted creature` / + // `the equipped creature` recipient forms and the demonstrative / + // triggering-spell referents — instead of a parallel table that would + // drift. Without this branch the clause errored out and the whole + // "create X … tokens, where X is …" effect dropped to `Unimplemented`. + let (after, scope) = parse_object_prepositional_scope(rest)?; + return Ok((after, QuantityRef::ObjectManaValue { scope })); } let (rest, scope) = parse_object_possessive_scope(input)?; @@ -4344,7 +4364,11 @@ fn parse_object_typeline_component_count_for_each(input: &str) -> OracleResult<' } fn parse_object_typeline_scope(input: &str) -> OracleResult<'_, ObjectScope> { - alt((parse_object_color_of_scope, parse_object_possessive_scope)).parse(input) + alt(( + parse_object_prepositional_scope, + parse_object_possessive_scope, + )) + .parse(input) } /// CR 201.1 + CR 201.2: Parse @@ -4381,11 +4405,11 @@ fn parse_mana_symbols_in_object_mana_cost_for_each(input: &str) -> OracleResult< /// CR 105.1 + CR 601.2f: "for each color[s] of " — scoped object-color /// count for cost reductions and similar per-color riders. Delegates object -/// binding to `parse_object_color_of_scope` (target/enchanted/equipped/it- +/// binding to `parse_object_prepositional_scope` (target/enchanted/equipped/it- /// targets anaphors). fn parse_color_of_object_for_each(input: &str) -> OracleResult<'_, QuantityRef> { let (rest, _) = alt((tag("color of "), tag("colors of "))).parse(input)?; - let (rest, scope) = parse_object_color_of_scope(rest)?; + let (rest, scope) = parse_object_prepositional_scope(rest)?; Ok((rest, QuantityRef::ObjectColorCount { scope })) } @@ -4417,7 +4441,7 @@ fn parse_number_of_object_colors_tail(input: &str) -> OracleResult<'_, QuantityR value(ObjectScope::EventSource, tag("colors that spell is")), |i| { let (rest, _) = tag("colors of ").parse(i)?; - let (rest, scope) = parse_object_color_of_scope(rest)?; + let (rest, scope) = parse_object_prepositional_scope(rest)?; Ok((rest, scope)) }, )) @@ -4511,7 +4535,15 @@ fn parse_object_possessive_scope(input: &str) -> OracleResult<'_, ObjectScope> { .parse(input) } -fn parse_object_color_of_scope(input: &str) -> OracleResult<'_, ObjectScope> { +/// CR 202.3 + CR 608.2c: the shared prepositional ("of ") object-scope +/// grammar — the "of"-form sibling of [`parse_object_possessive_scope`]. It is +/// property-agnostic: colors (`parse_color_of_object_for_each`), typeline +/// components (`parse_object_typeline_scope`) and mana value +/// (`parse_object_mana_value_ref`) all bind their object through this one table +/// so the anaphor coverage cannot drift per property. Callers that support a +/// `target ...` phrase run their own `parse_target` path first; the +/// `target creature` / `target permanent` arms here are the bare fallback. +fn parse_object_prepositional_scope(input: &str) -> OracleResult<'_, ObjectScope> { alt(( value(ObjectScope::Recipient, tag("it")), value(ObjectScope::Recipient, tag("the enchanted creature")), @@ -10677,4 +10709,61 @@ mod tests { "the sacrificed-permanent COST referent must stay CostPaidObject, got {q:?}" ); } + + /// CR 202.3 + CR 608.2c (issue #1718 — Ovika, Enigma Goliath): the + /// prepositional "the mana value of that spell" of-form must bind to the + /// SAME referent as the possessive front-form "that spell's mana value". + /// Before the fix the of-form required a "target" keyword and errored out, + /// dropping the whole "create X … tokens, where X is the mana value of that + /// spell" effect to `Unimplemented`. + #[test] + fn mana_value_of_form_mirrors_possessive_scope() { + // Each of-form phrase must produce the same ObjectManaValue scope as its + // possessive counterpart (asserted by `parse_object_possessive_scope`). + let cases = [ + ("the mana value of that spell", ObjectScope::EventSource), + ("mana value of that spell", ObjectScope::EventSource), + ( + "the mana value of the triggering spell", + ObjectScope::EventSource, + ), + ("the mana value of that creature", ObjectScope::Target), + ("the mana value of that permanent", ObjectScope::Target), + ("the mana value of this spell", ObjectScope::Source), + ("the mana value of this creature", ObjectScope::Source), + // Sibling recipient forms inherited from the shared prepositional + // object-scope table (`parse_object_prepositional_scope`) — these + // are the forms a mana-value-only anaphor table would have missed. + ("the mana value of it", ObjectScope::Recipient), + ( + "the mana value of the enchanted creature", + ObjectScope::Recipient, + ), + ( + "the mana value of the equipped creature", + ObjectScope::Recipient, + ), + ]; + for (phrase, expected_scope) in cases { + let (rest, q) = parse_quantity_ref(phrase) + .unwrap_or_else(|_| panic!("of-form {phrase:?} should bind")); + assert_eq!(rest, "", "of-form {phrase:?} left residue {rest:?}"); + assert_eq!( + q, + QuantityRef::ObjectManaValue { + scope: expected_scope, + }, + "of-form {phrase:?} must bind ObjectManaValue{{{expected_scope:?}}}, got {q:?}" + ); + } + + // Negative control: the "target" of-form still routes to the target-slot + // reference (`TargetObjectManaValue`), never the demonstrative anaphor. + let (_, q) = parse_quantity_ref("mana value of target creature") + .expect("targeted of-form must still bind"); + assert!( + matches!(q, QuantityRef::TargetObjectManaValue { .. }), + "targeted of-form must stay TargetObjectManaValue, got {q:?}" + ); + } }