feat(ui): on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s

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 new `conformance` CI job. The server stays
authoritative — submit_play re-validates — so the local result is an advisory
accelerator only; any cache miss, storage eviction or a bad-connection breaker
falls back to the network evaluate.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: an IndexedDB blob cache (best-effort, storage.persist()) + a loader
  (memory -> IndexedDB -> network, session-scoped bad-connection breaker) + a lobby
  prefetch (your-turn first); an adapter over the existing premiums/alphabet; a
  DictWarmup overlay while a cold dictionary loads (120ms flash-guard, 5s cap ->
  network); a ?nolocal flag; the DebugPanel reset also clears the dict cache.
- parity: generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run
  in CI against the release dictionaries.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
This commit is contained in:
Ilia Denisov
2026-07-01 20:13:01 +02:00
parent f0399e1bbc
commit d24a127406
38 changed files with 2375 additions and 11 deletions
+4
View File
@@ -33,6 +33,10 @@
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/store').then((m) => m.clearDictCache());
void import('../lib/dict/loader').then((m) => m.clearDictInstances());
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>
+66
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,12 @@
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;
let zoomed = $state(false);
let selected = $state<number | null>(null);
let focus = $state<{ row: number; col: number } | null>(null);
@@ -244,6 +251,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) => {
if (m.LOCAL_EVAL_ENABLED) 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.
@@ -638,6 +663,28 @@
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;
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);
await Promise.race([d.getDawg(v.game.variant, v.game.dictVersion), new Promise((r) => setTimeout(r, 5000))]);
settled = true;
clearTimeout(grace);
dictWarming = false;
}
let previewTimer: ReturnType<typeof setTimeout> | null = null;
function recompute() {
preview = null;
@@ -646,6 +693,20 @@
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 */
}
}
}
previewTimer = setTimeout(async () => {
try {
preview = await gateway.evaluate(id, sub.tiles, variant);
@@ -1122,6 +1183,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). */
+5
View File
@@ -97,6 +97,11 @@ export interface GatewayClient {
/** 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. */
fetchDict(variant: Variant, version: string): 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
+6
View File
@@ -0,0 +1,6 @@
// Feature flag for the on-device move preview (the local eval). Default on; append
// `?nolocal` to the URL to force the network preview instead, for a side-by-side
// check on the test contour. The network preview is always the fallback, so
// disabling the flag only removes the acceleration, never the feature.
export const LOCAL_EVAL_ENABLED =
typeof location === 'undefined' || !new URLSearchParams(location.search).has('nolocal');
+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,
);
}
});
+192
View File
@@ -0,0 +1,192 @@
// 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;
// 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;
}
+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 } from './loader';
export { LOCAL_EVAL_ENABLED } from './config';
+117
View File
@@ -0,0 +1,117 @@
// 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, 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 dictionary downloads fail in a session, stop
// attempting them so the player is not stuck watching the warm-up overlay when
// switching games. It is session-scoped (in memory) — an app relaunch is a natural
// retry, when the connection may have recovered. Cached dictionaries still load.
const FAILURE_LIMIT = 3;
let failures = 0;
let dictDisabled = false;
function noteFailure(): void {
if (!dictDisabled && ++failures >= FAILURE_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;
}
/**
* 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): 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).finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
async function load(variant: Variant, version: string, key: string): 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 {
/* fall through to the network */
}
// Tier 2: the session-gated download — gated by the bad-connection breaker.
if (dictDisabled) return null;
let buf: ArrayBuffer;
try {
buf = await gateway.fetchDict(variant, version);
} catch {
noteFailure();
return null;
}
let reader: Dawg;
try {
reader = new Dawg(new Uint8Array(buf));
} catch {
noteFailure();
return null; // a corrupt blob — 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();
failures = 0;
dictDisabled = false;
}
+55
View File
@@ -0,0 +1,55 @@
// Lobby-time warm-up of the local move-preview dictionaries: after the lobby
// loads, fetch the DAWG of each of the player's unfinished games in the
// background, so opening one previews moves locally with no wait. It mirrors
// preloadGames — bounded, best-effort, dedup'd — and warms "your turn" games
// first, since those are the ones the player is most likely to open and play.
// Each distinct (variant, version) is fetched once.
import { getDawg, hasDawg } from './loader';
import { dictKey } from './store';
import { isMyTurn } from '../lobbysort';
import type { GameView, Variant } from '../model';
// Cap on dictionaries warmed at once. A player rarely spans more than a couple of
// (variant, version) pairs, so this is mostly a safety bound on a burst.
const MAX_CONCURRENT = 3;
// Dictionaries being warmed right now, so the lobby's repeated refreshes never
// queue the same one twice.
const inflight = new Set<string>();
/**
* preloadDicts warms the local dictionary cache for the player's unfinished games,
* your-turn games first (myId identifies the viewer). It is best-effort and
* bounded; finished games and already-loaded or in-flight dictionaries are
* skipped. It resolves once every queued dictionary has settled.
*/
export async function preloadDicts(games: GameView[], myId: string): Promise<void> {
const ordered = [...games].sort((a, b) => Number(isMyTurn(b, myId)) - Number(isMyTurn(a, myId)));
const targets: Array<{ variant: Variant; version: string; key: string }> = [];
const seen = new Set<string>();
for (const g of ordered) {
if (g.status === 'finished' || !g.dictVersion) continue;
const key = dictKey(g.variant, g.dictVersion);
if (seen.has(key) || inflight.has(key) || hasDawg(g.variant, g.dictVersion)) continue;
seen.add(key);
targets.push({ variant: g.variant, version: g.dictVersion, key });
}
if (targets.length === 0) return;
for (const t of targets) inflight.add(t.key);
let next = 0;
const worker = async (): Promise<void> => {
while (next < targets.length) {
const t = targets[next++];
try {
await getDawg(t.variant, t.version);
} catch {
/* best-effort: the game falls back to the network preview on open */
} finally {
inflight.delete(t.key);
}
}
};
await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT, targets.length) }, worker));
}
+106
View File
@@ -0,0 +1,106 @@
// 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 */
}
}
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 */
}
}
/**
* 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': 'Учётная запись заблокирована',
+5
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): 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)),
+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).
+9
View File
@@ -62,6 +62,15 @@ export function createTransport(baseUrl: string): GatewayClient {
token = t;
},
async fetchDict(variant, version) {
if (!token) throw new Error('fetchDict: no session');
const res = await fetch(`${origin}/dict/${encodeURIComponent(variant)}/${encodeURIComponent(version)}`, {
headers: { authorization: `Bearer ${token}` },
});
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())));
},
+8 -1
View File
@@ -51,7 +51,14 @@
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
if (connection.online) void preloadGames(games);
if (connection.online) {
void preloadGames(games);
// Warm the local move-preview dictionaries (your-turn first). The dict subsystem is
// dynamically imported so it stays out of the initial app bundle — a progressive
// enhancement; without it a game simply falls back to the network preview.
const myId = app.session?.userId ?? '';
void import('../lib/dict/prefetch').then((m) => m.preloadDicts(games, myId));
}
} catch (e) {
handleError(e);
} finally {