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;