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
71 changes: 52 additions & 19 deletions crates/engine/src/game/casting_costs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use crate::types::keywords::{GiftKind, Keyword};
use crate::types::mana::{ManaCost, ManaCostShard, ManaType, PaymentContext};
use crate::types::player::PlayerId;
use crate::types::replacements::ReplacementEvent;
use crate::types::resolved_commands::ResolvedStackEntryFinalizeCommand;
use crate::types::statics::{CostModifyMode, StaticMode, StaticModeKind};
use crate::types::zones::{ExileCostSourceZone, Zone};

Expand Down Expand Up @@ -8712,37 +8713,69 @@ fn finalize_cast_with_phyrexian_choices_inner(
// 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.
let entry = state
//
// 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_mut()
.rfind(|entry| entry.id == object_id)
.iter()
.rposition(|entry| entry.id == object_id)
.expect("spell stack entry from announcement still present at finalize");
entry.kind = StackEntryKind::Spell {
let resulting_kind = StackEntryKind::Spell {
card_id,
ability: stack_ability,
casting_variant,
actual_mana_spent,
};
// Read-then-assign rather than `mem::replace`: this keeps the retag as the
// same plain `entry.kind = ..` write the CR733 mutation census already
// classifies as one site, instead of a `&mut` borrow the census counts
// twice. The clone is cheap next to a correct write-site inventory.
let entry = state
.stack
.get_mut(entry_position)
.expect("rposition yielded a live stack index");
let expected_old_kind = entry.kind.clone();
entry.kind = resulting_kind.clone();
let distinct_colors_spent = state
.objects
.get(&object_id)
.map(|obj| obj.colors_spent_to_cast.distinct_colors() as u32)
.unwrap_or_default();
state.stack_paid_facts.insert(
object_id,
StackPaidSnapshot {
actual_mana_spent,
x_value: cost_x_paid,
distinct_colors_spent,
kickers_paid: kickers_paid.len(),
additional_cost_payment_count,
additional_cost_payments: additional_cost_payments.clone(),
additional_cost_paid,
casting_variant,
cast_transformed,
convoked_creatures: convoked_creature_count,
},
);
let resulting_paid_facts = StackPaidSnapshot {
actual_mana_spent,
x_value: cost_x_paid,
distinct_colors_spent,
kickers_paid: kickers_paid.len(),
additional_cost_payment_count,
additional_cost_payments: additional_cost_payments.clone(),
additional_cost_paid,
casting_variant,
cast_transformed,
convoked_creatures: convoked_creature_count,
};
let expected_old_paid_facts = state
.stack_paid_facts
.insert(object_id, resulting_paid_facts.clone());

// CR 733: journal the settled finalization once BOTH mutations are written,
// so the record carries the values that were installed rather than the
// inputs they were derived from.
let cause = state.current_or_begin_rules_execution_node();
state
.resolved_rules_journal
.record_stack_entry_finalize(ResolvedStackEntryFinalizeCommand {
object: object_id,
entry_position,
expected_old_kind: Box::new(expected_old_kind),
resulting_kind: Box::new(resulting_kind),
expected_old_paid_facts: expected_old_paid_facts.map(Box::new),
resulting_paid_facts: Box::new(resulting_paid_facts),
cause,
})
.expect("resolved stack entry finalize must have a live journal cause");

// Track commander cast count for tax calculation
if was_in_command_zone {
Expand Down
62 changes: 62 additions & 0 deletions crates/engine/src/game/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::types::game_state::{
use crate::types::identifiers::ObjectId;
use crate::types::player::PlayerId;
use crate::types::resolved_commands::{
ResolvedStackEntryFinalizeCommand, ResolvedStackEntryFinalizeReplayInvariantError,
ResolvedStackPushCommand, ResolvedStackPushOrigin, ResolvedStackPushReplayInvariantError,
};
use crate::types::zones::Zone;
Expand Down Expand Up @@ -205,6 +206,67 @@ pub fn apply_resolved_stack_push(
Ok(())
}

/// Installs one already-resolved CR 601.2i cast finalization verbatim.
///
/// CR 601.2i: the recorded finalized entry and paid-facts snapshot are written
/// back exactly as they were recorded. Nothing is re-derived — in particular the
/// entry is located by its recorded CR 405.2 position rather than by repeating
/// the authority's last-match scan, and `distinct_colors_spent` is taken from
/// the snapshot rather than re-read from the object's `colors_spent_to_cast`,
/// which a replayed predecessor need not still agree with.
///
/// Fails closed on any disagreement with the predecessor state: a position past
/// the stack depth, a different entry at that position, a pre-finalize entry
/// that is not the one recorded, or different pre-existing paid facts.
pub fn apply_resolved_stack_entry_finalize(
state: &mut GameState,
command: &ResolvedStackEntryFinalizeCommand,
) -> Result<(), ResolvedStackEntryFinalizeReplayInvariantError> {
let depth = state.stack.len();
let entry = state.stack.get(command.entry_position).ok_or(
ResolvedStackEntryFinalizeReplayInvariantError::PositionOutOfRange {
position: command.entry_position,
depth,
},
)?;
if entry.id != command.object {
return Err(
ResolvedStackEntryFinalizeReplayInvariantError::EntryIdentityMismatch {
position: command.entry_position,
expected: command.object,
found: entry.id,
},
);
}
if entry.kind != *command.expected_old_kind {
return Err(
ResolvedStackEntryFinalizeReplayInvariantError::EntryKindMismatch(
command.entry_position,
),
);
}
// CR 601.2i settles the entry retag and the paid-facts snapshot together, so
// the snapshot precondition is checked BEFORE either is installed — a replay
// must not leave a finalized entry behind when the snapshot side is the half
// that disagrees.
if state.stack_paid_facts.get(&command.object) != command.expected_old_paid_facts.as_deref() {
return Err(
ResolvedStackEntryFinalizeReplayInvariantError::PaidFactsMismatch(command.object),
);
}

state
.stack
.get_mut(command.entry_position)
.expect("the entry was just read at this position")
.kind = command.resulting_kind.as_ref().clone();
state.stack_paid_facts.insert(
command.object,
command.resulting_paid_facts.as_ref().clone(),
);
Ok(())
}

/// The ability currently represented by a stack entry for presentation.
///
/// A spell is placed on the stack before its cast is finalized (CR 601.2a-b),
Expand Down
5 changes: 3 additions & 2 deletions crates/engine/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ pub use resolved_commands::{
ResolvedObjectStatusReplayInvariantError, ResolvedOncePerTurnPermission, ResolvedPlayerEdit,
ResolvedPlayerEditCommand, ResolvedPlayerEditReplayInvariantError,
ResolvedRngReplayInvariantError, ResolvedRulesCommand, ResolvedRulesJournal,
ResolvedRulesJournalError, ResolvedStackPushCommand, ResolvedStackPushOrigin,
ResolvedStackPushReplayInvariantError, ResolvedTriggerCollection,
ResolvedRulesJournalError, ResolvedStackEntryFinalizeCommand,
ResolvedStackEntryFinalizeReplayInvariantError, ResolvedStackPushCommand,
ResolvedStackPushOrigin, ResolvedStackPushReplayInvariantError, ResolvedTriggerCollection,
ResolvedTriggerCollectionCommand, ResolvedTriggerCollectionReplayInvariantError,
ResolvedTriggerLedgerEdit, RulesExecutionNodeKind, RulesExecutionNodeRef, SettlementNode,
SettlementNodeOrdinal, SpentManaUnit,
Expand Down
102 changes: 100 additions & 2 deletions crates/engine/src/types/resolved_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ use super::card::TokenImageRef;
use super::card_type::CoreType;
use super::counter::CounterType;
use super::game_state::{
DelayedTrigger, SpellCastRecord, StackEntry, TransientContinuousEffect, ZoneChangeRecord,
DelayedTrigger, SpellCastRecord, StackEntry, StackEntryKind, StackPaidSnapshot,
TransientContinuousEffect, ZoneChangeRecord,
};
use super::identifiers::{ObjectId, ObjectIncarnationRef, LEGACY_INCARNATION};
use super::mana::{ManaPipId, ManaUnit};
Expand Down Expand Up @@ -1023,6 +1024,74 @@ pub enum ResolvedStackPushReplayInvariantError {
UnknownController(PlayerId),
}

/// One exact CR 601.2i cast finalization, retagging an announced stack entry.
///
/// CR 601.2a puts the spell on the stack at announcement, but the entry that
/// lands there is a stub: `ability: None` and `actual_mana_spent: 0`, because
/// neither is known until costs are chosen and paid. CR 601.2i is where "the
/// spell becomes cast" — the point the finalized ability and the mana actually
/// spent are written back onto that same entry, together with the paid-facts
/// snapshot the rest of the engine reads for X, kicker, and convoke questions.
///
/// The two mutations are ONE command rather than two families because they
/// settle together: nothing observes the retagged entry without also observing
/// the snapshot, and a replay that installed one without the other would leave a
/// finalized spell whose paid facts are missing (or vice versa).
///
/// `entry_position` is recorded rather than re-found. The authority locates its
/// entry with `rfind`, a LAST-match scan, so a replay that re-derived the target
/// could retag a different entry than the original execution did — the same
/// hazard `ResolvedZoneChangeCommand::turn_zone_change_index` and the
/// battlefield-entry retags already record positions to avoid.
///
/// `expected_old_paid_facts` is an `Option` rather than an absence assertion.
/// The authority is re-entered from the top by its resume callers (Phyrexian
/// shard choices, paused mana abilities, prepaid casts), so "no snapshot is
/// present yet" is not a property this record can assert without proving no
/// resume path re-reaches the insert. Recording the prior value instead makes
/// the precondition exact under either reading and fails closed on a replay
/// whose predecessor disagrees.
///
/// SCOPE: the CR 601.2i retag of an already-announced entry. The CR 601.2a push
/// that created the entry is a separate family and a separate seam — it does not
/// move atomically with this retag, which is precisely why the announcement
/// snapshot and the finalized entry are two records rather than one.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedStackEntryFinalizeCommand {
/// The stack entry's own id. This family keys on the ENTRY rather than on
/// an `ObjectIncarnationRef` because the retag targets a stack entry, not an
/// object record — the same reason `ResolvedStackPushCommand` identifies its
/// subject by `entry.id`.
pub object: ObjectId,
/// Zero-based index of the retagged entry (CR 405.2), recorded so replay
/// never repeats the authority's `rfind`.
pub entry_position: usize,
/// Boxed because `StackEntryKind` embeds a whole `ResolvedAbility`, which
/// would otherwise widen every `ResolvedRulesCommand` in the journal.
pub expected_old_kind: Box<StackEntryKind>,
pub resulting_kind: Box<StackEntryKind>,
pub expected_old_paid_facts: Option<Box<StackPaidSnapshot>>,
pub resulting_paid_facts: Box<StackPaidSnapshot>,
pub cause: RulesExecutionNodeRef,
}

/// Typed failure while applying one already-resolved CR 601.2i finalization.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ResolvedStackEntryFinalizeReplayInvariantError {
#[error("stack-entry finalize targets position {position}, but the stack holds {depth}")]
PositionOutOfRange { position: usize, depth: usize },
#[error("stack-entry finalize targets {expected:?} at position {position}, found {found:?}")]
EntryIdentityMismatch {
position: usize,
expected: ObjectId,
found: ObjectId,
},
#[error("stack-entry finalize expected a different pre-finalize entry at position {0}")]
EntryKindMismatch(usize),
#[error("stack-entry finalize expected different pre-existing paid facts for {0:?}")]
PaidFactsMismatch(ObjectId),
}

/// Semantic command payload currently carried by a resolved-rules journal entry.
///
/// Additional command families are intentionally added by their owning P2
Expand Down Expand Up @@ -1051,6 +1120,7 @@ pub enum ResolvedRulesCommand {
FrameTransition(Box<ResolvedFrameTransitionCommand>),
TriggerCollection(ResolvedTriggerCollectionCommand),
StackPush(Box<ResolvedStackPushCommand>),
StackEntryFinalize(Box<ResolvedStackEntryFinalizeCommand>),
}

/// An append-only trigger collection command has no replay-time precondition.
Expand Down Expand Up @@ -2106,6 +2176,17 @@ impl ResolvedRulesJournal {
)
}

/// Records one exact CR 601.2i cast finalization under its causal node.
pub fn record_stack_entry_finalize(
&mut self,
command: ResolvedStackEntryFinalizeCommand,
) -> Result<ResolvedCommandOrdinal, ResolvedRulesJournalError> {
self.append_command(
command.cause,
ResolvedRulesCommand::StackEntryFinalize(Box::new(command)),
)
}

fn begin_settlement(
&mut self,
identity_for: impl FnOnce(SettlementNodeOrdinal) -> RulesExecutionNodeRef,
Expand Down Expand Up @@ -2320,7 +2401,8 @@ impl ResolvedRulesJournal {
| ResolvedRulesCommand::ZoneChange(_)
| ResolvedRulesCommand::FrameTransition(_)
| ResolvedRulesCommand::TriggerCollection(_)
| ResolvedRulesCommand::StackPush(_) => {}
| ResolvedRulesCommand::StackPush(_)
| ResolvedRulesCommand::StackEntryFinalize(_) => {}
}
}
for node in &self.nodes {
Expand Down Expand Up @@ -2747,6 +2829,22 @@ impl ResolvedRulesJournal {
));
}
}
ResolvedRulesCommand::StackEntryFinalize(command) => {
// Cause-only. There is no allocator receipt to cross-check:
// CR 601.2i retags an entry that CR 601.2a already created, so
// this authority draws no id and no timestamp and holds no
// high-water a forged journal could jump. Its remaining
// preconditions (CR 405.2 position, entry identity, the
// pre-finalize kind, and the prior paid facts) are all
// state-dependent and are enforced by
// `stack::apply_resolved_stack_entry_finalize`, where
// the state exists to check them against.
if entry.node != command.cause {
return Err(ResolvedRulesJournalError::InvalidSerializedAuthority(
"stack-entry finalize command has an unrelated cause".to_string(),
));
}
}
}
Ok(())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ fn apply_semantic_command(state: &mut GameState, command: &ResolvedRulesCommand)
ResolvedRulesCommand::StackPush(command) => {
engine::game::stack::apply_resolved_stack_push(state, command.as_ref()).unwrap();
}
ResolvedRulesCommand::StackEntryFinalize(command) => {
engine::game::stack::apply_resolved_stack_entry_finalize(state, command.as_ref())
.unwrap();
}
}
}

Expand Down Expand Up @@ -234,7 +238,10 @@ fn exact_mana_spend_rejects_a_second_removal() {
| ResolvedRulesCommand::Information(_)
| ResolvedRulesCommand::FrameTransition(_)
| ResolvedRulesCommand::TriggerCollection(_)
| ResolvedRulesCommand::StackPush(_) => apply_semantic_command(&mut replay, command),
| ResolvedRulesCommand::StackPush(_)
| ResolvedRulesCommand::StackEntryFinalize(_) => {
apply_semantic_command(&mut replay, command)
}
}
}
assert!(
Expand Down
4 changes: 4 additions & 0 deletions crates/engine/tests/integration/cr733_resolved_draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ fn apply_semantic_command(state: &mut GameState, command: &ResolvedRulesCommand)
ResolvedRulesCommand::StackPush(command) => {
engine::game::stack::apply_resolved_stack_push(state, command.as_ref()).unwrap();
}
ResolvedRulesCommand::StackEntryFinalize(command) => {
engine::game::stack::apply_resolved_stack_entry_finalize(state, command.as_ref())
.unwrap();
}
}
}

Expand Down
Loading
Loading