8c67d679d9
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.
128 lines
5.0 KiB
TypeScript
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;
|
|
}
|