-
-
Notifications
You must be signed in to change notification settings - Fork 148
fix(engine): Ovika parses 'mana value of that spell' of-form, not just possessive #6526
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9e42854
4b83ea2
f2a95b7
1d5376e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Comment on lines
+36400
to
+36407
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Confirm which CR governs "X defined by the ability's own text" vs mana-symbol enumeration.
grep -n "^107\.3" -A2 docs/MagicCompRules.txt | head -40
grep -n "^107\.4" -A2 docs/MagicCompRules.txt | head -20
# Confirm what ObjectScope "the mana value of that spell" (of-form) actually resolves to.
rg -n "fn parse_object_prepositional_scope" -A 60 crates/engine/src/parser/oracle_nom/Repository: phase-rs/phase Length of output: 6117 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== locate CR text =="
fd -a 'MagicCompRules.txt' . || true
fd -a 'ComprehensiveRules.txt' . || true
fd -a 'CR*.txt' docs . || true
echo "== inspect relevant CR lines if found =="
for f in $(fd -a 'MagicCompRules.txt' . 2>/dev/null; true); do
echo "--- $f"
grep -n "^107\.[34]" -A4 -B2 "$f" | head -60
done
echo "== inspect object mana value resolver =="
rg -n "resolve_object_mana_value|ObjectManaValue|Demonstrative|that spell's mana value|mana value of that spell" crates/engine/src -A40 -B20
echo "== inspect surrounding parser for object prepositional scope =="
sed -n '4538,4572p' crates/engine/src/parser/oracle_nom/quantity.rs
echo "== inspect the exact test/comment location =="
sed -n '36384,36416p' crates/engine/src/game/casting_tests.rsRepository: phase-rs/phase Length of output: 50371 Replace the CR 107.4 citation 🤖 Prompt for AI AgentsSource: Path instructions |
||
| 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<ManaUnit>, | ||
| ) -> 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::*; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.