From 4fa77bf82c39d0ad8172e6b912b7787f7c2fd2da Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 08:22:47 +0200 Subject: [PATCH] feat(offline): local game persistence + replay (Phase B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store an offline game durably and reconstruct it — the offline counterpart of the server's replay rehydration. Builds on the engine (#191); not wired into the UI yet (Phase B3). - serialize.ts: a LocalGameRecord (seed + rules + seat metadata + the alphabet-index move journal) and replayGame() — reconstruct a live LocalGame by seeding a fresh engine identically and replaying the journal. The board/bag/racks are not stored; they are deterministic from the seed and the replayed operations, so the record stays small. The journal is dictionary-independent (alphabet-index space, stable per variant). - store.ts: an IndexedDB store for local games (save/get/list/delete), mirroring lib/dict/store.ts — its own database, best-effort, guarded when IndexedDB is absent. - engine.ts: record the swapped tiles on an exchange (needed for exact replay) and expose the game's rule config. - serialize.test.ts: a round-trip — reconstruct a mid-game and a finished game by replay and assert the state (board/racks/bag/scores/turn/log) is identical. Pure additive library code; no runtime behavior change (bundle unchanged). --- ui/src/lib/localgame/engine.ts | 13 ++- ui/src/lib/localgame/serialize.test.ts | 86 ++++++++++++++++++++ ui/src/lib/localgame/serialize.ts | 107 +++++++++++++++++++++++++ ui/src/lib/localgame/store.ts | 99 +++++++++++++++++++++++ 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 ui/src/lib/localgame/serialize.test.ts create mode 100644 ui/src/lib/localgame/serialize.ts create mode 100644 ui/src/lib/localgame/store.ts diff --git a/ui/src/lib/localgame/engine.ts b/ui/src/lib/localgame/engine.ts index 10921cf..58fc872 100644 --- a/ui/src/lib/localgame/engine.ts +++ b/ui/src/lib/localgame/engine.ts @@ -35,7 +35,11 @@ export interface LocalMove { action: 'play' | 'pass' | 'exchange' | 'resign'; dir?: Direction; tiles?: Placement[]; + /** ActionExchange only: number of tiles swapped. */ count?: number; + /** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank), + * recorded so the game can be reconstructed by replaying the journal (see localgame/serialize). */ + exchanged?: number[]; score: number; total: number; } @@ -163,6 +167,7 @@ export class LocalGame { private readonly values: readonly number[]; private readonly rackSize: number; private readonly dawg: Dawg; + private readonly multipleWords: boolean; private readonly dropoutTiles: DropoutTiles; private readonly board: LocalBoard; @@ -184,6 +189,7 @@ export class LocalGame { this.version = opts.version; this.seed = opts.seed; this.dawg = opts.dawg; + this.multipleWords = opts.multipleWords; this.dropoutTiles = opts.dropoutTiles ?? 'remove'; this.vrs = buildRuleset(opts.variant, opts.multipleWords); this.values = RULESETS[opts.variant].values; @@ -231,6 +237,11 @@ export class LocalGame { get winnerIndex(): number { return winner(this.over, this.reason, this.scores, this.resigned); } + /** config returns the rule settings the game was created with — the bits, alongside the seed and + * journal, needed to reconstruct it (see localgame/serialize). */ + get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } { + return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles }; + } /** history returns a copy of the move log. */ get history(): LocalMove[] { return this.log.map((m) => ({ ...m })); @@ -303,7 +314,7 @@ export class LocalGame { this.bag.return(tiles); this.scorelessRun++; - const rec: LocalMove = { player, action: 'exchange', count: tiles.length, score: 0, total: this.scores[player] }; + const rec: LocalMove = { player, action: 'exchange', count: tiles.length, exchanged: [...tiles], score: 0, total: this.scores[player] }; this.log.push(rec); this.endTurnAfterScoreless(); return rec; diff --git a/ui/src/lib/localgame/serialize.test.ts b/ui/src/lib/localgame/serialize.test.ts new file mode 100644 index 0000000..24ffbce --- /dev/null +++ b/ui/src/lib/localgame/serialize.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { Dawg } from '../dict/dawg'; +import { RACK_SIZE } from '../premiums'; +import { LocalGame } from './engine'; +import { serializeGame, replayGame, type Seat } from './serialize'; +import { decide } from '../robot/strategy'; +import type { Move } from '../dict/validate'; + +// A game reconstructed from its record (seed + journal) must be identical to the original — same +// board, racks, bag, scores, turn and log — since the bag is deterministic from the seed and the +// replayed sequence of operations. Uses the committed sample dictionary. +const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url)))); +const seats: Seat[] = [ + { kind: 'human', name: 'You' }, + { kind: 'robot', name: 'Robot' }, +]; + +function bestOpponentScore(game: LocalGame, seat: number): number { + let best = 0; + for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i)); + return best; +} + +// drive plays one turn with the robot's choice (legal plays only — see engine.test.ts). +function drive(game: LocalGame, seed: bigint): void { + const seat = game.currentPlayer; + const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2); + const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength); + if (dec.kind === 'play') game.play(dec.move.dir, dec.move.tiles); + else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) game.exchange(dec.exchange); + else game.pass(); +} + +function snapshot(game: LocalGame): unknown { + const scores: number[] = []; + const hands: number[][] = []; + for (let i = 0; i < game.playerCount; i++) { + scores.push(game.scoreOf(i)); + hands.push(game.handOf(i).slice().sort((a, b) => a - b)); + } + return { scores, hands, toMove: game.currentPlayer, bagLen: game.bagLength, over: game.isOver, reason: game.endReason, history: game.history }; +} + +function makeGame(seed: bigint): LocalGame { + return new LocalGame({ variant: 'scrabble_en', version: 'sample', seed, players: 2, dawg, multipleWords: true }); +} + +const meta = { id: 'g1', seats, createdAtUnix: 1, updatedAtUnix: 2, robotLastMoveAtUnix: 3 }; + +describe('local game serialize + replay', () => { + it('reconstructs a mid-game position by replay', () => { + const g = makeGame(777n); + for (let i = 0; i < 15 && !g.isOver; i++) drive(g, g.seed); + const rec = serializeGame(g, meta); + const r = replayGame(rec, dawg); + expect(snapshot(r)).toEqual(snapshot(g)); + }); + + it('reconstructs a finished game by replay', () => { + const g = makeGame(2024n); + let t = 0; + while (!g.isOver && t < 2000) { + drive(g, g.seed); + t++; + } + expect(g.isOver).toBe(true); + const rec = serializeGame(g, meta); + expect(rec.status).toBe('finished'); + const r = replayGame(rec, dawg); + expect(snapshot(r)).toEqual(snapshot(g)); + }); + + it('serialises the seed, rules and journal', () => { + const g = makeGame(9999999999n); + for (let i = 0; i < 5 && !g.isOver; i++) drive(g, g.seed); + const rec = serializeGame(g, meta); + expect(rec.seed).toBe('9999999999'); + expect(rec.variant).toBe('scrabble_en'); + expect(rec.multipleWords).toBe(true); + expect(rec.players).toBe(2); + expect(rec.seats).toEqual(seats); + expect(rec.journal.length).toBe(g.moveCount); + expect(replayGame(rec, dawg).seed).toBe(9999999999n); + }); +}); diff --git a/ui/src/lib/localgame/serialize.ts b/ui/src/lib/localgame/serialize.ts new file mode 100644 index 0000000..9471362 --- /dev/null +++ b/ui/src/lib/localgame/serialize.ts @@ -0,0 +1,107 @@ +// 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'; + +/** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */ +export interface Seat { + kind: 'human' | 'robot'; + name: string; +} + +/** 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'; + createdAtUnix: number; + updatedAtUnix: number; + /** Unix seconds of the robot's most recent move — the local hint gate (>30 min) reads this. */ + robotLastMoveAtUnix: number; +} + +/** The record fields the caller owns (the engine supplies the rest). */ +export interface RecordMeta { + id: string; + seats: Seat[]; + createdAtUnix: number; + updatedAtUnix: number; + robotLastMoveAtUnix: 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', + createdAtUnix: meta.createdAtUnix, + updatedAtUnix: meta.updatedAtUnix, + robotLastMoveAtUnix: meta.robotLastMoveAtUnix, + }; +} + +/** + * 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; +} diff --git a/ui/src/lib/localgame/store.ts b/ui/src/lib/localgame/store.ts new file mode 100644 index 0000000..d319869 --- /dev/null +++ b/ui/src/lib/localgame/store.ts @@ -0,0 +1,99 @@ +// Persistent store for local (offline) games, keyed by game id, in its own IndexedDB database +// (separate from the dict blob cache in lib/dict/store.ts and from the session store). Mirrors that +// module's shape: everything is best-effort — any failure resolves to null / an empty list / a +// no-op, and the caller degrades gracefully (a game that cannot be read is simply absent). +// +// A record is small (seed + rules + seat metadata + the move journal — see localgame/serialize), +// so games are stored whole; the live board/bag/racks are rebuilt by replay on load. + +import type { LocalGameRecord } from './serialize'; + +const DB_NAME = 'scrabble-localgames'; +const STORE = 'games'; + +let dbPromise: Promise | null | undefined; + +function openDb(): Promise | null { + if (dbPromise !== undefined) return dbPromise; + if (typeof indexedDB === 'undefined') { + dbPromise = null; + return null; + } + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: 'id' }); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }).catch(() => { + dbPromise = null; + throw new Error('indexedDB unavailable'); + }); + return dbPromise; +} + +/** saveLocalGame stores (inserts or replaces) a game record, swallowing any failure (best-effort). */ +export async function saveLocalGame(record: LocalGameRecord): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(record); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + /* best-effort: a failed save just loses this snapshot */ + } +} + +/** getLocalGame returns the record for id, or null on a miss or any failure. */ +export async function getLocalGame(id: string): Promise { + const db = openDb(); + if (!db) return null; + try { + const d = await db; + return await new Promise((resolve, reject) => { + const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(id); + r.onsuccess = () => resolve((r.result ?? null) as LocalGameRecord | null); + r.onerror = () => reject(r.error); + }); + } catch { + return null; + } +} + +/** listLocalGames returns every stored game record (unordered), or an empty list on any failure — + * the offline lobby sorts and filters them. */ +export async function listLocalGames(): Promise { + const db = openDb(); + if (!db) return []; + try { + const d = await db; + return await new Promise((resolve) => { + const req = d.transaction(STORE, 'readonly').objectStore(STORE).getAll(); + req.onsuccess = () => resolve((req.result ?? []) as LocalGameRecord[]); + req.onerror = () => resolve([]); + }); + } catch { + return []; + } +} + +/** deleteLocalGame removes a game record, swallowing any failure (best-effort). */ +export async function deleteLocalGame(id: string): Promise { + const db = openDb(); + if (!db) return; + try { + const d = await db; + await new Promise((resolve) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + /* best-effort */ + } +} -- 2.52.0