5689f7f6a3
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).
111 lines
4.0 KiB
TypeScript
111 lines
4.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { Dawg } from './dawg';
|
|
import { evaluateLocal } from './eval';
|
|
import { setAlphabet } from '../alphabet';
|
|
import type { Board as ClientBoard, BoardCell } from '../board';
|
|
import type { PlacedTile } from '../client';
|
|
import type { Variant } from '../model';
|
|
|
|
// End-to-end conformance for the local-eval ADAPTER: it drives evaluateLocal through
|
|
// the real letter-space client path (the server-sent alphabet table, a letter board,
|
|
// letter placements) and asserts it matches the Go engine's EvalResult for every
|
|
// fixture. This is the cross-test the algorithm-level validate.parity suite does not
|
|
// cover — it exercises the letter<->index mapping, the ruleset assembly and the word
|
|
// decode, not just the index-space validator. Env-gated like the other parity suites.
|
|
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[];
|
|
}
|
|
interface Fx {
|
|
board: number;
|
|
ignoreCrossWords: boolean;
|
|
tiles: FxCell[];
|
|
legal: boolean;
|
|
score: number;
|
|
main?: FxWord;
|
|
cross?: FxWord[];
|
|
}
|
|
interface Alpha {
|
|
index: number;
|
|
letter: string;
|
|
value: number;
|
|
}
|
|
interface VF {
|
|
rows: number;
|
|
cols: number;
|
|
alphabet: Alpha[];
|
|
boards: (FxCell[] | null)[];
|
|
fixtures: Fx[];
|
|
}
|
|
|
|
describe.skipIf(!ready)('local-eval adapter 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;
|
|
|
|
// Seed the alphabet cache exactly as the server would (wire entries, lower-cased
|
|
// letters); the adapter re-encodes through it, just like production.
|
|
setAlphabet(v, vf.alphabet.map((a) => ({ index: a.index, letter: a.letter, value: a.value })));
|
|
const upper = vf.alphabet.map((a) => a.letter.toUpperCase());
|
|
const lower = vf.alphabet.map((a) => a.letter);
|
|
|
|
let legalCount = 0;
|
|
let mismatches = 0;
|
|
const first: string[] = [];
|
|
|
|
for (let fi = 0; fi < vf.fixtures.length; fi++) {
|
|
const fx = vf.fixtures[fi];
|
|
|
|
// Build the client's letter-space board and placements from the fixture.
|
|
const board: ClientBoard = Array.from({ length: vf.rows }, () =>
|
|
Array.from({ length: vf.cols }, () => null as BoardCell | null),
|
|
);
|
|
for (const cl of vf.boards[fx.board] ?? []) board[cl.R][cl.C] = { letter: upper[cl.Letter], blank: cl.Blank };
|
|
const tiles: PlacedTile[] = fx.tiles.map((t) => ({ row: t.R, col: t.C, letter: upper[t.Letter], blank: t.Blank }));
|
|
|
|
const res = evaluateLocal(dawg, v, board, tiles, !fx.ignoreCrossWords);
|
|
|
|
if (fx.legal) legalCount++;
|
|
|
|
let ok = res.legal === fx.legal;
|
|
if (ok && fx.legal) {
|
|
const wantWords = [fx.main!, ...(fx.cross ?? [])].map((w) => w.Letters.map((i) => lower[i]).join(''));
|
|
ok = res.score === fx.score && res.words.length === wantWords.length && res.words.every((w, i) => w === wantWords[i]);
|
|
}
|
|
if (!ok) {
|
|
mismatches++;
|
|
if (first.length < 8) {
|
|
first.push(`#${fi} exp legal=${fx.legal} score=${fx.score} got legal=${res.legal} score=${res.score} words=${JSON.stringify(res.words)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(legalCount, 'fixtures should include legal plays').toBeGreaterThan(0);
|
|
expect(mismatches, first.join(' | ')).toBe(0);
|
|
},
|
|
120_000,
|
|
);
|
|
}
|
|
});
|