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

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:
Ilia Denisov
2026-07-06 08:22:47 +02:00
parent afa44d41b4
commit 4fa77bf82c
4 changed files with 304 additions and 1 deletions
+86
View File
@@ -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);
});
});