From 9f68038260b9cec7bba37f49aa0f9344095d4c78 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 19:24:35 -0700 Subject: [PATCH 1/3] fix(parser): bind Kentaro alternative cost to mana value --- crates/engine/src/game/casting.rs | 7 +- crates/engine/src/game/keywords.rs | 8 +- .../src/parser/oracle_static/cost_mod.rs | 43 +++++++++-- .../engine/src/parser/oracle_static/tests.rs | 29 ++++++++ .../issue_5899_kentaro_alternative_cost.rs | 73 +++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 6 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index f531cb2a94..7c3eb781e4 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -2518,7 +2518,12 @@ pub(super) fn granted_spell_alternative_cost_for( }); if matches { return Some(GrantedSpellAlternativeCost { - cost: cost.clone(), + // CR 107.3c + CR 118.9: A static's alternative cost can bind X + // to the affected spell's mana value (Kentaro). Concretize the + // typed placeholder before affordability or payment; the mana + // payment layer otherwise treats unresolved placeholders as a + // zero mana component. + cost: super::keywords::resolve_self_mana_in_ability_cost(state, object_id, cost), timing_permission: *timing_permission, once_per_turn_source: (*frequency == CastFrequency::OncePerTurn) .then_some(source_obj.id), diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index 6f1d97197a..2c749fe4e2 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -371,10 +371,10 @@ pub(crate) fn resolve_keyword_mana_cost( } } -/// CR 602.1a + CR 702.141a: Resolve `SelfManaCost` / `SelfManaValue` placeholders -/// anywhere in an activated ability's cost tree before legality or payment. -/// The mana payment path treats those placeholders as free, so every activation -/// fetch must concretize them against the source object (Sliver Gravemother class). +/// CR 601.2f + CR 602.1a: Resolve `SelfManaCost` / `SelfManaValue` placeholders +/// anywhere in an `AbilityCost` tree before affordability or payment. The mana +/// payment path treats those placeholders as free, so every payable cost must +/// concretize them against its source object (Kentaro and Sliver Gravemother classes). pub(crate) fn resolve_self_mana_in_ability_cost( state: &GameState, source_id: ObjectId, diff --git a/crates/engine/src/parser/oracle_static/cost_mod.rs b/crates/engine/src/parser/oracle_static/cost_mod.rs index 6a019be66a..0ae664d15b 100644 --- a/crates/engine/src/parser/oracle_static/cost_mod.rs +++ b/crates/engine/src/parser/oracle_static/cost_mod.rs @@ -5,6 +5,7 @@ use super::prelude::*; #[allow(unused_imports)] use super::support::*; use crate::types::ability::CastTimingPermission; +use crate::types::mana::{ManaCost, ManaCostShard}; /// CR 602.1: Parse the leading keyword of a " abilities of …" class-wide /// activation cost-modification static, returning the canonical keyword string that @@ -370,6 +371,18 @@ pub(crate) fn parse_alt_cost_frequency_prefix(input: &str) -> OracleResult<'_, C /// /// Strict-fails to `None` (never misparses) when the payment cannot be parsed /// as an `AbilityCost` (Dream Halls discard, Bolas's Citadel life-as-MV). +/// +/// CR 107.3c + CR 118.9: a trailing "where X is that spell's mana value" +/// defines the alternative-cost X rather than leaving it for the caster to +/// choose. The binding is only valid for the standalone `{X}` mana-cost shape. +fn parse_x_bound_to_spell_mana_value(input: &str) -> OracleResult<'_, ()> { + let (input, _) = opt(tag(",")).parse(input)?; + let (input, _) = + preceded(opt(tag(" ")), tag("where x is that spell's mana value")).parse(input)?; + let (input, _) = opt(tag(".")).parse(input)?; + Ok((input, ())) +} + pub(crate) fn parse_spells_alternative_cost(text: &str) -> Option { type VE<'a> = OracleError<'a>; @@ -440,10 +453,33 @@ pub(crate) fn parse_spells_alternative_cost(text: &str) -> Option AbilityCost::Mana { + cost: ManaCost::SelfManaValue, + }, + _ => return None, + } + } else { + parsed_cost + }; + // Optional "with mana value N or greater" qualifier (Jodah MV-5+ class). If // an MV qualifier is present but does not parse cleanly into FilterProp::Cmc, // strict-fail (None) rather than over-broadening to any spell. - let mv_filter = if after_spells.is_empty() { + let mv_filter = if after_spells.is_empty() || spell_mana_value_x { None } else { let (prop, consumed) = parse_mana_value_suffix(after_spells, &mut ParseContext::default())?; @@ -471,11 +507,6 @@ pub(crate) fn parse_spells_alternative_cost(text: &str) -> Option { + assert_eq!( + *cost, + AbilityCost::Mana { + cost: crate::types::mana::ManaCost::SelfManaValue, + } + ); + } + other => panic!("expected CastWithAlternativeCost, got {other:?}"), + } + match &def.affected { + Some(TargetFilter::Typed(tf)) => { + assert_eq!(tf.controller, Some(ControllerRef::You)); + assert_eq!(tf.get_subtype(), Some("Samurai")); + } + other => panic!("expected Typed(Samurai spells you cast), got {other:?}"), + } +} + /// CR 118.9 + CR 107.14 + CR 702.8a: Primal Prayers grants {E} as an /// alternative cost for creature spells with MV ≤ 3, with flash tied to that /// alternative-cost path. diff --git a/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs b/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs new file mode 100644 index 0000000000..f8b8d50257 --- /dev/null +++ b/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs @@ -0,0 +1,73 @@ +//! CR 107.3c + CR 118.9 + CR 601.2b regression for Kentaro, the Smiling Cat. +//! Its alternative-cost X is the matching Samurai spell's mana value, not a +//! second player-chosen X. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::{AbilityCost, AdditionalCost}; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, WaitingFor}; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::phase::Phase; + +const KENTARO_ORACLE: &str = "You may pay {X} rather than pay the mana cost for Samurai spells you cast, where X is that spell's mana value."; + +#[test] +fn kentaro_offers_and_pays_the_matching_samurai_mana_value() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_creature_from_oracle(P0, "Kentaro, the Smiling Cat", 2, 2, KENTARO_ORACLE); + let samurai = scenario + .add_creature_to_hand(P0, "Test Samurai", 3, 3, "") + .with_subtypes(vec!["Samurai"]) + .with_mana_cost(ManaCost::generic(3)) + .id(); + scenario.with_mana_pool( + P0, + (0..3) + .map(|_| ManaUnit::new(ManaType::Colorless, samurai, false, vec![])) + .collect(), + ); + + let mut runner = scenario.build(); + let card_id = runner.state().objects[&samurai].card_id; + runner + .act(GameAction::CastSpell { + object_id: samurai, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("Kentaro must offer a castable alternative cost for a Samurai"); + + match &runner.state().waiting_for { + WaitingFor::OptionalCostChoice { cost, .. } => assert!(matches!( + cost, + AdditionalCost::Choice( + AbilityCost::Mana { + cost: ManaCost::Cost { + shards, + generic: 3, + }, + }, + _ + ) if shards.is_empty() + )), + other => panic!("expected Kentaro alternative-cost choice, got {other:?}"), + } + + runner + .act(GameAction::DecideOptionalCost { pay: true }) + .expect("the mana-value alternative cost must be payable"); + + assert!( + !runner.state().stack.is_empty(), + "accepting Kentaro's alternative must complete casting without another X prompt" + ); + assert!( + runner.state().players[P0.0 as usize] + .mana_pool + .mana + .is_empty(), + "the alternative cost must consume the Samurai spell's mana value" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index aee4e68e16..672cf250aa 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -634,6 +634,7 @@ mod issue_581_mystic_remora_cumulative_upkeep; mod issue_5820_susan_foreman; mod issue_5821_psychic_paper_attach_choice; mod issue_583_vivi_ornitier_mana_source; +mod issue_5899_kentaro_alternative_cost; mod issue_5900_conjurers_mantle; mod issue_5901_depthshaker_titan; mod issue_5902_heart_shaped_herb; From ad753b75de5a4ac30c1d66e82551baca85d940f9 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 19:40:56 -0700 Subject: [PATCH 2/3] test(engine): correct Kentaro scenario setup --- .../tests/integration/issue_5899_kentaro_alternative_cost.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs b/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs index f8b8d50257..95d5294a55 100644 --- a/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs +++ b/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs @@ -17,7 +17,7 @@ fn kentaro_offers_and_pays_the_matching_samurai_mana_value() { scenario.at_phase(Phase::PreCombatMain); scenario.add_creature_from_oracle(P0, "Kentaro, the Smiling Cat", 2, 2, KENTARO_ORACLE); let samurai = scenario - .add_creature_to_hand(P0, "Test Samurai", 3, 3, "") + .add_creature_to_hand(P0, "Test Samurai", 3, 3) .with_subtypes(vec!["Samurai"]) .with_mana_cost(ManaCost::generic(3)) .id(); From f5815ddfc40db128f2ceec94e487c9a4dfbf5d49 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 12 Aug 2026 20:45:10 -0700 Subject: [PATCH 3/3] test(engine): prove Kentaro alternative payment --- .../integration/issue_5899_kentaro_alternative_cost.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs b/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs index 95d5294a55..37feca1586 100644 --- a/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs +++ b/crates/engine/tests/integration/issue_5899_kentaro_alternative_cost.rs @@ -6,7 +6,7 @@ use engine::game::scenario::{GameScenario, P0}; use engine::types::ability::{AbilityCost, AdditionalCost}; use engine::types::actions::GameAction; use engine::types::game_state::{CastPaymentMode, WaitingFor}; -use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; use engine::types::phase::Phase; const KENTARO_ORACLE: &str = "You may pay {X} rather than pay the mana cost for Samurai spells you cast, where X is that spell's mana value."; @@ -19,7 +19,10 @@ fn kentaro_offers_and_pays_the_matching_samurai_mana_value() { let samurai = scenario .add_creature_to_hand(P0, "Test Samurai", 3, 3) .with_subtypes(vec!["Samurai"]) - .with_mana_cost(ManaCost::generic(3)) + .with_mana_cost(ManaCost::Cost { + generic: 2, + shards: vec![ManaCostShard::Red], + }) .id(); scenario.with_mana_pool( P0,