Compare commits

..

5 Commits

Author SHA1 Message Date
Ilia Denisov 38644f2a59 perf(ui): cancel the in-flight move preview when tiles move; debug cache-bust
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 57s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Placing or recalling a tile fires the network preview; on a slow link the requests
piled up and an out-of-order response could overwrite the newest with a stale score.
Cancel the previous in-flight evaluate on each change — evaluate is non-mutating, so
aborting is safe (only submit_play and the debounced draft save persist), so only the
latest request runs and the newest result wins; the game also aborts it on unmount.
The abort threads an AbortSignal through the Connect exec (an intentional cancel does
not retry). The debug reset now also bypasses the browser HTTP cache on the next
dictionary load, so a "clear" forces a genuinely cold test — clearing IndexedDB alone
left the immutable blob served straight from the HTTP cache.
2026-07-01 22:42:41 +02:00
Ilia Denisov 6a6fdffd7f fix(ui): abort a stalled dictionary download; trip the breaker on warm timeouts
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
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 1m20s
On a slow link (EDGE) a dictionary download kept running in the background after the
warm-up gave up or the game was left, starving the channel — a new vs-AI game would
not open, and the breaker (which only counted fetch errors) never tripped, so the
warm-up overlay kept reappearing instead of settling to the network after a few
tries. Now:
- the warm-up passes an AbortSignal and aborts the download at its 5s cap, and the
  game aborts it on unmount (leaving the game), so a large download stops holding the
  channel — a subsequent action (creating a game) gets the bandwidth.
- the breaker counts warm-up misses (a load that failed OR hit the cap), not just
  fetch errors, so a persistently slow link trips it after 3 and the preview then
  goes straight to the network.
2026-07-01 22:21:08 +02:00
Ilia Denisov d57dbbab4f perf(ui): deprioritise the dictionary download; drop the lobby prefetch
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
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 1m27s
On a slow link (EDGE) the background dictionary download starved the game's own
traffic — the board and the move preview stalled behind a 450KB fetch. Fetch the
dawg at low priority (Priority Hints; ignored on iOS WebKit, harmless) so the
browser schedules it behind the game's requests, and load it only on game open —
the lobby no longer prefetches, which was its main competitor for the channel.
Evict a cached blob the reader rejects (a stale or partial entry) so it self-heals
instead of lingering and re-fetches. Raise the app-entry bundle budget to 110KB
(the feature's small in-entry game/debug wiring; the heavy dict code stays lazy).
2026-07-01 21:31:23 +02:00
Ilia Denisov 2776ef83c2 fix(deploy): route /dict through caddy to the gateway (local eval)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
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 1m30s
The edge caddy @gateway matcher was missing /dict/*, so the client's dictionary
fetch fell to the static landing catch-all: it received a non-dawg blob (200 OK),
which the reader parsed into a bogus finder that reports every word missing — a
valid first move showed as illegal with no network fallback. Add /dict/* to the
gateway route.

Hardening + the cross-test that would have caught it:
- reader: reject a blob whose 32-bit size header != its byte length, so a
  non-dawg page or a truncated download now throws and the loader falls back to
  the network preview instead of silently validating against garbage.
- eval.parity: an adapter conformance suite that drives evaluateLocal through the
  real letter-space path (the server alphabet table, a letter board and letter
  placements) against the engine — the algorithm-level validate.parity did not
  exercise the adapter. validategen now emits the alphabet table for it.
- a CI deploy probe asserts {edge}/dict reaches the gateway (401), not the landing.
- the DebugPanel reports the feature flag, the bad-connection breaker and the
  cached dictionaries with their sizes; its reset also clears the dict cache.
2026-07-01 20:55:20 +02:00
Ilia Denisov d24a127406 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).
2026-07-01 20:13:01 +02:00
4 changed files with 9 additions and 2 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
const m = await import('../lib/dict'); const m = await import('../lib/dict');
const list = await m.listCachedDicts(); const list = await m.listCachedDicts();
const cached = list.length ? list.map((d) => `${d.key}=${(d.bytes / 1024).toFixed(0)}KB`).join(' ') : 'none'; const cached = list.length ? list.map((d) => `${d.key}=${(d.bytes / 1024).toFixed(0)}KB`).join(' ') : 'none';
dictInfo = `dict breaker: ${m.dictLoadingDisabled() ? 'TRIPPED' : 'ok'}\ndicts cached: ${cached}`; dictInfo = `local eval: ${m.LOCAL_EVAL_ENABLED ? 'on' : 'off'} breaker: ${m.dictLoadingDisabled() ? 'TRIPPED' : 'ok'}\ndicts cached: ${cached}`;
} catch { } catch {
dictInfo = 'local eval: n/a'; dictInfo = 'local eval: n/a';
} }
+1 -1
View File
@@ -260,7 +260,7 @@
// must never see the warm-up overlay, which would intercept the e2e's taps). // must never see the warm-up overlay, which would intercept the e2e's taps).
if (import.meta.env.MODE !== 'mock') { if (import.meta.env.MODE !== 'mock') {
void import('../lib/dict').then((m) => { void import('../lib/dict').then((m) => {
dict = m; if (m.LOCAL_EVAL_ENABLED) dict = m;
}); });
} }
}); });
+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');
+1
View File
@@ -5,3 +5,4 @@
export { evaluateLocal } from './eval'; export { evaluateLocal } from './eval';
export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader'; export { getDawg, peekDawg, hasDawg, dictLoadingDisabled, noteDictMiss, clearDictInstances, bustDictHttpCache } from './loader';
export { clearDictCache, listCachedDicts } from './store'; export { clearDictCache, listCachedDicts } from './store';
export { LOCAL_EVAL_ENABLED } from './config';