// Serialising a local game to a durable record and reconstructing it by replay — the offline // counterpart of the server's game rehydration (backend replay: engine.New(seed) + replayMove per // journal row). A record carries the seed, the rules, the seat metadata and the move journal; the // board, bag and racks are NOT stored — they are rebuilt by replaying the journal on a fresh engine // seeded the same way (the bag is deterministic from the seed and the sequence of operations). // // The journal is in alphabet-index space, which is dictionary-independent: a variant's alphabet // (Latin 26 / embedded Russian 33) is fixed, so the same journal replays against any dictionary // version of that variant (only the word list changes, not the indices). The dictionary version is // pinned in the record, and replay applies each recorded move against that dawg. import { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine'; import type { Dawg } from '../dict/dawg'; import type { Variant } from '../model'; import type { PinLock } from '../pin'; /** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */ export interface Seat { kind: 'human' | 'robot'; name: string; /** The player's real account id for a human seat, so the client identifies "you" and the correct * turn exactly as in an online game; absent for the robot (a synthetic id is derived). */ accountId?: string; /** Offline hotseat only: the seat's optional PIN lock. When set, the seat's rack stays hidden * until the PIN is entered (a social lock for a shared device — see lib/pin). */ pin?: PinLock; } /** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */ export interface LocalGameRecord { id: string; variant: Variant; dictVersion: string; /** The bag seed, serialised as a decimal string (it may exceed JS safe integers). */ seed: string; players: number; multipleWords: boolean; dropoutTiles: DropoutTiles; seats: Seat[]; /** The dictionary-independent, alphabet-index-space move journal. */ journal: LocalMove[]; status: 'active' | 'finished'; /** Offline pass-and-play (hotseat): 2-4 humans share one device. Absent on vs_ai records (and on * older records predating the feature), which the source reads as false. */ hotseat?: boolean; /** Offline hotseat only: the mandatory master (host/referee) PIN lock, needed to skip/resign a * seat, exclude a player or terminate the game (see lib/pin). Absent on vs_ai records. */ hostPin?: PinLock; createdAtUnix: number; updatedAtUnix: number; /** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0 * while open (no robot move yet). Persisted so the wait survives leaving and reopening the app; * read through a sanitiser (cap at now + window) so a device clock set back cannot freeze it — * see lib/hints. */ hintUnlockAtMs: number; } /** The record fields the caller owns (the engine supplies the rest). */ export interface RecordMeta { id: string; seats: Seat[]; hotseat?: boolean; hostPin?: PinLock; createdAtUnix: number; updatedAtUnix: number; hintUnlockAtMs: number; } /** * serializeGame builds the durable record for game, taking the caller-owned metadata (id, seats, * timestamps) and deriving the rest — the seed, rules, journal and status — from the game itself. */ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecord { const cfg = game.config; return { id: meta.id, variant: game.variant, dictVersion: game.version, seed: game.seed.toString(), players: game.playerCount, multipleWords: cfg.multipleWords, dropoutTiles: cfg.dropoutTiles, seats: meta.seats, journal: game.history, status: game.isOver ? 'finished' : 'active', hotseat: meta.hotseat, hostPin: meta.hostPin, createdAtUnix: meta.createdAtUnix, updatedAtUnix: meta.updatedAtUnix, hintUnlockAtMs: meta.hintUnlockAtMs, }; } /** * replayGame reconstructs the live LocalGame from a record and the loaded dictionary, by seeding a * fresh engine identically and replaying every journal move in order. The recorded plays stay legal * against the pinned dictionary, so replay reproduces the exact board, racks, bag and scores. */ export function replayGame(record: LocalGameRecord, dawg: Dawg): LocalGame { const opts: LocalGameOptions = { variant: record.variant, version: record.dictVersion, seed: BigInt(record.seed), players: record.players, dawg, multipleWords: record.multipleWords, dropoutTiles: record.dropoutTiles, }; const game = new LocalGame(opts); for (const m of record.journal) { switch (m.action) { case 'play': game.play(m.dir!, m.tiles!); break; case 'pass': game.pass(); break; case 'exchange': game.exchange(m.exchanged!); break; case 'resign': game.resignSeat(m.player); break; } } return game; }