Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 6 additions & 1 deletion crates/engine/src/game/casting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 4 additions & 4 deletions crates/engine/src/game/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 37 additions & 6 deletions crates/engine/src/parser/oracle_static/cost_mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<Keyword> abilities of …" class-wide
/// activation cost-modification static, returning the canonical keyword string that
Expand Down Expand Up @@ -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<StaticDefinition> {
type VE<'a> = OracleError<'a>;

Expand Down Expand Up @@ -440,10 +453,33 @@ pub(crate) fn parse_spells_alternative_cost(text: &str) -> Option<StaticDefiniti
let type_prefix_original = subject.original[..type_prefix_lower.len()].trim();
let after_spells = after_spells_lower.trim();

let parsed_cost = parse_oracle_cost(cost_slice);
if !supported_alternative_cast_cost(&parsed_cost) {
return None;
}

// CR 107.3c + CR 118.9: Kentaro-class alternatives bind their lone `{X}`
// to the spell's mana value. This is distinct from an announced X, which
// is left as `ManaCostShard::X` by normal cost parsing.
let spell_mana_value_x = parse_x_bound_to_spell_mana_value(after_spells)
.is_ok_and(|(rest, _)| rest.trim().is_empty());
let cost = if spell_mana_value_x {
match parsed_cost {
AbilityCost::Mana {
cost: ManaCost::Cost { shards, generic: 0 },
} if shards == vec![ManaCostShard::X] => 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())?;
Expand Down Expand Up @@ -471,11 +507,6 @@ pub(crate) fn parse_spells_alternative_cost(text: &str) -> Option<StaticDefiniti
let affected =
apply_spell_keyword_subject_constraints(base_filter, None, mv_filter, Vec::new());

let cost = parse_oracle_cost(cost_slice);
if !supported_alternative_cast_cost(&cost) {
return None;
}

Some(
StaticDefinition::new(StaticMode::CastWithAlternativeCost {
cost,
Expand Down
29 changes: 29 additions & 0 deletions crates/engine/src/parser/oracle_static/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3511,6 +3511,35 @@ fn alt_cost_fist_of_suns_any_spell_wubrg() {
}
}

/// CR 107.3c + CR 118.9: Kentaro's `{X}` is defined by the affected spell's
/// mana value, so it is a dynamic alternative cost rather than a new X choice.
#[test]
fn alt_cost_kentaro_binds_x_to_the_affected_spells_mana_value() {
let def = parse_spells_alternative_cost(
"You may pay {X} rather than pay the mana cost for Samurai spells you cast, where X is that spell's mana value.",
)
.expect("Kentaro must parse to a mana-value alternative-cost static");

match &def.mode {
StaticMode::CastWithAlternativeCost { cost, .. } => {
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! 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, 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.";

#[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::Cost {
generic: 2,
shards: vec![ManaCostShard::Red],
})
.id();
scenario.with_mana_pool(
P0,
(0..3)
.map(|_| ManaUnit::new(ManaType::Colorless, samurai, false, vec![]))
.collect(),
);
Comment thread
matthewevans marked this conversation as resolved.

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"
);
}
1 change: 1 addition & 0 deletions crates/engine/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading