feat(offline): local game persistence + replay (Phase B2)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
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).
This commit is contained in:
@@ -35,7 +35,11 @@ export interface LocalMove {
|
|||||||
action: 'play' | 'pass' | 'exchange' | 'resign';
|
action: 'play' | 'pass' | 'exchange' | 'resign';
|
||||||
dir?: Direction;
|
dir?: Direction;
|
||||||
tiles?: Placement[];
|
tiles?: Placement[];
|
||||||
|
/** ActionExchange only: number of tiles swapped. */
|
||||||
count?: number;
|
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;
|
score: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
@@ -163,6 +167,7 @@ export class LocalGame {
|
|||||||
private readonly values: readonly number[];
|
private readonly values: readonly number[];
|
||||||
private readonly rackSize: number;
|
private readonly rackSize: number;
|
||||||
private readonly dawg: Dawg;
|
private readonly dawg: Dawg;
|
||||||
|
private readonly multipleWords: boolean;
|
||||||
private readonly dropoutTiles: DropoutTiles;
|
private readonly dropoutTiles: DropoutTiles;
|
||||||
|
|
||||||
private readonly board: LocalBoard;
|
private readonly board: LocalBoard;
|
||||||
@@ -184,6 +189,7 @@ export class LocalGame {
|
|||||||
this.version = opts.version;
|
this.version = opts.version;
|
||||||
this.seed = opts.seed;
|
this.seed = opts.seed;
|
||||||
this.dawg = opts.dawg;
|
this.dawg = opts.dawg;
|
||||||
|
this.multipleWords = opts.multipleWords;
|
||||||
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
|
this.dropoutTiles = opts.dropoutTiles ?? 'remove';
|
||||||
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
|
this.vrs = buildRuleset(opts.variant, opts.multipleWords);
|
||||||
this.values = RULESETS[opts.variant].values;
|
this.values = RULESETS[opts.variant].values;
|
||||||
@@ -231,6 +237,11 @@ export class LocalGame {
|
|||||||
get winnerIndex(): number {
|
get winnerIndex(): number {
|
||||||
return winner(this.over, this.reason, this.scores, this.resigned);
|
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. */
|
/** history returns a copy of the move log. */
|
||||||
get history(): LocalMove[] {
|
get history(): LocalMove[] {
|
||||||
return this.log.map((m) => ({ ...m }));
|
return this.log.map((m) => ({ ...m }));
|
||||||
@@ -303,7 +314,7 @@ export class LocalGame {
|
|||||||
this.bag.return(tiles);
|
this.bag.return(tiles);
|
||||||
this.scorelessRun++;
|
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.log.push(rec);
|
||||||
this.endTurnAfterScoreless();
|
this.endTurnAfterScoreless();
|
||||||
return rec;
|
return rec;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<IDBDatabase> | null | undefined;
|
||||||
|
|
||||||
|
function openDb(): Promise<IDBDatabase> | null {
|
||||||
|
if (dbPromise !== undefined) return dbPromise;
|
||||||
|
if (typeof indexedDB === 'undefined') {
|
||||||
|
dbPromise = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
dbPromise = new Promise<IDBDatabase>((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<void> {
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return;
|
||||||
|
try {
|
||||||
|
const d = await db;
|
||||||
|
await new Promise<void>((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<LocalGameRecord | null> {
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return null;
|
||||||
|
try {
|
||||||
|
const d = await db;
|
||||||
|
return await new Promise<LocalGameRecord | null>((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<LocalGameRecord[]> {
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return [];
|
||||||
|
try {
|
||||||
|
const d = await db;
|
||||||
|
return await new Promise<LocalGameRecord[]>((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<void> {
|
||||||
|
const db = openDb();
|
||||||
|
if (!db) return;
|
||||||
|
try {
|
||||||
|
const d = await db;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const tx = d.transaction(STORE, 'readwrite');
|
||||||
|
tx.objectStore(STORE).delete(id);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => resolve();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user