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

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:
Ilia Denisov
2026-07-01 22:58:40 +02:00
parent f0399e1bbc
commit 5689f7f6a3
38 changed files with 2612 additions and 28 deletions
+35 -7
View File
@@ -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 = `dict 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<void> {
@@ -33,6 +54,13 @@
function resetVisited(e: MouseEvent): void {
e.stopPropagation();
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').then((m) => {
m.clearDictCache();
m.clearDictInstances();
m.bustDictHttpCache(); // also bypass the browser HTTP cache on the next load (a true cold test)
});
resetLabel = 'Cleared — relaunch';
setTimeout(() => (resetLabel = 'Reset visited'), 1800);
}
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
// A cute emoji cycler shown while a game's dictionary warms for the local move
// preview (docs/UI_DESIGN.md). One emoji per 500ms tick — 300ms static, then a
// 200ms clockwise spin with the current emoji fading out and the next fading in —
// looping through the sequence; the ten emojis span the 5s warm-up cap.
const EMOJIS = ['🎲', '🤔', '📡', '📀', '💾', '⏳', '🐌', '🙈', '🇷🇺', '✌️'];
const TICK_MS = 500;
const SPIN_MS = 200;
let index = $state(0);
let timer: ReturnType<typeof setInterval> | null = null;
onMount(() => {
timer = setInterval(() => (index = (index + 1) % EMOJIS.length), TICK_MS);
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
// A simultaneous fade + clockwise spin used as both the in and out transition on the
// keyed glyph, so the outgoing and incoming emoji cross-fade in place. Under
// reduce-motion it degrades to a plain fade.
function spinFade(_node: Element, { duration = SPIN_MS }: { duration?: number } = {}) {
const spin = !app.reduceMotion;
return {
duration,
css: (u: number) => (spin ? `opacity:${u};transform:rotate(${(1 - u) * 360}deg)` : `opacity:${u}`),
};
}
</script>
<div class="overlay" role="status" aria-live="polite">
<div class="box">
<div class="stage">
{#key index}
<span class="emoji" in:spinFade out:spinFade>{EMOJIS[index]}</span>
{/key}
</div>
<span class="caption">{t('dict.loading')}</span>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 200;
display: grid;
place-items: center;
/* Darker than the onboarding scrim — this blocks the board until the dictionary is ready. */
background: rgba(0, 0, 0, 0.72);
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
.box {
display: grid;
justify-items: center;
gap: 1rem;
}
.stage {
display: grid;
place-items: center;
/* Reserve the glyph box so the caption never shifts as the emoji swaps. */
width: 4rem;
height: 4rem;
}
.emoji {
grid-area: 1 / 1;
font-size: 3.25rem; /* about twice a tab-bar button */
line-height: 1;
will-change: transform, opacity;
}
.caption {
color: #fff;
font-size: 1rem;
opacity: 0.85;
}
</style>
+94 -2
View File
@@ -4,6 +4,7 @@
import TabBar from '../components/TabBar.svelte';
import TapConfirm from '../components/TapConfirm.svelte';
import Modal from '../components/Modal.svelte';
import DictWarmup from '../components/DictWarmup.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway';
@@ -50,6 +51,15 @@
let placement = $state<Placement>(newPlacement([]));
let preview = $state<EvalResult | null>(null);
let busy = $state(false);
// The local move-preview subsystem (dynamically imported when enabled) and the warm-up
// overlay state. dict is null until the subsystem loads, or when the feature is disabled.
let dict = $state<typeof import('../lib/dict') | null>(null);
let dictWarming = $state(false);
let warmStarted = false;
// Aborts the in-flight dictionary download (at the warm-up cap, or when leaving the game)
// so a large download does not keep starving the channel on a slow link.
let warmCtrl: AbortController | null = null;
let zoomed = $state(false);
let selected = $state<number | null>(null);
let focus = $state<{ row: number; col: number } | null>(null);
@@ -244,6 +254,24 @@
void load();
void loadFriends();
void loadBlocked();
// Load the local move-preview subsystem (kept out of the initial bundle) when enabled,
// so composing a move can be scored on-device; the network preview stays the fallback.
// Skipped in mock mode (no dictionary server; the mock uses the network preview path and
// must never see the warm-up overlay, which would intercept the e2e's taps).
if (import.meta.env.MODE !== 'mock') {
void import('../lib/dict').then((m) => {
dict = m;
});
}
});
// Warm the game's dictionary for the local move preview once, when both the game and the
// dynamically-imported preview subsystem are available (see warmDict).
$effect(() => {
if (dict && view && !warmStarted) {
warmStarted = true;
void warmDict();
}
});
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
@@ -569,6 +597,10 @@
clearReorder();
}
onDestroy(() => {
// Leaving the game: abort a still-downloading dictionary and any in-flight preview so they
// stop holding the channel.
warmCtrl?.abort();
evalCtrl?.abort();
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
@@ -638,19 +670,74 @@
scheduleDraftSave();
}
// warmDict loads the game's dictionary for the local move preview when the player opens the
// game. When it is already cached the preview is instant and no overlay shows; a cold
// dictionary downloads behind a non-dismissable warm-up overlay, which hides on load or after
// a 5s cap — the preview then uses the network until the download finishes in the background.
// The bad-connection breaker skips the overlay and goes straight to the network.
async function warmDict() {
const d = dict;
const v = view;
if (!d || !v) return;
if (d.hasDawg(v.game.variant, v.game.dictVersion) || d.dictLoadingDisabled()) return;
const ctrl = new AbortController();
warmCtrl = ctrl;
let settled = false;
// A short flash-guard: a disk-cached dictionary loads in a few ms, so only show the
// overlay if the load has not settled by now — a cold (network) load always outlasts it.
const grace = setTimeout(() => {
if (!settled) dictWarming = true;
}, 120);
const loaded = await Promise.race([
d.getDawg(v.game.variant, v.game.dictVersion, ctrl.signal),
new Promise<null>((r) => setTimeout(() => r(null), 5000)),
]);
settled = true;
clearTimeout(grace);
dictWarming = false;
if (!loaded) {
// Did not load within the cap: abort the download so it stops starving the channel this
// session, and count the miss toward the bad-connection breaker (3 -> stop warming).
ctrl.abort();
d.noteDictMiss();
}
}
let previewTimer: ReturnType<typeof setTimeout> | null = null;
let evalCtrl: AbortController | null = null;
function recompute() {
preview = null;
if (previewTimer) clearTimeout(previewTimer);
// The tiles changed: cancel any in-flight network preview (evaluate is non-mutating, so
// aborting is safe) — a stale, out-of-order response cannot overwrite the newer one, and
// rapid placements do not pile up requests on a slow link.
evalCtrl?.abort();
evalCtrl = null;
// Off-turn the composition is position-only: no score preview or evaluate.
if (!isMyTurn) return;
const sub = toSubmit(placement);
if (!sub) return;
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
const d = dict;
const v = view;
if (d && v) {
const reader = d.peekDawg(v.game.variant, v.game.dictVersion);
if (reader && hasAlphabet(v.game.variant)) {
try {
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn);
return;
} catch {
/* fall through to the network preview */
}
}
}
const ctrl = new AbortController();
evalCtrl = ctrl;
previewTimer = setTimeout(async () => {
try {
preview = await gateway.evaluate(id, sub.tiles, variant);
preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal);
} catch {
/* best-effort */
/* best-effort (or aborted) */
}
}, 250);
}
@@ -1122,6 +1209,11 @@
{/if}
</Screen>
<!-- Warm-up overlay while the game's dictionary loads for the local move preview. -->
{#if dictWarming}
<DictWarmup />
{/if}
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet scoreboardBlock()}
+6
View File
@@ -73,6 +73,12 @@ export function valueForLetter(variant: Variant, letter: string): number {
return i === undefined ? 0 : t.values[i];
}
/** alphabetValues returns a variant's tile values indexed by alphabet letter index, or an
* empty array when the table is not yet cached. The local move validator scores with it. */
export function alphabetValues(variant: Variant): readonly number[] {
return cache.get(variant)?.values ?? [];
}
/** indexForLetter maps a display letter to its wire index; a blank ("?") maps to the blank
* sentinel. It throws when the letter is outside the cached alphabet — a placement bug, not
* user input (the UI constrains every entry point to the variant's alphabet). */
+8 -1
View File
@@ -91,12 +91,19 @@ export interface GatewayClient {
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
resign(gameId: string): Promise<MoveResult>;
hint(gameId: string): Promise<HintResult>;
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<EvalResult>;
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise<EvalResult>;
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
complaint(gameId: string, word: string, note: string): Promise<void>;
/** Hide a finished game from the caller's own lobby list; per-account, irreversible. */
hideGame(gameId: string): Promise<void>;
// --- dictionary (local move preview) ---
/** Fetch the raw serialized dictionary blob for a (variant, version) pair. Session-gated;
* lets the client validate and score a move locally instead of a network round trip.
* opts.signal aborts a stalled download; opts.reload bypasses the browser HTTP cache (the
* debug reset, for testing a cold load). */
fetchDict(variant: Variant, version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer>;
// --- draft ---
/** The player's server-persisted client-side composition (rack order + board tiles), so a
* reload or a second device resumes the same arrangement. The JSON is opaque to the
+71
View File
@@ -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,
);
}
});
+202
View File
@@ -0,0 +1,202 @@
// In-memory reader for the dafsa DAWG binary format, ported byte-for-byte from
// github.com/iliadenisov/dafsa (bits.go / disk.go / dawg.go). It answers the one
// query the local move validator needs: given a word as alphabet-index bytes, is
// it stored in the dictionary and at what insertion index.
//
// The Go reader treats the file as a big-endian, MSB-first bit stream. Every
// field this reader touches on the lookup path is at most ~31 bits wide, so the
// bit reader accumulates into a plain JavaScript number (no BigInt) and stays
// exact. Faithfulness to the Go reader is enforced by dawg.parity.test.ts, which
// checks indexOf against the authoritative Go output across the whole dictionary.
// bitsLen returns the number of bits needed to represent x (Go math/bits.Len).
function bitsLen(x: number): number {
return x === 0 ? 0 : 32 - Math.clz32(x);
}
/**
* Dawg is a read-only view over a serialized dafsa dictionary held in memory.
* Construct it from the raw bytes of a `.dawg` file, then call {@link indexOf}.
*/
export class Dawg {
private readonly bytes: Uint8Array;
private p = 0; // current position, in bits
private readonly cbits: number;
private readonly abits: number;
private readonly numEdges: number;
private readonly wbits: number;
private readonly firstNodeOffset: number;
private readonly hasEmptyWord: boolean;
/** Number of words stored (dafsa NumAdded). */
readonly numAdded: number;
/** Number of graph nodes (dafsa NumNodes). */
readonly numNodes: number;
// Scratch for the last edge resolved by getEdge, mirroring dafsa's edgeEnd +
// final flag. Reused to keep the lookup path allocation-free.
private eNode = 0;
private eCount = 0;
private eFinal = false;
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;
this.cbits = this.readBits(8);
this.abits = this.readBits(8);
this.skipString(); // language code — not needed to walk index bytes
const numAdded = this.readUnsigned();
const numNodes = this.readUnsigned();
this.numEdges = this.readUnsigned();
this.firstNodeOffset = this.p;
this.hasEmptyWord = this.readBits(1) === 1;
this.numAdded = numAdded;
this.numNodes = numNodes;
this.wbits = bitsLen(numAdded);
}
/**
* indexOf returns the insertion index of the given word (as alphabet-index
* bytes), or -1 if the word was never added. Mirrors dafsa IndexOfB.
*/
indexOf(word: ArrayLike<number>): number {
let skipped = 0;
let node = 0; // rootNode
let final = this.hasEmptyWord;
for (let i = 0; i < word.length; i++) {
if (!this.getEdge(node, word[i])) {
return -1;
}
node = this.eNode;
final = this.eFinal;
skipped += this.eCount;
}
return final ? skipped : -1;
}
/** has reports whether the word (as alphabet-index bytes) is in the dictionary. */
has(word: ArrayLike<number>): boolean {
return this.indexOf(word) >= 0;
}
// getEdge resolves the outgoing edge for ch from the node at the given bit
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
// dafsa (*dawg).getEdge.
private getEdge(node: number, ch: number): boolean {
if (this.numEdges <= 0) {
return false;
}
const pos = node === 0 ? this.firstNodeOffset : node;
this.p = pos;
const nodeFinal = this.readBits(1);
const fallthrough = this.readBits(1);
if (fallthrough === 1) {
const edgeCh = this.readBits(this.cbits);
if (edgeCh === ch) {
this.eCount = nodeFinal;
this.eNode = this.p; // the fallthrough target is the physically next node
this.eFinal = this.readBits(1) === 1;
return true;
}
return false;
}
const singleEdge = this.readBits(1);
let numEdges = 1;
const nskiplen = bitsLen(this.wbits);
let nskip = 0;
if (singleEdge !== 1) {
numEdges = this.readUnsigned();
nskip = this.readBits(nskiplen);
}
const base = this.p; // bit offset of the first edge record
const recordBits = this.cbits + nskip + this.abits;
// Binary search over the edges, which are sorted ascending by character.
let high = numEdges;
let low = -1;
while (high - low > 1) {
const probe = (high + low) >> 1;
// The first edge omits its (zero) skip field, so every later record is
// shifted back by nskip bits.
let seekTo = base + probe * recordBits;
if (probe > 0) {
seekTo -= nskip;
}
this.p = seekTo;
const edgeCh = this.readBits(this.cbits);
const cmp = edgeCh - ch;
if (cmp === 0) {
this.eCount = probe > 0 ? this.readBits(nskip) : nodeFinal;
this.eNode = this.readBits(this.abits);
this.p = this.eNode;
this.eFinal = this.readBits(1) === 1;
return true;
} else if (cmp < 0) {
low = probe;
} else {
high = probe;
}
}
return false;
}
// readBits reads n (<= 32) bits MSB-first from the current position and
// advances it. Mirrors dafsa (*bitSeeker).ReadBits for the widths on this path.
private readBits(n: number): number {
let result = 0;
let p = this.p;
const bytes = this.bytes;
while (n > 0) {
const byteIndex = p >>> 3;
const bitInByte = p & 7;
const avail = 8 - bitInByte;
const take = avail < n ? avail : n;
const shift = avail - take;
const chunk = (bytes[byteIndex] >>> shift) & ((1 << take) - 1);
result = result * (1 << take) + chunk;
p += take;
n -= take;
}
this.p = p;
return result;
}
// readUnsigned reads a dafsa "7code" varint (7 payload bits per byte, high bit
// continues). Mirrors dafsa readUnsigned.
private readUnsigned(): number {
let result = 0;
for (;;) {
const d = this.readBits(8);
result = result * 128 + (d & 0x7f);
if ((d & 0x80) === 0) {
break;
}
}
return result;
}
// skipString advances past a 7code-length-prefixed byte string.
private skipString(): void {
const n = this.readUnsigned();
this.p += n * 8;
}
}
+73
View File
@@ -0,0 +1,73 @@
// Play-direction inference, ported from the backend engine (direction.go
// resolveDirection/runLength and domain.go (*Game).playDirection, v1.1.1). The
// client sends tiles without an orientation; the server infers it, so the local
// move preview must infer the same one or its words and score would diverge.
import { validatePlay, Horizontal, Vertical, type Board, type Ruleset, type Placement, type Direction, type Dict } from './validate';
// runLength mirrors engine.runLength: how many cells the word through (row, col)
// along dir spans, counting the target square plus the filled runs either side.
function runLength(b: Board, row: number, col: number, dir: Direction): number {
let dr = 0;
let dc = 1;
if (dir === Vertical) {
dr = 1;
dc = 0;
}
let n = 1;
for (let r = row - dr, c = col - dc; b.filled(r, c); r -= dr, c -= dc) n++;
for (let r = row + dr, c = col + dc; b.filled(r, c); r += dr, c += dc) n++;
return n;
}
/**
* resolveDirection infers a play's orientation from geometry alone, mirroring
* engine.resolveDirection: two or more tiles by the line they share; a single tile
* by the axis along which it abuts existing tiles, preferring the longer word and
* horizontal on a tie; a tile abutting nothing falls back to horizontal.
*/
export function resolveDirection(b: Board, placements: Placement[]): Direction {
if (placements.length >= 2) {
const row = placements[0].row;
for (let i = 1; i < placements.length; i++) {
if (placements[i].row !== row) return Vertical;
}
return Horizontal;
}
if (placements.length === 1) {
const p = placements[0];
const h = runLength(b, p.row, p.col, Horizontal);
const v = runLength(b, p.row, p.col, Vertical);
if (v >= 2 && v > h) return Vertical;
if (h >= 2) return Horizontal;
if (v >= 2) return Vertical;
}
return Horizontal;
}
/**
* playDirection resolves the orientation for a live play, mirroring engine
* (*Game).playDirection. It is the geometric resolution, except a single tile under
* the single-word rule (ignoreCrossWords) is ambiguous — its two orientations may
* differ in legality — so both are tried through the validator and the
* higher-scoring legal one is kept (horizontal breaks a tie).
*/
export function playDirection(b: Board, rs: Ruleset, dict: Dict, placements: Placement[]): Direction {
const geo = resolveDirection(b, placements);
if (placements.length !== 1 || !rs.ignoreCrossWords) {
return geo;
}
let best = geo;
let found = false;
let bestScore = 0;
for (const dir of [Horizontal, Vertical] as Direction[]) {
const r = validatePlay(b, rs, dict, dir, placements);
if (!r.legal || !r.move) continue;
if (!found || r.move.score > bestScore) {
best = dir;
found = true;
bestScore = r.move.score;
}
}
return best;
}
+110
View File
@@ -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<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,
);
}
});
+104
View File
@@ -0,0 +1,104 @@
// The local move preview: it produces the same EvalResult the network `evaluate`
// returns (legality, score, the words formed, the inferred orientation), computed
// on-device from a loaded dictionary so composing a move needs no round trip. It
// adapts the client's letter-space board and placements into the validator's
// index space (lib/dict/validate.ts), assembling the per-variant ruleset from the
// static geometry/constants (lib/premiums.ts) and the server-sent tile values
// (lib/alphabet.ts). The server stays authoritative — submit re-validates — so any
// throw here is caught by the caller, which falls back to the network preview.
import { validatePlay, Horizontal, type Board as VBoard, type Ruleset, type Placement as VPlacement, type Dict } from './validate';
import { playDirection } from './direction';
import type { Board as ClientBoard } from '../board';
import type { PlacedTile } from '../client';
import type { EvalResult, Variant } from '../model';
import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums';
import { alphabetValues, indexForLetter, letterForIndex } from '../alphabet';
function letterMultOf(p: Premium): number {
return p === 'DL' ? 2 : p === 'TL' ? 3 : 1;
}
function wordMultOf(p: Premium): number {
return p === 'DW' ? 2 : p === 'TW' ? 3 : 1;
}
// buildBoard adapts the client's letter-space board into the validator's
// index-space read view, precomputing the letter indices once.
function buildBoard(variant: Variant, board: ClientBoard): VBoard {
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(BOARD_SIZE * BOARD_SIZE).fill(null);
let empty = true;
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
const cell = board[r][c];
if (cell) {
grid[r * BOARD_SIZE + c] = { letter: indexForLetter(variant, cell.letter), blank: cell.blank };
empty = false;
}
}
}
const inBounds = (r: number, c: number): boolean => r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE;
return {
inBounds,
filled: (r, c) => inBounds(r, c) && grid[r * BOARD_SIZE + c] !== null,
cellAt: (r, c) => grid[r * BOARD_SIZE + c]!,
isEmpty: () => empty,
};
}
// buildRuleset assembles the per-variant scoring rules the validator needs.
function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset {
const prem = premiumGrid(variant);
const ctr = centre(variant);
const values = alphabetValues(variant);
return {
cols: BOARD_SIZE,
center: ctr.row * BOARD_SIZE + ctr.col,
rackSize: RACK_SIZE,
bingo: BINGO[variant],
values,
letterMult: (r, c) => letterMultOf(prem[r][c]),
wordMult: (r, c) => wordMultOf(prem[r][c]),
ignoreCrossWords: !multipleWords,
};
}
// decodeWord turns a word's alphabet indices back into a lower-cased string, the
// form the network EvalResult carries (the caption renders it directly).
function decodeWord(variant: Variant, letters: number[]): string {
let s = '';
for (const i of letters) s += letterForIndex(variant, i);
return s.toLowerCase();
}
/**
* evaluateLocal computes the move preview for tiles placed on board in the given
* game, returning the same shape as the network `evaluate`. dict is the loaded
* dictionary reader for the game's (variant, version); multipleWords is the game's
* cross-word rule. It may throw if the variant's alphabet table is not loaded — the
* caller guards with hasAlphabet and falls back to the network on any failure.
*/
export function evaluateLocal(
dict: Dict,
variant: Variant,
board: ClientBoard,
tiles: PlacedTile[],
multipleWords: boolean,
): EvalResult {
const vboard = buildBoard(variant, board);
const rs = buildRuleset(variant, multipleWords);
const vtiles: VPlacement[] = tiles.map((t) => ({
row: t.row,
col: t.col,
letter: indexForLetter(variant, t.letter),
blank: t.blank,
}));
const dir = playDirection(vboard, rs, dict, vtiles);
const res = validatePlay(vboard, rs, dict, dir, vtiles);
if (!res.legal || !res.move) {
return { legal: false, score: 0, words: [], dir: '' };
}
const m = res.move;
const words = [m.main, ...m.cross].map((w) => decodeWord(variant, w.letters));
return { legal: true, score: m.score, words, dir: dir === Horizontal ? 'H' : 'V' };
}
+7
View File
@@ -0,0 +1,7 @@
// Public surface of the local move-preview subsystem, imported dynamically by the
// game so the whole dict subsystem (the reader, the validator, the cache) stays out
// 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, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader';
export { clearDictCache, listCachedDicts } from './store';
+136
View File
@@ -0,0 +1,136 @@
// Loads the dictionary reader for a (variant, version) pair for the local move
// preview, three tiers deep: an in-memory reader instance, then the persistent
// IndexedDB blob cache, then a session-gated network fetch (which repopulates the
// cache). Every tier is best-effort: on any failure getDawg resolves to null and
// the caller falls back to the network `evaluate`. Concurrent requests for the
// same dictionary share one in-flight load.
import { Dawg } from './dawg';
import { dictKey, idbGetDawg, idbPutDawg, idbDelDawg, requestPersist } from './store';
import { gateway } from '../gateway';
import type { Variant } from '../model';
// Loaded readers by key. A dictionary is immutable, so an instance is reused for
// the whole app session once loaded.
const instances = new Map<string, Dawg>();
// In-flight loads by key, so overlapping callers (prefetch + a game open) share one.
const inflight = new Map<string, Promise<Dawg | null>>();
// Bad-connection breaker: after a few dictionaries fail to become available in a session
// (a load that failed or a warm-up that hit its cap), stop attempting them so the player
// is not stuck re-watching the warm-up overlay when switching games. Session-scoped (in
// memory) — an app relaunch is a natural retry, when the connection may have recovered.
// Cached dictionaries still load.
const MISS_LIMIT = 3;
let misses = 0;
let dictDisabled = false;
/**
* noteDictMiss records that a dictionary did not become available in time — a load that
* failed or a warm-up that hit its cap. After MISS_LIMIT in a session the breaker trips.
*/
export function noteDictMiss(): void {
if (!dictDisabled && ++misses >= MISS_LIMIT) dictDisabled = true;
}
/** dictLoadingDisabled reports whether the bad-connection breaker has tripped for this
* session, so callers can skip the warm-up overlay and go straight to the network. */
export function dictLoadingDisabled(): boolean {
return dictDisabled;
}
// One-shot: the debug reset sets this so the next network fetch bypasses the browser HTTP
// cache. The immutable dict blob is otherwise cached for a year, so clearing IndexedDB alone
// still reloads it instantly from the HTTP cache — this forces a genuinely cold load.
let bustNext = false;
/** bustDictHttpCache makes the next dictionary download bypass the browser HTTP cache. */
export function bustDictHttpCache(): void {
bustNext = true;
}
/**
* getDawg resolves the reader for the (variant, version) dictionary, or null when
* it cannot be obtained (no session, offline, storage or decode failure) — the
* caller then previews over the network instead. Successful loads are cached in
* memory and, when freshly fetched, in IndexedDB.
*/
export function getDawg(variant: Variant, version: string, signal?: AbortSignal): Promise<Dawg | null> {
const key = dictKey(variant, version);
const have = instances.get(key);
if (have) return Promise.resolve(have);
const pending = inflight.get(key);
if (pending) return pending;
const p = load(variant, version, key, signal).finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
async function load(variant: Variant, version: string, key: string, signal?: AbortSignal): Promise<Dawg | null> {
// Tier 1: the persistent cache.
try {
const cached = await idbGetDawg(key);
if (cached) {
const reader = new Dawg(new Uint8Array(cached));
instances.set(key, reader);
return reader;
}
} catch {
// A cached blob the reader rejected (a stale or partial entry): evict it so the next
// load re-fetches instead of failing on it forever, then fall through to the network.
void idbDelDawg(key);
}
// Tier 2: the session-gated download — gated by the bad-connection breaker. A caller's
// signal aborts it (the warm-up giving up at its cap, or the game being left) so a large
// download does not keep starving the channel on a slow link; the caller counts the miss.
if (dictDisabled) return null;
let buf: ArrayBuffer;
const reload = bustNext;
bustNext = false;
try {
buf = await gateway.fetchDict(variant, version, { signal, reload });
} catch {
return null; // network error or aborted
}
let reader: Dawg;
try {
reader = new Dawg(new Uint8Array(buf));
} catch {
return null; // not a dawg — do not cache it
}
instances.set(key, reader);
void idbPutDawg(key, buf);
void requestPersist();
return reader;
}
/**
* hasDawg reports whether the (variant, version) reader is already loaded in
* memory (a warm hit), so the UI can decide instantly whether to preview locally
* or show the warm-up state while the dictionary loads.
*/
export function hasDawg(variant: Variant, version: string): boolean {
return instances.has(dictKey(variant, version));
}
/**
* peekDawg returns the already-loaded reader for (variant, version), or null. It is
* a synchronous in-memory lookup (no load), so the move preview can validate locally
* without awaiting when the dictionary is already warm.
*/
export function peekDawg(variant: Variant, version: string): Dawg | null {
return instances.get(dictKey(variant, version)) ?? null;
}
/**
* clearDictInstances drops the in-memory readers (e.g. on logout). The persistent
* IndexedDB cache is left intact — a dictionary version is immutable and reusable
* by the next account on the device.
*/
export function clearDictInstances(): void {
instances.clear();
misses = 0;
dictDisabled = false;
}
+154
View File
@@ -0,0 +1,154 @@
// Persistent cache for dictionary DAWG blobs, keyed by `${variant}@${version}`.
// It lives in its own IndexedDB database, separate from session.ts's 'scrabble'
// DB, so the multi-hundred-KB binary blobs never interfere with the session /
// prefs store or its schema versioning. Everything here is best-effort: any
// failure resolves to a miss or a no-op and the caller falls back to the network.
const DB_NAME = 'scrabble-dict';
const STORE = 'dawg';
let dbPromise: Promise<IDBDatabase> | null | undefined;
function openDb(): Promise<IDBDatabase> | null {
if (dbPromise !== undefined) return dbPromise;
if (typeof indexedDB === 'undefined') {
dbPromise = null;
return null;
}
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
}).catch(() => {
dbPromise = null;
throw new Error('indexedDB unavailable');
});
return dbPromise;
}
/** dictKey is the persistent cache key for a (variant, version) dictionary. */
export function dictKey(variant: string, version: string): string {
return `${variant}@${version}`;
}
/** idbGetDawg returns the cached blob for key, or null on a miss or any failure. */
export async function idbGetDawg(key: string): Promise<ArrayBuffer | null> {
const db = openDb();
if (!db) return null;
try {
const d = await db;
return await new Promise<ArrayBuffer | null>((resolve, reject) => {
const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key);
r.onsuccess = () => resolve((r.result ?? null) as ArrayBuffer | null);
r.onerror = () => reject(r.error);
});
} catch {
return null;
}
}
/** idbPutDawg stores the blob under key, swallowing any failure (best-effort). */
export async function idbPutDawg(key: string, data: ArrayBuffer): Promise<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve, reject) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(data, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
/* best-effort: a failed persist just re-downloads next time */
}
}
/** idbDelDawg removes a cached blob — e.g. a stale or partial entry the reader rejected, so
* the next load re-fetches instead of failing on it forever. Best-effort. */
export async function idbDelDawg(key: string): Promise<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
/* best-effort */
}
}
let persistRequested = false;
/**
* requestPersist asks the browser (once) not to evict this origin's storage, so a
* cached dictionary survives storage pressure. The grant is honoured unevenly
* across platforms (see docs), so it is only a hint — the cache stays best-effort.
*/
export async function requestPersist(): Promise<void> {
if (persistRequested) return;
persistRequested = true;
try {
if (typeof navigator !== 'undefined' && navigator.storage?.persist) {
await navigator.storage.persist();
}
} catch {
/* ignore — persistence is only a hint */
}
}
/**
* 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<Array<{ key: string; bytes: number }>> {
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
* open handle. Best-effort: a failure just leaves the cache to be reused.
*/
export async function clearDictCache(): Promise<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve, reject) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
/* best-effort */
}
}
+163
View File
@@ -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,
);
}
});
+338
View File
@@ -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;
}
+1
View File
@@ -4,6 +4,7 @@
export const en = {
'app.title': 'Scrabble',
'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…',
'blocked.title': 'Account blocked',
+1
View File
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
export const ru: Record<MessageKey, string> = {
'app.title': 'Scrabble',
'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…',
'blocked.title': 'Учётная запись заблокирована',
+6 -1
View File
@@ -161,6 +161,11 @@ export class MockGateway implements GatewayClient {
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
return { blocked: false, permanent: false, until: '', reason: '' };
}
async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
// No local dictionary in mock mode; the caller falls back to the mock evaluate.
throw new Error('fetchDict unsupported in mock');
}
async gamesList(): Promise<GameList> {
return {
games: [...this.games.values()].map((g) => structuredClone(g.view)),
@@ -432,7 +437,7 @@ export class MockGateway implements GatewayClient {
};
}
async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise<EvalResult> {
async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant, _signal?: AbortSignal): Promise<EvalResult> {
const g = this.game(gameId);
if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' };
let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
+16 -2
View File
@@ -85,5 +85,19 @@ export function centre(variant: Variant): { row: number; col: number } {
return { row: 7, col: 7 };
}
// Tile values and the per-variant alphabet now arrive from the server (lib/alphabet.ts);
// the board geometry above is all this module owns.
/** RACK_SIZE is the number of tiles drawn to a full rack — 7 for every variant. It sizes
* the all-tiles (bingo) bonus test in the local move preview. */
export const RACK_SIZE = 7;
/** BINGO is the all-tiles bonus per variant, ported from scrabble-solver/rules/rules.go:
* 50 for the Scrabble variants, 15 for Эрудит. The local move preview adds it when a play
* uses the whole rack. The dict conformance test pins these to the engine's rulesets. */
export const BINGO: Record<Variant, number> = {
scrabble_en: 50,
scrabble_ru: 50,
erudit_ru: 15,
};
// Tile values and the per-variant alphabet arrive from the server (lib/alphabet.ts); the
// board geometry, centre, rack size and bingo above are the static per-variant constants
// this module owns (all ported from scrabble-solver/rules/rules.go).
+22 -4
View File
@@ -36,12 +36,13 @@ export function createTransport(baseUrl: string): GatewayClient {
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
// indicator. A successful round-trip marks the gateway reachable; a domain result_code is final.
async function exec(messageType: string, payload: Uint8Array): Promise<Uint8Array> {
async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> {
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() });
res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers(), signal });
} catch (e) {
if (signal?.aborted) throw e; // an intentional cancel (e.g. the tiles moved) — do not retry
const err = toGatewayError(e);
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
reportOffline();
@@ -62,6 +63,23 @@ export function createTransport(baseUrl: string): GatewayClient {
token = t;
},
async fetchDict(variant, version, opts) {
if (!token) throw new Error('fetchDict: no session');
// Low priority so the browser schedules the (large) dictionary behind the game's own
// requests — the move eval, state and live events — on a slow link (EDGE). Ignored
// where the Priority Hints API is unsupported (iOS WebKit), which is harmless. The
// signal aborts a stalled download so it stops holding the channel; reload (the debug
// "clear") bypasses the browser HTTP cache to force a fresh copy.
const res = await fetch(`${origin}/dict/${encodeURIComponent(variant)}/${encodeURIComponent(version)}`, {
headers: { authorization: `Bearer ${token}` },
priority: 'low',
signal: opts?.signal,
cache: opts?.reload ? 'reload' : undefined,
} as RequestInit);
if (!res.ok) throw new Error(`fetchDict ${variant}/${version}: HTTP ${res.status}`);
return res.arrayBuffer();
},
async authTelegram(initData) {
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
},
@@ -119,8 +137,8 @@ export function createTransport(baseUrl: string): GatewayClient {
async hint(id) {
return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id)));
},
async evaluate(id, tiles, variant) {
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant)));
async evaluate(id, tiles, variant, signal) {
return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, tiles, variant), signal));
},
async checkWord(id, word, variant) {
return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word, variant)));