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,71 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Dawg } from './dawg';
|
||||
|
||||
// Conformance gate for the ported dawg reader: it must agree with the
|
||||
// authoritative Go dafsa reader on indexOf for EVERY stored word and EVERY
|
||||
// negative, across all three shipped dictionaries. The golden vectors are
|
||||
// produced by `go run ./backend/cmd/dictgen`; point the test at them with:
|
||||
// DICT_DAWG_DIR=<scrabble-solver>/dawg DICT_GOLD_DIR=<dictgen out>
|
||||
// When those are unset the suite skips (kept out of the default unit run until
|
||||
// a committed sample fixture lands in Phase 1).
|
||||
const dawgDir = process.env.DICT_DAWG_DIR;
|
||||
const goldDir = process.env.DICT_GOLD_DIR;
|
||||
const ready = !!dawgDir && !!goldDir && existsSync(dawgDir) && existsSync(goldDir);
|
||||
|
||||
const names = ['en_sowpods', 'ru_scrabble', 'ru_erudit'];
|
||||
|
||||
// eachWord walks the [1-byte length][index bytes] framing emitted by dictgen.
|
||||
function* eachWord(buf: Buffer): Generator<Uint8Array> {
|
||||
let off = 0;
|
||||
while (off < buf.length) {
|
||||
const len = buf[off];
|
||||
yield buf.subarray(off + 1, off + 1 + len);
|
||||
off += 1 + len;
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!ready)('dawg reader parity vs Go dafsa', () => {
|
||||
for (const name of names) {
|
||||
it(
|
||||
name,
|
||||
() => {
|
||||
const bytes = new Uint8Array(readFileSync(join(dawgDir!, `${name}.dawg`)));
|
||||
const meta = JSON.parse(readFileSync(join(goldDir!, `${name}.meta.json`), 'utf8'));
|
||||
const dawg = new Dawg(bytes);
|
||||
|
||||
expect(dawg.numAdded).toBe(meta.numAdded);
|
||||
expect(dawg.numNodes).toBe(meta.numNodes);
|
||||
|
||||
// Every stored word: indexOf(word) equals its position in index order.
|
||||
let idx = 0;
|
||||
let mismatches = 0;
|
||||
const firstBad: string[] = [];
|
||||
for (const word of eachWord(readFileSync(join(goldDir!, `${name}.words.bin`)))) {
|
||||
const got = dawg.indexOf(word);
|
||||
if (got !== idx) {
|
||||
mismatches++;
|
||||
if (firstBad.length < 5) firstBad.push(`idx ${idx}: got ${got} word [${Array.from(word)}]`);
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
expect(idx).toBe(meta.numAdded);
|
||||
expect(mismatches, firstBad.join('; ')).toBe(0);
|
||||
|
||||
// Negatives must resolve to -1.
|
||||
let negMismatches = 0;
|
||||
const firstNeg: string[] = [];
|
||||
for (const word of eachWord(readFileSync(join(goldDir!, `${name}.neg.bin`)))) {
|
||||
const got = dawg.indexOf(word);
|
||||
if (got !== -1) {
|
||||
negMismatches++;
|
||||
if (firstNeg.length < 5) firstNeg.push(`got ${got} word [${Array.from(word)}]`);
|
||||
}
|
||||
}
|
||||
expect(negMismatches, firstNeg.join('; ')).toBe(0);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user