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
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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()}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export const en = {
|
||||
'app.title': 'Scrabble',
|
||||
'dict.loading': 'Loading…',
|
||||
'connection.connecting': 'Connecting…',
|
||||
|
||||
'blocked.title': 'Account blocked',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MessageKey } from './en';
|
||||
|
||||
export const ru: Record<MessageKey, string> = {
|
||||
'app.title': 'Scrabble',
|
||||
'dict.loading': 'Загрузка…',
|
||||
'connection.connecting': 'Подключение…',
|
||||
|
||||
'blocked.title': 'Учётная запись заблокирована',
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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())));
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user