diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 2372a5fb74..b2164c23a0 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -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}; @@ -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 { diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 8573d3ef3d..0c9e80044c 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -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; @@ -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), diff --git a/crates/engine/src/types/mod.rs b/crates/engine/src/types/mod.rs index 772d32e833..b5b65c7de1 100644 --- a/crates/engine/src/types/mod.rs +++ b/crates/engine/src/types/mod.rs @@ -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, diff --git a/crates/engine/src/types/resolved_commands.rs b/crates/engine/src/types/resolved_commands.rs index 1f2e4e8db7..d00d27425a 100644 --- a/crates/engine/src/types/resolved_commands.rs +++ b/crates/engine/src/types/resolved_commands.rs @@ -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}; @@ -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, + pub resulting_kind: Box, + pub expected_old_paid_facts: Option>, + pub resulting_paid_facts: Box, + 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 @@ -1051,6 +1120,7 @@ pub enum ResolvedRulesCommand { FrameTransition(Box), TriggerCollection(ResolvedTriggerCollectionCommand), StackPush(Box), + StackEntryFinalize(Box), } /// An append-only trigger collection command has no replay-time precondition. @@ -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 { + self.append_command( + command.cause, + ResolvedRulesCommand::StackEntryFinalize(Box::new(command)), + ) + } + fn begin_settlement( &mut self, identity_for: impl FnOnce(SettlementNodeOrdinal) -> RulesExecutionNodeRef, @@ -2320,7 +2401,8 @@ impl ResolvedRulesJournal { | ResolvedRulesCommand::ZoneChange(_) | ResolvedRulesCommand::FrameTransition(_) | ResolvedRulesCommand::TriggerCollection(_) - | ResolvedRulesCommand::StackPush(_) => {} + | ResolvedRulesCommand::StackPush(_) + | ResolvedRulesCommand::StackEntryFinalize(_) => {} } } for node in &self.nodes { @@ -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(()) } diff --git a/crates/engine/tests/integration/cr733_resolved_commands_p2.rs b/crates/engine/tests/integration/cr733_resolved_commands_p2.rs index f9bdbc07e6..9b3b838f9b 100644 --- a/crates/engine/tests/integration/cr733_resolved_commands_p2.rs +++ b/crates/engine/tests/integration/cr733_resolved_commands_p2.rs @@ -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(); + } } } @@ -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!( diff --git a/crates/engine/tests/integration/cr733_resolved_draw.rs b/crates/engine/tests/integration/cr733_resolved_draw.rs index a4db9cf56e..85fc0c3bb8 100644 --- a/crates/engine/tests/integration/cr733_resolved_draw.rs +++ b/crates/engine/tests/integration/cr733_resolved_draw.rs @@ -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(); + } } } diff --git a/crates/engine/tests/integration/cr733_resolved_stack_entry_finalize.rs b/crates/engine/tests/integration/cr733_resolved_stack_entry_finalize.rs new file mode 100644 index 0000000000..9f2e760f8f --- /dev/null +++ b/crates/engine/tests/integration/cr733_resolved_stack_entry_finalize.rs @@ -0,0 +1,344 @@ +//! CR733 P2 coverage for the CR 601.2i cast finalization. +//! +//! CR 601.2a puts a spell on the stack as a STUB: `announce_spell_on_stack` +//! pushes `StackEntryKind::Spell { ability: None, actual_mana_spent: 0, .. }` +//! because neither is known until costs are chosen and paid. CR 601.2i is where +//! "the spell becomes cast" and both are written back onto that same entry, +//! together with the `stack_paid_facts` snapshot the rest of the engine reads +//! for X, kicker, convoke, and colors-spent questions. +//! +//! Both mutations are one command because they settle together. A replay that +//! installed the retagged entry without the snapshot would leave a finalized +//! spell whose paid facts are missing, which is why the applier checks the +//! snapshot precondition BEFORE installing either half. +//! +//! The fixture is a sorcery WITH a spell ability, deliberately. A vanilla +//! permanent spell has `ability: None` on both sides of the retag (see the +//! `prepared.ability_def.is_none()` branch in `casting.rs`), so it can only +//! move `actual_mana_spent` and cannot tell "the ability was written back" from +//! "the ability was never written". Do not replace it with a creature. + +use engine::game::scenario::{GameScenario, P0}; +use engine::game::stack::apply_resolved_stack_entry_finalize; +use engine::types::actions::GameAction; +use engine::types::game_state::{CastPaymentMode, GameState, StackEntryKind}; +use engine::types::mana::{ManaCost, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::resolved_commands::{ + ResolvedRulesCommand, ResolvedStackEntryFinalizeCommand, + ResolvedStackEntryFinalizeReplayInvariantError, +}; + +const DRAW_ORACLE: &str = "Draw a card."; + +/// Casts a 2-generic sorcery and leaves it on the stack, unresolved. +/// +/// The spell is NOT resolved: the CR 601.2i retag is what is under test, and +/// resolving would pop the entry and clear its paid facts. +fn cast_and_hold_on_stack() -> (GameState, usize) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Journal Draw", true, DRAW_ORACLE) + .with_mana_cost(ManaCost::generic(2)) + .id(); + scenario.with_mana_pool( + P0, + (0..2) + .map(|_| ManaUnit::new(ManaType::Colorless, spell, false, Vec::new())) + .collect(), + ); + + let mut runner = scenario.build(); + let journal_start = runner.state().resolved_rules_journal.entries().len(); + let card_id = runner.state().objects[&spell].card_id; + runner + .act(GameAction::CastSpell { + object_id: spell, + card_id, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("a 2-generic sorcery is castable with exactly two colorless in pool"); + + (runner.state().clone(), journal_start) +} + +/// Every CR 601.2i finalization journaled after `from`, in journal order. +fn finalizations(state: &GameState, from: usize) -> Vec { + state + .resolved_rules_journal + .entries() + .iter() + .skip(from) + .filter_map(|entry| entry.command.as_ref()) + .filter_map(|command| match command { + ResolvedRulesCommand::StackEntryFinalize(command) => Some(command.as_ref().clone()), + _ => None, + }) + .collect() +} + +/// Rebuilds the pre-finalize predecessor from the post-cast state by restoring +/// exactly what the command says it replaced. +/// +/// The predecessor for this family is mid-cast — after the CR 601.2a +/// announcement, before the CR 601.2i retag — so no fixture can capture it +/// directly. Reconstructing it from the recorded `expected_*` values is what +/// makes the replay assertion meaningful: if the applier installed anything the +/// command did not record, the round trip would not land back on the live state. +fn pre_finalize_state(state: &GameState, command: &ResolvedStackEntryFinalizeCommand) -> GameState { + let mut predecessor = state.clone(); + predecessor + .stack + .get_mut(command.entry_position) + .expect("the recorded position is live in the post-cast state") + .kind = command.expected_old_kind.as_ref().clone(); + match command.expected_old_paid_facts.as_deref() { + Some(previous) => { + predecessor + .stack_paid_facts + .insert(command.object, previous.clone()); + } + None => { + predecessor.stack_paid_facts.remove(&command.object); + } + } + predecessor +} + +#[test] +fn real_cast_journals_an_exact_stack_entry_finalize() { + let (state, journal_start) = cast_and_hold_on_stack(); + + // CR 601.2i reach guard: the spell is on the stack AND finalized. Without + // this the journal assertions below could pass on a cast that never + // committed. + let entry = state.stack.back().expect("the spell is on the stack"); + let (live_ability, live_mana) = match &entry.kind { + StackEntryKind::Spell { + ability, + actual_mana_spent, + .. + } => (ability.is_some(), *actual_mana_spent), + other => panic!("expected a spell stack entry, found {other:?}"), + }; + assert!( + live_ability, + "CR 601.2i: the finalized entry carries the spell ability the stub lacked" + ); + assert_eq!( + live_mana, 2, + "CR 601.2i: the finalized entry carries the mana actually spent" + ); + + // The discriminating assertion: the finalization is journaled as an exact + // resolved command. A raw retag records nothing here. + let commands = finalizations(&state, journal_start); + assert_eq!( + commands.len(), + 1, + "the finalize authority must journal exactly one resolved command" + ); + let command = &commands[0]; + assert_eq!(command.object, entry.id); + assert_eq!( + state.stack[command.entry_position].id, entry.id, + "CR 405.2: the recorded position indexes the entry that was retagged" + ); + + // The recorded transition is stub -> finalized, which is what proves the + // journal point sits AFTER the retag rather than before it. + match command.expected_old_kind.as_ref() { + StackEntryKind::Spell { + ability, + actual_mana_spent, + .. + } => { + assert!( + ability.is_none(), + "CR 601.2a: the recorded predecessor is the announcement stub" + ); + assert_eq!(*actual_mana_spent, 0); + } + other => panic!("expected a spell stack entry, found {other:?}"), + } + assert_eq!( + command.resulting_kind.as_ref(), + &entry.kind, + "the recorded result is the entry that is actually live" + ); + assert_eq!( + command.resulting_paid_facts.actual_mana_spent, 2, + "the recorded snapshot is the one the engine reads for paid facts" + ); + assert_eq!( + state.stack_paid_facts[&entry.id], *command.resulting_paid_facts, + "the recorded snapshot is the snapshot that was installed" + ); + + // Replay-exactness: from the reconstructed predecessor, applying the record + // lands back on the live entry and the live snapshot with nothing + // re-derived. + let mut replay = pre_finalize_state(&state, command); + apply_resolved_stack_entry_finalize(&mut replay, command) + .expect("the recorded finalization must replay against its predecessor"); + assert_eq!( + replay.stack[command.entry_position].kind, entry.kind, + "CR 601.2i: replay installs the recorded finalized entry" + ); + assert_eq!( + replay.stack_paid_facts[&entry.id], state.stack_paid_facts[&entry.id], + "CR 601.2i: replay installs the recorded paid-facts snapshot" + ); + + // Re-applying is not idempotent: the predecessor no longer matches, so the + // command fails closed rather than silently re-retagging. + assert!( + matches!( + apply_resolved_stack_entry_finalize(&mut replay, command), + Err(ResolvedStackEntryFinalizeReplayInvariantError::EntryKindMismatch(_)) + ), + "a second application must fail closed on the pre-finalize precondition" + ); +} + +#[test] +fn stack_entry_finalize_rejects_a_divergent_predecessor() { + let (state, journal_start) = cast_and_hold_on_stack(); + let commands = finalizations(&state, journal_start); + let command = &commands[0]; + + // A position past the live stack depth. + let mut past_end = command.clone(); + past_end.entry_position = state.stack.len(); + assert!(matches!( + apply_resolved_stack_entry_finalize(&mut pre_finalize_state(&state, command), &past_end), + Err(ResolvedStackEntryFinalizeReplayInvariantError::PositionOutOfRange { .. }) + )); + + // A live entry at the recorded position that is not the recorded entry. + let mut predecessor = pre_finalize_state(&state, command); + predecessor + .stack + .get_mut(command.entry_position) + .expect("the recorded position is live") + .id = engine::types::identifiers::ObjectId(9999); + assert!(matches!( + apply_resolved_stack_entry_finalize(&mut predecessor, command), + Err(ResolvedStackEntryFinalizeReplayInvariantError::EntryIdentityMismatch { .. }) + )); + + // Applying against the already-finalized state: the entry is live and has + // the right id, but it is not the pre-finalize entry the record replaces. + assert!(matches!( + apply_resolved_stack_entry_finalize(&mut state.clone(), command), + Err(ResolvedStackEntryFinalizeReplayInvariantError::EntryKindMismatch(_)) + )); +} + +/// The half-install guard: when the paid-facts side of the predecessor +/// disagrees, the entry retag must not have happened either. +/// +/// CR 601.2i settles both mutations together, so a replay that retagged the +/// entry and only then discovered the snapshot mismatch would leave a finalized +/// entry with foreign paid facts — a state no execution ever produced. This is +/// the test that pins the applier's check-before-install ordering; moving the +/// snapshot check below the retag turns it red. +#[test] +fn a_divergent_paid_facts_predecessor_installs_neither_half() { + let (state, journal_start) = cast_and_hold_on_stack(); + let commands = finalizations(&state, journal_start); + let command = &commands[0]; + + let mut predecessor = pre_finalize_state(&state, command); + let stub_kind = predecessor.stack[command.entry_position].kind.clone(); + // Diverge ONLY the snapshot side, leaving the entry precondition satisfied, + // so the rejection can only come from the paid-facts check. + let mut foreign = command.resulting_paid_facts.as_ref().clone(); + foreign.actual_mana_spent += 7; + predecessor + .stack_paid_facts + .insert(command.object, foreign.clone()); + + assert!( + matches!( + apply_resolved_stack_entry_finalize(&mut predecessor, command), + Err(ResolvedStackEntryFinalizeReplayInvariantError::PaidFactsMismatch(_)) + ), + "a divergent snapshot predecessor must be rejected" + ); + assert_eq!( + predecessor.stack[command.entry_position].kind, stub_kind, + "the rejected replay must not have retagged the entry" + ); + assert_eq!( + predecessor.stack_paid_facts[&command.object], foreign, + "the rejected replay must not have installed its snapshot either" + ); +} +/// The `Some` side of `expected_old_paid_facts`, which no ordinary cast +/// produces. +/// +/// The field is an `Option` because the authority is re-entered from the top by +/// its resume callers, so "no snapshot is present yet" is not a property the +/// record can assert without first proving no resume path re-reaches the insert. +/// That reasoning is only worth anything if the `Some` path actually works, and +/// a fresh cast always records `None` — so every other test in this file +/// exercises exactly one branch. This drives the other one. +/// +/// Synthesised rather than driven through a resume path on purpose: the claim +/// under test is the APPLIER's contract (install iff the recorded predecessor +/// matches), which is a property of the command shape, not of any particular +/// caller that produces it. +#[test] +fn a_recorded_prior_snapshot_is_required_to_match_before_install() { + let (state, journal_start) = cast_and_hold_on_stack(); + let commands = finalizations(&state, journal_start); + let command = &commands[0]; + assert_eq!( + command.expected_old_paid_facts, None, + "reach guard: an ordinary cast records no prior snapshot, which is why \ + the Some path needs synthesising" + ); + + // A command that claims a prior snapshot was present. + let mut prior = command.resulting_paid_facts.as_ref().clone(); + prior.actual_mana_spent = 1; + let mut with_prior = command.clone(); + with_prior.expected_old_paid_facts = Some(Box::new(prior.clone())); + + // Against a predecessor that HAS that snapshot, it installs. + let mut matching = pre_finalize_state(&state, command); + matching + .stack_paid_facts + .insert(command.object, prior.clone()); + apply_resolved_stack_entry_finalize(&mut matching, &with_prior) + .expect("a recorded prior snapshot that matches the predecessor must install"); + assert_eq!( + matching.stack_paid_facts[&command.object], *command.resulting_paid_facts, + "the recorded result overwrites the recorded prior snapshot" + ); + assert_eq!( + matching.stack[command.entry_position].kind, *command.resulting_kind, + "the entry half installs alongside it" + ); + + // Against a predecessor that has NO snapshot, the same command fails closed. + // This is the asymmetry the `Option` exists to express: absent and present + // are different predecessors, not interchangeable ones. + let mut absent = pre_finalize_state(&state, command); + let stub_kind = absent.stack[command.entry_position].kind.clone(); + assert!( + matches!( + apply_resolved_stack_entry_finalize(&mut absent, &with_prior), + Err(ResolvedStackEntryFinalizeReplayInvariantError::PaidFactsMismatch(_)) + ), + "a command recording a prior snapshot must not apply where none exists" + ); + assert_eq!( + absent.stack[command.entry_position].kind, stub_kind, + "and the rejected replay installs neither half" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index b72950ab88..f8e7c5b9ec 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -104,6 +104,7 @@ mod cr733_resolved_frame_transition; mod cr733_resolved_modifier_install; mod cr733_resolved_object_cease; mod cr733_resolved_player_leave; +mod cr733_resolved_stack_entry_finalize; mod cr733_resolved_stack_push; mod cr733_resolved_token_creation; mod cr733_resolved_transform;