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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user