feat: on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s

Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
  game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
  IndexedDB (best-effort, self-healing on a rejected blob) and reused across
  sessions; a warm-up overlay covers a cold load, then the network preview is the
  fallback; a bad-connection breaker stops warming after repeated misses; the move
  preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
  CI against the release dictionaries. A hidden debug readout lists the cached
  dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
This commit is contained in:
Ilia Denisov
2026-07-01 22:58:40 +02:00
parent f0399e1bbc
commit 5689f7f6a3
38 changed files with 2612 additions and 28 deletions
+94 -2
View File
@@ -4,6 +4,7 @@
import TabBar from '../components/TabBar.svelte';
import TapConfirm from '../components/TapConfirm.svelte';
import Modal from '../components/Modal.svelte';
import DictWarmup from '../components/DictWarmup.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway';
@@ -50,6 +51,15 @@
let placement = $state<Placement>(newPlacement([]));
let preview = $state<EvalResult | null>(null);
let busy = $state(false);
// The local move-preview subsystem (dynamically imported when enabled) and the warm-up
// overlay state. dict is null until the subsystem loads, or when the feature is disabled.
let dict = $state<typeof import('../lib/dict') | null>(null);
let dictWarming = $state(false);
let warmStarted = false;
// Aborts the in-flight dictionary download (at the warm-up cap, or when leaving the game)
// so a large download does not keep starving the channel on a slow link.
let warmCtrl: AbortController | null = null;
let zoomed = $state(false);
let selected = $state<number | null>(null);
let focus = $state<{ row: number; col: number } | null>(null);
@@ -244,6 +254,24 @@
void load();
void loadFriends();
void loadBlocked();
// Load the local move-preview subsystem (kept out of the initial bundle) when enabled,
// so composing a move can be scored on-device; the network preview stays the fallback.
// Skipped in mock mode (no dictionary server; the mock uses the network preview path and
// must never see the warm-up overlay, which would intercept the e2e's taps).
if (import.meta.env.MODE !== 'mock') {
void import('../lib/dict').then((m) => {
dict = m;
});
}
});
// Warm the game's dictionary for the local move preview once, when both the game and the
// dynamically-imported preview subsystem are available (see warmDict).
$effect(() => {
if (dict && view && !warmStarted) {
warmStarted = true;
void warmDict();
}
});
// cacheSnapshot returns the open game's current state as a CachedGame for the delta reducers.
@@ -569,6 +597,10 @@
clearReorder();
}
onDestroy(() => {
// Leaving the game: abort a still-downloading dictionary and any in-flight preview so they
// stop holding the channel.
warmCtrl?.abort();
evalCtrl?.abort();
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
window.removeEventListener('pointerdown', onExtraPointer);
@@ -638,19 +670,74 @@
scheduleDraftSave();
}
// warmDict loads the game's dictionary for the local move preview when the player opens the
// game. When it is already cached the preview is instant and no overlay shows; a cold
// dictionary downloads behind a non-dismissable warm-up overlay, which hides on load or after
// a 5s cap — the preview then uses the network until the download finishes in the background.
// The bad-connection breaker skips the overlay and goes straight to the network.
async function warmDict() {
const d = dict;
const v = view;
if (!d || !v) return;
if (d.hasDawg(v.game.variant, v.game.dictVersion) || d.dictLoadingDisabled()) return;
const ctrl = new AbortController();
warmCtrl = ctrl;
let settled = false;
// A short flash-guard: a disk-cached dictionary loads in a few ms, so only show the
// overlay if the load has not settled by now — a cold (network) load always outlasts it.
const grace = setTimeout(() => {
if (!settled) dictWarming = true;
}, 120);
const loaded = await Promise.race([
d.getDawg(v.game.variant, v.game.dictVersion, ctrl.signal),
new Promise<null>((r) => setTimeout(() => r(null), 5000)),
]);
settled = true;
clearTimeout(grace);
dictWarming = false;
if (!loaded) {
// Did not load within the cap: abort the download so it stops starving the channel this
// session, and count the miss toward the bad-connection breaker (3 -> stop warming).
ctrl.abort();
d.noteDictMiss();
}
}
let previewTimer: ReturnType<typeof setTimeout> | null = null;
let evalCtrl: AbortController | null = null;
function recompute() {
preview = null;
if (previewTimer) clearTimeout(previewTimer);
// The tiles changed: cancel any in-flight network preview (evaluate is non-mutating, so
// aborting is safe) — a stale, out-of-order response cannot overwrite the newer one, and
// rapid placements do not pile up requests on a slow link.
evalCtrl?.abort();
evalCtrl = null;
// Off-turn the composition is position-only: no score preview or evaluate.
if (!isMyTurn) return;
const sub = toSubmit(placement);
if (!sub) return;
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
const d = dict;
const v = view;
if (d && v) {
const reader = d.peekDawg(v.game.variant, v.game.dictVersion);
if (reader && hasAlphabet(v.game.variant)) {
try {
preview = d.evaluateLocal(reader, v.game.variant, board, sub.tiles, v.game.multipleWordsPerTurn);
return;
} catch {
/* fall through to the network preview */
}
}
}
const ctrl = new AbortController();
evalCtrl = ctrl;
previewTimer = setTimeout(async () => {
try {
preview = await gateway.evaluate(id, sub.tiles, variant);
preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal);
} catch {
/* best-effort */
/* best-effort (or aborted) */
}
}, 250);
}
@@ -1122,6 +1209,11 @@
{/if}
</Screen>
<!-- Warm-up overlay while the game's dictionary loads for the local move preview. -->
{#if dictWarming}
<DictWarmup />
{/if}
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet scoreboardBlock()}