Files
scrabble-game/ui/src/lib/dict/store.ts
T
Ilia Denisov 5689f7f6a3
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
feat: on-device move preview (local eval) with network fallback
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).
2026-07-01 22:58:40 +02:00

155 lines
5.0 KiB
TypeScript

// Persistent cache for dictionary DAWG blobs, keyed by `${variant}@${version}`.
// It lives in its own IndexedDB database, separate from session.ts's 'scrabble'
// DB, so the multi-hundred-KB binary blobs never interfere with the session /
// prefs store or its schema versioning. Everything here is best-effort: any
// failure resolves to a miss or a no-op and the caller falls back to the network.
const DB_NAME = 'scrabble-dict';
const STORE = 'dawg';
let dbPromise: Promise<IDBDatabase> | null | undefined;
function openDb(): Promise<IDBDatabase> | null {
if (dbPromise !== undefined) return dbPromise;
if (typeof indexedDB === 'undefined') {
dbPromise = null;
return null;
}
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
}).catch(() => {
dbPromise = null;
throw new Error('indexedDB unavailable');
});
return dbPromise;
}
/** dictKey is the persistent cache key for a (variant, version) dictionary. */
export function dictKey(variant: string, version: string): string {
return `${variant}@${version}`;
}
/** idbGetDawg returns the cached blob for key, or null on a miss or any failure. */
export async function idbGetDawg(key: string): Promise<ArrayBuffer | null> {
const db = openDb();
if (!db) return null;
try {
const d = await db;
return await new Promise<ArrayBuffer | null>((resolve, reject) => {
const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key);
r.onsuccess = () => resolve((r.result ?? null) as ArrayBuffer | null);
r.onerror = () => reject(r.error);
});
} catch {
return null;
}
}
/** idbPutDawg stores the blob under key, swallowing any failure (best-effort). */
export async function idbPutDawg(key: string, data: ArrayBuffer): Promise<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve, reject) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(data, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
/* best-effort: a failed persist just re-downloads next time */
}
}
/** idbDelDawg removes a cached blob — e.g. a stale or partial entry the reader rejected, so
* the next load re-fetches instead of failing on it forever. Best-effort. */
export async function idbDelDawg(key: string): Promise<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch {
/* best-effort */
}
}
let persistRequested = false;
/**
* requestPersist asks the browser (once) not to evict this origin's storage, so a
* cached dictionary survives storage pressure. The grant is honoured unevenly
* across platforms (see docs), so it is only a hint — the cache stays best-effort.
*/
export async function requestPersist(): Promise<void> {
if (persistRequested) return;
persistRequested = true;
try {
if (typeof navigator !== 'undefined' && navigator.storage?.persist) {
await navigator.storage.persist();
}
} catch {
/* ignore — persistence is only a hint */
}
}
/**
* listCachedDicts returns the key (variant@version) and byte length of every cached
* dictionary blob, for the debug panel's local-dictionary readout. Best-effort: an
* empty list on any failure.
*/
export async function listCachedDicts(): Promise<Array<{ key: string; bytes: number }>> {
const db = openDb();
if (!db) return [];
try {
const d = await db;
return await new Promise((resolve) => {
const out: Array<{ key: string; bytes: number }> = [];
const req = d.transaction(STORE, 'readonly').objectStore(STORE).openCursor();
req.onsuccess = () => {
const cur = req.result;
if (!cur) {
resolve(out);
return;
}
const v = cur.value as ArrayBuffer;
out.push({ key: String(cur.key), bytes: v?.byteLength ?? 0 });
cur.continue();
};
req.onerror = () => resolve(out);
});
} catch {
return [];
}
}
/**
* clearDictCache empties the dictionary blob store (backs the DebugPanel reset). The
* store is cleared rather than the database deleted, to avoid a delete blocked on an
* open handle. Best-effort: a failure just leaves the cache to be reused.
*/
export async function clearDictCache(): Promise<void> {
const db = openDb();
if (!db) return;
try {
const d = await db;
await new Promise<void>((resolve, reject) => {
const tx = d.transaction(STORE, 'readwrite');
tx.objectStore(STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
/* best-effort */
}
}