From bcd5a1d02dd9bc66438145748698d7bd07f08ab2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 12 Jul 2026 16:41:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20offline-first=20foundations=20?= =?UTF-8?q?=E2=80=94=20bundled=20dicts=20+=20local-guest=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The additive groundwork for the native offline-first experience (ANDROID_PLAN.md §D). Inert until the native cold-boot lands: on the web these paths never fire, so web / VK / Telegram behaviour is unchanged. - ui/scripts/bundle-dicts.mjs copies the scrabble-dictionary release DAWGs into dist/dict/@.dawg for the native pipeline (run after `pnpm build`, before `cap sync`). - The dict loader gains a bundled tier between the IndexedDB and network tiers, attempted only on a native channel (a packaged app serves ./dict/.dawg from its assets). Native-gated rather than the plan's "404 on the web" because the relative path would otherwise hit the gateway's own session-gated /dict/ route. - __DICT_VERSION__ Vite define (from VITE_DICT_VERSION, default "dev") + its ambient declaration; the offline vs_ai / hotseat creates in NewGame fall back to it when a device-local guest has no profile-advertised version. - New lib/localguest.ts: a persisted device-local guest id (no DB row); the offline vs_ai human seat uses it (and the localized common.guest name) when there is no server session. svelte-check clean, vitest green, native + web builds clean. The cold-boot rewrite, reconciliation, the Profile soft-sign-in gating and the offline-first e2e follow. --- ui/scripts/bundle-dicts.mjs | 42 ++++++++++++++++++++++++++++++++ ui/src/lib/dict/loader.ts | 28 +++++++++++++++++++-- ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/localguest.ts | 46 +++++++++++++++++++++++++++++++++++ ui/src/screens/NewGame.svelte | 17 +++++++------ ui/src/vite-env.d.ts | 4 +++ ui/vite.config.ts | 1 + 8 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 ui/scripts/bundle-dicts.mjs create mode 100644 ui/src/lib/localguest.ts diff --git a/ui/scripts/bundle-dicts.mjs b/ui/scripts/bundle-dicts.mjs new file mode 100644 index 0000000..aa6174d --- /dev/null +++ b/ui/scripts/bundle-dicts.mjs @@ -0,0 +1,42 @@ +// Copies the production dictionary DAWGs into the build's app assets so a native (offline-first) +// install can obtain a dictionary with no network: the loader's bundled tier fetches +// ./dict/.dawg, where dictKey is "@" (see lib/dict/store.ts). Run only in +// the native pipeline (after `pnpm build`, before `cap sync`); web builds skip it and stay slim. +// +// Source: DICT_DIR — the unpacked scrabble-dictionary release (scrabble-dawg-.tar.gz), +// the SAME set the backend image and CI consume, NOT the solver's committed test fixtures. The bundled +// version label comes from VITE_DICT_VERSION (default "dev") and MUST equal the client's __DICT_VERSION__ +// so the loader requests the exact (variant, version) the file is named for. OUT_DIR overrides the +// output root (default `dist`) — the e2e build points it at dist-e2e. + +import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const srcDir = process.env.DICT_DIR; +if (!srcDir) { + console.error('bundle-dicts: DICT_DIR is required (the unpacked scrabble-dictionary release dir)'); + process.exit(1); +} +const version = process.env.VITE_DICT_VERSION || 'dev'; +const outDir = join(process.env.OUT_DIR || 'dist', 'dict'); + +// The app's Variant enum value -> the release dawg file name (matches e2e-dict.mjs and the movegen +// parity mapping). +const dawgFor = { + scrabble_en: 'en_sowpods', + scrabble_ru: 'ru_scrabble', + erudit_ru: 'ru_erudit', +}; + +mkdirSync(outDir, { recursive: true }); +let copied = 0; +for (const [variant, file] of Object.entries(dawgFor)) { + const src = join(srcDir, `${file}.dawg`); + if (!existsSync(src)) { + console.warn(`bundle-dicts: missing ${src} — the bundled tier will 404 for ${variant} (set DICT_DIR)`); + continue; + } + copyFileSync(src, join(outDir, `${variant}@${version}.dawg`)); + copied++; +} +console.log(`bundle-dicts: copied ${copied}/${Object.keys(dawgFor).length} dawgs -> ${outDir} (version ${version})`); diff --git a/ui/src/lib/dict/loader.ts b/ui/src/lib/dict/loader.ts index 31c63ce..351c62c 100644 --- a/ui/src/lib/dict/loader.ts +++ b/ui/src/lib/dict/loader.ts @@ -8,6 +8,7 @@ import { Dawg } from './dawg'; import { dictKey, idbGetDawg, idbPutDawg, idbDelDawg, requestPersist } from './store'; import { gateway } from '../gateway'; +import { clientChannel } from '../channel'; import { noteDictFetched, noteDictCacheHit, noteDictMiss as noteDictMissMetric } from '../localeval-metrics'; import type { Variant } from '../model'; @@ -80,11 +81,34 @@ async function load(variant: Variant, version: string, key: string, signal?: Abo } } catch { // A cached blob the reader rejected (a stale or partial entry): evict it so the next - // load re-fetches instead of failing on it forever, then fall through to the network. + // load re-fetches instead of failing on it forever, then fall through to the bundled/network tiers. void idbDelDawg(key); } - // Tier 2: the session-gated download — gated by the bad-connection breaker. A caller's + // Tier 2: a dictionary bundled in a native app's assets (offline-first). Attempted only on a native + // channel, where a packaged app serves ./dict/.dawg from its own assets. Skipped on the web and + // in the Mini Apps — there are no bundled dicts, and the relative path would otherwise hit the + // gateway's own session-gated /dict/ route — so those go straight to the network tier. On a hit, seed + // the persistent cache so later loads take tier 1. A bundled hit is a local asset, not a network + // fetch, so it records no fetch metric. + const channel = clientChannel(); + if (channel === 'android' || channel === 'ios') { + try { + const resp = await fetch(`./dict/${key}.dawg`); + if (resp.ok) { + const buf = await resp.arrayBuffer(); + const reader = new Dawg(new Uint8Array(buf)); + instances.set(key, reader); + void idbPutDawg(key, buf); + void requestPersist(); + return reader; + } + } catch { + // A network-off fetch error or a decode failure — fall through to the network tier. + } + } + + // Tier 3: the session-gated download — gated by the bad-connection breaker. A caller's // signal aborts it (the warm-up giving up at its cap, or the game being left) so a large // download does not keep starving the channel on a slow link; the caller counts the miss. if (dictDisabled) return null; diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 23e8be9..c7fec61 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -34,6 +34,7 @@ export const en = { 'common.loading': 'Loading…', 'common.retry': 'Retry', 'common.you': 'You', + 'common.guest': 'Guest', 'common.save': 'Save', 'login.title': 'Sign in', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index c7fc6ef..ee855e0 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -35,6 +35,7 @@ export const ru: Record = { 'common.loading': 'Загрузка…', 'common.retry': 'Повторить', 'common.you': 'Вы', + 'common.guest': 'Гость', 'common.save': 'Сохранить', 'login.title': 'Вход', diff --git a/ui/src/lib/localguest.ts b/ui/src/lib/localguest.ts new file mode 100644 index 0000000..a73f113 --- /dev/null +++ b/ui/src/lib/localguest.ts @@ -0,0 +1,46 @@ +// The device-local play identity: a persisted id that exists from the very first launch with no +// network and no DB row. It fills the human seat's accountId in a local vs_ai game when there is no +// server session yet (so the seat is recognised as "you" across reloads); hotseat seats keep their +// own independent local identities. A purely-offline user never mints a server guest and consumes no +// server resources — when the app first reaches the network the reconciliation (app.svelte.ts) mints +// a server guest and adopts its session, but the local games created under this id stay device-only. +// +// The display name is not persisted here — it is a UI concern the caller localises (t('common.guest')), +// so this module stays free of i18n and unit-tests in the node env alongside the other localgame libs. + +const ID_KEY = 'scrabble.localGuestId'; + +/** LOCAL_GUEST_PREFIX marks an account id as a device-local guest (no DB row). */ +export const LOCAL_GUEST_PREFIX = 'localguest:'; + +function mintId(): string { + // No crypto API, so it also works on the older engines the SPA still supports (mirrors + // localgame/id.ts newLocalGameId). + return `${LOCAL_GUEST_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * localGuestId returns the persisted device-local guest id, minting and storing one on first use. + * Best-effort persistence: where storage is unavailable it returns a fresh ephemeral id each call (a + * device that cannot persist still plays, it just does not carry a stable local identity across + * reloads). + */ +export function localGuestId(): string { + try { + if (typeof localStorage !== 'undefined') { + const existing = localStorage.getItem(ID_KEY); + if (existing) return existing; + const minted = mintId(); + localStorage.setItem(ID_KEY, minted); + return minted; + } + } catch { + /* storage unavailable — fall through to an ephemeral id */ + } + return mintId(); +} + +/** isLocalGuestId reports whether an account id is a device-local guest id. */ +export function isLocalGuestId(id: string): boolean { + return id.startsWith(LOCAL_GUEST_PREFIX); +} diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 54a4c4f..1d3b112 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -13,6 +13,7 @@ import { offlineMode } from '../lib/offline.svelte'; import { localSource } from '../lib/gamesource'; import { newLocalGameId, randomSeed } from '../lib/localgame/id'; + import { localGuestId } from '../lib/localguest'; import { navigate } from '../lib/router.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { validDisplayName } from '../lib/profileValidation'; @@ -78,10 +79,11 @@ starting = true; try { // Offline mode: create a device-local vs_ai game instead of enqueuing on the backend. The - // dictionary version comes from the profile (advertised for exactly this), and the bag is - // seeded locally; the same game screen then drives it through the local source. + // dictionary version comes from the profile (advertised for exactly this) or, for a device-local + // guest with no profile yet, the bundled __DICT_VERSION__; the bag is seeded locally and the same + // game screen then drives it through the local source. if (offlineMode.active) { - const version = app.profile?.dictVersions?.[v]; + const version = app.profile?.dictVersions?.[v] ?? __DICT_VERSION__; if (!version) { handleError(new Error('dict_unavailable')); return; @@ -94,9 +96,10 @@ seed: randomSeed(), multipleWords: multipleWordsForRequest(v, multipleWords), seats: [ - // The human seat carries the real account id so the game screen and the lobby identify - // "you" and the correct turn exactly as for an online game (the robot uses a synthetic id). - { kind: 'human', name: app.profile?.displayName ?? 'You', accountId: app.session?.userId }, + // The human seat carries the account id — the server session's, or a device-local guest id + // when there is no session yet — so the game screen and the lobby identify "you" and the + // correct turn exactly as for an online game (the robot uses a synthetic id). + { kind: 'human', name: app.profile?.displayName ?? t('common.guest'), accountId: app.session?.userId ?? localGuestId() }, { kind: 'robot', name: 'Robot' }, ], }); @@ -274,7 +277,7 @@ if (starting || !hostPin || !inviteVariant || !rosterReady(rows)) return; starting = true; try { - const version = app.profile?.dictVersions?.[inviteVariant]; + const version = app.profile?.dictVersions?.[inviteVariant] ?? __DICT_VERSION__; if (!version) { handleError(new Error('dict_unavailable')); return; diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index f607cb6..af7aa8d 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -19,3 +19,7 @@ interface ImportMeta { /** App version string, injected by Vite's define from `git describe` at build time. */ declare const __APP_VERSION__: string; + +/** Bundled-dictionary version, injected by Vite's define from VITE_DICT_VERSION (default "dev"). The + * native offline path requests this (variant, version) so it matches the packaged ./dict files. */ +declare const __DICT_VERSION__: string; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5a36909..f5af8c3 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -54,6 +54,7 @@ export default defineConfig(({ mode }) => ({ // via a Docker build-arg. Falls back to "dev" for a plain local/mock build, // so a missing build-arg never breaks the build. __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'), + __DICT_VERSION__: JSON.stringify(process.env.VITE_DICT_VERSION || 'dev'), }, // emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the // index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.