Skip to content
Open
193 changes: 105 additions & 88 deletions crates/engine/src/game/casting.rs

Large diffs are not rendered by default.

78 changes: 61 additions & 17 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12393,14 +12393,17 @@ pub(crate) fn spell_cost_is_payable_from_pool(
) -> bool {
let spell_meta = super::casting::build_spell_meta(state, player, object_id);
let spell_ctx = spell_meta.as_ref().map(PaymentContext::Spell);
let any_color = super::casting::player_can_spend_as_any_color_for_payment(
let mana_spend_permission = super::casting::player_mana_spend_permission_for_payment(
state,
player,
Some(object_id),
spell_ctx.as_ref(),
);
let permissions =
super::static_abilities::build_cost_permission_context(state, player, any_color);
let permissions = super::static_abilities::build_cost_permission_context(
state,
player,
mana_spend_permission,
);
state
.players
.iter()
Expand Down Expand Up @@ -12671,9 +12674,9 @@ fn auto_tap_mana_sources_inner(
let spell_ctx = spell_meta.as_ref().map(PaymentContext::Spell);
let effective_ctx = payment_context.or(spell_ctx.as_ref());
// CR 609.4b: Auto-tap planning must use the same spend-as-any-color authority
// as legality dry-runs and real payment (`player_can_spend_as_any_color_for_payment`),
// as legality dry-runs and real payment (`player_mana_spend_permission_for_payment`),
// including activation-source-filtered grants (Agatha's Soul Cauldron class).
let any_color = super::casting::player_can_spend_as_any_color_for_payment(
let mana_spend_permission = super::casting::player_mana_spend_permission_for_payment(
state,
player,
deprioritize_source,
Expand All @@ -12688,7 +12691,7 @@ fn auto_tap_mana_sources_inner(
&p.mana_pool,
cost,
effective_ctx,
any_color,
mana_spend_permission,
sub_cost_demand,
)
})
Expand Down Expand Up @@ -12741,27 +12744,57 @@ fn auto_tap_mana_sources_inner(
use crate::game::mana_payment::{shard_to_mana_type, ShardRequirement};
match shard_to_mana_type(*shard) {
ShardRequirement::Single(color) => {
let acceptable = if any_color { Vec::new() } else { vec![color] };
let acceptable = if mana_spend_permission
.is_some_and(|permission| permission.allows_payment_as(color))
{
Vec::new()
} else {
vec![color]
};
needs.push((acceptable, false, false, false));
}
ShardRequirement::Phyrexian(color) => {
// CR 107.4f: Mark as phyrexian (4th field = true) so MCV deprioritizes it
// compared to strict Single color requirements. Phyrexian can be paid with
// life, so we should consume other mana sources for strict requirements first.
let acceptable = if any_color { Vec::new() } else { vec![color] };
let acceptable = if mana_spend_permission
.is_some_and(|permission| permission.allows_payment_as(color))
{
Vec::new()
} else {
vec![color]
};
needs.push((acceptable, false, false, true));
}
ShardRequirement::Hybrid(a, b) => {
let acceptable = if any_color { Vec::new() } else { vec![a, b] };
let acceptable = if mana_spend_permission.is_some_and(|permission| {
permission.allows_payment_as(a) || permission.allows_payment_as(b)
}) {
Vec::new()
} else {
vec![a, b]
};
needs.push((acceptable, false, false, false));
}
ShardRequirement::HybridPhyrexian(a, b) => {
// CR 107.4f: Hybrid Phyrexian also allows life payment, so deprioritize.
let acceptable = if any_color { Vec::new() } else { vec![a, b] };
let acceptable = if mana_spend_permission.is_some_and(|permission| {
permission.allows_payment_as(a) || permission.allows_payment_as(b)
}) {
Vec::new()
} else {
vec![a, b]
};
needs.push((acceptable, false, false, true));
}
ShardRequirement::TwoGenericHybrid(color) => {
let acceptable = if any_color { Vec::new() } else { vec![color] };
let acceptable = if mana_spend_permission
.is_some_and(|permission| permission.allows_payment_as(color))
{
Vec::new()
} else {
vec![color]
};
needs.push((acceptable, true, false, false));
}
// CR 107.4f: K'rrik promotion never reaches the auto-tap
Expand All @@ -12770,11 +12803,19 @@ fn auto_tap_mana_sources_inner(
// tap-planning shape as the unpromoted `TwoGenericHybrid` but
// with potential life payment, so deprioritize.
ShardRequirement::TwoGenericHybridPhyrexian(color) => {
let acceptable = if any_color { Vec::new() } else { vec![color] };
let acceptable = if mana_spend_permission
.is_some_and(|permission| permission.allows_payment_as(color))
{
Vec::new()
} else {
vec![color]
};
needs.push((acceptable, true, false, true));
}
ShardRequirement::ColorlessHybrid(color) => {
let acceptable = if any_color {
let acceptable = if mana_spend_permission
.is_some_and(|permission| permission.allows_payment_as(color))
{
Vec::new()
} else {
vec![ManaType::Colorless, color]
Expand Down Expand Up @@ -14045,7 +14086,7 @@ pub(super) fn apply_committed_assist(
&probe,
None,
None,
false,
None,
None,
crate::types::mana::LifePaymentColors::EMPTY,
&[],
Expand Down Expand Up @@ -15225,7 +15266,7 @@ pub(super) fn maybe_pause_for_phyrexian_choice(
.flatten();
let spell_ctx = spell_meta.as_ref().map(PaymentContext::Spell);
let effective_payment_context = payment_context.or(spell_ctx.as_ref());
let any_color = super::casting::player_can_spend_as_any_color_for_payment(
let mana_spend_permission = super::casting::player_mana_spend_permission_for_payment(
&preview,
player,
Some(source_id),
Expand All @@ -15234,8 +15275,11 @@ pub(super) fn maybe_pause_for_phyrexian_choice(
// CR 107.4f + CR 118.1: Single-authority permission bundle — passes
// `life_colors` through to `compute_phyrexian_shards` so K'rrik-promoted
// shards surface in the pause UI.
let permissions =
super::static_abilities::build_cost_permission_context(&preview, player, any_color);
let permissions = super::static_abilities::build_cost_permission_context(
&preview,
player,
mana_spend_permission,
);

let (shards, payable) = {
let player_data = preview.players.iter().find(|p| p.id == player)?;
Expand Down
34 changes: 19 additions & 15 deletions crates/engine/src/game/casting_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34964,12 +34964,12 @@ fn phyrexian_submit_rejects_stale_paylife_under_insufficient_life() {
// will reject PayLife. This path is exercised through the dispatcher, not directly.
// Here we assert the shape is correct by re-computing shards.
let spell_meta = build_spell_meta(&state, PlayerId(0), spell);
let any_color =
crate::game::static_abilities::player_can_spend_as_any_color(&state, PlayerId(0));
let mana_spend_permission =
crate::game::static_abilities::player_board_wide_mana_spend_permission(&state, PlayerId(0));
let permissions = crate::game::static_abilities::build_cost_permission_context(
&state,
PlayerId(0),
any_color,
mana_spend_permission,
);
let spell_ctx = spell_meta.as_ref().map(PaymentContext::Spell);
let current_shards = crate::game::mana_payment::compute_phyrexian_shards(
Expand Down Expand Up @@ -48448,9 +48448,10 @@ fn exile_static_any_color_casts_off_color_for_authorized_controller_only() {
!spell_objects_available_to_cast(&state, PlayerId(1)).contains(&spell),
"the card owner must not inherit the source controller's static permission"
);
assert!(exile_static_permission_grants_any_color(
&state, player, spell, source
));
assert_eq!(
exile_static_mana_spend_permission(&state, player, spell, source),
Some(ManaSpendPermission::AnyColor)
);

let mut runner = crate::game::scenario::GameRunner::from_state(state);
let outcome = runner.cast(spell).resolve();
Expand Down Expand Up @@ -48527,6 +48528,7 @@ fn add_vizier_filtered_any_type_source(state: &mut GameState, player: PlayerId)
let def = StaticDefinition::new(StaticMode::SpendManaAsAnyColor {
spell_filter: Some(TargetFilter::Typed(TypedFilter::creature())),
activation_source_filter: None,
concession: crate::types::ability::ManaSpendPermission::AnyTypeOrColor,
})
.affected(TargetFilter::Controller);
state
Expand Down Expand Up @@ -48569,8 +48571,8 @@ fn add_single_blue_spell(
/// form, the noncreature assertion below flips (a sorcery would also become
/// payable). If the static is removed entirely, the creature assertion flips
/// (the {U} cost becomes unpayable from a red-only pool). The seam under test
/// is `player_can_spend_as_any_color_for_spell_object` →
/// `player_can_spend_as_any_color_for_optional_spell` →
/// is `player_mana_spend_permission_for_spell_object` →
/// `player_mana_spend_permission_for_optional_spell` →
/// `can_pay_cost_after_auto_tap`.
#[test]
fn vizier_filtered_static_grants_any_type_mana_for_creature_spells() {
Expand Down Expand Up @@ -48601,10 +48603,11 @@ fn vizier_filtered_static_grants_any_type_mana_for_creature_spells() {
add_mana(&mut state, player, ManaType::Red, 2);

// POSITIVE: a creature spell matches the filter, so off-color mana pays.
assert!(
crate::game::static_abilities::player_can_spend_as_any_color_for_spell_object(
assert_eq!(
crate::game::static_abilities::player_mana_spend_permission_for_spell_object(
&state, player, creature
),
Some(ManaSpendPermission::AnyTypeOrColor),
"the filtered static must grant any-type-mana spend for a creature spell"
);
assert!(
Expand All @@ -48620,10 +48623,11 @@ fn vizier_filtered_static_grants_any_type_mana_for_creature_spells() {
// NEGATIVE: a noncreature spell does NOT match the filter — off-color mana
// must NOT help. This is what distinguishes the filtered static from the
// unfiltered board-wide form.
assert!(
!crate::game::static_abilities::player_can_spend_as_any_color_for_spell_object(
assert_eq!(
crate::game::static_abilities::player_mana_spend_permission_for_spell_object(
&state, player, sorcery
),
None,
"the filtered static must NOT grant any-type-mana spend for a noncreature spell"
);
assert!(
Expand Down Expand Up @@ -52963,7 +52967,7 @@ mod plot_from_library {
/// must (a) forward the concession onto the granted `ExileWithAltCost`
/// (`grant_lingering_permissions`) at the spell's PRINTED cost, and (b) let the
/// grantee pay an off-color cost from a red-only pool
/// (`player_can_spend_as_any_color_for_optional_spell`). Drives the production
/// (`player_mana_spend_permission_for_optional_spell`). Drives the production
/// grant resolver (`cast_from_zone::resolve`) and the production payability
/// gate (`can_pay_cost_after_auto_tap`), then a full cast through `apply`.
///
Expand Down Expand Up @@ -54063,7 +54067,7 @@ fn graveyard_paid_cast_accept_off_color_pays_via_any_type_concession() {
);
// The concession is scoped to THIS spell via the granted permission.
assert!(
player_can_spend_as_any_color_for_optional_spell(&state, PlayerId(0), Some(spell)),
player_mana_spend_permission_for_optional_spell(&state, PlayerId(0), Some(spell)).is_some(),
"the any-type concession must be in force for the granted spell (CR 609.4b)"
);

Expand Down Expand Up @@ -55565,7 +55569,7 @@ fn exact_resolution_offer_without_concession_does_not_inherit_later_any_color_si

assert!(matches!(state.waiting_for, WaitingFor::ManaPayment { .. }));
assert!(
!player_can_spend_as_any_color_for_optional_spell(&state, PlayerId(0), Some(spell)),
player_mana_spend_permission_for_optional_spell(&state, PlayerId(0), Some(spell)).is_none(),
"the later sibling's AnyColor concession must not bind to the elected slot"
);
assert!(
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/game/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ pub(crate) fn is_data_carrying_static(mode: &StaticMode) -> bool {
// the spell-filtered `Some` shape (Vizier of the Menagerie) carries
// an unbounded filter value space, so coverage support lives here.
// Runtime enforcement is in
// casting.rs::player_can_spend_as_any_color_for_optional_spell.
// casting.rs::player_mana_spend_permission_for_optional_spell.
| StaticMode::SpendManaAsAnyColor { .. }
// CR 121.6: CantDraw carries `who` (controller vs all_players) —
// runtime enforcement is in game/effects/draw.rs::allowed_draw_count.
Expand Down
19 changes: 9 additions & 10 deletions crates/engine/src/game/derived_views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1177,15 +1177,9 @@ pub struct ClientGameState {
/// cast — activated-ability mana payment keeps its full-cost display, and
/// convoke/improvise/delve pay via board taps tracked by their own staged UI.
///
/// KNOWN LIMITATION: reduces with `any_color = false` and no life-for-color
/// permissions, so under an any-color spend permission (Chromatic Orrery) or a
/// K'rrik-style life-as-colored-mana grant the displayed residual can over-state
/// the cost (a colorless unit pinned toward `{R}` reads as not covering it).
/// This is deliberately consistent with the pin-eligibility gate
/// (`mana_unit_eligible_for_cost`), which is also `any_color`-blind and would
/// reject such a pin — both layers agree on the stricter behavior, and the
/// common cases (generic + plain colored costs) are exact. Threading the real
/// permission bundle through both sites is the follow-up to lift this.
/// Uses the current spell's typed mana-spend permission when matching pinned
/// units to colored or colorless requirements. This projection subtracts only
/// pinned mana units; it does not subtract life payments.
fn pending_payment_remaining(state: &GameState, viewer: PlayerId) -> Option<ManaCost> {
use crate::types::game_state::WaitingFor;
use crate::types::mana::{ManaPool, PaymentContext};
Expand Down Expand Up @@ -1227,7 +1221,12 @@ fn pending_payment_remaining(state: &GameState, viewer: PlayerId) -> Option<Mana
&selected,
&cost,
ctx.as_ref(),
false,
crate::game::casting::player_mana_spend_permission_for_payment(
state,
viewer,
Some(pending.object_id),
ctx.as_ref(),
),
None,
))
}
Expand Down
2 changes: 1 addition & 1 deletion crates/engine/src/game/effects/cast_from_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2347,7 +2347,7 @@ fn record_lingering_permissions(
// that spell" (Quistis Trepe, Tinybones the Pickpocket) onto
// the grant so the concession is scoped to this specific
// cast, read at payment by
// `player_can_spend_as_any_color_for_optional_spell`.
// `player_mana_spend_permission_for_optional_spell`.
mana_spend_permission,
// CR 601.2f: "Spells you cast this way cost {N} less to
// cast" (Urianger Augurelt) — stamped onto the CAST
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/game/effects/perpetual.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ mod tests {
/// `AbilityDefinition` onto the conjured duplicate's
/// `abilities`/`base_abilities` (wrongly board-wide in scope, AND never
/// even checked: `static_abilities.rs`'s
/// `player_can_spend_as_any_color_for_spell_object` only ever scans
/// `player_mana_spend_permission_for_spell_object` only ever scans
/// `game_active_statics` -- battlefield + command zone -- never hand or
/// the stack, per CR 113.6e the very zones this self-cast concession
/// would need to function in).
Expand Down Expand Up @@ -779,7 +779,7 @@ mod tests {
// action is rejected outright regardless of whether the parser gate
// above works. NON-DISCRIMINATING (see the mutation-test note above
// this test): `static_abilities.rs`'s
// `player_can_spend_as_any_color_for_spell_object` only ever scans
// `player_mana_spend_permission_for_spell_object` only ever scans
// `game_active_statics` -- battlefield + command zone -- so even a
// rejected-gate regression that let the grant install onto the
// duplicate's HAND-zone `abilities` would never be found by that
Expand Down
Loading
Loading