diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index dbf4bce..97059c7 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -395,6 +395,23 @@ jobs: docker logs --tail 50 scrabble-backend || true exit 1 + - name: Probe the /dict edge route reaches the gateway + run: | + set -u + # The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version} + # for the local move preview. If caddy does not route /dict to the gateway the request + # falls to the static landing and the client silently gets a non-dawg blob. Probed + # unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a + # 404/200 from the landing catch-all. + out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)" + echo "$out" | grep -E "HTTP/" || true + if echo "$out" | grep -q " 401"; then + echo "ok: /dict reaches the gateway (401 unauthenticated)" + else + echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?" + exit 1 + fi + - name: Probe the Telegram validator and bot liveness run: | set -u diff --git a/backend/cmd/validategen/main.go b/backend/cmd/validategen/main.go index bd5e3e9..c47bcca 100644 --- a/backend/cmd/validategen/main.go +++ b/backend/cmd/validategen/main.go @@ -67,18 +67,28 @@ type fixture struct { Cross []word `json:"cross,omitempty"` } +// alphaEntry mirrors one row of the per-variant alphabet table the server sends the +// client (index, concrete letter as the ruleset emits it, tile value), so the adapter +// cross-test can drive the letter-space client path exactly as production does. +type alphaEntry struct { + Index int `json:"index"` + Letter string `json:"letter"` + Value int `json:"value"` +} + // variantFile is the whole conformance payload for one variant. type variantFile struct { - Variant string `json:"variant"` - Rows int `json:"rows"` - Cols int `json:"cols"` - Center int `json:"center"` - RackSize int `json:"rackSize"` - Bingo int `json:"bingo"` - Values []int `json:"values"` - Premiums []int `json:"premiums"` // row-major rules.Premium codes - Boards [][]cell `json:"boards"` - Fixtures []fixture `json:"fixtures"` + Variant string `json:"variant"` + Rows int `json:"rows"` + Cols int `json:"cols"` + Center int `json:"center"` + RackSize int `json:"rackSize"` + Bingo int `json:"bingo"` + Values []int `json:"values"` + Premiums []int `json:"premiums"` // row-major rules.Premium codes + Alphabet []alphaEntry `json:"alphabet"` + Boards [][]cell `json:"boards"` + Fixtures []fixture `json:"fixtures"` } func main() { @@ -123,7 +133,7 @@ func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error { out := variantFile{ Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values, - Premiums: premiumCodes(rs), + Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs), } // Capture under both the standard rule and the single-word rule, building the @@ -284,6 +294,16 @@ func toWord(w scrabble.Word) *word { } } +func alphabetOf(rs *rules.Ruleset) []alphaEntry { + n := rs.Alphabet.Size() + out := make([]alphaEntry, n) + for i := range n { + ch, _ := rs.Alphabet.Character(byte(i)) + out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]} + } + return out +} + func premiumCodes(rs *rules.Ruleset) []int { codes := make([]int, rs.Rows*rs.Cols) for i := range codes { diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 50df064..c335674 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -53,7 +53,7 @@ # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). - @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /scrabble.edge.v1.Gateway/* + @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/ui/src/components/DebugPanel.svelte b/ui/src/components/DebugPanel.svelte index 8c3d6c4..b38e695 100644 --- a/ui/src/components/DebugPanel.svelte +++ b/ui/src/components/DebugPanel.svelte @@ -4,18 +4,39 @@ // snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a // clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the // Telegram Android presentation quirks. Drawn from the top, just under the app header. + import { onMount } from 'svelte'; import { app, closeDebug, resetOnboarding } from '../lib/app.svelte'; import { connection } from '../lib/connection.svelte'; import { shareText } from '../lib/share'; import { telegramChromeDiag } from '../lib/telegram'; - const report = [ - `app: ${__APP_VERSION__}`, - `locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`, - `online: ${connection.online} streamAlive: ${app.streamAlive}`, - `userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`, - telegramChromeDiag(), - ].join('\n'); + // The local move-preview snapshot — the feature flag, the bad-connection breaker and the + // dictionaries cached in IndexedDB with their sizes — loads async so a report about the + // preview is precise. Dynamically imported so the debug panel keeps the dict code lazy. + let dictInfo = $state('local eval: …'); + onMount(() => { + void (async () => { + try { + const m = await import('../lib/dict'); + const list = await m.listCachedDicts(); + const cached = list.length ? list.map((d) => `${d.key}=${(d.bytes / 1024).toFixed(0)}KB`).join(' ') : 'none'; + dictInfo = `local eval: ${m.LOCAL_EVAL_ENABLED ? 'on' : 'off'} breaker: ${m.dictLoadingDisabled() ? 'TRIPPED' : 'ok'}\ndicts cached: ${cached}`; + } catch { + dictInfo = 'local eval: n/a'; + } + })(); + }); + + const report = $derived( + [ + `app: ${__APP_VERSION__}`, + `locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`, + `online: ${connection.online} streamAlive: ${app.streamAlive}`, + `userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`, + telegramChromeDiag(), + dictInfo, + ].join('\n'), + ); let label = $state('Share'); async function share(e: MouseEvent): Promise { @@ -35,8 +56,10 @@ resetOnboarding(); // Also clear the local move-preview dictionary cache (safe — it re-downloads). // Dynamically imported so the debug panel keeps the dict code out of the app bundle. - void import('../lib/dict/store').then((m) => m.clearDictCache()); - void import('../lib/dict/loader').then((m) => m.clearDictInstances()); + void import('../lib/dict').then((m) => { + m.clearDictCache(); + m.clearDictInstances(); + }); resetLabel = 'Cleared — relaunch'; setTimeout(() => (resetLabel = 'Reset visited'), 1800); } diff --git a/ui/src/lib/dict/dawg.ts b/ui/src/lib/dict/dawg.ts index a125354..0953c5f 100644 --- a/ui/src/lib/dict/dawg.ts +++ b/ui/src/lib/dict/dawg.ts @@ -43,6 +43,16 @@ export class Dawg { constructor(bytes: Uint8Array) { this.bytes = bytes; + // Reject anything that is not a serialized dawg — e.g. an HTML error page or a + // truncated download routed here by mistake. The 32-bit big-endian header size is + // the total byte length; if it does not match, the blob is not a dawg. Without + // this guard a non-dawg blob would parse into a bogus reader that silently reports + // every word as missing (so the caller must throw here to fall back to the network). + const declaredSize = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0; + if (declaredSize !== bytes.length) { + throw new Error(`dawg: not a dawg blob (size header ${declaredSize} != ${bytes.length} bytes)`); + } + // Header: 32-bit size (skipped — the whole file is already in memory), then // cbits, abits, the language code string, and the word/node/edge counts. this.p = 32; diff --git a/ui/src/lib/dict/eval.parity.test.ts b/ui/src/lib/dict/eval.parity.test.ts new file mode 100644 index 0000000..c34d6e5 --- /dev/null +++ b/ui/src/lib/dict/eval.parity.test.ts @@ -0,0 +1,110 @@ +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 = { + 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, + ); + } +}); diff --git a/ui/src/lib/dict/index.ts b/ui/src/lib/dict/index.ts index 3f285a5..c7bf724 100644 --- a/ui/src/lib/dict/index.ts +++ b/ui/src/lib/dict/index.ts @@ -3,5 +3,6 @@ // of the initial app bundle — it is a progressive enhancement over the network // preview, which remains the fallback. export { evaluateLocal } from './eval'; -export { getDawg, peekDawg, hasDawg, dictLoadingDisabled } from './loader'; +export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, clearDictInstances } from './loader'; +export { clearDictCache, listCachedDicts } from './store'; export { LOCAL_EVAL_ENABLED } from './config'; diff --git a/ui/src/lib/dict/store.ts b/ui/src/lib/dict/store.ts index 1cee37b..e0dc21d 100644 --- a/ui/src/lib/dict/store.ts +++ b/ui/src/lib/dict/store.ts @@ -84,6 +84,36 @@ export async function requestPersist(): Promise { } } +/** + * listCachedDicts returns the key (variant@version) and byte length of every cached + * dictionary blob, for the debug panel's local-dictionary readout. Best-effort: an + * empty list on any failure. + */ +export async function listCachedDicts(): Promise> { + const db = openDb(); + if (!db) return []; + try { + const d = await db; + return await new Promise((resolve) => { + const out: Array<{ key: string; bytes: number }> = []; + const req = d.transaction(STORE, 'readonly').objectStore(STORE).openCursor(); + req.onsuccess = () => { + const cur = req.result; + if (!cur) { + resolve(out); + return; + } + const v = cur.value as ArrayBuffer; + out.push({ key: String(cur.key), bytes: v?.byteLength ?? 0 }); + cur.continue(); + }; + req.onerror = () => resolve(out); + }); + } catch { + return []; + } +} + /** * clearDictCache empties the dictionary blob store (backs the DebugPanel reset). The * store is cleared rather than the database deleted, to avoid a delete blocked on an