From a622d51e2ed12183437c10dd6ae2e5297b349ea0 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 08:26:31 -0700 Subject: [PATCH 1/8] fix(engine): abandon paused casts when player leaves --- crates/engine/src/game/casting_costs.rs | 114 ++++++++---- crates/engine/src/game/elimination.rs | 228 +++++++++++++++++++++--- crates/engine/src/game/engine.rs | 9 +- 3 files changed, 295 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 451c1983e0..6cd42dc149 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -48,6 +48,24 @@ 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 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(EngineError::InvalidAction( + ABANDONED_CAST_FINALIZATION_ERROR.to_string(), + )); + } + Ok(()) +} /// The mana payment authority stamps this on the spell object before casting /// finalization publishes the spell-cast event. @@ -9144,25 +9162,6 @@ fn finalize_cast_with_phyrexian_choices_inner( events: &mut Vec, ) -> Result { 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, @@ -9188,6 +9187,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(|| EngineError::InvalidAction(ABANDONED_CAST_FINALIZATION_ERROR.to_string()))?; + + // 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, @@ -9662,20 +9689,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), @@ -12462,6 +12478,16 @@ 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 state + .pending_cast + .as_ref() + .is_some_and(|pending| ensure_pending_spell_announcement_is_live(state, pending).is_err()) + { + state.pending_cast = None; + return Err(EngineError::InvalidAction( + ABANDONED_CAST_FINALIZATION_ERROR.to_string(), + )); + } if let Some(pending_ref) = state.pending_cast.as_ref() { let mana_cost = pending_ref.cost.clone(); let source_id = pending_ref.object_id; @@ -12509,6 +12535,9 @@ fn finalize_mana_payment_with_resume( .pending_cast .take() .ok_or_else(|| EngineError::InvalidAction("No pending cast to finalize".to_string()))?; + if let Err(err) = ensure_pending_spell_announcement_is_live(state, &pending) { + return Err(err); + } let resumed_prepaid_actual_mana_spent = pending.prepaid_actual_mana_spent.take(); let mut pending_for_restore = pending.clone(); @@ -12822,6 +12851,14 @@ fn finalize_mana_payment_with_resume( state.active_casting_permission_index = None; match finalize_result { Ok(waiting_for) => Ok(waiting_for), + Err(err) + if matches!( + &err, + EngineError::InvalidAction(message) if message == ABANDONED_CAST_FINALIZATION_ERROR + ) => + { + 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 @@ -12881,6 +12918,9 @@ pub fn finalize_mana_payment_with_phyrexian_choices( .pending_cast .take() .ok_or_else(|| EngineError::InvalidAction("No pending cast to finalize".to_string()))?; + if let Err(err) = ensure_pending_spell_announcement_is_live(state, &pending) { + return Err(err); + } let resumed_prepaid_actual_mana_spent = pending.prepaid_actual_mana_spent.take(); let mut pending_for_restore = pending.clone(); let mana_resume = ManaAbilityResume::PhyrexianCastPayment { @@ -13206,6 +13246,14 @@ 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 matches!( + &err, + EngineError::InvalidAction(message) if message == ABANDONED_CAST_FINALIZATION_ERROR + ) => + { + 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) => { diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index ef8f3e75ca..f2f6df0cde 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1,7 +1,10 @@ use std::collections::HashSet; use crate::types::events::GameEvent; -use crate::types::game_state::{ActiveSearchDecisionAuthority, GameState, WaitingFor}; +use crate::types::game_state::{ + ActiveSearchDecisionAuthority, CollectEvidenceResume, DeferredLifeCostResume, GameState, + PendingCast, PendingCostMoveResume, WaitingFor, +}; use crate::types::identifiers::ObjectIncarnationRef; use crate::types::match_config::MatchPhase; use crate::types::player::PlayerId; @@ -12,6 +15,79 @@ use crate::types::zones::Zone; use super::players; +/// CR 800.4a: A spell that has been announced but not yet cast can be parked in +/// several replacement-aware cost continuations. Once its controller leaves, +/// none may resume into finalization after the announcement stack entry has +/// been removed. +fn abandon_pending_spell_casts( + state: &mut GameState, + departing_player: PlayerId, + spell_ids: &[crate::types::identifiers::ObjectId], +) { + let is_abandoned_spell = |pending: &PendingCast| { + pending.activation_ability_index.is_none() + && (spell_ids.contains(&pending.object_id) + || pending.ability.controller == departing_player) + }; + + if state + .pending_cast + .as_ref() + .is_some_and(|pending| is_abandoned_spell(pending)) + { + state.pending_cast = None; + } + + if state + .waiting_for + .pending_cast_ref() + .is_some_and(is_abandoned_spell) + { + state.waiting_for = WaitingFor::Priority { + player: state.active_player, + }; + } + + if matches!( + state.pending_deferred_life_cost_resume.as_ref(), + Some(DeferredLifeCostResume::Cast { + pending: Some(pending), + .. + }) if is_abandoned_spell(pending) + ) { + state.pending_deferred_life_cost_resume = None; + } + + if let Some(resume) = state.pending_cost_move_resume.take() { + let abandons_spell = match &resume { + PendingCostMoveResume::Cast { + pending: Some(pending), + .. + } + | PendingCostMoveResume::SacrificeForCost { pending, .. } => { + is_abandoned_spell(pending) + } + PendingCostMoveResume::CollectEvidencePayment { resume, .. } => matches!( + resume.as_ref(), + CollectEvidenceResume::Casting { pending_cast, .. } + if is_abandoned_spell(pending_cast) + ), + _ => false, + }; + if !abandons_spell { + state.pending_cost_move_resume = Some(resume); + } + } + + if state + .pending_discard_for_cost + .as_ref() + .is_some_and(|resume| is_abandoned_spell(&resume.pending)) + { + state.pending_discard_for_cost = None; + } +} + /// Eliminate a player from the game per CR 800.4. /// /// - Marks the player as eliminated @@ -656,18 +732,26 @@ fn do_eliminate( // one unjournalable mutation; removing by position instead records each // entry with the index it occupied at the moment IT was removed, so a replay // reproduces both the count and the surviving entries' relative order. + let mut abandoned_spell_ids = Vec::new(); while let Some(idx) = state .stack .iter() .position(|entry| entry.controller == player) { - super::stack::remove_nonresolving_stack_entry_at( + let removed = super::stack::remove_nonresolving_stack_entry_at( state, idx, super::lifecycle::DelayedTerminalDisposition::Eliminated, ) .expect("position yielded a live stack index"); + if matches!( + removed.entry.kind, + crate::types::game_state::StackEntryKind::Spell { .. } + ) { + abandoned_spell_ids.push(removed.entry.id); + } } + abandon_pending_spell_casts(state, player, &abandoned_spell_ids); // CR 800.4a + CR 800.4b: A control-another-player effect (CR 723, e.g. // Mindslaver / Secret of Bloodbending) ends when EITHER party leaves the @@ -820,24 +904,6 @@ fn do_eliminate( state.pending_trigger_event_batch.clear(); } - // CR 800.4a: Abandon any not-yet-resolved cast this player controls. A spell - // paused mid-cast (e.g. a convoke spell awaiting `WaitingFor::ManaPayment`) - // is held in `state.pending_cast`, not as a stack entry, so the stack retain - // above does not clear it. Left behind, the in-progress cast lingers in the - // GameState after the player leaves — and because the WASM engine is a - // singleton reused across games, it can resurface as a stuck mana-payment - // window in a later game. Only clear a pending cast the *leaving* player - // controls; another living player's mid-cast must survive an opponent's - // departure, so key off the spell object's controller (the caster). - if state - .pending_cast - .as_ref() - .and_then(|pc| state.objects.get(&pc.object_id)) - .is_some_and(|obj| obj.controller == player) - { - state.pending_cast = None; - } - // CR 800.4a + CR 616.1 + CR 704.4: Abandon a parked replacement choice this // leaving player was answering. A CR 616.1 replacement-order (or optional // MayCost / MayCost sub-choice re-park) is held in `state.pending_replacement` @@ -1249,8 +1315,10 @@ mod tests { use super::*; use crate::game::zones::create_object; use crate::types::ability::{ - Effect, EffectKind, PostReplacementContinuation, ResolvedAbility, TargetRef, + Effect, EffectKind, PostReplacementContinuation, ReplacementDefinition, ReplacementMode, + ResolvedAbility, TargetRef, }; + use crate::types::actions::GameAction; use crate::types::counter::CounterType; use crate::types::format::FormatConfig; use crate::types::game_state::{ @@ -1261,7 +1329,7 @@ mod tests { }; use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef}; use crate::types::mana::ManaCost; - use crate::types::proposed_event::{CounterPlacement, ProposedEvent}; + use crate::types::proposed_event::{CounterPlacement, ProposedEvent, ReplacementEvent}; fn setup_two_player() -> GameState { let mut state = GameState::new_two_player(42); @@ -2329,6 +2397,122 @@ mod tests { ); } + #[test] + fn elimination_abandons_deferred_life_cast_without_touching_living_cast() { + // CR 104.3a + CR 800.4a: a player may concede during a paused life-cost + // continuation. The announcement leaves the stack at departure, so the + // deferred cast must be retired rather than resumed into finalization. + let mut state = setup_three_player(); + let replacement_source = create_object( + &mut state, + CardId(100), + PlayerId(2), + "Living replacement controller".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&replacement_source) + .expect("replacement source exists") + .replacement_definitions + .push( + ReplacementDefinition::new(ReplacementEvent::Discard) + .mode(ReplacementMode::Optional { decline: None }), + ); + let discarded = create_object( + &mut state, + CardId(101), + PlayerId(2), + "Replacement-prompt discard".to_string(), + Zone::Hand, + ); + let mut setup_events = Vec::new(); + assert!(matches!( + crate::game::replacement::replace_event( + &mut state, + ProposedEvent::Discard { + player_id: PlayerId(2), + object_id: discarded, + source_id: None, + caused_by_effect: false, + discard_frame: None, + applied: HashSet::new(), + }, + &mut setup_events, + ), + crate::game::replacement::ReplacementResult::NeedsChoice(PlayerId(2)) + )); + assert!(matches!( + state.waiting_for, + WaitingFor::ReplacementChoice { + player: PlayerId(2), + .. + } + )); + + let leaving_spell = stash_pending_cast(&mut state, PlayerId(1)); + let leaving_pending = state.pending_cast.take().expect("test cast exists"); + state.stack.push_back(StackEntry { + id: leaving_spell, + source_id: leaving_spell, + controller: PlayerId(1), + kind: StackEntryKind::Spell { + card_id: CardId(99), + ability: None, + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + state.pending_deferred_life_cost_resume = Some(DeferredLifeCostResume::Cast { + player: PlayerId(1), + pending: Some(leaving_pending), + remaining_life_payments: vec![], + resume_at_resolution_depth: 0, + }); + let living_spell = stash_pending_cast(&mut state, PlayerId(2)); + + let result = super::super::engine::apply_as_current( + &mut state, + GameAction::Concede { + player_id: PlayerId(1), + }, + ) + .expect("a player may concede during a paused cast"); + + assert!( + state.pending_deferred_life_cost_resume.is_none(), + "a departed caster's deferred life-payment continuation must not resume" + ); + assert!( + !state.stack.iter().any(|entry| entry.id == leaving_spell), + "the departed caster's announced spell leaves the stack" + ); + assert_eq!( + state.pending_cast.as_ref().map(|pending| pending.object_id), + Some(living_spell), + "a living opponent's unrelated pending cast survives" + ); + assert!(matches!( + result.waiting_for, + WaitingFor::ReplacementChoice { + player: PlayerId(2), + .. + } + )); + let resolved = super::super::engine::apply_as_current( + &mut state, + GameAction::ChooseReplacement { index: 1 }, + ) + .expect("the living player's replacement choice must not resume the abandoned cast"); + assert!( + !resolved + .events + .iter() + .any(|event| matches!(event, GameEvent::SpellCast { source_id, .. } if *source_id == leaving_spell)), + "answering the living player's replacement choice must not cast the departed player's spell" + ); + } + #[test] fn simultaneous_elimination_clears_object_referential_replacement_for_eliminated_chooser() { // CR 800.4a + CR 616.1: 4-player FFA so two simultaneous losses leave the diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 01f19f68d3..7d58637893 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -6143,7 +6143,14 @@ fn drain_pending_deferred_life_cost_resume( } } })(); - if result.is_err() && state.pending_deferred_life_cost_resume.is_none() { + if result.is_err() + && !matches!( + &result, + Err(EngineError::InvalidAction(message)) + if message == super::casting_costs::ABANDONED_CAST_FINALIZATION_ERROR + ) + && state.pending_deferred_life_cost_resume.is_none() + { state.pending_deferred_life_cost_resume = Some(resume_for_restore); } result From 473839eba1646c915875fec80fa57d572ce3ce56 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 08:34:13 -0700 Subject: [PATCH 2/8] fix(engine): preserve cast abandonment ownership --- crates/engine/src/game/elimination.rs | 39 +++++++++++++++++---------- crates/engine/src/game/engine.rs | 4 ++- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index f2f6df0cde..fdc9353da6 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -19,21 +19,29 @@ use super::players; /// several replacement-aware cost continuations. Once its controller leaves, /// none may resume into finalization after the announcement stack entry has /// been removed. +fn is_abandoned_spell( + state: &GameState, + departing_player: PlayerId, + spell_ids: &[crate::types::identifiers::ObjectId], + pending: &PendingCast, +) -> bool { + pending.activation_ability_index.is_none() + && (spell_ids.contains(&pending.object_id) + || state + .objects + .get(&pending.object_id) + .is_some_and(|object| object.controller == departing_player)) +} + fn abandon_pending_spell_casts( state: &mut GameState, departing_player: PlayerId, spell_ids: &[crate::types::identifiers::ObjectId], ) { - let is_abandoned_spell = |pending: &PendingCast| { - pending.activation_ability_index.is_none() - && (spell_ids.contains(&pending.object_id) - || pending.ability.controller == departing_player) - }; - if state .pending_cast .as_ref() - .is_some_and(|pending| is_abandoned_spell(pending)) + .is_some_and(|pending| is_abandoned_spell(state, departing_player, spell_ids, pending)) { state.pending_cast = None; } @@ -41,7 +49,7 @@ fn abandon_pending_spell_casts( if state .waiting_for .pending_cast_ref() - .is_some_and(is_abandoned_spell) + .is_some_and(|pending| is_abandoned_spell(state, departing_player, spell_ids, pending)) { state.waiting_for = WaitingFor::Priority { player: state.active_player, @@ -53,7 +61,7 @@ fn abandon_pending_spell_casts( Some(DeferredLifeCostResume::Cast { pending: Some(pending), .. - }) if is_abandoned_spell(pending) + }) if is_abandoned_spell(state, departing_player, spell_ids, pending) ) { state.pending_deferred_life_cost_resume = None; } @@ -65,12 +73,12 @@ fn abandon_pending_spell_casts( .. } | PendingCostMoveResume::SacrificeForCost { pending, .. } => { - is_abandoned_spell(pending) + is_abandoned_spell(state, departing_player, spell_ids, pending) } PendingCostMoveResume::CollectEvidencePayment { resume, .. } => matches!( resume.as_ref(), CollectEvidenceResume::Casting { pending_cast, .. } - if is_abandoned_spell(pending_cast) + if is_abandoned_spell(state, departing_player, spell_ids, pending_cast) ), _ => false, }; @@ -82,7 +90,9 @@ fn abandon_pending_spell_casts( if state .pending_discard_for_cost .as_ref() - .is_some_and(|resume| is_abandoned_spell(&resume.pending)) + .is_some_and(|resume| { + is_abandoned_spell(state, departing_player, spell_ids, &resume.pending) + }) { state.pending_discard_for_cost = None; } @@ -1329,7 +1339,8 @@ mod tests { }; use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef}; use crate::types::mana::ManaCost; - use crate::types::proposed_event::{CounterPlacement, ProposedEvent, ReplacementEvent}; + use crate::types::proposed_event::{CounterPlacement, ProposedEvent}; + use crate::types::replacements::ReplacementEvent; fn setup_two_player() -> GameState { let mut state = GameState::new_two_player(42); @@ -2508,7 +2519,7 @@ mod tests { !resolved .events .iter() - .any(|event| matches!(event, GameEvent::SpellCast { source_id, .. } if *source_id == leaving_spell)), + .any(|event| matches!(event, GameEvent::SpellCast { object_id, .. } if *object_id == leaving_spell)), "answering the living player's replacement choice must not cast the departed player's spell" ); } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 7d58637893..dc250e283b 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16452,7 +16452,9 @@ mod stage2_injector_tests { // FIRST and both fired GREEN on the run that caught this — total still 37, // partition still 5/7/25. The change constructs no `WaitingFor` of any kind; // it threads an attachment-legality authority through an existing call. - "game/engine.rs:12003".to_string(), + // #4155 adds seven lines above this producer for abandoned-cast + // finalization, moving only this coordinate to `:12010`. + "game/engine.rs:12010".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ From 5b1317c0a229bda083225f2db875632e65111031 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 08:45:26 -0700 Subject: [PATCH 3/8] fix(engine): preserve replacement chooser during cast abandonment --- crates/engine/src/game/casting_costs.rs | 49 ++++++++++--------------- crates/engine/src/game/elimination.rs | 29 +++++++++++---- crates/engine/src/game/engine.rs | 8 ++-- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 6cd42dc149..307a024348 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -50,6 +50,17 @@ 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, @@ -60,9 +71,7 @@ fn ensure_pending_spell_announcement_is_live( .iter() .any(|entry| entry.id == pending.object_id) { - return Err(EngineError::InvalidAction( - ABANDONED_CAST_FINALIZATION_ERROR.to_string(), - )); + return Err(abandoned_cast_finalization_error()); } Ok(()) } @@ -9194,7 +9203,7 @@ fn finalize_cast_with_phyrexian_choices_inner( .stack .iter() .rposition(|entry| entry.id == object_id) - .ok_or_else(|| EngineError::InvalidAction(ABANDONED_CAST_FINALIZATION_ERROR.to_string()))?; + .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 @@ -12478,15 +12487,11 @@ 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 state - .pending_cast - .as_ref() - .is_some_and(|pending| ensure_pending_spell_announcement_is_live(state, pending).is_err()) - { - state.pending_cast = None; - return Err(EngineError::InvalidAction( - ABANDONED_CAST_FINALIZATION_ERROR.to_string(), - )); + 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(); @@ -12851,14 +12856,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 matches!( - &err, - EngineError::InvalidAction(message) if message == ABANDONED_CAST_FINALIZATION_ERROR - ) => - { - Err(err) - } + 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 @@ -13246,14 +13244,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 matches!( - &err, - EngineError::InvalidAction(message) if message == ABANDONED_CAST_FINALIZATION_ERROR - ) => - { - Err(err) - } + 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) => { diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index fdc9353da6..992629a744 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -80,7 +80,18 @@ fn abandon_pending_spell_casts( CollectEvidenceResume::Casting { pending_cast, .. } if is_abandoned_spell(state, departing_player, spell_ids, pending_cast) ), - _ => false, + PendingCostMoveResume::ActivationMillPayment { pending, .. } => { + is_abandoned_spell(state, departing_player, spell_ids, pending) + } + PendingCostMoveResume::Cast { pending: None, .. } + | PendingCostMoveResume::WardSacrificePayment { .. } + | PendingCostMoveResume::ReplacementMayCost { .. } + | PendingCostMoveResume::Foretell { .. } + | PendingCostMoveResume::DelveManaPayment { .. } + | PendingCostMoveResume::UnlessBouncePayment { .. } + | PendingCostMoveResume::ManaAbilityPayment { .. } + | PendingCostMoveResume::LoyaltyActivation { .. } + | PendingCostMoveResume::CounterAdditionUnlessPayment { .. } => false, }; if !abandons_spell { state.pending_cost_move_resume = Some(resume); @@ -730,6 +741,10 @@ fn do_eliminate( .record_player_leave(command) .expect("resolved player leave must have a live journal cause"); + // CR 800.4a + CR 616.1: Capture the parked replacement chooser before + // cast-abandonment teardown can replace its prompt with priority. + let leaving_is_latched_chooser = state.waiting_for.acting_player() == Some(player); + abandon_source_bound_resolution_prompt(state, player); retire_pending_zone_change_contexts_owned_by(state, player); abandon_change_zone_family_for_controller(state, player); @@ -898,7 +913,8 @@ fn do_eliminate( // `begin_pending_trigger_target_selection` (which gates on `pending_trigger`) // back into target selection for a dead entry id, panicking in // `mutate_pending_trigger_entry`. Clear the cursor only when the entry it - // tracks is no longer on the stack, mirroring the `pending_cast` cleanup below. + // tracks is no longer on the stack, mirroring the early + // `abandon_pending_spell_casts` teardown above. if state .pending_trigger_entry .is_some_and(|entry_id| !state.stack.iter().any(|entry| entry.id == entry_id)) @@ -928,9 +944,9 @@ fn do_eliminate( // Key off the LATCHED chooser identity, not the mutating object graph: // `waiting_for.acting_player()` is the affected player for both // `ReplacementChoice{player}` (game_state.rs) and a MayCost sub-choice re-park - // (payer == affected, replacement.rs), and `do_eliminate` never mutates - // `waiting_for` (the rewrite runs after the loop), so this key is CONSTANT - // across a simultaneous multi-elimination batch and object-graph-independent. + // (payer == affected, replacement.rs). The identity was captured before + // teardown can mutate `waiting_for`, so it remains constant across a + // simultaneous multi-elimination batch and object-graph-independent. // (`ProposedEvent::affected_player` would mis-resolve here: once a co-eliminated // lower-id loser has exiled the affected object, its effective controller is // reverted to its owner — CR 616.1's owner-fallback is pre-existing and NOT @@ -947,7 +963,6 @@ fn do_eliminate( // remaining players (not field-nulling), tracked as a separate follow-up. This // fix deliberately addresses only the CR 704.4 SBA-freeze introduced by the // `pending_replacement` guard. - let leaving_is_latched_chooser = state.waiting_for.acting_player() == Some(player); // CR 800.4a: Tear down choices and continuations owned by the leaving player. // CR 616.1: SearchFound owns an outer per-card batch, and a replacement- // selected zone move may own a nested batch completion. The @@ -981,7 +996,7 @@ fn do_eliminate( // `pending_replacement` (nested `ContinueZoneDeliveryTail` early-return, // engine_replacement.rs), so it is torn down under its OWN controller-keyed // guard — cleared only for the LEAVING player's own resolution (mirroring the - // `pending_cast` controller key above) so a living player's paused resolution + // cast-abandonment controller key above) so a living player's paused resolution // survives an opponent's departure. if state .active_spell_resolution() diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index dc250e283b..dd1d4f538e 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -6144,11 +6144,9 @@ fn drain_pending_deferred_life_cost_resume( } })(); if result.is_err() - && !matches!( - &result, - Err(EngineError::InvalidAction(message)) - if message == super::casting_costs::ABANDONED_CAST_FINALIZATION_ERROR - ) + && !result + .as_ref() + .is_err_and(super::casting_costs::is_abandoned_cast_finalization) && state.pending_deferred_life_cost_resume.is_none() { state.pending_deferred_life_cost_resume = Some(resume_for_restore); From 5638a1310963a3a80d9afb5c9159016b539bbcff Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 08:52:57 -0700 Subject: [PATCH 4/8] fix(engine): propagate abandoned cast finalization errors --- crates/engine/src/game/casting_costs.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 307a024348..9c98db6e03 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -12540,9 +12540,7 @@ fn finalize_mana_payment_with_resume( .pending_cast .take() .ok_or_else(|| EngineError::InvalidAction("No pending cast to finalize".to_string()))?; - if let Err(err) = ensure_pending_spell_announcement_is_live(state, &pending) { - return Err(err); - } + 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(); @@ -12916,9 +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()))?; - if let Err(err) = ensure_pending_spell_announcement_is_live(state, &pending) { - return Err(err); - } + 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 { From 428b60fcf92df209266b8f0d7b32ed1a8e615d22 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 09:08:00 -0700 Subject: [PATCH 5/8] test(engine): keep cast-abandonment regression valid --- crates/engine/src/game/elimination.rs | 9 +-------- crates/engine/src/game/engine.rs | 5 +++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 992629a744..6a1606473c 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -2424,7 +2424,7 @@ mod tests { } #[test] - fn elimination_abandons_deferred_life_cast_without_touching_living_cast() { + fn elimination_abandons_deferred_life_cast_and_preserves_living_replacement() { // CR 104.3a + CR 800.4a: a player may concede during a paused life-cost // continuation. The announcement leaves the stack at departure, so the // deferred cast must be retired rather than resumed into finalization. @@ -2495,8 +2495,6 @@ mod tests { remaining_life_payments: vec![], resume_at_resolution_depth: 0, }); - let living_spell = stash_pending_cast(&mut state, PlayerId(2)); - let result = super::super::engine::apply_as_current( &mut state, GameAction::Concede { @@ -2513,11 +2511,6 @@ mod tests { !state.stack.iter().any(|entry| entry.id == leaving_spell), "the departed caster's announced spell leaves the stack" ); - assert_eq!( - state.pending_cast.as_ref().map(|pending| pending.object_id), - Some(living_spell), - "a living opponent's unrelated pending cast survives" - ); assert!(matches!( result.waiting_for, WaitingFor::ReplacementChoice { diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index dd1d4f538e..765dcc7a9d 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16451,8 +16451,9 @@ mod stage2_injector_tests { // partition still 5/7/25. The change constructs no `WaitingFor` of any kind; // it threads an attachment-legality authority through an existing call. // #4155 adds seven lines above this producer for abandoned-cast - // finalization, moving only this coordinate to `:12010`. - "game/engine.rs:12010".to_string(), + // finalization, while its deferred-resume cleanup removes two; + // the net +5 moves this coordinate to `:12008`. + "game/engine.rs:12008".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ From 90ed63382abb43bde84332cc12a866ab74ffc9c4 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 09:26:56 -0700 Subject: [PATCH 6/8] test(engine): construct living replacement continuation --- crates/engine/src/game/elimination.rs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 6a1606473c..690e364f17 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1341,7 +1341,7 @@ mod tests { use crate::game::zones::create_object; use crate::types::ability::{ Effect, EffectKind, PostReplacementContinuation, ReplacementDefinition, ReplacementMode, - ResolvedAbility, TargetRef, + ResolvedAbility, TargetFilter, TargetRef, }; use crate::types::actions::GameAction; use crate::types::counter::CounterType; @@ -2429,29 +2429,23 @@ mod tests { // continuation. The announcement leaves the stack at departure, so the // deferred cast must be retired rather than resumed into finalization. let mut state = setup_three_player(); - let replacement_source = create_object( + let discarded = create_object( &mut state, - CardId(100), + CardId(101), PlayerId(2), - "Living replacement controller".to_string(), - Zone::Battlefield, + "Replacement-prompt discard".to_string(), + Zone::Hand, ); state .objects - .get_mut(&replacement_source) - .expect("replacement source exists") + .get_mut(&discarded) + .expect("discarded card exists") .replacement_definitions .push( ReplacementDefinition::new(ReplacementEvent::Discard) + .valid_card(TargetFilter::SelfRef) .mode(ReplacementMode::Optional { decline: None }), ); - let discarded = create_object( - &mut state, - CardId(101), - PlayerId(2), - "Replacement-prompt discard".to_string(), - Zone::Hand, - ); let mut setup_events = Vec::new(); assert!(matches!( crate::game::replacement::replace_event( From bbd61ae3b09948ef73a061278e17a7eab5ee8625 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 09:28:18 -0700 Subject: [PATCH 7/8] test(engine): park living replacement prompt --- crates/engine/src/game/elimination.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index 690e364f17..c3b68455f8 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -2462,6 +2462,7 @@ mod tests { ), crate::game::replacement::ReplacementResult::NeedsChoice(PlayerId(2)) )); + crate::game::replacement::park_waiting_for(&mut state, PlayerId(2)); assert!(matches!( state.waiting_for, WaitingFor::ReplacementChoice { From 0a29a96cd799fb1ff6a0454da2ceb2a22ffe66bb Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 09:38:21 -0700 Subject: [PATCH 8/8] test(engine): avoid duplicate replacement filter import --- crates/engine/src/game/elimination.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/elimination.rs b/crates/engine/src/game/elimination.rs index c3b68455f8..7adc2a83e7 100644 --- a/crates/engine/src/game/elimination.rs +++ b/crates/engine/src/game/elimination.rs @@ -1341,7 +1341,7 @@ mod tests { use crate::game::zones::create_object; use crate::types::ability::{ Effect, EffectKind, PostReplacementContinuation, ReplacementDefinition, ReplacementMode, - ResolvedAbility, TargetFilter, TargetRef, + ResolvedAbility, TargetRef, }; use crate::types::actions::GameAction; use crate::types::counter::CounterType; @@ -2443,7 +2443,7 @@ mod tests { .replacement_definitions .push( ReplacementDefinition::new(ReplacementEvent::Discard) - .valid_card(TargetFilter::SelfRef) + .valid_card(crate::types::ability::TargetFilter::SelfRef) .mode(ReplacementMode::Optional { decline: None }), ); let mut setup_events = Vec::new();