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:
@@ -4,18 +4,39 @@
|
||||
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
|
||||
// clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the
|
||||
// Telegram Android presentation quirks. Drawn from the top, just under the app header.
|
||||
import { onMount } from 'svelte';
|
||||
import { app, closeDebug, resetOnboarding } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { shareText } from '../lib/share';
|
||||
import { telegramChromeDiag } from '../lib/telegram';
|
||||
|
||||
const report = [
|
||||
`app: ${__APP_VERSION__}`,
|
||||
`locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`,
|
||||
`online: ${connection.online} streamAlive: ${app.streamAlive}`,
|
||||
`userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`,
|
||||
telegramChromeDiag(),
|
||||
].join('\n');
|
||||
// The local move-preview snapshot — the feature flag, the bad-connection breaker and the
|
||||
// dictionaries cached in IndexedDB with their sizes — loads async so a report about the
|
||||
// preview is precise. Dynamically imported so the debug panel keeps the dict code lazy.
|
||||
let dictInfo = $state('local eval: …');
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const m = await import('../lib/dict');
|
||||
const list = await m.listCachedDicts();
|
||||
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}`;
|
||||
} catch {
|
||||
dictInfo = 'local eval: n/a';
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const report = $derived(
|
||||
[
|
||||
`app: ${__APP_VERSION__}`,
|
||||
`locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`,
|
||||
`online: ${connection.online} streamAlive: ${app.streamAlive}`,
|
||||
`userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`,
|
||||
telegramChromeDiag(),
|
||||
dictInfo,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
let label = $state('Share');
|
||||
async function share(e: MouseEvent): Promise<void> {
|
||||
@@ -33,6 +54,13 @@
|
||||
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').then((m) => {
|
||||
m.clearDictCache();
|
||||
m.clearDictInstances();
|
||||
m.bustDictHttpCache(); // also bypass the browser HTTP cache on the next load (a true cold test)
|
||||
});
|
||||
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>
|
||||
Reference in New Issue
Block a user