feat(ui): offline-first foundations — bundled dicts + local-guest identity
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/<variant>@<version>.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/<key>.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.
This commit is contained in:
@@ -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/<dictKey>.dawg, where dictKey is "<variant>@<version>" (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-<DICT_VERSION>.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})`);
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import { Dawg } from './dawg';
|
import { Dawg } from './dawg';
|
||||||
import { dictKey, idbGetDawg, idbPutDawg, idbDelDawg, requestPersist } from './store';
|
import { dictKey, idbGetDawg, idbPutDawg, idbDelDawg, requestPersist } from './store';
|
||||||
import { gateway } from '../gateway';
|
import { gateway } from '../gateway';
|
||||||
|
import { clientChannel } from '../channel';
|
||||||
import { noteDictFetched, noteDictCacheHit, noteDictMiss as noteDictMissMetric } from '../localeval-metrics';
|
import { noteDictFetched, noteDictCacheHit, noteDictMiss as noteDictMissMetric } from '../localeval-metrics';
|
||||||
import type { Variant } from '../model';
|
import type { Variant } from '../model';
|
||||||
|
|
||||||
@@ -80,11 +81,34 @@ async function load(variant: Variant, version: string, key: string, signal?: Abo
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// A cached blob the reader rejected (a stale or partial entry): evict it so the next
|
// 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);
|
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/<key>.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
|
// 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.
|
// download does not keep starving the channel on a slow link; the caller counts the miss.
|
||||||
if (dictDisabled) return null;
|
if (dictDisabled) return null;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const en = {
|
|||||||
'common.loading': 'Loading…',
|
'common.loading': 'Loading…',
|
||||||
'common.retry': 'Retry',
|
'common.retry': 'Retry',
|
||||||
'common.you': 'You',
|
'common.you': 'You',
|
||||||
|
'common.guest': 'Guest',
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
|
|
||||||
'login.title': 'Sign in',
|
'login.title': 'Sign in',
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'common.loading': 'Загрузка…',
|
'common.loading': 'Загрузка…',
|
||||||
'common.retry': 'Повторить',
|
'common.retry': 'Повторить',
|
||||||
'common.you': 'Вы',
|
'common.you': 'Вы',
|
||||||
|
'common.guest': 'Гость',
|
||||||
'common.save': 'Сохранить',
|
'common.save': 'Сохранить',
|
||||||
|
|
||||||
'login.title': 'Вход',
|
'login.title': 'Вход',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import { offlineMode } from '../lib/offline.svelte';
|
import { offlineMode } from '../lib/offline.svelte';
|
||||||
import { localSource } from '../lib/gamesource';
|
import { localSource } from '../lib/gamesource';
|
||||||
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
|
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
|
||||||
|
import { localGuestId } from '../lib/localguest';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import { validDisplayName } from '../lib/profileValidation';
|
import { validDisplayName } from '../lib/profileValidation';
|
||||||
@@ -78,10 +79,11 @@
|
|||||||
starting = true;
|
starting = true;
|
||||||
try {
|
try {
|
||||||
// Offline mode: create a device-local vs_ai game instead of enqueuing on the backend. The
|
// 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
|
// dictionary version comes from the profile (advertised for exactly this) or, for a device-local
|
||||||
// seeded locally; the same game screen then drives it through the local source.
|
// 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) {
|
if (offlineMode.active) {
|
||||||
const version = app.profile?.dictVersions?.[v];
|
const version = app.profile?.dictVersions?.[v] ?? __DICT_VERSION__;
|
||||||
if (!version) {
|
if (!version) {
|
||||||
handleError(new Error('dict_unavailable'));
|
handleError(new Error('dict_unavailable'));
|
||||||
return;
|
return;
|
||||||
@@ -94,9 +96,10 @@
|
|||||||
seed: randomSeed(),
|
seed: randomSeed(),
|
||||||
multipleWords: multipleWordsForRequest(v, multipleWords),
|
multipleWords: multipleWordsForRequest(v, multipleWords),
|
||||||
seats: [
|
seats: [
|
||||||
// The human seat carries the real account id so the game screen and the lobby identify
|
// The human seat carries the account id — the server session's, or a device-local guest id
|
||||||
// "you" and the correct turn exactly as for an online game (the robot uses a synthetic id).
|
// when there is no session yet — so the game screen and the lobby identify "you" and the
|
||||||
{ kind: 'human', name: app.profile?.displayName ?? 'You', accountId: app.session?.userId },
|
// 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' },
|
{ kind: 'robot', name: 'Robot' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -274,7 +277,7 @@
|
|||||||
if (starting || !hostPin || !inviteVariant || !rosterReady(rows)) return;
|
if (starting || !hostPin || !inviteVariant || !rosterReady(rows)) return;
|
||||||
starting = true;
|
starting = true;
|
||||||
try {
|
try {
|
||||||
const version = app.profile?.dictVersions?.[inviteVariant];
|
const version = app.profile?.dictVersions?.[inviteVariant] ?? __DICT_VERSION__;
|
||||||
if (!version) {
|
if (!version) {
|
||||||
handleError(new Error('dict_unavailable'));
|
handleError(new Error('dict_unavailable'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
Vendored
+4
@@ -19,3 +19,7 @@ interface ImportMeta {
|
|||||||
|
|
||||||
/** App version string, injected by Vite's define from `git describe` at build time. */
|
/** App version string, injected by Vite's define from `git describe` at build time. */
|
||||||
declare const __APP_VERSION__: string;
|
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;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
// via a Docker build-arg. Falls back to "dev" for a plain local/mock build,
|
// via a Docker build-arg. Falls back to "dev" for a plain local/mock build,
|
||||||
// so a missing build-arg never breaks the build.
|
// so a missing build-arg never breaks the build.
|
||||||
__APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'),
|
__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
|
// 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.
|
// index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.
|
||||||
|
|||||||
Reference in New Issue
Block a user