From 5c773f34cec0b93fdee69c6f1afd882f9ffd44bf Mon Sep 17 00:00:00 2001 From: matthewevans Date: Sun, 26 Jul 2026 01:03:23 -0700 Subject: [PATCH] feat(engine): journal CR 405.1 and CR 707.10 stack pushes as resolved commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both stack authorities now record each settled push as one `ResolvedStackPushCommand`, paired with an `apply_resolved_stack_push` replay applier. One parameterized command, not two siblings. Every state-derived value either authority computes is written INTO the entry before the push: `push_to_stack` stamps the CR 701.27f generation (guarded on `is_none()`), the CR 400.7 incarnation, and the CR 509.1c force-block referent; `push_copy_to_stack` stamps the generation unconditionally and deliberately does not re-bind force-block. Journaling after all stamping absorbs every one of those differences into the recorded entry's field values, so both arms record the identical operand set — the finished entry plus its CR 405.2 position — and differ only in which rule to cite. `ResolvedStackPushOrigin` carries that. Two siblings would have carried byte-identical field lists and would have created the first sibling-cluster smell on `ResolvedRulesCommand`. The applier installs the recorded entry verbatim: no restamp, no rescan, no re-selection. Re-deriving the force-block binding would swap a choice-time referent for a live global scan, which CR 707.10b forbids. Fail-closed preconditions verify the CR 405.2 depth, reject a duplicate entry id, and reject an unknown controller — but deliberately do NOT require the source object to exist, because the ordinary path tolerates a missing source (synthetic game-rule triggers push with `ObjectId(0)`). No allocator receipt: neither authority draws an id or a timestamp, so there is no post-draw high-water to record or validate. `resulting_position` is a fail-closed storage-position canary. Stack POPS are not journaled yet (CR 608.1 resolve-pop, CR 603.3c/d abort-pop, CR 701.6a counter-removal, CR 601.2a cast-abort), so past the first un-journaled removal the replayed depth diverges from every later recorded position and the applier refuses. The stack family is therefore NOT end-to-end replayable until those pop units land — a known scheduled gap, documented on both the command and the applier so it cannot become a mystery failure. Do not weaken the precondition to make a replay pass. SCOPE. A cast is two-phase and this covers only the first phase. `announce_spell_on_stack` pushes at CR 601.2a with `ability: None`; the finalized ability and mana are retagged onto that same entry later at CR 601.2i in `casting_costs.rs`, with the `stack_paid_facts` insert immediately after — so that side table moves atomically with finalization, not with this push. That retag is not a lone special case: it is one of roughly ten production sites that mutate an entry IN PLACE after it is on the stack, a third mutation class alongside pushes and pops that is invisible to any census keyed on container verbs because it reaches the element through `iter_mut()`. None of it is journaled today. An activated or triggered ability has no CR 601.2i phase, so for those kinds the record is the finished entry. `stack_trigger_event_batches` belongs to the trigger authorities; neither side table has a writer inside either stack authority. Adding a `ResolvedRulesCommand` variant requires edits at five sites, not three: the three cross-crate test matches plus two inside `resolved_commands.rs` itself (the catch-all sweep and the per-variant arm in the journal validator). `rg` from outside that file undercounts by exactly those two, so the compiler is the only reliable census. Tests drive the real pipeline. Auriok Siege Sled's activated ability is the reachable input shape for `bind_force_block_source_recursive`, so it is the fixture that distinguishes "journal after stamping" from "journal before stamping" — a plain spell is degenerate there, stamping nothing. Its replay case removes the source object first, so a rescanning applier would yield `None` where installing the record verbatim keeps the exact referent. --- crates/engine/src/game/stack.rs | 82 ++++ crates/engine/src/types/mod.rs | 9 +- crates/engine/src/types/resolved_commands.rs | 141 +++++- .../integration/cr733_resolved_commands_p2.rs | 8 +- .../tests/integration/cr733_resolved_draw.rs | 3 + .../integration/cr733_resolved_stack_push.rs | 459 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 7 files changed, 694 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/integration/cr733_resolved_stack_push.rs diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 6ca4d5cf0a..8573d3ef3d 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -14,6 +14,9 @@ use crate::types::game_state::{ }; use crate::types::identifiers::ObjectId; use crate::types::player::PlayerId; +use crate::types::resolved_commands::{ + ResolvedStackPushCommand, ResolvedStackPushOrigin, ResolvedStackPushReplayInvariantError, +}; use crate::types::zones::Zone; use super::ability_utils::{ @@ -65,6 +68,10 @@ pub fn push_to_stack(state: &mut GameState, mut entry: StackEntry, events: &mut events.push(GameEvent::StackPushed { object_id: entry.id, }); + // CR 733: journal the settled push after every source-referential stamp + // above has been written into the entry, so the record carries the stamped + // values themselves rather than the state they were derived from. + journal_stack_push(state, &entry, ResolvedStackPushOrigin::Put); state.stack.push_back(entry); } @@ -120,9 +127,84 @@ pub(crate) fn push_copy_to_stack( events.push(GameEvent::StackPushed { object_id: entry.id, }); + // CR 733: same journal point as [`push_to_stack`]. The copy's deliberately + // different stamping is already baked into `entry`, so this records the same + // operand set under a different origin rather than a sibling command. + journal_stack_push(state, &entry, ResolvedStackPushOrigin::Copy); state.stack.push_back(entry); } +/// Records one settled stack push for both stack authorities. +/// +/// CR 405.2: an object goes on top of everything already on the stack, so the +/// index it will occupy is the current depth. Reading that here — before either +/// caller's `push_back` — is the one piece of shared logic the two authorities +/// could otherwise get out of step on. +fn journal_stack_push(state: &mut GameState, entry: &StackEntry, origin: ResolvedStackPushOrigin) { + let resulting_position = state.stack.len(); + let cause = state.current_or_begin_rules_execution_node(); + let command = ResolvedStackPushCommand { + entry: Box::new(entry.clone()), + origin, + resulting_position, + cause, + }; + state + .resolved_rules_journal + .record_stack_push(command) + .expect("resolved stack push must have a live journal cause"); +} + +/// Installs one already-resolved stack push verbatim. +/// +/// CR 405.1 / CR 707.10: the recorded entry is pushed exactly as it was +/// recorded. Nothing is restamped — the CR 701.27f generation, the CR 400.7 +/// incarnation, and the CR 509.1c force-block referent all travel inside the +/// entry, so replay never repeats the live `state.objects` lookups that produced +/// them. Re-deriving the force-block binding in particular would swap a +/// choice-time referent for a global rescan (see [`push_copy_to_stack`]). +/// +/// Deliberately does NOT require the source object to exist: the ordinary path +/// tolerates a missing source (synthetic game-rule triggers push with +/// `ObjectId(0)`), so a source-existence precondition would reject pushes the +/// engine legitimately performs. +/// +/// `StackDepthMismatch` is a deliberate fail-closed canary, not a bug to route +/// around. Stack POPS are not journaled yet (CR 608.1 resolve-pop, CR 603.3c/d +/// abort-pop, CR 701.6a counter-removal, CR 601.2a cast-abort), so once any +/// un-journaled removal runs, the replayed depth diverges from every later +/// recorded position and this check refuses instead of installing an entry +/// somewhere the recording never described. Do not weaken it to make a replay +/// pass; see [`ResolvedStackPushCommand`] for the scheduled gap. +pub fn apply_resolved_stack_push( + state: &mut GameState, + command: &ResolvedStackPushCommand, +) -> Result<(), ResolvedStackPushReplayInvariantError> { + if state.stack.len() != command.resulting_position { + return Err(ResolvedStackPushReplayInvariantError::StackDepthMismatch { + expected: command.resulting_position, + found: state.stack.len(), + }); + } + if state.stack.iter().any(|entry| entry.id == command.entry.id) { + return Err(ResolvedStackPushReplayInvariantError::DuplicateStackEntry( + command.entry.id, + )); + } + if !state + .players + .iter() + .any(|player| player.id == command.entry.controller) + { + return Err(ResolvedStackPushReplayInvariantError::UnknownController( + command.entry.controller, + )); + } + + state.stack.push_back(command.entry.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 18bf74f3ee..772d32e833 100644 --- a/crates/engine/src/types/mod.rs +++ b/crates/engine/src/types/mod.rs @@ -91,10 +91,11 @@ pub use resolved_commands::{ ResolvedObjectStatusReplayInvariantError, ResolvedOncePerTurnPermission, ResolvedPlayerEdit, ResolvedPlayerEditCommand, ResolvedPlayerEditReplayInvariantError, ResolvedRngReplayInvariantError, ResolvedRulesCommand, ResolvedRulesJournal, - ResolvedRulesJournalError, ResolvedTriggerCollection, ResolvedTriggerCollectionCommand, - ResolvedTriggerCollectionReplayInvariantError, ResolvedTriggerLedgerEdit, - RulesExecutionNodeKind, RulesExecutionNodeRef, SettlementNode, SettlementNodeOrdinal, - SpentManaUnit, + ResolvedRulesJournalError, ResolvedStackPushCommand, ResolvedStackPushOrigin, + ResolvedStackPushReplayInvariantError, ResolvedTriggerCollection, + ResolvedTriggerCollectionCommand, ResolvedTriggerCollectionReplayInvariantError, + ResolvedTriggerLedgerEdit, RulesExecutionNodeKind, RulesExecutionNodeRef, SettlementNode, + SettlementNodeOrdinal, SpentManaUnit, }; pub use statics::StaticMode; pub use stickers::{AppliedSticker, StickerKind, StickerLocator}; diff --git a/crates/engine/src/types/resolved_commands.rs b/crates/engine/src/types/resolved_commands.rs index ac4d5de5fc..1f2e4e8db7 100644 --- a/crates/engine/src/types/resolved_commands.rs +++ b/crates/engine/src/types/resolved_commands.rs @@ -16,7 +16,7 @@ use super::card::TokenImageRef; use super::card_type::CoreType; use super::counter::CounterType; use super::game_state::{ - DelayedTrigger, SpellCastRecord, TransientContinuousEffect, ZoneChangeRecord, + DelayedTrigger, SpellCastRecord, StackEntry, TransientContinuousEffect, ZoneChangeRecord, }; use super::identifiers::{ObjectId, ObjectIncarnationRef, LEGACY_INCARNATION}; use super::mana::{ManaPipId, ManaUnit}; @@ -913,6 +913,116 @@ pub struct ResolvedTriggerCollectionCommand { pub cause: RulesExecutionNodeRef, } +/// Which rule put one object onto the stack. +/// +/// This is a provenance discriminator, not an operand-set discriminator. Both +/// arms record the same fields (see [`ResolvedStackPushCommand`]); a copy stack +/// entry is structurally indistinguishable from an original, so the citing rule +/// is not recoverable from the entry alone and has to be carried. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResolvedStackPushOrigin { + /// CR 405.1 + CR 601.2a: an object was put onto the stack — a cast spell, or + /// an activated or triggered ability going on without a card. + Put, + /// CR 707.10: a *copy* of a spell or ability was put onto the stack. The + /// copy is not cast and not activated. + Copy, +} + +/// One exact object landing on the stack. +/// +/// CR 405.2: the stack keeps the order objects were added in, and each new +/// object goes on top of everything already there. `resulting_position` is the +/// index this entry occupies after its push, which for a top-of-stack append is +/// also the live stack depth the applier must find before installing. It is +/// RECORDED rather than re-derived at replay time for the same reason +/// `ResolvedControllerOverrideCommand` records its snapshot indices: the +/// resolve-time authority knows exactly where the entry landed, and trusting a +/// replayed board's own depth would install at whatever position that board +/// happens to have reached. +/// +/// `resulting_position` is therefore a STORAGE-POSITION CANARY, and its +/// `StackDepthMismatch` is a feature. It fails a replay closed the moment +/// journal order stops matching execution order. That moment is currently +/// reachable, because **stack POPS are not journaled yet**: CR 608.1 +/// resolve-pop, CR 603.3c/d abort-pop, CR 701.6a counter-removal, and +/// CR 601.2a cast-abort each remove an entry with no corresponding record. As +/// soon as any of those runs, the replayed depth diverges from every later +/// recorded position and this precondition refuses rather than installing an +/// entry at a position the recording never described. +/// +/// **The stack family is consequently NOT end-to-end replayable until the pop +/// units land.** That is a known, scheduled gap, not a defect, and the +/// precondition must not be weakened to paper over it — a canary that has been +/// silenced cannot warn. Until then this family is exact for prefixes that +/// contain no pop, which is what its tests replay. +/// +/// This is ONE parameterized command rather than a CR 405.1 sibling and a +/// CR 707.10 sibling because the two authorities' divergence is entirely +/// upstream of this record. Both stamp their source-referential values *into* +/// `entry` before pushing — `push_to_stack` stamps the CR 701.27f generation +/// only when unset, additionally stamps the CR 400.7 incarnation, and binds the +/// CR 509.1c force-block source; `push_copy_to_stack` stamps the generation +/// unconditionally and deliberately leaves the force-block binding alone. Every +/// one of those differences is already resolved into a field value by the time +/// the entry is recorded, so the two arms record identical operand sets and the +/// only real difference is which rule to cite. `origin` carries that. +/// +/// Nothing here is re-derived on replay: the applier installs the recorded +/// entry verbatim, so the stamped generation, incarnation, and force-block +/// referent survive exactly rather than being recomputed from a live rescan. +/// +/// SCOPE: the push itself, which for a cast spell is only the first half of the +/// cast. `announce_spell_on_stack` pushes at CR 601.2a with `ability: None` and +/// `actual_mana_spent: 0`; the finalized ability and mana are retagged onto that +/// same entry later at CR 601.2i (`casting_costs.rs`). So a recorded `Put` for a +/// spell is the *announcement* snapshot, not the finalized spell. +/// +/// That CR 601.2i retag is NOT a lone special case. It is one of roughly ten +/// production sites that mutate an entry IN PLACE after it is on the stack, +/// spread across cast finalization, triggers, copy retargeting, planechase, and +/// the engine's own entry fix-ups. In-place mutation is a third mutation class +/// alongside pushes and pops, and it is invisible to any census keyed on +/// container verbs (`push_back` / `pop_back` / `retain`) because it reaches the +/// element through `iter_mut()`. Whoever journals it is building a class, not +/// patching a card, and none of it is journaled today. +/// +/// `stack_paid_facts` is written immediately after that retag, so it moves +/// atomically with cast FINALIZATION rather than with this push, and belongs to +/// the same future unit. `stack_trigger_event_batches` likewise belongs to the +/// trigger authorities. Neither side table has a writer inside either stack +/// authority, which is why neither is part of this record. +/// +/// An activated or triggered ability has no CR 601.2i phase: its entry is +/// complete when it is pushed, so for those kinds the record IS the finished +/// entry. +/// +/// There is no allocator receipt because neither authority allocates: both are +/// handed an already-built entry, and the CR 400.7 incarnation is read from the +/// source rather than drawn. A recorded high-water here would have nothing +/// behind it to validate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedStackPushCommand { + /// Boxed because `StackEntryKind` embeds a whole `ResolvedAbility`, which + /// would otherwise widen every `ResolvedRulesCommand` in the journal. + pub entry: Box, + pub origin: ResolvedStackPushOrigin, + /// Zero-based index the entry occupies after the push (CR 405.2). + pub resulting_position: usize, + pub cause: RulesExecutionNodeRef, +} + +/// Typed failure while applying one already-resolved stack push. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ResolvedStackPushReplayInvariantError { + #[error("stack-push command targets depth {expected}, found {found}")] + StackDepthMismatch { expected: usize, found: usize }, + #[error("stack-push command would duplicate stack entry {0:?}")] + DuplicateStackEntry(ObjectId), + #[error("stack-push command references an unknown controller {0:?}")] + UnknownController(PlayerId), +} + /// Semantic command payload currently carried by a resolved-rules journal entry. /// /// Additional command families are intentionally added by their owning P2 @@ -940,6 +1050,7 @@ pub enum ResolvedRulesCommand { ZoneChange(Box), FrameTransition(Box), TriggerCollection(ResolvedTriggerCollectionCommand), + StackPush(Box), } /// An append-only trigger collection command has no replay-time precondition. @@ -1973,6 +2084,17 @@ impl ResolvedRulesJournal { ) } + /// Records one exact object landing on the stack under its causal node. + pub fn record_stack_push( + &mut self, + command: ResolvedStackPushCommand, + ) -> Result { + self.append_command( + command.cause, + ResolvedRulesCommand::StackPush(Box::new(command)), + ) + } + /// Records one exact trigger/LKI collection append under its causal node. pub fn record_trigger_collection( &mut self, @@ -2197,7 +2319,8 @@ impl ResolvedRulesJournal { | ResolvedRulesCommand::LibraryShuffle(_) | ResolvedRulesCommand::ZoneChange(_) | ResolvedRulesCommand::FrameTransition(_) - | ResolvedRulesCommand::TriggerCollection(_) => {} + | ResolvedRulesCommand::TriggerCollection(_) + | ResolvedRulesCommand::StackPush(_) => {} } } for node in &self.nodes { @@ -2610,6 +2733,20 @@ impl ResolvedRulesJournal { )); } } + ResolvedRulesCommand::StackPush(command) => { + // Cause-only, like the frame-transition and trigger-collection + // arms. There is no allocator receipt to cross-check: neither + // stack authority draws an id or a timestamp, so this record + // holds no high-water that could be forged past. Its remaining + // preconditions (CR 405.2 depth, duplicate entry id, live + // controller) are all state-dependent and are enforced by + // `stack::apply_resolved_stack_push` where the state exists. + if entry.node != command.cause { + return Err(ResolvedRulesJournalError::InvalidSerializedAuthority( + "stack-push 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 41ffe0d375..f9bdbc07e6 100644 --- a/crates/engine/tests/integration/cr733_resolved_commands_p2.rs +++ b/crates/engine/tests/integration/cr733_resolved_commands_p2.rs @@ -130,6 +130,9 @@ fn apply_semantic_command(state: &mut GameState, command: &ResolvedRulesCommand) ResolvedRulesCommand::TriggerCollection(command) => { engine::game::triggers::apply_resolved_trigger_collection(state, command).unwrap(); } + ResolvedRulesCommand::StackPush(command) => { + engine::game::stack::apply_resolved_stack_push(state, command.as_ref()).unwrap(); + } } } @@ -230,9 +233,8 @@ fn exact_mana_spend_rejects_a_second_removal() { | ResolvedRulesCommand::ZoneChange(_) | ResolvedRulesCommand::Information(_) | ResolvedRulesCommand::FrameTransition(_) - | ResolvedRulesCommand::TriggerCollection(_) => { - apply_semantic_command(&mut replay, command) - } + | ResolvedRulesCommand::TriggerCollection(_) + | ResolvedRulesCommand::StackPush(_) => 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 6d5f671110..a4db9cf56e 100644 --- a/crates/engine/tests/integration/cr733_resolved_draw.rs +++ b/crates/engine/tests/integration/cr733_resolved_draw.rs @@ -95,6 +95,9 @@ fn apply_semantic_command(state: &mut GameState, command: &ResolvedRulesCommand) ResolvedRulesCommand::TriggerCollection(command) => { engine::game::triggers::apply_resolved_trigger_collection(state, command).unwrap(); } + ResolvedRulesCommand::StackPush(command) => { + engine::game::stack::apply_resolved_stack_push(state, command.as_ref()).unwrap(); + } } } diff --git a/crates/engine/tests/integration/cr733_resolved_stack_push.rs b/crates/engine/tests/integration/cr733_resolved_stack_push.rs new file mode 100644 index 0000000000..42128a4405 --- /dev/null +++ b/crates/engine/tests/integration/cr733_resolved_stack_push.rs @@ -0,0 +1,459 @@ +//! CR733 P2 coverage for objects landing on the stack. +//! +//! Two authorities feed one command. `stack::push_to_stack` is CR 405.1 / +//! CR 601.2a; `stack::push_copy_to_stack` is CR 707.10. They are journaled as a +//! single `ResolvedStackPushCommand` parameterized by `ResolvedStackPushOrigin` +//! rather than as two siblings, because both journal *after* every +//! source-referential stamp has been written into the entry — so both record the +//! same operand set (the finished entry plus its CR 405.2 position) and differ +//! only in which rule to cite. +//! +//! The load-bearing fixture is Auriok Siege Sled, whose activated ability is +//! `Effect::ForceBlock { attacker: Some(Source) }`. That is the one input shape +//! that reaches `bind_force_block_source_recursive`, so it is the only fixture +//! that can tell "journal after stamping" apart from "journal before stamping". +//! A plain spell is degenerate here: with no back face on the source and no +//! force-block referent to bind, the authority stamps nothing and the journal +//! point is unobservable. Do not replace it with a simpler card. +//! +//! Every replay below applies a recorded push to a prefix containing no stack +//! POP, because pops are not journaled yet (CR 608.1 / CR 603.3c/d / CR 701.6a +//! / CR 601.2a). Past the first un-journaled removal the recorded +//! `resulting_position` no longer matches the replayed depth and the applier +//! fails closed by design — so a future test that replays across a pop should +//! assert `StackDepthMismatch`, not relax the precondition. + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::game::stack::apply_resolved_stack_push; +use engine::types::ability::TargetRef; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::{CastPaymentMode, GameState, StackEntryKind, WaitingFor}; +use engine::types::identifiers::{ObjectId, ObjectIncarnationRef}; +use engine::types::mana::{ManaColor, ManaCost}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::resolved_commands::{ + ResolvedRulesCommand, ResolvedStackPushCommand, ResolvedStackPushOrigin, + ResolvedStackPushReplayInvariantError, +}; + +// Verbatim Oracle text (Scryfall, 2026-07-25). The first ability is the one +// under test; "this creature" is the self-reference the force-block parser +// lowers to `ForceBlockAttackerRef::Source`. +const AURIOK_SIEGE_SLED: &str = "{1}: Target artifact creature blocks this creature this turn if \ + able.\n{1}: Target artifact creature can't block this creature \ + this turn."; + +// Real Twincast (M19 etc.), verbatim. +const TWINCAST: &str = "Copy target instant or sorcery spell. You may choose new targets for the \ + copy."; + +// A no-target sorcery so the copy pipeline never has to retarget anything. +const ELVISH_TOKEN_SPELL: &str = "Create a 1/1 green Elf Warrior creature token."; + +/// Every stack-push command journaled after `from`, in journal order. +fn stack_pushes(state: &GameState, from: usize) -> Vec { + state + .resolved_rules_journal + .entries() + .iter() + .skip(from) + .filter_map(|entry| entry.command.clone()) + .filter_map(|command| match command { + ResolvedRulesCommand::StackPush(command) => Some(*command), + _ => None, + }) + .collect() +} + +fn make_artifact_creature(runner: &mut GameRunner, id: ObjectId) { + let object = runner.state_mut().objects.get_mut(&id).unwrap(); + object.card_types.core_types = vec![CoreType::Artifact, CoreType::Creature]; + object.base_card_types = object.card_types.clone(); +} + +/// CR 405.1 + CR 601.2a: a real cast journals one exact `Put` push whose +/// recorded entry is the entry that landed, at the CR 405.2 position it landed +/// at, and replaying that record installs it without re-deriving anything. +#[test] +fn real_cast_journals_an_exact_put_stack_push() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Raise the Alarm", true, ELVISH_TOKEN_SPELL) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + let pre_state = runner.state().clone(); + let journal_start = pre_state.resolved_rules_journal.entries().len(); + assert!( + pre_state.stack.is_empty(), + "reach-guard: the stack is empty, so the push lands at CR 405.2 position 0" + ); + + 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("the real cast must put the spell on the stack"); + + // CR 405.1 reach guard: the spell genuinely landed on the stack, so the + // journal assertion below cannot pass vacuously. + let landed = runner + .state() + .stack + .iter() + .find(|entry| entry.id == spell) + .expect("CR 405.1: the cast spell is on the stack") + .clone(); + + let pushes = stack_pushes(runner.state(), journal_start); + let recorded: Vec<_> = pushes + .iter() + .filter(|push| push.entry.id == spell) + .collect(); + assert_eq!( + recorded.len(), + 1, + "the stack authority must journal exactly one push for the cast spell" + ); + let push = recorded[0]; + assert_eq!( + push.origin, + ResolvedStackPushOrigin::Put, + "CR 405.1 / CR 601.2a: a cast spell is a Put, not a Copy" + ); + assert_eq!(push.entry.id, spell); + assert_eq!(push.entry.source_id, landed.source_id); + assert_eq!(push.entry.controller, landed.controller); + + // A cast is two-phase, and this family covers only the first phase. + // `announce_spell_on_stack` pushes at CR 601.2a with `ability: None` and + // `actual_mana_spent: 0`; the finalized ability and mana are retagged onto + // the SAME entry later at CR 601.2i (`casting_costs.rs`, "Update the + // existing stack entry (pushed at announcement)"). So the record is the + // announcement snapshot, and the live entry has since moved on. Asserting + // both halves pins the seam instead of letting a future reader assume the + // record is the finalized spell. + assert!( + matches!( + push.entry.kind, + StackEntryKind::Spell { + ability: None, + actual_mana_spent: 0, + .. + } + ), + "CR 601.2a: the recorded push is the announcement entry, got {:?}", + push.entry.kind + ); + assert!( + matches!( + landed.kind, + StackEntryKind::Spell { + ability: Some(_), + .. + } + ), + "CR 601.2i: the live entry was retagged with its finalized ability by a \ + seam this family does not cover, so the record above is genuinely the \ + earlier CR 601.2a snapshot rather than a copy of the live entry" + ); + + // CR 405.2: the recorded index is the pre-push depth, which is where the + // entry ends up. Recording the post-push depth would make this 1. + assert_eq!( + push.resulting_position, 0, + "CR 405.2: the push onto an empty stack occupies index 0" + ); + + // Replay-exactness: the recorded push installs the entry verbatim against + // the captured predecessor state, with no restamp and no rescan. + let mut replay = pre_state; + apply_resolved_stack_push(&mut replay, push) + .expect("the recorded push must replay against its captured predecessor"); + assert_eq!( + replay.stack.len(), + 1, + "replay installs exactly one stack entry" + ); + assert_eq!( + replay.stack[push.resulting_position], *push.entry, + "CR 405.2: replay installs the recorded entry, verbatim, at the recorded index" + ); + // The push family covers the push only. The Hand → Stack move is the + // zone-change family's record, so this applier must not have moved the card. + assert_eq!( + replay.objects[&spell].zone, + engine::types::zones::Zone::Hand, + "the stack-push applier installs the entry and nothing else" + ); + + // Fail-closed: re-applying finds the stack one deeper than recorded. + assert!( + matches!( + apply_resolved_stack_push(&mut replay, push), + Err(ResolvedStackPushReplayInvariantError::StackDepthMismatch { + expected: 0, + found: 1 + }) + ), + "a stack push is not idempotent: a second application must fail closed" + ); + + // Fail-closed: even at the right depth, the same entry id cannot land twice. + let mut duplicate = push.clone(); + duplicate.resulting_position = 1; + assert!( + matches!( + apply_resolved_stack_push(&mut replay, &duplicate), + Err(ResolvedStackPushReplayInvariantError::DuplicateStackEntry(id)) if id == spell + ), + "the applier must refuse to duplicate a live stack entry" + ); + + // Fail-closed: the recorded controller must still be in the game. + let mut stranger = push.clone(); + stranger.entry.controller = PlayerId(99); + let mut fresh = runner.state().clone(); + fresh.stack.clear(); + assert!( + matches!( + apply_resolved_stack_push(&mut fresh, &stranger), + Err(ResolvedStackPushReplayInvariantError::UnknownController(p)) if p == PlayerId(99) + ), + "the applier must refuse a controller that is not a player in this game" + ); +} + +/// CR 400.7 + CR 509.1c: the journal point is AFTER the authority binds the +/// force-block source, so the record carries the bound referent itself rather +/// than the state it was derived from — and replay installs that referent +/// without going back to `state.objects` for it. +/// +/// This is the test that pins the journal *placement*. Auriok Siege Sled's +/// activated ability is the reachable input shape for +/// `bind_force_block_source_recursive`; journaling above that call records +/// `force_block_attacker: None`. +#[test] +fn recorded_push_carries_the_bound_force_block_source() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.add_basic_land(P0, ManaColor::White); + let sled = scenario + .add_creature_from_oracle(P0, "Auriok Siege Sled", 3, 5, AURIOK_SIEGE_SLED) + .id(); + let blocker = scenario.add_creature(P0, "Steel Wall", 0, 4).id(); + + let mut runner = scenario.build(); + make_artifact_creature(&mut runner, sled); + make_artifact_creature(&mut runner, blocker); + + let pre_state = runner.state().clone(); + let journal_start = pre_state.resolved_rules_journal.entries().len(); + let sled_ref = ObjectIncarnationRef::from_object(&pre_state.objects[&sled]); + + runner + .act(GameAction::ActivateAbility { + source_id: sled, + ability_index: 0, + }) + .expect("the real force-block ability must activate"); + if let WaitingFor::TargetSelection { .. } = runner.state().waiting_for.clone() { + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(blocker)], + }) + .expect("target the artifact creature that must block"); + } + + // Reach guard: the activated ability is on the stack AND it is the + // force-block shape, so this fixture reached the binding arm rather than + // some degenerate no-stamp path. + let entry = runner + .state() + .stack + .last() + .expect("CR 602.2a: the activated ability is on the stack") + .clone(); + let ability = entry + .ability() + .expect("an activated ability entry carries its resolved ability"); + assert_eq!( + ability.force_block_attacker, + Some(sled_ref), + "reach-guard: the live entry was bound to its source, so this fixture \ + does reach bind_force_block_source_recursive" + ); + + let pushes = stack_pushes(runner.state(), journal_start); + let push = pushes + .iter() + .find(|push| push.entry.id == entry.id) + .expect("the activated ability's push must be journaled"); + assert_eq!(push.origin, ResolvedStackPushOrigin::Put); + // An activated ability has no CR 601.2i finalization phase — its entry is + // complete when it is pushed — so this is where field-for-field equality + // between the record and the live entry is a meaningful assertion. + assert_eq!( + *push.entry, entry, + "CR 602.2a: the record is the entry that landed, field for field" + ); + + // THE PLACEMENT ASSERTION. Journaling before the bind records `None` here. + let recorded_ability = push + .entry + .ability() + .expect("the recorded entry carries its resolved ability"); + assert_eq!( + recorded_ability.force_block_attacker, + Some(sled_ref), + "CR 509.1c: the record is taken after the authority binds the source, so \ + it carries the bound referent, not an unbound ability" + ); + + // Non-rescan proof: replay into a state where the source object no longer + // exists. A live re-derivation would produce `None`; installing the record + // verbatim keeps the exact choice-time referent. + let mut replay = pre_state; + replay.objects.remove(&sled); + apply_resolved_stack_push(&mut replay, push) + .expect("replay must not require the source object to still exist"); + let replayed = replay.stack[push.resulting_position] + .ability() + .expect("the replayed entry carries its resolved ability"); + assert_eq!( + replayed.force_block_attacker, + Some(sled_ref), + "CR 400.7: replay installs the recorded referent even with the source \ + gone, so it cannot be a global rescan" + ); +} + +/// CR 707.10: a copy put onto the stack journals under the `Copy` origin, and +/// the originals it was copied from journal under `Put`. One command, two +/// origins — the discriminator has to actually discriminate on a real pipeline. +#[test] +fn twincast_copy_journals_a_copy_origin_push() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let mut tokens = + scenario.add_spell_to_hand_from_oracle(P0, "Raise the Alarm", false, ELVISH_TOKEN_SPELL); + tokens.with_mana_cost(ManaCost::generic(0)); + let sorcery = tokens.id(); + let mut tw = scenario.add_spell_to_hand_from_oracle(P0, "Twincast", true, TWINCAST); + tw.with_mana_cost(ManaCost::generic(0)); + let twincast = tw.id(); + + let mut runner = scenario.build(); + let journal_start = runner.state().resolved_rules_journal.entries().len(); + + let sorcery_card = runner.state().objects[&sorcery].card_id; + runner + .act(GameAction::CastSpell { + object_id: sorcery, + card_id: sorcery_card, + targets: vec![], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast the copyable sorcery from hand"); + let twincast_card = runner.state().objects[&twincast].card_id; + runner + .act(GameAction::CastSpell { + object_id: twincast, + card_id: twincast_card, + targets: vec![sorcery], + payment_mode: CastPaymentMode::Auto, + }) + .expect("cast Twincast targeting the sorcery"); + if let WaitingFor::TargetSelection { .. } = runner.state().waiting_for.clone() { + runner + .act(GameAction::SelectTargets { + targets: vec![TargetRef::Object(sorcery)], + }) + .expect("target the sorcery with Twincast"); + } + + // CR 405.2: the two casts stacked in order onto an empty stack. Recording + // the post-push depth instead would make these 1 and 2. + let casts = stack_pushes(runner.state(), journal_start); + assert_eq!( + casts + .iter() + .find(|push| push.entry.id == sorcery) + .expect("the sorcery's push is journaled") + .resulting_position, + 0, + "CR 405.2: the first cast occupies index 0" + ); + assert_eq!( + casts + .iter() + .find(|push| push.entry.id == twincast) + .expect("Twincast's push is journaled") + .resulting_position, + 1, + "CR 405.2: Twincast goes on top of the spell it targets" + ); + + // Resolve only far enough for the copy to be created, so the live stack can + // be compared against the copy's recorded index. + let mut copy = None; + for _ in 0..50 { + if let Some(found) = stack_pushes(runner.state(), journal_start) + .into_iter() + .find(|push| push.origin == ResolvedStackPushOrigin::Copy) + { + copy = Some(found); + break; + } + match runner.state().waiting_for.clone() { + WaitingFor::Priority { .. } => { + runner.act(GameAction::PassPriority).expect("pass priority"); + } + WaitingFor::CopyRetarget { .. } => { + runner + .act(GameAction::KeepAllCopyTargets) + .expect("the copied sorcery has no targets to change"); + } + other => panic!("unexpected prompt while resolving Twincast: {other:?}"), + } + } + let copy = copy.expect("CR 707.10: Twincast's resolution must journal a Copy-origin push"); + + assert_ne!( + copy.entry.id, sorcery, + "the copy is a new stack object, not the original" + ); + assert_ne!(copy.entry.id, twincast); + // CR 405.2 on the copy path: the recorded index is where the copy actually + // sits on the live stack. + assert_eq!( + runner.state().stack[copy.resulting_position].id, + copy.entry.id, + "CR 405.2: the copy's recorded index is its live stack position" + ); + + // The origin discriminator separates the two authorities: exactly one Copy, + // and both originals stayed Put. + let all = stack_pushes(runner.state(), journal_start); + assert_eq!( + all.iter() + .filter(|push| push.origin == ResolvedStackPushOrigin::Copy) + .count(), + 1, + "CR 707.10: exactly one copy was put onto the stack" + ); + assert!( + all.iter() + .filter(|push| push.entry.id == sorcery || push.entry.id == twincast) + .all(|push| push.origin == ResolvedStackPushOrigin::Put), + "CR 405.1: the cast originals are Put pushes, never Copy" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 426efcbd89..09a53aef3c 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_push; mod cr733_resolved_token_creation; mod cr733_resolved_transform; mod cr733_resolved_trigger_collection;