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,163 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Dawg } from './dawg';
|
||||
import { validatePlay, type Board, type Ruleset, type Placement, type Cell, type Direction } from './validate';
|
||||
import { playDirection } from './direction';
|
||||
import { premiumGrid, centre, BINGO, RACK_SIZE, BOARD_SIZE } from '../premiums';
|
||||
import type { Variant } from '../model';
|
||||
|
||||
// Conformance gate for the ported validator: for every fixture produced by
|
||||
// `go run ./backend/cmd/validategen`, validatePlay must agree with the Go engine
|
||||
// on legality, and — when legal — on the score, the bonus and the exact words
|
||||
// formed (main + cross), across all variants and both cross-word rules. Point it
|
||||
// at the fixtures + dictionaries with:
|
||||
// DICT_DAWG_DIR=<scrabble-solver>/dawg DICT_VALID_DIR=<validategen out>
|
||||
// It skips when those are unset (kept out of the default unit run until a
|
||||
// committed sample fixture lands).
|
||||
const dawgDir = process.env.DICT_DAWG_DIR;
|
||||
const validDir = process.env.DICT_VALID_DIR;
|
||||
const ready = !!dawgDir && !!validDir && existsSync(dawgDir) && existsSync(validDir);
|
||||
|
||||
const dawgFile: Record<string, string> = {
|
||||
scrabble_en: 'en_sowpods.dawg',
|
||||
scrabble_ru: 'ru_scrabble.dawg',
|
||||
erudit_ru: 'ru_erudit.dawg',
|
||||
};
|
||||
|
||||
interface FxCell {
|
||||
R: number;
|
||||
C: number;
|
||||
Letter: number;
|
||||
Blank: boolean;
|
||||
}
|
||||
interface FxWord {
|
||||
Letters: number[];
|
||||
Score: number;
|
||||
}
|
||||
interface Fx {
|
||||
board: number;
|
||||
dir: number;
|
||||
ignoreCrossWords: boolean;
|
||||
tiles: FxCell[];
|
||||
legal: boolean;
|
||||
score: number;
|
||||
bonus: number;
|
||||
main?: FxWord;
|
||||
cross?: FxWord[];
|
||||
}
|
||||
interface VF {
|
||||
rows: number;
|
||||
cols: number;
|
||||
center: number;
|
||||
rackSize: number;
|
||||
bingo: number;
|
||||
values: number[];
|
||||
premiums: number[];
|
||||
boards: (FxCell[] | null)[];
|
||||
fixtures: Fx[];
|
||||
}
|
||||
|
||||
// Mirror rules.Premium.LetterMult / WordMult over the premium codes
|
||||
// (0 none, 1 DL, 2 TL, 3 DW, 4 TW) emitted by validategen.
|
||||
const letterMultOf = (code: number): number => (code === 1 ? 2 : code === 2 ? 3 : 1);
|
||||
const wordMultOf = (code: number): number => (code === 3 ? 2 : code === 4 ? 3 : 1);
|
||||
|
||||
function makeBoard(rows: number, cols: number, cells: FxCell[]): Board {
|
||||
const grid: (Cell | null)[] = new Array(rows * cols).fill(null);
|
||||
for (const cl of cells) grid[cl.R * cols + cl.C] = { letter: cl.Letter, blank: cl.Blank };
|
||||
const inBounds = (r: number, c: number) => r >= 0 && r < rows && c >= 0 && c < cols;
|
||||
return {
|
||||
inBounds,
|
||||
filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null,
|
||||
cellAt: (r, c) => grid[r * cols + c] as Cell,
|
||||
isEmpty: () => cells.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function arrEq(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
describe.skipIf(!ready)('validator parity vs Go engine', () => {
|
||||
for (const variant of Object.keys(dawgFile)) {
|
||||
it(
|
||||
variant,
|
||||
() => {
|
||||
const vf: VF = JSON.parse(readFileSync(join(validDir!, `${variant}.fixtures.json`), 'utf8'));
|
||||
const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, dawgFile[variant]))));
|
||||
const v = variant as Variant;
|
||||
|
||||
// Pin the client's static per-variant rule constants to the engine's ruleset.
|
||||
expect(RACK_SIZE, 'rack size').toBe(vf.rackSize);
|
||||
expect(BINGO[v], 'bingo').toBe(vf.bingo);
|
||||
const ctr = centre(v);
|
||||
expect(ctr.row * BOARD_SIZE + ctr.col, 'centre').toBe(vf.center);
|
||||
const clientPrem = premiumGrid(v);
|
||||
const premCode = (p: string): number => (p === 'DL' ? 1 : p === 'TL' ? 2 : p === 'DW' ? 3 : p === 'TW' ? 4 : 0);
|
||||
let premOk = true;
|
||||
for (let r = 0; r < vf.rows; r++)
|
||||
for (let c = 0; c < vf.cols; c++) if (premCode(clientPrem[r][c]) !== vf.premiums[r * vf.cols + c]) premOk = false;
|
||||
expect(premOk, 'premium grid matches engine').toBe(true);
|
||||
|
||||
let legalCount = 0;
|
||||
let illegalCount = 0;
|
||||
let mismatches = 0;
|
||||
let dirMismatches = 0;
|
||||
const first: string[] = [];
|
||||
const firstDir: string[] = [];
|
||||
|
||||
for (let fi = 0; fi < vf.fixtures.length; fi++) {
|
||||
const fx = vf.fixtures[fi];
|
||||
const board = makeBoard(vf.rows, vf.cols, vf.boards[fx.board] ?? []);
|
||||
const rs: Ruleset = {
|
||||
cols: vf.cols,
|
||||
center: vf.center,
|
||||
rackSize: vf.rackSize,
|
||||
bingo: vf.bingo,
|
||||
values: vf.values,
|
||||
letterMult: (r, c) => letterMultOf(vf.premiums[r * vf.cols + c]),
|
||||
wordMult: (r, c) => wordMultOf(vf.premiums[r * vf.cols + c]),
|
||||
ignoreCrossWords: fx.ignoreCrossWords,
|
||||
};
|
||||
const tiles: Placement[] = fx.tiles.map((t) => ({ row: t.R, col: t.C, letter: t.Letter, blank: t.Blank }));
|
||||
const res = validatePlay(board, rs, dawg, fx.dir as Direction, tiles);
|
||||
|
||||
const gotDir = playDirection(board, rs, dawg, tiles);
|
||||
if (gotDir !== fx.dir) {
|
||||
dirMismatches++;
|
||||
if (firstDir.length < 8) firstDir.push(`#${fi} dir exp ${fx.dir} got ${gotDir}`);
|
||||
}
|
||||
|
||||
if (fx.legal) legalCount++;
|
||||
else illegalCount++;
|
||||
|
||||
let ok = res.legal === fx.legal;
|
||||
if (ok && fx.legal) {
|
||||
const m = res.move!;
|
||||
ok =
|
||||
m.score === fx.score &&
|
||||
m.bonus === fx.bonus &&
|
||||
arrEq(m.main.letters, fx.main!.Letters) &&
|
||||
m.cross.length === (fx.cross?.length ?? 0) &&
|
||||
m.cross.every((w, i) => arrEq(w.letters, fx.cross![i].Letters));
|
||||
}
|
||||
if (!ok) {
|
||||
mismatches++;
|
||||
if (first.length < 8) {
|
||||
first.push(`#${fi} exp legal=${fx.legal} score=${fx.score} got legal=${res.legal} score=${res.move?.score ?? '-'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(legalCount, 'fixtures should include legal plays').toBeGreaterThan(0);
|
||||
expect(illegalCount, 'fixtures should include illegal plays').toBeGreaterThan(0);
|
||||
expect(mismatches, first.join(' | ')).toBe(0);
|
||||
expect(dirMismatches, firstDir.join(' | ')).toBe(0);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user