d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record.
143 lines
6.0 KiB
TypeScript
143 lines
6.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;
|
|
/** Forbids laying a word that is already on the board (the Erudit rule). Pinned at creation from
|
|
* the variant, so a game started before the rule existed (where it is absent, read as false)
|
|
* replays and scores exactly as it was played. */
|
|
noRepeatWords?: 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 seated player's in-progress composition (rack order + pending board tiles) as the draft
|
|
* JSON the game screen serialises, or absent when there is none. A local game keeps it here —
|
|
* there is no server to hold it — so a reload of the game state restores the arrangement instead
|
|
* of dropping the tiles back into the rack. It belongs to the turn: the source clears it wherever
|
|
* the turn advances (a committed move, a resignation, a host skip), so a hotseat seat never
|
|
* inherits the previous player's arrangement. */
|
|
draft?: string;
|
|
}
|
|
|
|
/** 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;
|
|
draft?: string;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
noRepeatWords: cfg.noRepeatWords || undefined,
|
|
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,
|
|
draft: meta.draft,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
noRepeatWords: record.noRepeatWords,
|
|
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;
|
|
}
|