Skip to content
101 changes: 68 additions & 33 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,33 @@ use super::ability_utils::{
use super::life_costs::PayLifeCostResult;

const TERMINAL_CAST_CANCELLATION_ERROR: &str = "__terminal_cast_cancellation__";
pub(crate) const ABANDONED_CAST_FINALIZATION_ERROR: &str = "__abandoned_cast_finalization__";

fn abandoned_cast_finalization_error() -> EngineError {
EngineError::InvalidAction(ABANDONED_CAST_FINALIZATION_ERROR.to_string())
}

pub(crate) fn is_abandoned_cast_finalization(error: &EngineError) -> bool {
matches!(
error,
EngineError::InvalidAction(message) if message == ABANDONED_CAST_FINALIZATION_ERROR
)
}

fn ensure_pending_spell_announcement_is_live(
state: &GameState,
pending: &PendingCast,
) -> Result<(), EngineError> {
if pending.activation_ability_index.is_none()
&& !state
.stack
.iter()
.any(|entry| entry.id == pending.object_id)
{
return Err(abandoned_cast_finalization_error());
}
Ok(())
}

/// The mana payment authority stamps this on the spell object before casting
/// finalization publishes the spell-cast event.
Expand Down Expand Up @@ -9144,25 +9171,6 @@ fn finalize_cast_with_phyrexian_choices_inner(
events: &mut Vec<GameEvent>,
) -> Result<WaitingFor, EngineError> {
let cost_event_start = events.len();
// CR 702.150a: Record how many of this spell's Phyrexian mana symbols are
// being paid with life. A compleated planeswalker entering from this spell
// exposes this as an intrinsic AddCounter replacement so it can order with
// Doubling Season-class modifiers (CR 616.1). Harmless for non-compleated
// spells (the field is only read for `Keyword::Compleated` planeswalkers).
{
let phyrexian_life_paid = phyrexian_choices
.map(|choices| {
choices
.iter()
.filter(|c| matches!(**c, crate::types::game_state::ShardChoice::PayLife))
.count() as u32
})
.unwrap_or(0);
if let Some(obj) = state.objects.get_mut(&object_id) {
obj.phyrexian_life_paid = phyrexian_life_paid;
}
}

let FinalizePrePaymentChecks {
early_waiting_for,
cascade_cast_transformed,
Expand All @@ -9188,6 +9196,34 @@ fn finalize_cast_with_phyrexian_choices_inner(
return Ok(waiting_for);
}

// CR 601.2a + CR 800.4a: A departing caster's announcement leaves the stack.
// Validate and retain its position before payment or spell-object mutation,
// so its abandoned cast cannot spend costs or move an entryless object.
let entry_position = state
.stack
.iter()
.rposition(|entry| entry.id == object_id)
.ok_or_else(abandoned_cast_finalization_error)?;

// CR 702.150a: Record how many of this spell's Phyrexian mana symbols are
// being paid with life. A compleated planeswalker entering from this spell
// exposes this as an intrinsic AddCounter replacement so it can order with
// Doubling Season-class modifiers (CR 616.1). Harmless for non-compleated
// spells (the field is only read for `Keyword::Compleated` planeswalkers).
{
let phyrexian_life_paid = phyrexian_choices
.map(|choices| {
choices
.iter()
.filter(|c| matches!(**c, crate::types::game_state::ShardChoice::PayLife))
.count() as u32
})
.unwrap_or(0);
if let Some(obj) = state.objects.get_mut(&object_id) {
obj.phyrexian_life_paid = phyrexian_life_paid;
}
}

let cast_transformed = cascade_cast_transformed
|| super::casting::selected_exile_alt_cost_permission_casts_transformed(
state,
Expand Down Expand Up @@ -9662,20 +9698,9 @@ fn finalize_cast_with_phyrexian_choices_inner(
}
}

// CR 601.2i: Update the existing stack entry (pushed at announcement) with
// the finalized ability and the actual mana spent. The entry must still be
// present — no one else can have pushed/popped between announce and
// finalize within a single cast.
//
// CR 405.2: the position is captured rather than left implicit. This is a
// LAST-match scan, so recording the index it found is what lets a replay
// install into the same entry instead of re-scanning a stack that may have
// diverged.
let entry_position = state
.stack
.iter()
.rposition(|entry| entry.id == object_id)
.expect("spell stack entry from announcement still present at finalize");
// CR 601.2i: Retag the existing announcement entry with the finalized
// ability and actual mana spent. `entry_position` was validated before
// payment, while the cast owns this atomic payment/finalization interval.
let resulting_kind = StackEntryKind::Spell {
card_id,
ability: stack_ability.map(Box::new),
Expand Down Expand Up @@ -12462,6 +12487,12 @@ fn finalize_mana_payment_with_resume(
// Phyrexian mana AND at least one shard has both mana and life options available.
// `PendingCast` stays in `state.pending_cast` across the pause — the resume handler
// in `engine.rs` calls `finalize_mana_payment_with_phyrexian_choices`.
if let Some(pending) = state.pending_cast.as_ref() {
if let Err(error) = ensure_pending_spell_announcement_is_live(state, pending) {
state.pending_cast = None;
return Err(error);
}
}
if let Some(pending_ref) = state.pending_cast.as_ref() {
let mana_cost = pending_ref.cost.clone();
let source_id = pending_ref.object_id;
Expand Down Expand Up @@ -12509,6 +12540,7 @@ fn finalize_mana_payment_with_resume(
.pending_cast
.take()
.ok_or_else(|| EngineError::InvalidAction("No pending cast to finalize".to_string()))?;
ensure_pending_spell_announcement_is_live(state, &pending)?;
let resumed_prepaid_actual_mana_spent = pending.prepaid_actual_mana_spent.take();
let mut pending_for_restore = pending.clone();

Expand Down Expand Up @@ -12822,6 +12854,7 @@ fn finalize_mana_payment_with_resume(
state.active_casting_permission_index = None;
match finalize_result {
Ok(waiting_for) => Ok(waiting_for),
Err(err) if is_abandoned_cast_finalization(&err) => Err(err),
// CR 601.2h + CR 605.3b + CR 616.1: An auto-tapped mana ability may
// pause on a replacement-aware cost move. Its serialized cursor owns
// the source activation; retain the outer cast for the exact
Expand Down Expand Up @@ -12881,6 +12914,7 @@ pub fn finalize_mana_payment_with_phyrexian_choices(
.pending_cast
.take()
.ok_or_else(|| EngineError::InvalidAction("No pending cast to finalize".to_string()))?;
ensure_pending_spell_announcement_is_live(state, &pending)?;
let resumed_prepaid_actual_mana_spent = pending.prepaid_actual_mana_spent.take();
let mut pending_for_restore = pending.clone();
let mana_resume = ManaAbilityResume::PhyrexianCastPayment {
Expand Down Expand Up @@ -13206,6 +13240,7 @@ pub fn finalize_mana_payment_with_phyrexian_choices(
state.active_casting_permission_index = None;
match finalize_result {
Ok(waiting_for) => Ok(waiting_for),
Err(err) if is_abandoned_cast_finalization(&err) => Err(err),
// CR 601.2h + CR 605.3b + CR 616.1: See the ordinary payment resume
// above. A Phyrexian choice does not change the cursor ownership.
Err(_) if super::casting::mana_ability_cost_payment_is_paused(state) => {
Expand Down
Loading
Loading