// Hotseat: the whole game runs in this browser — the engine is pure // TypeScript, so no server is involved at all. The device is passed between // players; a hand-off screen keeps hands private between seats. The game // saves itself after every command (config + command log, replayed on // resume — the same determinism trick the server uses). import { applyCommand, createGame, viewFor, type Command, type GameConfig, type GameState, type GameView, type PlayerId, type GameEvent, redactEvent, } from "@wizwar/engine"; import { humanize, net } from "./net.svelte"; const SAVE_KEY = "wizwar-hotseat"; interface SavedHotseat { config: GameConfig; commands: { playerId: PlayerId; command: Command }[]; /** Anonymous tally identity + table-time bookkeeping; absent in older saves. */ tallyId?: string; activeMs?: number; lastMoveAt?: number; } /** Whose input does the game need right now? */ function actorId(state: GameState): PlayerId { return ( state.pendingDiscard ?? state.outOfTurnWindow?.playerId ?? state.stack?.waitingOn ?? state.players[state.turn.activeIndex]!.id ); } class LocalGame { active = $state(false); /** Hotseat setup round: players name themselves one by one. */ setup = $state<{ count: number; expansion: boolean; names: PlayerId[]; colors: number[] } | null>(null); gameState = $state(null); viewerId = $state(null); /** Set while the device should be handed to the named player. */ handoffTo = $state(null); log = $state([]); /** The opening roll-off, shown once as the boards flip. */ openingRolls = $state<{ rolls: Record; first: string; players: string[] } | null>(null); /** Board flourishes: the app hooks in to animate command results. */ onFx: ((events: GameEvent[]) => void) | null = null; /** The finished game as a reel, each step from its actor's own seat. */ replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null); view = $derived( this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null, ); private config: GameConfig | null = null; private commands: { playerId: PlayerId; command: Command }[] = []; private tallyId: string | null = null; private activeMs = 0; private lastMoveAt = 0; /** The tabletop D4 for house calls: chronicle only, no game state. */ rollTableDie(): void { if (!this.viewerId) return; const roll = 1 + (crypto.getRandomValues(new Uint32Array(1))[0]! % 4); this.log = [...this.log, `\u{1F3B2} ${this.viewerId} rolls the die \u2014 ${roll}`]; } /** Rebuild the whole game as replay steps (finished games only). */ buildReplay(): void { if (!this.config || this.gameState?.phase !== "finished") return; const { state } = createGame(this.config); let current = state; const steps: { seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] = []; this.commands.forEach((c, i) => { const r = applyCommand(current, c.playerId, c.command); if (!r.ok) return; current = r.state; steps.push({ seq: i, actor: c.playerId, events: r.events.map((e) => redactEvent(e, c.playerId)).filter((e): e is GameEvent => e !== null), view: viewFor(current, c.playerId), }); }); this.replaySteps = steps; } hasSave(): boolean { return localStorage.getItem(SAVE_KEY) !== null; } beginSetup(count: number, expansion: boolean): void { this.setup = { count, expansion, names: [], colors: [] }; } cancelSetup(): void { this.setup = null; } /** One wizard names themselves and picks a standee; the last starts the game. */ submitName(raw: string, colorIndex: number): string | null { if (!this.setup) return "no setup in progress"; const name = raw.trim(); if (!name) return "every wizard needs a name"; if (this.setup.names.some((n) => n.toLowerCase() === name.toLowerCase())) { return "that name is already taken at this table"; } if (this.setup.colors.includes(colorIndex)) { return "that wizard has already been claimed"; } this.setup = { ...this.setup, names: [...this.setup.names, name], colors: [...this.setup.colors, colorIndex], }; if (this.setup.names.length === this.setup.count) { const { names, expansion, colors } = this.setup; this.setup = null; return this.start(names, expansion, colors); } return null; } start(names: PlayerId[], expansion: boolean, colors?: number[]): string | null { const cleaned = [...new Set(names.map((n) => n.trim()).filter(Boolean))]; if (cleaned.length < 2 || cleaned.length > 6) return "two to six wizards, each with a name"; const seed = crypto.getRandomValues(new Uint32Array(1))[0]!; const config: GameConfig = { playerIds: cleaned, seed, sets: expansion ? ["basic", "expansion1"] : ["basic"], ...(colors ? { colors } : {}), deckRev: 30, }; const { state, events } = createGame(config); for (const e of events) { if (e.type === "gameStarted") { this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players }; } } this.config = config; this.commands = []; this.gameState = state; this.log = []; for (const e of events) { const line = humanize(e); if (line) this.log = [...this.log, line]; } this.active = true; this.viewerId = null; this.handoffTo = actorId(state); this.tallyId = crypto.randomUUID(); this.activeMs = 0; this.lastMoveAt = Date.now(); // iOS evicts unprotected origin storage under pressure; ask for durability // so a saved hotseat game survives more than a forced reload. navigator.storage?.persist?.().catch(() => {}); net.reportHotseat({ id: this.tallyId, stage: "started", players: cleaned.length }); this.persist(); return null; } resume(): boolean { const raw = localStorage.getItem(SAVE_KEY); if (!raw) return false; try { const saved = JSON.parse(raw) as SavedHotseat; const { state, events } = createGame(saved.config); let current = state; this.log = []; for (const e of events) { const line = humanize(e); if (line) this.log = [...this.log, line]; } for (const c of saved.commands) { const result = applyCommand(current, c.playerId, c.command); if (!result.ok) throw new Error(`replay failed: ${result.error}`); current = result.state; for (const e of result.events) { const line = humanize(e); if (line) this.log = [...this.log, line]; } } this.config = saved.config; this.commands = saved.commands; this.tallyId = saved.tallyId ?? crypto.randomUUID(); this.activeMs = saved.activeMs ?? 0; this.lastMoveAt = saved.lastMoveAt ?? Date.now(); this.gameState = current; this.active = true; this.viewerId = null; navigator.storage?.persist?.().catch(() => {}); this.handoffTo = actorId(current); return true; } catch (e) { console.error("hotseat resume failed:", e); localStorage.removeItem(SAVE_KEY); return false; } } /** The named player takes the device: their hand becomes visible. */ takeSeat(): void { if (!this.handoffTo) return; this.viewerId = this.handoffTo; this.handoffTo = null; } command(command: Command): void { if (!this.gameState || !this.viewerId) return; // The engine deep-clones with structuredClone, which cannot handle // Svelte's reactive proxies — hand it a plain snapshot. const plain = $state.snapshot(this.gameState) as GameState; const result = applyCommand(plain, this.viewerId, command); if (!result.ok) { this.log = [...this.log, `— ${result.error} —`]; return; } this.onFx?.(result.events); this.gameState = result.state; this.commands = [...this.commands, { playerId: this.viewerId, command }]; const now = Date.now(); if (this.lastMoveAt && now - this.lastMoveAt < 10 * 60 * 1000) this.activeMs += now - this.lastMoveAt; this.lastMoveAt = now; if (this.gameState.phase === "finished" && this.tallyId) { net.reportHotseat({ id: this.tallyId, stage: "finished", players: this.gameState.players.length, commands: this.commands.length, minutes: Math.round(this.activeMs / 60_000), winReason: this.gameState.winReason ?? undefined, }); } for (const e of result.events) { const line = humanize(e); if (line) this.log = [...this.log, line]; } this.persist(); if (this.gameState.phase === "playing") { const next = actorId(this.gameState); if (next !== this.viewerId) this.handoffTo = next; } } /** End the hotseat session (the save survives unless the game is over). */ leave(): void { if (this.gameState?.phase === "finished") localStorage.removeItem(SAVE_KEY); this.active = false; this.gameState = null; this.viewerId = null; this.handoffTo = null; this.log = []; } abandon(): void { localStorage.removeItem(SAVE_KEY); this.leave(); } private persist(): void { if (!this.config) return; localStorage.setItem( SAVE_KEY, JSON.stringify({ config: this.config, commands: this.commands, tallyId: this.tallyId ?? undefined, activeMs: this.activeMs, lastMoveAt: this.lastMoveAt, } satisfies SavedHotseat), ); } } export const local = new LocalGame();