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