diff --git a/client/src/adapter/engine-worker-client.ts b/client/src/adapter/engine-worker-client.ts index 6330f98bbe..6d91c495d8 100644 --- a/client/src/adapter/engine-worker-client.ts +++ b/client/src/adapter/engine-worker-client.ts @@ -295,6 +295,10 @@ export class EngineWorkerClient { return this.request({ type: "exportState" }); } + async exportPersistedState(): Promise { + return this.request({ type: "exportPersistedState" }); + } + async restoreState(stateJson: string): Promise { await this.request({ type: "restoreState", stateJson }); } diff --git a/client/src/adapter/engine-worker.ts b/client/src/adapter/engine-worker.ts index 2cd7c50f65..58dc65767f 100644 --- a/client/src/adapter/engine-worker.ts +++ b/client/src/adapter/engine-worker.ts @@ -25,6 +25,7 @@ import init, { apply_seat_mutation, project_seat_view, export_game_state_json, + export_persisted_game_state_json, clear_game_state, set_multiplayer_mode, resolve_all, @@ -73,6 +74,7 @@ type EngineRequest = | { type: "restoreState"; id: number; stateJson: string } | { type: "resumeMultiplayerHostState"; id: number; stateJson: string } | { type: "exportState"; id: number } + | { type: "exportPersistedState"; id: number } | { type: "loadCardDbFromUrl"; id: number } | { type: "buildAiCardSubset"; id: number } | { type: "evaluateDeckCompatibility"; id: number; request: unknown } @@ -339,6 +341,12 @@ self.onmessage = async (e: MessageEvent) => { break; } + case "exportPersistedState": { + const json = export_persisted_game_state_json(); + result(msg.id, json); + break; + } + case "resetGame": { clear_game_state(); result(msg.id, null); diff --git a/client/src/adapter/p2p-adapter.ts b/client/src/adapter/p2p-adapter.ts index 4129b8888c..ccb7245ff1 100644 --- a/client/src/adapter/p2p-adapter.ts +++ b/client/src/adapter/p2p-adapter.ts @@ -300,13 +300,13 @@ export class P2PHostAdapter implements EngineAdapter { /** True when the adapter was constructed from a persisted session (resume flow). */ private readonly isResume: boolean; /** - * Pending GameState snapshot to hand to `wasm.resumeMultiplayerHostState` - * during `initialize()`. Set in the constructor from `resumeData.state`; + * Pending persisted snapshot to hand to `wasm.resumeMultiplayerHostState` + * during `initialize()`. Set in the constructor from `resumeData.persistedJson`; * nulled after the WASM call consumes it. Held on the adapter rather * than threaded through `initialize()` so the EngineAdapter interface * stays uniform across fresh/resume flows. */ - private resumeGameState: GameState | null = null; + private resumeGameStateJson: string | null = null; constructor( private readonly hostDeckData: unknown, @@ -354,7 +354,7 @@ export class P2PHostAdapter implements EngineAdapter { gameId: string; roomCode: string; hostDisplayName?: string; - resumeData?: { state: GameState; session: PersistedP2PHostSession }; + resumeData?: { persistedJson: string; session: PersistedP2PHostSession }; }, ) { if (playerCount < 2 || playerCount > 6) { @@ -379,7 +379,7 @@ export class P2PHostAdapter implements EngineAdapter { this.isResume = persistence?.resumeData !== undefined; if (persistence?.resumeData) { - this.resumeGameState = persistence.resumeData.state; + this.resumeGameStateJson = persistence.resumeData.persistedJson; this.rehydrateFromPersistedSession(persistence.resumeData.session); this.pregameSeatView = seatStateToView(this.pregameSeatState); } @@ -776,9 +776,9 @@ export class P2PHostAdapter implements EngineAdapter { // mirrors server-core's `from_persisted` pattern. Fresh-host path: // just flip the flag; engine state is populated by the guests // joining + `initializeGame`. - if (this.isResume && this.resumeGameState) { - await this.wasm.resumeMultiplayerHostState(this.resumeGameState); - this.resumeGameState = null; + if (this.isResume && this.resumeGameStateJson) { + await this.wasm.resumeMultiplayerHostState(this.resumeGameStateJson); + this.resumeGameStateJson = null; traceAdapter("Host", "initialize-resume", { tokens: this.playerTokens.size, gameStarted: this.gameStarted, diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index a91aaa1194..f7d9aa7fde 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -2639,7 +2639,9 @@ export interface EngineAdapter { aiSeats: { playerId: number; difficulty: string }[], maxResolutions?: number, ): Promise; - restoreState(state: GameState): void | Promise; + restoreState(state: GameState, persistedJson?: string): void | Promise; + /** Authoritative persisted envelope for browser/P2P resume (rollback sidecar). */ + exportPersistedState?(): Promise; dispose(): void; /** diff --git a/client/src/adapter/wasm-adapter.ts b/client/src/adapter/wasm-adapter.ts index 23b5bc6416..eae7653546 100644 --- a/client/src/adapter/wasm-adapter.ts +++ b/client/src/adapter/wasm-adapter.ts @@ -429,14 +429,20 @@ export class WasmAdapter implements EngineAdapter { throw new Error("resolveAll requires worker-based engine"); } - async restoreState(state: GameState): Promise { + async restoreState(state: GameState, persistedJson?: string): Promise { this.assertInitialized(); await this.ensureCardDb(); - const json = JSON.stringify(state); + const json = persistedJson ?? JSON.stringify(state); if (this.engine) await this.engine.restoreState(json); else await this.fallback!.restoreState(json); } + async exportPersistedState(): Promise { + this.assertInitialized(); + if (this.engine) return this.engine.exportPersistedState(); + return this.fallback!.exportPersistedState(); + } + /** * Toggle the engine's multiplayer enforcement flag. When enabled, the * Rust side refuses `restore_game_state` with a descriptive error — @@ -479,9 +485,8 @@ export class WasmAdapter implements EngineAdapter { * Distinct from `restoreState` (undo semantics, deterministic re-seed). * Mirrors `server-core::GameSession::from_persisted`. */ - async resumeMultiplayerHostState(state: GameState): Promise { + async resumeMultiplayerHostState(persistedJson: string): Promise { this.assertInitialized(); - const json = JSON.stringify(state); if (this.engine) { // Ensure the card database is loaded before the engine rehydrates // ability definitions on restore. Same sequential-queue guarantee @@ -492,9 +497,9 @@ export class WasmAdapter implements EngineAdapter { () => { /* card DB is best-effort */ }, ); } - await this.engine.resumeMultiplayerHostState(json); + await this.engine.resumeMultiplayerHostState(persistedJson); } else { - this.fallback!.resumeMultiplayerHostState(json); + this.fallback!.resumeMultiplayerHostState(persistedJson); } } @@ -641,6 +646,7 @@ interface MainThreadFallback { getViewerSnapshot(viewerId: number): Promise; getAiAction(difficulty: string, playerId: number, waitingForType?: WaitingFor["type"]): Promise; restoreState(stateJson: string): Promise; + exportPersistedState(): Promise; resumeMultiplayerHostState(stateJson: string): void; setMultiplayerMode(enabled: boolean): void; applySeatMutation(stateJson: string, mutationJson: string): Promise; @@ -733,6 +739,9 @@ async function createMainThreadFallback(): Promise { restoreState: (stateJson: string) => enqueue(() => wasm.restore_game_state(stateJson)), + exportPersistedState: () => + enqueue(() => wasm.export_persisted_game_state_json()), + resumeMultiplayerHostState: (stateJson: string) => { enqueue(() => wasm.resume_multiplayer_host_state(stateJson)); }, diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index 09a6f10a39..32aa66dc1c 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -12,7 +12,7 @@ import { flashInGameRolls } from "./diceContest"; import i18n from "../i18n"; import { useAnimationStore } from "../stores/animationStore"; import { useAppNotificationStore } from "../stores/appToastStore"; -import { isMultiplayerMode, useGameStore, legalResultState, saveGame, saveCheckpoints } from "../stores/gameStore"; +import { isMultiplayerMode, useGameStore, legalResultState, saveGameWithAdapter, saveCheckpoints } from "../stores/gameStore"; import { getOpponentDisplayName } from "../stores/multiplayerStore"; import { usePreferencesStore } from "../stores/preferencesStore"; import { useUiStore } from "../stores/uiStore"; @@ -313,7 +313,7 @@ async function processAction(action: GameAction, actor: number): Promise { } } const { gameId } = useGameStore.getState(); - if (gameId) saveGame(gameId, newState); + if (gameId) await saveGameWithAdapter(gameId, newState, adapter); // 3c. Feed the throughput tracker: count stack entries that left the stack // this action (resolved, countered, or otherwise removed), id-diffed so a @@ -749,7 +749,7 @@ export async function restoreGameState( turnCheckpoints: preservedCheckpoints, }); if (gameId) { - await saveGame(gameId, restoredState); + await saveGameWithAdapter(gameId, restoredState, adapter); await saveCheckpoints(gameId, preservedCheckpoints); } @@ -872,7 +872,7 @@ export async function dispatchResolveAll( const { gameId } = useGameStore.getState(); const newState = useGameStore.getState().gameState; - if (gameId && newState) saveGame(gameId, newState); + if (gameId && newState) await saveGameWithAdapter(gameId, newState, adapter); } finally { batchResolveInProgress = false; setIsResolvingAll(false); diff --git a/client/src/providers/GameProvider.tsx b/client/src/providers/GameProvider.tsx index 96f6412f65..94038b194f 100644 --- a/client/src/providers/GameProvider.tsx +++ b/client/src/providers/GameProvider.tsx @@ -44,6 +44,7 @@ import { clearP2PHostSession, loadActiveGame, loadGame, + loadPersistedGameJson, loadP2PHostSession, saveActiveGame, useGameStore, @@ -711,15 +712,15 @@ export function GameProvider({ // keyed on `phase-`) still match. Partial state // (only one record present) is treated as inconsistent: // clear both and fall through to a fresh game. - const [savedState, savedSession] = await Promise.all([ - loadGame(gameId), + const [savedPersistedJson, savedSession] = await Promise.all([ + loadPersistedGameJson(gameId), loadP2PHostSession(gameId), ]); signal.throwIfAborted(); const isResume = - savedState !== null && savedSession !== null && savedSession.gameStarted; - if ((savedState !== null) !== (savedSession !== null)) { + savedPersistedJson !== null && savedSession !== null && savedSession.gameStarted; + if ((savedPersistedJson !== null) !== (savedSession !== null)) { // Inconsistent: one record present, the other missing. // Drop both so the menu's Resume button doesn't re-offer. await clearGame(gameId); @@ -800,8 +801,8 @@ export function GameProvider({ gameId, roomCode: host.roomCode, hostDisplayName: useMultiplayerStore.getState().displayName || undefined, - resumeData: isResume && savedState && savedSession - ? { state: savedState, session: savedSession } + resumeData: isResume && savedPersistedJson && savedSession + ? { persistedJson: savedPersistedJson, session: savedSession } : undefined, }, ); @@ -1142,7 +1143,10 @@ export function GameProvider({ const setupLocal = async () => { if (cancelled) return; - const savedState = await loadGame(gameId); + const [savedState, persistedJson] = await Promise.all([ + loadGame(gameId), + loadPersistedGameJson(gameId), + ]); const adapter = getSharedAdapter(); if (savedState) { @@ -1151,7 +1155,7 @@ export function GameProvider({ // and handle token creation / effects after resume. await ensureCardDatabase().catch(() => {/* card DB is best-effort */}); if (cancelled) return; - await resumeGame(gameId, adapter, savedState); + await resumeGame(gameId, adapter, savedState, persistedJson); if (cancelled) return; // Derive player count from the restored state — the URL param may be // absent on resume (e.g. navigating directly to a saved game URL). diff --git a/client/src/services/gamePersistence.ts b/client/src/services/gamePersistence.ts index 19ffe9772b..4c1d51e384 100644 --- a/client/src/services/gamePersistence.ts +++ b/client/src/services/gamePersistence.ts @@ -1,6 +1,7 @@ import { createStore, del, get, set } from "idb-keyval"; import type { FormatConfig, GameState, MatchConfig } from "../adapter/types"; +import type { EngineAdapter } from "../adapter/types"; import type { SeatState } from "../multiplayer/seatTypes"; import { ACTIVE_GAME_KEY, GAME_CHECKPOINTS_PREFIX, GAME_KEY_PREFIX } from "../constants/storage"; @@ -98,7 +99,31 @@ function getGameStore(): ReturnType { // ── Game State (IndexedDB) ────────────────────────────────────────────── -export async function saveGame(gameId: string, state: GameState): Promise { +/** Wire envelope for authoritative browser/P2P persistence. */ +export interface PersistedGameStateWire { + state: GameState; + pending_cast_sacrifice_rollbacks?: Record; +} + +function isPersistedGameStateWire(value: unknown): value is PersistedGameStateWire { + return ( + typeof value === "object" + && value !== null + && "state" in value + && typeof (value as PersistedGameStateWire).state === "object" + ); +} + +/** + * Persist authoritative engine state. When `persistedJson` is provided it is + * written verbatim (includes sacrifice rollback sidecar); otherwise the bare + * `GameState` is stored for legacy/manual-import paths. + */ +export async function saveGame( + gameId: string, + state: GameState, + persistedJson?: string, +): Promise { if ( state.match_phase === "Completed" || (!state.match_phase && state.waiting_for.type === "GameOver") @@ -107,7 +132,8 @@ export async function saveGame(gameId: string, state: GameState): Promise return; } try { - await set(GAME_KEY_PREFIX + gameId, state, getGameStore()); + const toStore = persistedJson ? JSON.parse(persistedJson) : state; + await set(GAME_KEY_PREFIX + gameId, toStore, getGameStore()); } catch (err) { console.warn("[saveGame] IndexedDB write failed:", err); } @@ -115,13 +141,44 @@ export async function saveGame(gameId: string, state: GameState): Promise export async function loadGame(gameId: string): Promise { try { - const state = await get(GAME_KEY_PREFIX + gameId, getGameStore()); - return state ?? null; + const raw = await get(GAME_KEY_PREFIX + gameId, getGameStore()); + if (!raw) return null; + if (isPersistedGameStateWire(raw)) return raw.state; + return raw as GameState; } catch { return null; } } +/** Load the raw persisted envelope for engine resume (includes rollback sidecar). */ +export async function loadPersistedGameJson(gameId: string): Promise { + try { + const raw = await get(GAME_KEY_PREFIX + gameId, getGameStore()); + if (!raw) return null; + return JSON.stringify(raw); + } catch { + return null; + } +} + +/** Persist authoritative engine state when the adapter can export rollbacks. */ +export async function saveGameWithAdapter( + gameId: string, + state: GameState, + adapter: EngineAdapter, +): Promise { + if (adapter.exportPersistedState) { + try { + const persistedJson = await adapter.exportPersistedState(); + await saveGame(gameId, state, persistedJson); + return; + } catch (err) { + console.warn("[saveGameWithAdapter] exportPersistedState failed:", err); + } + } + await saveGame(gameId, state); +} + export async function clearGame(gameId: string): Promise { try { await del(GAME_KEY_PREFIX + gameId, getGameStore()); diff --git a/client/src/stores/gameStore.ts b/client/src/stores/gameStore.ts index 34285ae20c..5616f8f0ee 100644 --- a/client/src/stores/gameStore.ts +++ b/client/src/stores/gameStore.ts @@ -17,7 +17,7 @@ import type { import { MAX_UNDO_HISTORY, UNDOABLE_ACTIONS } from "../constants/game"; import { applySpellPaymentPreference } from "../game/castPaymentMode"; import { getPlayerId } from "../hooks/usePlayerId"; -import { loadCheckpoints, saveGame } from "../services/gamePersistence"; +import { loadCheckpoints, loadPersistedGameJson, saveGameWithAdapter } from "../services/gamePersistence"; import { resetStackThroughput } from "../utils/stackThroughput"; /** Map a LegalActionsResult to the store fields it owns — single source of truth. */ @@ -35,7 +35,9 @@ export function legalResultState(result: LegalActionsResult): Pick Promise; - resumeGame: (gameId: string, adapter: EngineAdapter, savedState: GameState) => Promise; + resumeGame: ( + gameId: string, + adapter: EngineAdapter, + savedState: GameState, + persistedJson?: string | null, + ) => Promise; /** * Resume a P2P host game. Distinct from `resumeGame` because the * adapter already loaded engine state internally via @@ -244,15 +251,16 @@ export const useGameStore = create()( turnCheckpoints: [], startingContest, }); - saveGame(gameId, state); + await saveGameWithAdapter(gameId, state, adapter); }, - resumeGame: async (gameId, adapter, savedState) => { + resumeGame: async (gameId, adapter, savedState, persistedJson) => { // Reset stack-pacing throughput — resuming may load a different game than // the one just played; stale churn must not carry across. resetStackThroughput(); await adapter.initialize(); - await adapter.restoreState(savedState); + const resumeJson = persistedJson ?? await loadPersistedGameJson(gameId); + await adapter.restoreState(savedState, resumeJson ?? undefined); const state = await adapter.getState(); const legalResult = await adapter.getLegalActions(); const savedCheckpoints = await loadCheckpoints(gameId); @@ -348,7 +356,7 @@ export const useGameStore = create()( }; }); - if (gameId) saveGame(gameId, newState); + if (gameId) await saveGameWithAdapter(gameId, newState, adapter); return result.events; }, diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index 34b607c5ab..5a0b930bf5 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -84,6 +84,12 @@ export function evaluate_deck_compatibility_js(request: any): any; */ export function export_game_state_json(): string; +/** + * Export authoritative game state for browser/P2P persistence, including + * sacrifice rollback snapshots kept outside filtered `GameState` serde. + */ +export function export_persisted_game_state_json(): string; + /** * Return the authoritative list of user-selectable formats as a typed array. * The frontend treats this as the single source of truth for rendering @@ -356,6 +362,7 @@ export interface InitOutput { readonly estimate_bracket_for_deck: (a: any) => [number, number, number]; readonly evaluate_deck_compatibility_js: (a: any) => [number, number, number]; readonly export_game_state_json: () => [number, number, number, number]; + readonly export_persisted_game_state_json: () => [number, number, number, number]; readonly getFormatRegistry: () => any; readonly get_ai_action: (a: number, b: number, c: number) => [number, number, number]; readonly get_ai_scored_candidates: (a: number, b: number, c: number, d: bigint) => [number, number, number]; diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index e0794658cb..fe7be6f482 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -25,6 +25,7 @@ use engine::types::format::{FormatConfig, GameFormat}; use engine::types::identifiers::ObjectId; use engine::types::mana::ManaCost; use engine::types::match_config::MatchConfig; +use engine::types::persisted_game_state::{deserialize_resumable_game_state, PersistedGameState}; use engine::types::{GameAction, GameState, PlayerId}; use engine::game::resolve_player_deck_list; @@ -1170,6 +1171,18 @@ pub fn export_game_state_json() -> Result { })? } +/// Export the authoritative game state for browser/P2P persistence, including +/// server-authoritative sacrifice rollback snapshots kept outside filtered +/// `GameState` serde (CR 400.2). +#[wasm_bindgen] +pub fn export_persisted_game_state_json() -> Result { + with_state(|state| { + let persisted = PersistedGameState::capture(state); + serde_json::to_string(&persisted) + .map_err(|e| JsValue::from_str(&format!("Failed to serialize PersistedGameState: {e}"))) + })? +} + /// Restore the game state from a JSON string. /// Uses serde_json which handles string-keyed maps (from localStorage round-trip) /// correctly deserializing into HashMap. @@ -1184,8 +1197,8 @@ pub fn restore_game_state(json_str: &str) -> Result<(), JsValue> { "restore_game_state refused: undo is disabled in multiplayer sessions", )); } - let mut state: GameState = serde_json::from_str(json_str) - .map_err(|e| JsValue::from_str(&format!("Failed to deserialize GameState: {}", e)))?; + let mut state = deserialize_resumable_game_state(json_str) + .map_err(|e| JsValue::from_str(&format!("Failed to deserialize game state: {}", e)))?; state.rng = ChaCha20Rng::seed_from_u64(state.rng_seed); state.debug_mode = true; CARD_DB.with(|cell| { @@ -1239,8 +1252,8 @@ pub fn resume_multiplayer_host_state(json_str: &str) -> Result<(), JsValue> { )); } - let mut state: GameState = serde_json::from_str(json_str) - .map_err(|e| JsValue::from_str(&format!("Failed to deserialize GameState: {}", e)))?; + let mut state = deserialize_resumable_game_state(json_str) + .map_err(|e| JsValue::from_str(&format!("Failed to deserialize game state: {}", e)))?; // Stale `rng_seed` replays the pre-save ChaCha20 sequence because // stream position is `#[serde(skip)]`. Mirrors server-core. diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs index 3ad07c0b1a..3fbe652b5b 100644 --- a/crates/engine/src/game/casting.rs +++ b/crates/engine/src/game/casting.rs @@ -15176,6 +15176,12 @@ pub fn handle_activate_ability( Ok(WaitingFor::Priority { player }) } +/// CR 601.2: Drop any in-flight sacrifice rollback state once a cast or +/// activation commits to the stack, or after cancel rollback consumes it. +pub(crate) fn clear_pending_cast_sacrifice_rollback(state: &mut GameState, cast_id: ObjectId) { + state.pending_cast_sacrifice_rollbacks.remove(&cast_id); +} + /// CR 601.2i: If the player is unable or unwilling to complete a cast, the /// process is reversed: the spell is removed from the stack and any costs /// paid/choices made are rewound. The engine exposes this as @@ -15284,6 +15290,26 @@ pub fn handle_cancel_cast( && !(unit.is_convoke_payment() && delved_cards.contains(&unit.source_id)) }); } + // CR 601.2i + CR 733.1: Roll back sacrificed permanents and purge any + // sacrifice-side triggers parked for a later drain. + if let Some(rollback) = state + .pending_cast_sacrifice_rollbacks + .remove(&pending.object_id) + { + state + .deferred_triggers + .truncate(rollback.deferred_trigger_floor); + for snapshot in rollback.snapshots { + super::zones::restore_object_for_cancel(state, snapshot); + } + } + if state + .pending_cast + .as_ref() + .is_some_and(|cast| cast.object_id == pending.object_id) + { + state.pending_cast = None; + } if let Some(obj) = state.objects.get_mut(&pending.object_id) { obj.convoked_creatures.clear(); } diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index 94c7913239..27961858e2 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -1,3 +1,4 @@ +use std::collections::hash_map::Entry; use std::collections::HashSet; use crate::game::functioning_abilities::static_kind_present; @@ -14,8 +15,8 @@ use crate::types::events::{GameEvent, ManaTapState}; use crate::types::game_state::{ ActivationResidual, AssistState, CastPaymentMode, CastingVariant, ConvokeMode, CostResume, CounterCostChoice, CounterRemoveChoice, DistributionUnit, GameState, PayCostKind, PendingCast, - PendingDiscardForCostResume, SpellCostSource, StackEntry, StackEntryKind, StackPaidSnapshot, - WaitingFor, + PendingCastSacrificeRollback, PendingDiscardForCostResume, SpellCostSource, StackEntry, + StackEntryKind, StackPaidSnapshot, WaitingFor, }; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::keywords::Keyword; @@ -1736,10 +1737,27 @@ pub(crate) fn handle_sacrifice_for_cost( let cost_event_start = events.len(); // Sacrifice each chosen permanent + let cast_id = pending.object_id; + let deferred_trigger_floor = state.deferred_triggers.len(); + let mut snapshots = Vec::new(); + for &id in chosen { + snapshots.push(state.objects.get(&id).cloned().ok_or_else(|| { + EngineError::InvalidAction(format!("sacrifice target {id:?} missing")) + })?); + } for &id in chosen { super::sacrifice::sacrifice_permanent(state, id, player, events) .map_err(|e| EngineError::InvalidAction(format!("{e}")))?; } + match state.pending_cast_sacrifice_rollbacks.entry(cast_id) { + Entry::Occupied(mut entry) => entry.get_mut().snapshots.extend(snapshots), + Entry::Vacant(entry) => { + entry.insert(PendingCastSacrificeRollback { + deferred_trigger_floor, + snapshots, + }); + } + } // CR 603.10a + CR 701.21a + CR 601.2h + CR 118.8: permanents sacrificed to pay // one cost component leave the battlefield together; a co-departing observer @@ -3105,6 +3123,7 @@ fn push_ability_entry( // CR 606.2: Classify loyalty vs. normal from the source ability cost. kind: super::planeswalker::activated_ability_kind(state, source_id, ability_index), }); + super::casting::clear_pending_cast_sacrifice_rollback(state, source_id); // CR 702.142b: Emit additional event when a boast ability is activated. super::casting_targets::emit_keyword_ability_event_if_tagged( state, @@ -6551,6 +6570,7 @@ pub(super) fn finalize_cast_with_phyrexian_choices( convoked_creatures: convoked_creature_count, }, ); + super::casting::clear_pending_cast_sacrifice_rollback(state, object_id); // Track commander cast count for tax calculation if was_in_command_zone { diff --git a/crates/engine/src/game/casting_targets.rs b/crates/engine/src/game/casting_targets.rs index 727073f4e1..ea1a7f116a 100644 --- a/crates/engine/src/game/casting_targets.rs +++ b/crates/engine/src/game/casting_targets.rs @@ -355,6 +355,7 @@ pub(crate) fn handle_select_targets( player, events, ); + super::casting::clear_pending_cast_sacrifice_rollback(state, pending.object_id); priority::clear_priority_passes(state); return Ok(WaitingFor::Priority { player }); } @@ -481,6 +482,7 @@ pub(crate) fn handle_choose_target( player, events, ); + super::casting::clear_pending_cast_sacrifice_rollback(state, pending.object_id); priority::clear_priority_passes(state); return Ok(drain_deferred_triggers_after_stack_object_announcement( state, diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 1b40a114b4..6497e44645 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -851,6 +851,16 @@ pub fn filter_state_for_viewer(state: &GameState, viewer: PlayerId) -> GameState // (object_id, card_id, ability, cost) — the card's identity is already visible via // the stack object. + // CR 400.2: Pre-payment sacrifice rollback snapshots carry hidden permanent + // identity and must not leak to non-controllers through filtered state. + if state + .pending_cast + .as_ref() + .is_none_or(|pending| pending.ability.controller != viewer) + { + filtered.pending_cast_sacrifice_rollbacks.clear(); + } + for pool in &mut filtered.deck_pools { if pool.player != viewer { // Per-seat redaction: replace the Arc'd decks with fresh empties. diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 21802a20db..a51b569645 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -520,6 +520,68 @@ pub fn create_object( id } +/// CR 733.1: Restore a permanent sacrificed to pay a cost when its pending +/// cast/activation is canceled. Replays the pre-payment snapshot without +/// normal zone-change cleanup or battlefield-entry resets. +pub fn restore_object_for_cancel(state: &mut GameState, snapshot: GameObject) { + let object_id = snapshot.id; + let owner = snapshot.owner; + let target_zone = snapshot.zone; + let attached_to = snapshot.attached_to; + let child_attachments = snapshot.attachments.clone(); + + if let Some(current) = state.objects.get(&object_id) { + remove_from_zone(state, object_id, current.zone, owner); + } + + state.objects.insert(object_id, snapshot); + add_to_zone(state, object_id, target_zone, owner); + restore_attachment_graph_edges(state, object_id, attached_to, &child_attachments); +} + +/// CR 303.4: Replay attachment graph edges severed during the sacrificed +/// permanent's zone exit so cancel rollback restores host `attachments` lists +/// and attachment `attached_to` pointers consistently. +fn restore_attachment_graph_edges( + state: &mut GameState, + object_id: ObjectId, + attached_to: Option, + child_attachments: &[ObjectId], +) { + let mut graph_changed = false; + + if let Some(host_id) = attached_to.and_then(|target| target.as_object()) { + if let Some(host) = state.objects.get_mut(&host_id) { + if host.zone == Zone::Battlefield && !host.attachments.contains(&object_id) { + host.attachments.push(object_id); + graph_changed = true; + } + } + } + + for &child_id in child_attachments { + if let Some(child) = state.objects.get_mut(&child_id) { + if child.zone == Zone::Battlefield + && child.attached_to + != Some(crate::game::game_object::AttachTarget::Object(object_id)) + { + child.attached_to = Some(crate::game::game_object::AttachTarget::Object(object_id)); + graph_changed = true; + } + } + if let Some(host) = state.objects.get_mut(&object_id) { + if !host.attachments.contains(&child_id) { + host.attachments.push(child_id); + graph_changed = true; + } + } + } + + if graph_changed { + crate::game::layers::mark_layers_full(state); + } +} + /// CR 700.11: A player has "descended this turn" when a permanent card has /// been put into their graveyard from anywhere this turn. Single authority for /// the descend bookkeeping, shared by `move_to_zone` and the merge-split diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 77730f87d6..e8a1614b75 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -1950,6 +1950,16 @@ impl ActivationResidual { } } +/// CR 601.2i + CR 733.1: In-flight sacrifice rollback for a pending cast. +/// Stored on `GameState` rather than inside serializable `PendingCast` so +/// pre-payment snapshots of face-down permanents are not exposed to non-controllers +/// (CR 400.2). Persisted via `PersistedSession` / `PersistedGameState` sidecars. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingCastSacrificeRollback { + pub snapshots: Vec, + pub deferred_trigger_floor: usize, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PendingCast { pub object_id: ObjectId, @@ -7948,6 +7958,12 @@ pub struct GameState { #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_cast: Option>, + /// CR 601.2i + CR 733.1: Pre-payment sacrifice snapshots for in-flight + /// casts. Not serialized — kept server-side only so hidden permanent identity + /// cannot leak through opponent-visible `PendingCast` views (CR 400.2). + #[serde(skip)] + pub pending_cast_sacrifice_rollbacks: HashMap, + /// CR 701.54: Per-player ring level (0-3, 4 levels total). #[serde(default)] pub ring_level: HashMap, @@ -8714,6 +8730,7 @@ impl GameState { current_triggered_mana_override: None, pending_discard_for_cost: None, pending_cast: None, + pending_cast_sacrifice_rollbacks: HashMap::new(), ring_level: HashMap::new(), ring_bearer: HashMap::new(), dungeon_progress: HashMap::new(), diff --git a/crates/engine/src/types/mod.rs b/crates/engine/src/types/mod.rs index 75772a0364..5405e93df8 100644 --- a/crates/engine/src/types/mod.rs +++ b/crates/engine/src/types/mod.rs @@ -14,6 +14,7 @@ pub mod layers; pub mod log; pub mod mana; pub mod match_config; +pub mod persisted_game_state; pub mod phase; pub mod player; pub mod proposed_event; diff --git a/crates/engine/src/types/persisted_game_state.rs b/crates/engine/src/types/persisted_game_state.rs new file mode 100644 index 0000000000..f563d6b09e --- /dev/null +++ b/crates/engine/src/types/persisted_game_state.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::types::game_state::{GameState, PendingCastSacrificeRollback}; +use crate::types::identifiers::ObjectId; + +/// Authoritative game-state snapshot for browser/P2P persistence. +/// +/// Mirrors `server_core::PersistedSession`'s sidecar pattern: `GameState` +/// serde skips `pending_cast_sacrifice_rollbacks` so hidden permanent identity +/// cannot leak through filtered client views (CR 400.2), while the wrapper +/// carries rollback snapshots for authoritative resume. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedGameState { + pub state: GameState, + /// CR 601.2i + CR 733.1: sacrifice rollback snapshots for in-flight casts. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub pending_cast_sacrifice_rollbacks: HashMap, +} + +impl PersistedGameState { + pub fn capture(state: &GameState) -> Self { + Self { + state: state.clone(), + pending_cast_sacrifice_rollbacks: state.pending_cast_sacrifice_rollbacks.clone(), + } + } + + pub fn into_game_state(self) -> GameState { + let mut game_state = self.state; + if !self.pending_cast_sacrifice_rollbacks.is_empty() { + game_state.pending_cast_sacrifice_rollbacks = self.pending_cast_sacrifice_rollbacks; + } + game_state + } +} + +/// Deserialize either a `PersistedGameState` wrapper or a legacy bare `GameState`. +pub fn deserialize_resumable_game_state(json: &str) -> Result { + if let Ok(wrapper) = serde_json::from_str::(json) { + return Ok(wrapper.into_game_state()); + } + serde_json::from_str(json) +} diff --git a/crates/engine/tests/integration/issue_3870_yawgmoth_sacrifice_cancel.rs b/crates/engine/tests/integration/issue_3870_yawgmoth_sacrifice_cancel.rs new file mode 100644 index 0000000000..b4f7229560 --- /dev/null +++ b/crates/engine/tests/integration/issue_3870_yawgmoth_sacrifice_cancel.rs @@ -0,0 +1,651 @@ +//! Regression for issue #3870: cancelling Yawgmoth's activated ability after +//! paying a sacrifice cost must return the sacrificed permanent. +//! +//! https://github.com/phase-rs/phase/issues/3870 + +use engine::game::game_object::AttachTarget; +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::{ + AbilityDefinition, AbilityKind, Effect, ReplacementDefinition, StaticDefinition, TargetFilter, +}; +use engine::types::actions::GameAction; +use engine::types::counter::CounterType; +use engine::types::game_state::{PayCostKind, WaitingFor}; +use engine::types::phase::Phase; +use engine::types::replacements::ReplacementEvent; +use engine::types::statics::StaticMode; +use engine::types::zones::{EtbTapState, Zone}; + +const YAWGMOTH_ORACLE: &str = "Protection from Humans\n\ +Pay 1 life, Sacrifice another creature: Put a -1/-1 counter on up to one target creature and draw a card.\n\ +{B}{B}, Discard a card: Proliferate."; + +const ETB_DRAW_ORACLE: &str = "When this creature enters, draw a card."; +const DIES_DRAW_ORACLE: &str = "When this creature dies, draw a card."; + +/// CR 614.6: Rest in Peace / Leyline of the Void graveyard-to-exile redirect. +fn graveyard_exile_replacement() -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Graveyard) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + destination: Zone::Exile, + origin: None, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )) + .description( + "If a card would be put into a graveyard from anywhere, exile it instead.".to_string(), + ) +} + +fn yawgmoth_sacrifice_ability_index(runner: &engine::game::scenario::GameRunner) -> usize { + let yawgmoth = runner + .state() + .objects + .iter() + .find(|(_, obj)| { + obj.abilities.iter().any(|ability| { + ability + .description + .as_deref() + .is_some_and(|d| d.contains("Sacrifice another creature")) + }) + }) + .map(|(id, _)| *id) + .expect("Yawgmoth must be on the battlefield"); + runner.state().objects[&yawgmoth] + .abilities + .iter() + .position(|ability| { + ability + .description + .as_deref() + .is_some_and(|d| d.contains("Sacrifice another creature")) + }) + .expect("Yawgmoth must expose the sacrifice ability") +} + +fn mark_token( + runner: &mut engine::game::scenario::GameRunner, + id: engine::types::identifiers::ObjectId, +) { + runner.state_mut().objects.get_mut(&id).unwrap().is_token = true; +} + +#[test] +fn yawgmoth_cancel_after_sacrifice_cost_returns_token() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let spawn = scenario + .add_creature(P0, "Eldrazi Spawn", 0, 1) + .with_subtypes(vec!["Eldrazi", "Spawn"]) + .id(); + + let mut runner = scenario.build(); + mark_token(&mut runner, spawn); + let ability_index = yawgmoth_sacrifice_ability_index(&runner); + + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + + let WaitingFor::PayCost { + kind: PayCostKind::Sacrifice, + choices, + .. + } = &runner.state().waiting_for + else { + panic!( + "expected sacrifice PayCost after announcing Yawgmoth, got {:?}", + runner.state().waiting_for + ); + }; + assert!( + choices.contains(&spawn), + "Eldrazi Spawn must be legal sacrifice fodder" + ); + + runner + .act(GameAction::SelectCards { cards: vec![spawn] }) + .expect("sacrifice Eldrazi Spawn for cost"); + + assert_eq!( + runner.state().objects[&spawn].zone, + Zone::Graveyard, + "precondition: sacrifice cost must move the spawn to the graveyard" + ); + + runner + .act(GameAction::CancelCast) + .expect("cancel after sacrifice cost"); + + assert_eq!( + runner.state().objects[&spawn].zone, + Zone::Battlefield, + "cancelled activation must return the sacrificed token to the battlefield" + ); + assert!( + matches!(runner.state().waiting_for, WaitingFor::Priority { .. }), + "cancel must return to priority" + ); +} + +#[test] +fn yawgmoth_cancel_restores_exiled_sacrifice_to_battlefield() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario + .add_creature(P0, "Rest in Peace", 0, 0) + .as_enchantment() + .with_replacement_definition(graveyard_exile_replacement()); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let spawn = scenario + .add_creature(P0, "Eldrazi Spawn", 0, 1) + .with_subtypes(vec!["Eldrazi", "Spawn"]) + .id(); + + let mut runner = scenario.build(); + mark_token(&mut runner, spawn); + let ability_index = yawgmoth_sacrifice_ability_index(&runner); + + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + runner + .act(GameAction::SelectCards { cards: vec![spawn] }) + .expect("sacrifice Eldrazi Spawn for cost"); + + assert_eq!( + runner.state().objects[&spawn].zone, + Zone::Exile, + "precondition: Rest in Peace must exile the sacrificed permanent" + ); + + runner + .act(GameAction::CancelCast) + .expect("cancel after redirected sacrifice cost"); + + assert_eq!( + runner.state().objects[&spawn].zone, + Zone::Battlefield, + "cancel must restore the sacrificed permanent to its pre-payment zone" + ); +} + +#[test] +fn yawgmoth_cancel_restore_does_not_queue_etb_triggers() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let visionary = scenario + .add_creature_from_oracle(P0, "Elvish Visionary", 1, 1, ETB_DRAW_ORACLE) + .id(); + + let mut runner = scenario.build(); + let hand_before = runner.state().players[P0.0 as usize].hand.len(); + let ability_index = yawgmoth_sacrifice_ability_index(&runner); + + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + runner + .act(GameAction::SelectCards { + cards: vec![visionary], + }) + .expect("sacrifice Elvish Visionary for cost"); + runner + .act(GameAction::CancelCast) + .expect("cancel after sacrifice cost"); + + assert_eq!( + runner.state().objects[&visionary].zone, + Zone::Battlefield, + "cancel must restore the sacrificed creature" + ); + assert!( + !matches!(runner.state().waiting_for, WaitingFor::OrderTriggers { .. }), + "CR 733.1: cancel rollback must not queue ETB triggers, got {:?}", + runner.state().waiting_for + ); + assert!( + runner.state().stack.is_empty(), + "cancel rollback must leave the stack empty" + ); + assert_eq!( + runner.state().players[P0.0 as usize].hand.len(), + hand_before, + "cancel rollback must not fire the restored creature's ETB draw" + ); +} + +#[test] +fn yawgmoth_cancel_restore_does_not_queue_dies_triggers() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let fodder = scenario + .add_creature_from_oracle(P0, "Fodder", 1, 1, DIES_DRAW_ORACLE) + .id(); + + let mut runner = scenario.build(); + let hand_before = runner.state().players[P0.0 as usize].hand.len(); + let ability_index = yawgmoth_sacrifice_ability_index(&runner); + + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + runner + .act(GameAction::SelectCards { + cards: vec![fodder], + }) + .expect("sacrifice fodder for cost"); + runner + .act(GameAction::CancelCast) + .expect("cancel after sacrifice cost"); + + assert_eq!( + runner.state().objects[&fodder].zone, + Zone::Battlefield, + "cancel must restore the sacrificed creature" + ); + assert!( + runner.state().deferred_triggers.is_empty(), + "CR 733.1: cancel must purge sacrifice-side deferred triggers" + ); + assert!( + runner.state().stack.is_empty(), + "cancel rollback must leave the stack empty" + ); + assert_eq!( + runner.state().players[P0.0 as usize].hand.len(), + hand_before, + "cancel rollback must not fire the restored creature's dies draw" + ); +} + +#[test] +fn yawgmoth_cancel_restores_tapped_and_counters() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let spawn = scenario.add_creature(P0, "Stateful Spawn", 2, 2).id(); + + let mut runner = scenario.build(); + { + let obj = runner.state_mut().objects.get_mut(&spawn).unwrap(); + obj.tapped = true; + obj.counters.insert(CounterType::Plus1Plus1, 1); + } + + let ability_index = yawgmoth_sacrifice_ability_index(&runner); + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + runner + .act(GameAction::SelectCards { cards: vec![spawn] }) + .expect("sacrifice stateful spawn for cost"); + runner + .act(GameAction::CancelCast) + .expect("cancel after sacrifice cost"); + + let obj = &runner.state().objects[&spawn]; + assert_eq!(obj.zone, Zone::Battlefield); + assert!(obj.tapped, "cancel must restore tapped state"); + assert_eq!( + obj.counters.get(&CounterType::Plus1Plus1).copied(), + Some(1), + "cancel must restore counters" + ); +} + +fn activate_yawgmoth_and_sacrifice( + runner: &mut engine::game::scenario::GameRunner, + yawgmoth: engine::types::identifiers::ObjectId, + sacrifice: engine::types::identifiers::ObjectId, +) { + let ability_index = yawgmoth_sacrifice_ability_index(runner); + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + runner + .act(GameAction::SelectCards { + cards: vec![sacrifice], + }) + .expect("sacrifice for Yawgmoth cost"); +} + +fn commit_yawgmoth_activation_to_stack(runner: &mut engine::game::scenario::GameRunner) { + for _ in 0..16 { + match runner.state().waiting_for.clone() { + WaitingFor::TargetSelection { .. } => { + runner + .act(GameAction::ChooseTarget { target: None }) + .expect("decline optional -1/-1 target"); + } + WaitingFor::Priority { .. } if !runner.state().stack.is_empty() => return, + WaitingFor::Priority { .. } => { + runner.pass_both_players(); + } + other => { + panic!("unexpected waiting state while committing Yawgmoth activation: {other:?}") + } + } + } + panic!("Yawgmoth activation never reached the stack"); +} + +#[test] +fn completed_activation_does_not_restore_prior_sacrifice_on_later_cancel() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let first_fodder = scenario.add_creature(P0, "First Fodder", 1, 1).id(); + let second_fodder = scenario.add_creature(P0, "Second Fodder", 1, 1).id(); + scenario.add_spell_to_library_top(P0, "Library Filler A", true); + scenario.add_spell_to_library_top(P0, "Library Filler B", true); + + let mut runner = scenario.build(); + + activate_yawgmoth_and_sacrifice(&mut runner, yawgmoth, first_fodder); + commit_yawgmoth_activation_to_stack(&mut runner); + runner.advance_until_stack_empty(); + for _ in 0..12 { + match runner.state().waiting_for.clone() { + WaitingFor::DeclareAttackers { .. } => { + runner + .act(GameAction::DeclareAttackers { + attacks: vec![], + bands: vec![], + }) + .expect("skip attack step before second activation"); + } + WaitingFor::DeclareBlockers { .. } => { + runner + .act(GameAction::DeclareBlockers { + assignments: vec![], + }) + .expect("skip block step before second activation"); + } + WaitingFor::Priority { .. } + if matches!( + runner.state().phase, + Phase::PreCombatMain | Phase::PostCombatMain + ) => + { + break; + } + WaitingFor::Priority { .. } => runner.pass_both_players(), + other => panic!("unexpected state before second activation: {other:?}"), + } + } + + assert_ne!( + runner.state().objects[&first_fodder].zone, + Zone::Battlefield, + "first activation sacrifice must stay spent after the ability resolves" + ); + + activate_yawgmoth_and_sacrifice(&mut runner, yawgmoth, second_fodder); + runner + .act(GameAction::CancelCast) + .expect("cancel second activation after sacrifice"); + + assert_ne!( + runner.state().objects[&first_fodder].zone, + Zone::Battlefield, + "cancel on a later activation must not resurrect an earlier sacrifice" + ); + assert_eq!( + runner.state().objects[&second_fodder].zone, + Zone::Battlefield, + "cancel must restore only the current activation's sacrifice" + ); +} + +#[test] +fn yawgmoth_cancel_after_prevented_sacrifice_does_not_duplicate_battlefield() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let protected = scenario + .add_creature(P0, "Protected Fodder", 1, 1) + .with_static_definition( + StaticDefinition::new(StaticMode::Other("CantBeSacrificed".to_string())) + .affected(TargetFilter::SelfRef), + ) + .id(); + + let mut runner = scenario.build(); + let ability_index = yawgmoth_sacrifice_ability_index(&runner); + + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + runner + .act(GameAction::SelectCards { + cards: vec![protected], + }) + .expect("attempt sacrifice on protected creature"); + runner + .act(GameAction::CancelCast) + .expect("cancel after prevented sacrifice cost"); + + assert_eq!( + runner.state().objects[&protected].zone, + Zone::Battlefield, + "prevented sacrifice must leave the creature on the battlefield" + ); + let battlefield_dupes = runner + .state() + .battlefield + .iter() + .filter(|&&id| id == protected) + .count(); + assert_eq!( + battlefield_dupes, 1, + "cancel rollback must not duplicate battlefield zone-list entries for same-zone snapshots" + ); +} + +#[test] +fn yawgmoth_cancel_restores_attached_equipment_host_graph() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let host = scenario.add_creature(P0, "Equipped Host", 2, 2).id(); + let equipment = scenario + .add_creature(P0, "Test Equipment", 0, 0) + .as_artifact() + .with_subtypes(vec!["Equipment"]) + .id(); + + let mut runner = scenario.build(); + { + let host_obj = runner.state_mut().objects.get_mut(&host).unwrap(); + host_obj.attachments.push(equipment); + let equip_obj = runner.state_mut().objects.get_mut(&equipment).unwrap(); + equip_obj.attached_to = Some(AttachTarget::Object(host)); + } + + let ability_index = yawgmoth_sacrifice_ability_index(&runner); + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + runner + .act(GameAction::SelectCards { cards: vec![host] }) + .expect("sacrifice equipped host creature for cost"); + runner + .act(GameAction::CancelCast) + .expect("cancel after sacrificing equipped host"); + + let host_obj = &runner.state().objects[&host]; + let equip_obj = &runner.state().objects[&equipment]; + assert_eq!(host_obj.zone, Zone::Battlefield, "host must be restored"); + assert_eq!( + equip_obj.zone, + Zone::Battlefield, + "equipment must stay on battlefield" + ); + assert_eq!( + equip_obj.attached_to, + Some(AttachTarget::Object(host)), + "cancel must restore the equipment's attached_to pointer" + ); + assert!( + host_obj.attachments.contains(&equipment), + "cancel must restore the host's attachments list" + ); +} + +#[test] +fn yawgmoth_cancel_restore_survives_persisted_session_roundtrip() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let fodder = scenario.add_creature(P0, "Persist Fodder", 1, 1).id(); + + let mut runner = scenario.build(); + let ability_index = yawgmoth_sacrifice_ability_index(&runner); + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + runner + .act(GameAction::SelectCards { + cards: vec![fodder], + }) + .expect("sacrifice fodder for cost"); + + let rollbacks = runner.state().pending_cast_sacrifice_rollbacks.clone(); + let pending_cast = runner.state().pending_cast.clone(); + assert!( + rollbacks.contains_key(&yawgmoth), + "precondition: sacrifice rollback must be recorded before cancel" + ); + + // Simulate `PersistedSession` serde: `GameState` drops skipped rollbacks, the + // server sidecar restores them on resume. + let json = serde_json::to_string(runner.state()).expect("serialize paused state"); + let mut restored: engine::types::game_state::GameState = + serde_json::from_str(&json).expect("deserialize paused state"); + assert!( + restored.pending_cast_sacrifice_rollbacks.is_empty(), + "GameState serde must not expose rollback snapshots to clients" + ); + restored.pending_cast_sacrifice_rollbacks = rollbacks; + restored.pending_cast = pending_cast; + *runner.state_mut() = restored; + + runner + .act(GameAction::CancelCast) + .expect("cancel after persist boundary"); + + assert_eq!( + runner.state().objects[&fodder].zone, + Zone::Battlefield, + "cancel must restore sacrificed permanent after persist round-trip" + ); +} + +#[test] +fn yawgmoth_cancel_restore_survives_persisted_game_state_roundtrip() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let yawgmoth = scenario + .add_creature_from_oracle(P0, "Yawgmoth, Thran Physician", 2, 4, YAWGMOTH_ORACLE) + .id(); + let fodder = scenario.add_creature(P0, "Browser Fodder", 1, 1).id(); + + let mut runner = scenario.build(); + let ability_index = yawgmoth_sacrifice_ability_index(&runner); + runner + .act(GameAction::ActivateAbility { + source_id: yawgmoth, + ability_index, + }) + .expect("begin Yawgmoth activation"); + runner + .act(GameAction::SelectCards { + cards: vec![fodder], + }) + .expect("sacrifice fodder for cost"); + + let persisted = + engine::types::persisted_game_state::PersistedGameState::capture(runner.state()); + assert!( + !persisted.pending_cast_sacrifice_rollbacks.is_empty(), + "precondition: sacrifice rollback must be captured in persisted wrapper" + ); + + let json = serde_json::to_string(&persisted).expect("serialize persisted wrapper"); + let restored = engine::types::persisted_game_state::deserialize_resumable_game_state(&json) + .expect("deserialize persisted wrapper"); + *runner.state_mut() = restored; + + runner + .act(GameAction::CancelCast) + .expect("cancel after browser persist boundary"); + + assert_eq!( + runner.state().objects[&fodder].zone, + Zone::Battlefield, + "cancel must restore sacrificed permanent after PersistedGameState round-trip" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 0bf492c9ea..317b188bc1 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -355,6 +355,7 @@ mod issue_3862_ulvenwald_tracker_fight; mod issue_3864_swords_two_targets; mod issue_3866_insidious_roots_double_trigger; mod issue_3869_pentad_prism_mana; +mod issue_3870_yawgmoth_sacrifice_cancel; mod issue_3871_summoners_pact; mod issue_3872_tithe_taker_turn_scoped_tax; mod issue_3873_teferi_ageless_insight_multi_draw; diff --git a/crates/server-core/src/persist.rs b/crates/server-core/src/persist.rs index e41a0bf3d6..cb680842ad 100644 --- a/crates/server-core/src/persist.rs +++ b/crates/server-core/src/persist.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; -use engine::types::game_state::GameState; +use engine::types::game_state::{GameState, PendingCastSacrificeRollback}; +use engine::types::identifiers::ObjectId; use phase_ai::config::AiDifficulty; use serde::{Deserialize, Serialize}; @@ -19,6 +20,11 @@ use crate::protocol::DraftLobbyMetadata; pub struct PersistedSession { pub game_code: String, pub state: GameState, + /// CR 601.2i + CR 733.1: Server-authoritative sacrifice rollback snapshots + /// for in-flight casts. Kept outside `GameState` serde so hidden permanent + /// identity cannot leak through client-facing state views (CR 400.2). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub pending_cast_sacrifice_rollbacks: HashMap, pub player_tokens: Vec, pub display_names: Vec, pub timer_seconds: Option, diff --git a/crates/server-core/src/session.rs b/crates/server-core/src/session.rs index 73dbaeabd7..0518686ab1 100644 --- a/crates/server-core/src/session.rs +++ b/crates/server-core/src/session.rs @@ -595,6 +595,7 @@ impl GameSession { PersistedSession { game_code: self.game_code.clone(), state: self.state.clone(), + pending_cast_sacrifice_rollbacks: self.state.pending_cast_sacrifice_rollbacks.clone(), player_tokens: self.player_tokens.clone(), display_names: self.display_names.clone(), timer_seconds: self.timer_seconds, @@ -619,6 +620,7 @@ impl GameSession { let mut state = ps.state; // Restore #[serde(skip)] fields + state.pending_cast_sacrifice_rollbacks = ps.pending_cast_sacrifice_rollbacks; state.all_card_names = db.card_names().into(); state.log_player_names = ps.display_names.clone(); rehydrate_game_from_card_db(&mut state, db);