feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs. Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match. Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds. Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
This commit is contained in:
+17
-68
@@ -1,70 +1,29 @@
|
||||
// Pure helpers for the deliberate offline MODE — its device-scoped persistence and the readiness
|
||||
// decision — kept out of the reactive module (offline.svelte.ts) so they unit-test in the node env.
|
||||
// The deliberate offline mode is distinct from connection.svelte.ts's transient "can we reach the
|
||||
// gateway" signal: it is the player's own sticky choice, and it gates the network, tints the chrome
|
||||
// and shows only local games.
|
||||
|
||||
import type { Variant } from './model';
|
||||
// Pure helpers for offline-mode support, kept out of the reactive module (offline.svelte.ts) so they
|
||||
// unit-test in the node env. Offline became implicit — the net-state machine detects connectivity, and
|
||||
// there is no deliberate toggle — so only the background dict-preload eligibility check and a one-time
|
||||
// cleanup of the retired deliberate-offline preference remain here.
|
||||
|
||||
const STORAGE_KEY = 'scrabble.offlineMode';
|
||||
|
||||
/** loadOfflinePref reads the persisted offline-mode flag (device-scoped); false when unset or when
|
||||
* storage is unavailable, so a device that cannot persist simply starts online. */
|
||||
export function loadOfflinePref(): boolean {
|
||||
/**
|
||||
* clearOfflinePref removes the retired deliberate-offline preference key. The offline model is implicit
|
||||
* now — the app boots online and the machine detects offline — so a persisted flag from a pre-redesign
|
||||
* install is orphaned data that is never read again; boot clears it once (best-effort) so nobody is left
|
||||
* with a dead key.
|
||||
*/
|
||||
export function clearOfflinePref(): void {
|
||||
try {
|
||||
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||
if (typeof localStorage !== 'undefined') localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** saveOfflinePref persists the offline-mode flag (best-effort). */
|
||||
export function saveOfflinePref(on: boolean): void {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
|
||||
} catch {
|
||||
/* best-effort — a failed persist just reverts to online on the next launch */
|
||||
/* best-effort — a storage failure just leaves the orphaned key, which is never read anyway */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* offlineReady reports whether the device can play offline right now: at least one variant is
|
||||
* enabled and every enabled variant's dictionary is already available on the device (hasDict). The
|
||||
* offline toggle uses it to decide whether flipping to offline can succeed immediately.
|
||||
*/
|
||||
export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): boolean {
|
||||
return enabled.length > 0 && enabled.every((v) => hasDict(v));
|
||||
}
|
||||
|
||||
/** missingDicts lists the enabled variants whose dictionary is not yet available — the ones the
|
||||
* toggle must fetch (or wait on) before offline mode can be entered. */
|
||||
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
|
||||
return enabled.filter((v) => !hasDict(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* raceOfflineReady runs the dictionary fetch `run` against a `budgetMs` wait and reports whether
|
||||
* offline mode can be entered now: ready only when the fetch resolves with nothing still failed
|
||||
* before the budget elapses. On a timeout the caller stops waiting but does NOT abort `run` — it
|
||||
* keeps warming the on-device cache so a later flip to offline is instant. The sleep is injected so
|
||||
* the logic stays pure and unit-tests in the node env.
|
||||
*/
|
||||
export async function raceOfflineReady(
|
||||
run: Promise<{ failed: readonly unknown[] }>,
|
||||
budgetMs: number,
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
): Promise<boolean> {
|
||||
const elapsed = sleep(budgetMs).then(() => null);
|
||||
const res = await Promise.race([run, elapsed]);
|
||||
return res !== null && res.failed.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* offlinePreloadEligible reports whether a background dictionary preload should run in this
|
||||
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
|
||||
* with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth — the context
|
||||
* has no offline mode to prepare for — so kickDictPreload skips it. Mirrors the Settings offline
|
||||
* toggle's eligibility so the two never disagree.
|
||||
* offlinePreloadEligible reports whether a background dictionary preload should run in this context: an
|
||||
* installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab) with a confirmed
|
||||
* email, currently online. Elsewhere the preload is wasted bandwidth — the context has no offline play
|
||||
* to prepare for — so kickDictPreload skips it.
|
||||
*/
|
||||
export function offlinePreloadEligible(opts: {
|
||||
hasEmail: boolean;
|
||||
@@ -75,13 +34,3 @@ export function offlinePreloadEligible(opts: {
|
||||
}): boolean {
|
||||
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
|
||||
}
|
||||
|
||||
/**
|
||||
* shouldBootOffline decides whether a cold start skips the network and launches straight into
|
||||
* offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached
|
||||
* session and profile to start from. Without both (e.g. cleared storage, or a never-online install),
|
||||
* the app must boot online once first — the caller then drops the sticky flag and continues online.
|
||||
*/
|
||||
export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean {
|
||||
return opts.offlineActive && opts.hasSession && opts.hasProfile;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user