Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions crates/engine/src/game/casting_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36283,6 +36283,100 @@ 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.2f: fund the generic cost from the pool so the driver auto-pays.
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!(
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),
"each token must gain haste (\"They gain haste until end of turn\"), \
got keywords {:?}",
obj.keywords
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[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 701.43a / CR 701.43b / CR 502.3: Exert cost — Arena of Glory class.
mod exert_cost {
use super::*;
Expand Down
115 changes: 102 additions & 13 deletions crates/engine/src/parser/oracle_nom/quantity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
// <filter>") 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)?;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <object>" — 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 }))
}

Expand Down Expand Up @@ -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))
},
))
Expand Down Expand Up @@ -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>") 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")),
Expand Down Expand Up @@ -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:?}"
);
}
}
Loading