Files
scrabble-game/ui/src/lib/localgame/serialize.ts
T
Ilia Denisov 8c67d679d9 feat(offline): hotseat record schema + source (locks, host actions)
Extend the local game to offline pass-and-play (hotseat):
- serialize.ts: Seat.pin, record hotseat + hostPin (all optional; old
  vs_ai records read hotseat as false).
- source.ts: create() takes hotseat/hostPin + per-seat pins; stateView
  reveals the seat-to-move's rack and withholds it (locked) when that
  seat is PIN-locked, until unlockSeat; ephemeral per-turn unlock
  re-locks on advance; new unlockSeat / verifyHostPin / hostAction
  (skip/resign/terminate); gameView sets vsAi=!hotseat + the hotseat flag.
- model.ts: GameView.hotseat, StateView.locked (offline-only, optional).

Tests: source.hotseat (lock/unlock/re-lock, host skip/resign/terminate,
master-PIN gate, natural-end persistence) + serialize hotseat shape.
2026-07-07 11:20:39 +02:00

128 lines
5.0 KiB
TypeScript

// 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;
}