feat: on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s

Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
  game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
  IndexedDB (best-effort, self-healing on a rejected blob) and reused across
  sessions; a warm-up overlay covers a cold load, then the network preview is the
  fallback; a bad-connection breaker stops warming after repeated misses; the move
  preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
  CI against the release dictionaries. A hidden debug readout lists the cached
  dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
This commit is contained in:
Ilia Denisov
2026-07-01 22:58:40 +02:00
parent f0399e1bbc
commit 5689f7f6a3
38 changed files with 2612 additions and 28 deletions
+338
View File
@@ -0,0 +1,338 @@
// Local move validator + scorer, ported from the scrabble-solver engine
// (scrabble/score.go and scrabble/solver.go, v1.1.1). It answers exactly what the
// server `evaluate` endpoint answers — is a play legal, what words does it form,
// and for how many points — so the client can preview a move without a round
// trip. The server stays authoritative: `submit_play` re-validates on commit, so
// this is an advisory accelerator, never a source of truth.
//
// Everything here works in alphabet-index space, mirroring the Go engine. The
// caller adapts the client's board/placements (letters) into indices via
// lib/alphabet.ts and the premium geometry via lib/premiums.ts. Faithfulness to
// the Go engine is pinned by validate.parity.test.ts against golden fixtures.
/** Horizontal is an across play (fixed row, axis along columns). Mirrors scrabble.Horizontal. */
export const Horizontal = 0;
/** Vertical is a down play (fixed column, axis along rows). Mirrors scrabble.Vertical. */
export const Vertical = 1;
export type Direction = typeof Horizontal | typeof Vertical;
/** A single newly-placed tile (alphabet-index letter; blank scores 0). */
export interface Placement {
row: number;
col: number;
letter: number;
blank: boolean;
}
/** An occupied board square: an alphabet-index letter and whether it is a blank. */
export interface Cell {
letter: number;
blank: boolean;
}
/**
* Board is the minimal read view the validator needs over the current position.
* cellAt is only consulted for squares that filled() reports as occupied.
*/
export interface Board {
inBounds(row: number, col: number): boolean;
/** filled reports whether (row, col) is on the board AND occupied. */
filled(row: number, col: number): boolean;
cellAt(row: number, col: number): Cell;
/** isEmpty reports whether the whole board is empty (first-move detection). */
isEmpty(): boolean;
}
/**
* Ruleset carries the per-variant scoring data the validator needs. values is
* indexed by alphabet letter index; letterMult/wordMult return the premium
* multipliers of a square (1 when none); center is the row-major centre index;
* ignoreCrossWords selects the single-word-per-turn rule.
*/
export interface Ruleset {
cols: number;
center: number;
rackSize: number;
bingo: number;
values: readonly number[];
letterMult(row: number, col: number): number;
wordMult(row: number, col: number): number;
ignoreCrossWords: boolean;
}
/** Dict is the dictionary membership test (see Dawg.indexOf). */
export interface Dict {
indexOf(word: ArrayLike<number>): number;
}
/** A word formed by a play, with its location, letters (indices) and score. */
export interface Word {
row: number;
col: number;
dir: Direction;
letters: number[];
blanks: boolean[];
score: number;
}
/** A scored play: the main word, any cross words, the bingo bonus and the total. */
export interface Move {
dir: Direction;
tiles: Placement[];
main: Word;
cross: Word[];
bonus: number;
score: number;
}
/** Reason a play is rejected. Mirrors the error conditions in the Go engine. */
export type EvalError =
| 'empty'
| 'not-one-line'
| 'off-board'
| 'occupied'
| 'same-square'
| 'gap'
| 'too-short'
| 'main-not-in-dict'
| 'cross-not-in-dict'
| 'not-connected';
/** Result of {@link validatePlay}: a scored move plus whether it is legal. */
export interface ValidateResult {
move?: Move;
legal: boolean;
err?: EvalError;
}
// coord maps a line coordinate (fixed, axis) to a board (row, col) for dir.
function coord(dir: Direction, fixed: number, axis: number): [number, number] {
return dir === Horizontal ? [fixed, axis] : [axis, fixed];
}
// fixedAxis is the inverse of coord: it splits (row, col) into (fixed, axis).
function fixedAxis(dir: Direction, row: number, col: number): [number, number] {
return dir === Horizontal ? [row, col] : [col, row];
}
function perpendicular(d: Direction): Direction {
return d === Horizontal ? Vertical : Horizontal;
}
/**
* evaluate computes the words and score for placing tiles on b in direction dir.
* It checks geometry only (tiles on one line, on empty squares, contiguous); the
* dictionary and connectivity are layered on by {@link validatePlay}. Mirrors
* scrabble.EvaluateOpts.
*/
export function evaluate(
b: Board,
rs: Ruleset,
dir: Direction,
tiles: Placement[],
): { move?: Move; err?: EvalError } {
if (tiles.length === 0) {
return { err: 'empty' };
}
const ts = tiles.slice().sort((x, y) => fixedAxis(dir, x.row, x.col)[1] - fixedAxis(dir, y.row, y.col)[1]);
const fixed = fixedAxis(dir, ts[0].row, ts[0].col)[0];
let prevAxis = 0;
for (let i = 0; i < ts.length; i++) {
const t = ts[i];
const [f, a] = fixedAxis(dir, t.row, t.col);
if (f !== fixed) return { err: 'not-one-line' };
if (!b.inBounds(t.row, t.col)) return { err: 'off-board' };
if (b.filled(t.row, t.col)) return { err: 'occupied' };
if (i > 0 && a === prevAxis) return { err: 'same-square' };
prevAxis = a;
}
const main = buildMainWord(b, rs, dir, fixed, ts);
if ('err' in main) return { err: main.err };
const move: Move = { dir, tiles: ts, main: main.word, cross: [], bonus: 0, score: main.word.score };
if (!rs.ignoreCrossWords) {
for (const t of ts) {
const cw = crossWord(b, rs, dir, t);
if (cw) {
move.cross.push(cw);
move.score += cw.score;
}
}
}
if (ts.length === rs.rackSize) {
move.bonus = rs.bingo;
move.score += rs.bingo;
}
return { move };
}
// buildMainWord assembles and scores the word along dir through the sorted
// placements plus the existing tiles that extend and bridge them. Mirrors
// scrabble.buildMainWord.
function buildMainWord(
b: Board,
rs: Ruleset,
dir: Direction,
fixed: number,
ts: Placement[],
): { word: Word } | { err: EvalError } {
const minA = fixedAxis(dir, ts[0].row, ts[0].col)[1];
const maxA = fixedAxis(dir, ts[ts.length - 1].row, ts[ts.length - 1].col)[1];
let start = minA;
for (;;) {
const [r, c] = coord(dir, fixed, start - 1);
if (!b.filled(r, c)) break;
start--;
}
let end = maxA;
for (;;) {
const [r, c] = coord(dir, fixed, end + 1);
if (!b.filled(r, c)) break;
end++;
}
const letters: number[] = [];
const blanks: boolean[] = [];
let letterSum = 0;
let wordMult = 1;
let ti = 0;
for (let a = start; a <= end; a++) {
const [r, c] = coord(dir, fixed, a);
if (ti < ts.length) {
const ta = fixedAxis(dir, ts[ti].row, ts[ti].col)[1];
if (ta === a) {
const t = ts[ti];
ti++;
if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c);
wordMult *= rs.wordMult(r, c);
letters.push(t.letter);
blanks.push(t.blank);
continue;
}
}
if (b.filled(r, c)) {
const cell = b.cellAt(r, c);
if (!cell.blank) letterSum += rs.values[cell.letter];
letters.push(cell.letter);
blanks.push(cell.blank);
continue;
}
return { err: 'gap' };
}
const [wr, wc] = coord(dir, fixed, start);
return { word: { row: wr, col: wc, dir, letters, blanks, score: letterSum * wordMult } };
}
// crossWord builds the perpendicular word formed by a single new tile, or null
// when the tile has no perpendicular neighbour. Mirrors scrabble.crossWord.
function crossWord(b: Board, rs: Ruleset, dir: Direction, t: Placement): Word | null {
const cdir = perpendicular(dir);
const [fixed, axis] = fixedAxis(cdir, t.row, t.col);
let start = axis;
for (;;) {
const [r, c] = coord(cdir, fixed, start - 1);
if (!b.filled(r, c)) break;
start--;
}
let end = axis;
for (;;) {
const [r, c] = coord(cdir, fixed, end + 1);
if (!b.filled(r, c)) break;
end++;
}
if (start === end) return null;
const letters: number[] = [];
const blanks: boolean[] = [];
let letterSum = 0;
let wordMult = 1;
for (let a = start; a <= end; a++) {
const [r, c] = coord(cdir, fixed, a);
if (a === axis) {
if (!t.blank) letterSum += rs.values[t.letter] * rs.letterMult(r, c);
wordMult *= rs.wordMult(r, c);
letters.push(t.letter);
blanks.push(t.blank);
} else {
const cell = b.cellAt(r, c);
if (!cell.blank) letterSum += rs.values[cell.letter];
letters.push(cell.letter);
blanks.push(cell.blank);
}
}
const [wr, wc] = coord(cdir, fixed, start);
return { row: wr, col: wc, dir: cdir, letters, blanks, score: letterSum * wordMult };
}
/**
* validatePlay scores a play and checks that every word it forms is in the
* dictionary and that it connects to the board (or covers the centre on the
* first move). legal is true exactly when the play is legal. Mirrors
* scrabble.ValidatePlayOpts.
*/
export function validatePlay(
b: Board,
rs: Ruleset,
dict: Dict,
dir: Direction,
tiles: Placement[],
): ValidateResult {
const res = evaluate(b, rs, dir, tiles);
if (res.err) return { legal: false, err: res.err };
const m = res.move!;
if (m.main.letters.length < 2) return { move: m, legal: false, err: 'too-short' };
if (dict.indexOf(m.main.letters) < 0) return { move: m, legal: false, err: 'main-not-in-dict' };
for (const cw of m.cross) {
if (dict.indexOf(cw.letters) < 0) return { move: m, legal: false, err: 'cross-not-in-dict' };
}
if (!connected(b, rs, m)) return { move: m, legal: false, err: 'not-connected' };
return { move: m, legal: true };
}
// connected reports whether the play connects to the position (or covers the
// centre on the first move). Mirrors (*Solver).connected.
function connected(b: Board, rs: Ruleset, m: Move): boolean {
if (b.isEmpty()) {
const cr = Math.floor(rs.center / rs.cols);
const cc = rs.center % rs.cols;
return wordCovers(m.main, cr, cc);
}
if (rs.ignoreCrossWords) {
return m.main.letters.length > m.tiles.length;
}
return m.main.letters.length > m.tiles.length || touchesPerpendicular(b, m);
}
// touchesPerpendicular reports whether any new tile abuts an existing tile
// perpendicular to the main word. Mirrors scrabble.touchesPerpendicular.
function touchesPerpendicular(b: Board, m: Move): boolean {
const cdir = perpendicular(m.dir);
for (const t of m.tiles) {
const [fixed, axis] = fixedAxis(cdir, t.row, t.col);
let [r, c] = coord(cdir, fixed, axis - 1);
if (b.filled(r, c)) return true;
[r, c] = coord(cdir, fixed, axis + 1);
if (b.filled(r, c)) return true;
}
return false;
}
// wordCovers reports whether word w passes through square (r, c). Mirrors
// scrabble.wordCovers.
function wordCovers(w: Word, r: number, c: number): boolean {
for (let i = 0; i < w.letters.length; i++) {
let rr = w.row;
let cc = w.col;
if (w.dir === Horizontal) cc += i;
else rr += i;
if (rr === r && cc === c) return true;
}
return false;
}