diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index fcfe846c90..8c9a9f056a 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -49,6 +49,16 @@ use super::life_costs::PayLifeCostResult; const TERMINAL_CAST_CANCELLATION_ERROR: &str = "__terminal_cast_cancellation__"; +/// The mana payment authority stamps this on the spell object before casting +/// finalization publishes the spell-cast event. +fn recorded_mana_spent_to_cast(state: &GameState, object_id: ObjectId) -> u32 { + state + .objects + .get(&object_id) + .expect("spell object must exist while its cast is being finalized") + .mana_spent_to_cast_amount +} + fn stamp_controller_controlled_as_cast( state: &GameState, ability: &mut ResolvedAbility, @@ -1975,7 +1985,6 @@ fn finish_cost_object_moves( phyrexian_choices, cascade_cast_transformed, resolution_success_waiting_for, - pool_before, prepaid_actual_mana_spent, } => { let returned_creature = chosen @@ -1988,14 +1997,8 @@ fn finish_cost_object_moves( .retain(|attacker| attacker.object_id != returned_creature); combat.blocker_assignments.remove(&returned_creature); } - let pool_after = state - .players - .iter() - .find(|candidate| candidate.id == player) - .map(|candidate| candidate.mana_pool.produced_mana_total()) - .unwrap_or(0); let actual_mana_spent = prepaid_actual_mana_spent - .unwrap_or_else(|| pool_before.saturating_sub(pool_after) as u32); + .unwrap_or_else(|| recorded_mana_spent_to_cast(state, pending.object_id)); let deferred_life_resume_pending = pending.clone(); finalize_cast_with_phyrexian_choices_inner( state, @@ -9115,13 +9118,6 @@ fn finalize_cast_with_phyrexian_choices_inner( return Ok(waiting_for); } - // CR 700.14: Snapshot pool size before payment to compute actual mana spent. - let pool_before = state - .players - .iter() - .find(|p| p.id == player) - .map(|p| p.mana_pool.produced_mana_total()) - .unwrap_or(0); let cast_transformed = cascade_cast_transformed || super::casting::selected_exile_alt_cost_permission_casts_transformed( state, @@ -9163,14 +9159,8 @@ fn finalize_cast_with_phyrexian_choices_inner( ) })?; pending.cost = ManaCost::NoCost; - let pool_after = state - .players - .iter() - .find(|candidate| candidate.id == player) - .map(|candidate| candidate.mana_pool.produced_mana_total()) - .unwrap_or(0); pending.prepaid_actual_mana_spent = - Some(pool_before.saturating_sub(pool_after) as u32); + Some(recorded_mana_spent_to_cast(state, object_id)); state.pending_deferred_life_cost_resume = Some(crate::types::game_state::DeferredLifeCostResume::Cast { player, @@ -9212,7 +9202,6 @@ fn finalize_cast_with_phyrexian_choices_inner( phyrexian_choices: phyrexian_choices.map(|choices| choices.to_vec()), cascade_cast_transformed, resolution_success_waiting_for: resolution_success_waiting_for.map(Box::new), - pool_before, prepaid_actual_mana_spent, }, cost_event_start, @@ -9221,15 +9210,10 @@ fn finalize_cast_with_phyrexian_choices_inner( ); } - // CR 700.14: Compute actual mana deducted from pool (not declared cost). - let pool_after = state - .players - .iter() - .find(|p| p.id == player) - .map(|p| p.mana_pool.produced_mana_total()) - .unwrap_or(0); + // CR 700.14: Use payment's recorded amount; auto-tapped mana can be + // produced and spent between pool snapshots. let actual_mana_spent = - prepaid_actual_mana_spent.unwrap_or_else(|| pool_before.saturating_sub(pool_after) as u32); + prepaid_actual_mana_spent.unwrap_or_else(|| recorded_mana_spent_to_cast(state, object_id)); // CR 603.4 + CR 903.8: `origin_zone` preserves the pre-announcement zone so // that "cast from hand/graveyard/exile" conditions evaluate correctly and @@ -12620,15 +12604,9 @@ fn finalize_mana_payment_with_resume( Some(&mana_resume), events, )? { - let pool_after = state - .players - .iter() - .find(|candidate| candidate.id == player) - .map(|candidate| candidate.mana_pool.total()) - .unwrap_or(0); pending.cost = ManaCost::NoCost; pending.prepaid_actual_mana_spent = - Some(pool_before.saturating_sub(pool_after) as u32); + Some(recorded_mana_spent_to_cast(state, pending.object_id)); state.pending_deferred_life_cost_resume = Some(crate::types::game_state::DeferredLifeCostResume::Cast { player, @@ -13011,15 +12989,9 @@ pub fn finalize_mana_payment_with_phyrexian_choices( Some(&mana_resume), events, )? { - let pool_after = state - .players - .iter() - .find(|candidate| candidate.id == player) - .map(|candidate| candidate.mana_pool.total()) - .unwrap_or(0); pending.cost = ManaCost::NoCost; pending.prepaid_actual_mana_spent = - Some(pool_before.saturating_sub(pool_after) as u32); + Some(recorded_mana_spent_to_cast(state, pending.object_id)); state.pending_deferred_life_cost_resume = Some(crate::types::game_state::DeferredLifeCostResume::Cast { player, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 91a1bc4cf8..104bc73fbb 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -1184,14 +1184,12 @@ fn drain_pending_change_zone_iteration(state: &mut GameState, events: &mut Vec ZoneMoveCompletion { - if delivery_events.iter().any(|event| { - matches!( - event, - GameEvent::ZoneChanged { record, .. } - if record - .trigger_source_context() - .is_some_and(|context| context.identity.reference == member) - ) - }) { - ZoneMoveCompletion::Moved - } else { - ZoneMoveCompletion::Remained - } + PendingZoneChangeDelivery::completion_from_delivery_events(member, delivery_events) } pub(crate) enum ZoneDeliveryResult { diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 6b1ee0e89e..85723935a7 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -3417,6 +3417,42 @@ impl PendingZoneChangeDelivery { Ok(()) } + /// Returns the terminal completion recorded by the delivery pipeline, or + /// derives it from this paused delivery's exact event slice once its + /// resolution has returned to the owning resume driver. + /// + /// A returned prompt is terminal for this delivery. If an older pause path + /// omitted the sidecar classification, its own retained events remain the + /// authoritative witness; do not inspect live object state or global event + /// history, either of which could observe a later incarnation. + pub fn terminal_completion_after_resume(&self) -> ZoneMoveCompletion { + self.terminal_completion.unwrap_or_else(|| { + Self::completion_from_delivery_events(self.member, &self.delivery_events) + }) + } + + /// Classifies one explicit delivery slice against its pre-delivery + /// incarnation. Callers use this when a completed move does not have a + /// separately captured terminal sidecar. + pub fn completion_from_delivery_events( + member: ObjectIncarnationRef, + delivery_events: &[GameEvent], + ) -> ZoneMoveCompletion { + if delivery_events.iter().any(|event| { + matches!( + event, + GameEvent::ZoneChanged { record, .. } + if record + .trigger_source_context() + .is_some_and(|context| context.identity.reference == member) + ) + }) { + ZoneMoveCompletion::Moved + } else { + ZoneMoveCompletion::Remained + } + } + pub fn mark_counted(&mut self) { self.count = PausedZoneChangeDeliveryCount::AlreadyCounted; } @@ -5920,7 +5956,6 @@ pub enum PendingCostMoveCompletion { phyrexian_choices: Option>, cascade_cast_transformed: bool, resolution_success_waiting_for: Option>, - pool_before: usize, prepaid_actual_mana_spent: Option, }, } @@ -22526,6 +22561,35 @@ mod tests { } } + #[test] + fn paused_zone_change_delivery_derives_a_terminal_outcome_after_resume() { + let member = ObjectIncarnationRef::of(ObjectId(71), 4); + let mut delivery = PendingZoneChangeDelivery::new( + member, + ProposedEvent::zone_change( + member.object_id, + Zone::Battlefield, + Zone::Graveyard, + Some(ObjectId(72)), + ), + ); + + assert_eq!( + delivery.terminal_completion_after_resume(), + ZoneMoveCompletion::Remained, + "an answered pause with no original-incarnation event completed without moving" + ); + + delivery + .record_terminal_completion(ZoneMoveCompletion::Prevented) + .expect("the explicit replacement outcome is recorded once"); + assert_eq!( + delivery.terminal_completion_after_resume(), + ZoneMoveCompletion::Prevented, + "an explicit replacement outcome remains authoritative over slice inference" + ); + } + #[test] fn persisted_batched_zone_change_pairs_migrate_in_raw_and_trusted_envelopes() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/tests/integration/issue_7232_expend_auto_land_payment.rs b/crates/engine/tests/integration/issue_7232_expend_auto_land_payment.rs new file mode 100644 index 0000000000..40949a2aef --- /dev/null +++ b/crates/engine/tests/integration/issue_7232_expend_auto_land_payment.rs @@ -0,0 +1,50 @@ +//! Issue #7232: auto-tapping lands must count toward Expend. +//! +//! Bakersbane Duo's Oracle text (Scryfall): +//! "Whenever you expend 4, this creature gets +1/+1 until end of turn." + +use engine::game::layers::evaluate_layers; +use engine::game::scenario::{GameScenario, P0}; +use engine::types::mana::{ManaColor, ManaCost}; +use engine::types::phase::Phase; + +const BAKERSBANE_DUO_ORACLE: &str = "When this creature enters, create a Food token.\n\ +Whenever you expend 4, this creature gets +1/+1 until end of turn. (You expend 4 as you \ +spend your fourth total mana to cast spells during a turn.)"; + +#[test] +fn auto_tapped_lands_count_toward_expend() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let duo = scenario + .add_creature_from_oracle(P0, "Bakersbane Duo", 2, 2, BAKERSBANE_DUO_ORACLE) + .id(); + for _ in 0..4 { + scenario.add_basic_land(P0, ManaColor::Green); + } + let spell = scenario + .add_creature_to_hand(P0, "Four-Mana Test Creature", 2, 2) + .with_mana_cost(ManaCost::generic(4)) + .id(); + let mut runner = scenario.build(); + + runner.cast(spell).commit(); + + assert_eq!( + runner.state().mana_spent_on_spells_this_turn.get(&P0), + Some(&4), + "auto-tapped lands must contribute their four mana to Expend" + ); + + runner.advance_until_stack_empty(); + runner.state_mut().layers_dirty.mark_full(); + evaluate_layers(runner.state_mut()); + assert_eq!( + ( + runner.state().objects[&duo].power, + runner.state().objects[&duo].toughness, + ), + (Some(3), Some(3)), + "crossing Expend 4 must resolve Bakersbane Duo's +1/+1 trigger" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 951a41254b..9ed4634ee4 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -691,6 +691,7 @@ mod issue_7063_library_reorder; mod issue_7087_recruit_discard_provenance; mod issue_709_regression; mod issue_718_dina_sacrifice_draw; +mod issue_7232_expend_auto_land_payment; mod issue_735_amalia_power_threshold; mod issue_735_cost_paid_object_non_regression; mod issue_735_lily_bowen_power_double;