Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions crates/engine/src/game/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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),
Expand Down
9 changes: 5 additions & 4 deletions crates/engine/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
141 changes: 139 additions & 2 deletions crates/engine/src/types/resolved_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,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};
Expand Down Expand Up @@ -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<StackEntry>,
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
Expand Down Expand Up @@ -940,6 +1050,7 @@ pub enum ResolvedRulesCommand {
ZoneChange(Box<ResolvedZoneChangeCommand>),
FrameTransition(Box<ResolvedFrameTransitionCommand>),
TriggerCollection(ResolvedTriggerCollectionCommand),
StackPush(Box<ResolvedStackPushCommand>),
}

/// An append-only trigger collection command has no replay-time precondition.
Expand Down Expand Up @@ -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<ResolvedCommandOrdinal, ResolvedRulesJournalError> {
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,
Expand Down Expand Up @@ -2197,7 +2319,8 @@ impl ResolvedRulesJournal {
| ResolvedRulesCommand::LibraryShuffle(_)
| ResolvedRulesCommand::ZoneChange(_)
| ResolvedRulesCommand::FrameTransition(_)
| ResolvedRulesCommand::TriggerCollection(_) => {}
| ResolvedRulesCommand::TriggerCollection(_)
| ResolvedRulesCommand::StackPush(_) => {}
}
}
for node in &self.nodes {
Expand Down Expand Up @@ -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(())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}

Expand Down Expand Up @@ -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!(
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/tests/integration/cr733_resolved_draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}

Expand Down
Loading
Loading