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
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:
+94
-2
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user