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:
Ilia Denisov
2026-07-12 16:41:19 +02:00
parent a57fd355ba
commit bcd5a1d02d
8 changed files with 131 additions and 9 deletions
+26 -2
View File
@@ -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/<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
// download does not keep starving the channel on a slow link; the caller counts the miss.
if (dictDisabled) return null;