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

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:
Ilia Denisov
2026-07-13 01:44:28 +02:00
parent 09e05eef18
commit 2d1fadb50c
55 changed files with 2065 additions and 976 deletions
+55 -131
View File
@@ -56,8 +56,10 @@ import {
} from './session';
import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
import { offlineMode, setOfflineMode } from './offline.svelte';
import { shouldBootOffline } from './offline';
import { bootOffline, emit, initNetSignals, registerNetNudge, registerNetToast, registerRecovery } from './netstate.svelte';
import { latchUpdateNudge } from './update.svelte';
import { clearOfflinePref } from './offline';
import { initNativeNetwork } from './native';
import { clientChannel } from './channel';
import { localGuestId } from './localguest';
import { isConnectionCode } from './retry';
@@ -80,10 +82,6 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
bootError: boolean;
/** True while the cold-start "no connection — go offline?" dialog is up (the reachability check
* timed out with the network interface reportedly online). App.svelte renders it over the splash;
* bootstrap awaits the choice via resolveOfflinePrompt. */
offlinePrompt: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
@@ -160,7 +158,6 @@ export const app = $state<{
}>({
ready: false,
bootError: false,
offlinePrompt: false,
launchError: null,
debugOpen: false,
lobbyReady: false,
@@ -574,82 +571,25 @@ async function adoptSession(s: Session): Promise<void> {
void refreshNotifications();
}
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice
// here; App.svelte's dialog buttons call resolveOfflinePrompt.
// The cold-start reachability probe budget: how long the boot waits for the gateway to answer before
// launching from the cache into the implicit offline lobby (offline is auto-detected now — no dialog).
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
let offlinePromptResolve: ((goOffline: boolean) => void) | null = null;
/** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */
export function resolveOfflinePrompt(goOffline: boolean): void {
app.offlinePrompt = false;
const resolve = offlinePromptResolve;
offlinePromptResolve = null;
resolve?.(goOffline);
}
/** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */
function promptOfflineChoice(): Promise<boolean> {
app.offlinePrompt = true;
return new Promise<boolean>((resolve) => {
offlinePromptResolve = resolve;
});
}
/**
* initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode):
* losing the network interface enters offline mode automatically (session-only); regaining it
* verifies the gateway is really reachable (an interface being up does not guarantee it) and returns
* to online — but only if the offline was auto. A deliberate offline (the Settings toggle or the
* cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
* recover is the smart recovery the net-state store's watcher runs while connecting/offline: a
* session-less native local guest reconciles a server guest (the reconcile attempt IS the reachability
* probe — it needs no prior session), while any cached-session launch does a cheap authenticated read.
* It resolves when the gateway is reachable (the store then heals to online) and rejects to stay offline.
* bootstrap wires it to the store, which owns the poll/backoff timer and the OS-signal listeners.
*/
// While in auto-offline, poll for the network to return so the app comes back online — robust to the
// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it
// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the
// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in
// auto-offline (a deliberate offline is the player's choice) and stops on returning online.
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
export function scheduleRecovery(delayMs: number): void {
if (recoveryTimer) {
clearTimeout(recoveryTimer);
recoveryTimer = null;
async function recover(): Promise<void> {
const channel = clientChannel();
if ((channel === 'android' || channel === 'ios') && !app.session) {
await reconcileServerGuest();
if (!app.session) throw new Error('still a local guest'); // reconcile did not adopt — stay offline
return;
}
// Web / native only; under the mock the e2e drives connectivity + reconciliation directly (cf.
// initNetworkReactivity), so the poll must not race a mock reconcile to online before the spec can
// observe the offline lobby.
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock' || !offlineMode.active || !offlineMode.auto) return;
recoveryTimer = setTimeout(async () => {
recoveryTimer = null;
if (!offlineMode.active || !offlineMode.auto) return;
const interfaceUp = typeof navigator === 'undefined' || navigator.onLine;
if (interfaceUp) {
if (!app.session) {
// A native local guest has no session token for checkReachable's authenticated probe, so the
// reconciliation attempt itself is the reachability check: it mints + adopts a server guest and
// clears the auto-offline on success (a no-op off the native channel or once a session exists).
await reconcileServerGuest();
} else if (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS)) {
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
}
if (!offlineMode.active) return; // came back online
}
scheduleRecovery(4000);
}, delayMs);
}
export function initNetworkReactivity(): void {
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
window.addEventListener('offline', () => {
if (!offlineMode.active) {
setOfflineMode(true, false); // auto (session)
scheduleRecovery(4000); // poll for the network to return
}
});
// The online event, when it fires, just kicks an immediate reachability check; the poll above is
// the reliable fallback for platforms where it does not fire.
window.addEventListener('online', () => {
if (offlineMode.active && offlineMode.auto) scheduleRecovery(0);
});
if (!(await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) throw new Error('gateway unreachable');
}
// A concurrency guard: the boot kick, the recovery poll and the online event can all try to reconcile
@@ -672,9 +612,10 @@ async function reconcileServerGuest(): Promise<void> {
reconcileInFlight = true;
try {
const s = await gateway.authGuestSilent(app.locale);
// The gateway answered, so leave auto-offline BEFORE adopting: adoptSession's profile fetch and live
// stream ride the normal (online) transport, which the offline kill switch would otherwise refuse.
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
// The gateway answered, so heal the machine to online BEFORE adopting: adoptSession's profile fetch
// and live stream ride the normal (online) transport, which the offline kill switch would otherwise
// refuse. emit('callOk') is idempotent when already online (a re-entrant recover after adoption).
emit('callOk');
await adoptSession(s);
} catch {
// A too-old client (update_required, swallowed — stay a local guest, no overlay) or an unreachable
@@ -857,6 +798,9 @@ export async function bootstrap(): Promise<void> {
// has no real backend). Deliberately before any await, so even an early-return launch path
// (e.g. the Telegram launch-error screen) still counts as an app open.
if (import.meta.env.MODE !== 'mock') startLocalEvalMetrics();
// One-time cleanup of the retired deliberate-offline preference (offline is implicit now — the key is
// never read again). Runs for every channel, before the Mini-App branches return.
clearOfflinePref();
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
@@ -966,37 +910,27 @@ export async function bootstrap(): Promise<void> {
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
// and skipped in the mock build; see lib/pwa.svelte.
registerServiceWorker();
// React to mid-session network changes (e.g. flight mode) for the rest of the session.
initNetworkReactivity();
// Deliberate offline mode carried over from a prior session: with no network the session adoption
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
// with no cached profile is unreachable: enabling offline requires a prior online session.)
if (offlineMode.active) {
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
gateway.setToken(offlineSession!.token);
app.session = offlineSession!;
app.profile = offlineProfile!;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
// Wire the net-state machine's signal sources for the rest of the session: the OS connectivity hints
// (web navigator + native @capacitor/network), the smart recovery the store's watcher runs, and the
// offline/online toast. Reached only on web / native — the Telegram/VK branches returned above, so
// those channels are never fed an offline signal and the machine stays inert (always online) there.
registerNetToast((toast) => showToast(t(toast === 'offline' ? 'net.offline' : 'net.online')));
registerNetNudge(latchUpdateNudge);
registerRecovery(recover);
initNetSignals();
void initNativeNetwork();
const saved = await loadSession();
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
// from VK; capture and clear it here (it needs the restored session to link against).
const vkcb = pendingVKLink();
if (saved) {
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
// the network is down: a device with no network interface goes offline for the session (no
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
// Cold-start network auto-detect. With a cached profile to fall back on and no pending VK-link, do
// not hang on adoptSession's retrying profile fetch when the network is down: if the interface is
// down, or the gateway does not answer within a short window, launch straight from the cache into
// the implicit offline lobby (no dialog — offline is detected now). The store's recovery poll heals
// to online when the network returns. Web-only reaches here (the Mini-App branches returned above),
// so isStandalone gates it.
const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
if (canOffline) {
@@ -1004,21 +938,12 @@ export async function bootstrap(): Promise<void> {
gateway.setToken(saved.token);
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
if (!reachable) {
// No network interface → go offline for the session (no dialog); interface up but the
// gateway is unreachable → ambiguous, so ask.
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
if (goOffline) {
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
setOfflineMode(true, !interfaceOffline);
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
// watcher shows "Connecting…").
bootOffline();
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
await adoptSession(saved);
@@ -1031,15 +956,14 @@ export async function bootstrap(): Promise<void> {
navigate('/');
}
} else if (clientChannel() === 'android' || clientChannel() === 'ios') {
// Native offline-first cold boot: no cached server session, so enter as a device-local guest in a
// non-sticky (auto) offline mode and land straight in the lobby — never /login. reconcileServerGuest
// (kicked here and by the recovery poll) mints a server guest and clears the auto-offline once the
// gateway is reachable; until then the guest plays local vs_ai / hotseat with the APK's bundled
// dictionaries. Web / PWA / Telegram / VK keep the online-session rule below.
// Native offline-first cold boot: no cached server session, so enter as a device-local guest in the
// implicit offline lobby and land straight there — never /login. bootOffline(true) drops into offline
// and kicks the recovery poll now; recover() mints + adopts a server guest and heals to online once
// the gateway is reachable, and until then the guest plays local vs_ai / hotseat with the APK's
// bundled dictionaries. Web / PWA / Telegram / VK keep the online-session rule below.
localGuestId(); // establish + persist the device-local identity from the very first launch
setOfflineMode(true, false);
bootOffline(true); // offline-first + kick reconciliation now, then poll until the network returns
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
scheduleRecovery(0); // kick reconciliation now, then poll until the network returns
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login');
}
@@ -1424,9 +1348,9 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
navigate,
route: () => router.route.name,
};
// Drive the native offline-first reconciliation from the e2e: the recovery poll that fires it in a
// real build is skipped under the mock (scheduleRecovery), so the spec calls this to simulate "the
// network returned" and prove a server guest is minted + adopted and the auto-offline clears.
// Drive the native offline-first reconciliation from the e2e: the net-state store's recovery poll that
// fires it in a real build is inert under the mock, so the spec calls this to simulate "the network
// returned" and prove a server guest is minted + adopted and the machine heals to online.
(window as unknown as { __native?: { reconcile(): void } }).__native = {
reconcile: () => void reconcileServerGuest(),
};
+28 -59
View File
@@ -1,80 +1,49 @@
// Global connectivity signal. `online` is false while the app is actively failing to
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live
// stream dropped. The transport and the live-stream owner report transitions; the UI reads
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive
// actions. In mock mode nothing ever reports trouble, so it simply stays online.
//
// Recovery is guaranteed by a reachability watcher: while offline it periodically fires a
// registered probe (a lightweight read) until one succeeds, so the indicator clears even when no
// other traffic is in flight.
// connection.svelte.ts is now a thin shim over the net-state store (netstate.svelte.ts, O2). It keeps
// the historical surface — `connection.online` plus the report/probe helpers the transport and the live
// stream call — so those consumers are unchanged while the single net-state machine drives everything
// underneath. `online` is true only in the fully-online machine state; a failing call or dropped stream
// reports via reportOffline, which enters the connecting hysteresis buffer once, and the store's probe
// then decides whether it becomes offline — so a transient blip never flips the chrome.
import { backoffMs } from './retry';
import { offlineMode } from './offline.svelte';
let online = $state(true);
let watchTimer: ReturnType<typeof setTimeout> | null = null;
let probe: (() => Promise<void>) | null = null;
import {
netState,
emit,
registerProbe as registerNetProbe,
checkReachable as checkNetReachable,
resetNetState,
} from './netstate.svelte';
export const connection = {
/** online is true when the app believes it can reach the gateway. */
/** online is true when the app can reach the gateway (the fully-online machine state). */
get online(): boolean {
return online;
return netState.online;
},
};
/** registerProbe installs the reachability probe the watcher fires while offline. The transport
* wires a cheap authenticated read; it should reject when there is no session. */
/** registerProbe installs the reachability probe the store's watcher fires while connecting/offline.
* The transport wires a cheap authenticated read; it should reject when there is no session. */
export function registerProbe(fn: () => Promise<void>): void {
probe = fn;
registerNetProbe(fn);
}
/**
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
* gateway answered — a single attempt (no retry loop), for the cold-start network decision — while
* updating the online signal. It reports false when no probe is registered or the timeout wins.
*/
export async function checkReachable(timeoutMs: number): Promise<boolean> {
if (!probe) return false;
try {
await Promise.race([
probe(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
]);
return true;
} catch {
return false;
}
/** checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
* gateway answered — a single attempt for the cold-start / recovery decision. */
export function checkReachable(timeoutMs: number): Promise<boolean> {
return checkNetReachable(timeoutMs);
}
/** reportOnline marks the gateway reachable and stops the watcher. */
/** reportOnline marks the gateway reachable — a successful round-trip heals the machine to online. */
export function reportOnline(): void {
online = true;
if (watchTimer) {
clearTimeout(watchTimer);
watchTimer = null;
}
emit('callOk');
}
/** reportOffline marks the gateway unreachable and starts the reachability watcher (once). */
/** reportOffline reports a failing call or dropped stream. It opens the connecting hysteresis buffer
* once (only from online); the store's probe then decides whether it becomes offline. */
export function reportOffline(): void {
online = false;
if (!watchTimer && probe) scheduleProbe(1);
if (netState.online) emit('callFailed');
}
/** resetConnection restores the online state and stops the watcher (e.g. on logout). */
export function resetConnection(): void {
reportOnline();
}
function scheduleProbe(attempt: number): void {
watchTimer = setTimeout(
() => {
watchTimer = null;
// Never probe the network in offline mode (the kill switch); the online/offline events drive
// recovery there instead.
if (online || !probe || offlineMode.active) return;
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
},
backoffMs(attempt),
);
resetNetState();
}
-27
View File
@@ -1,27 +0,0 @@
// Browser orchestration for the offline-toggle readiness wait. Lazily imported by
// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main
// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by
// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the
// fetch running on a timeout so a later flip is instant.
import type { Profile } from '../model';
import { preloadDicts } from './preload';
import { getDawg, dictLoadingDisabled } from '../dict';
import { raceOfflineReady } from '../offline';
/**
* ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when
* already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is
* available — the offline toggle's readiness gate. With no enabled variant there is nothing to play
* offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the
* on-device cache in the background so a later flip to offline succeeds immediately.
*/
export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise<boolean> {
if (prof.variantPreferences.length === 0) return false;
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
getDawg,
disabled: dictLoadingDisabled,
retries: 1,
});
return raceOfflineReady(run, budgetMs);
}
+21 -4
View File
@@ -7,8 +7,9 @@ import { MockGateway } from './mock/client';
import { createTransport } from './transport';
import { setForcedSeed } from './localgame/id';
import { reportOffline, reportOnline } from './connection.svelte';
import { emit } from './netstate.svelte';
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
import { reportUpdateRequired } from './update.svelte';
import { reportUpdateRecommended, reportUpdateRequired } from './update.svelte';
const isMock = import.meta.env.MODE === 'mock';
@@ -32,9 +33,25 @@ if (isMock && typeof window !== 'undefined') {
off: clearMaintenance,
recover: maintenanceRecovered,
};
// The mock never receives a real update_required from the edge, so the e2e drives the terminal
// "update required" overlay directly (it is terminal — there is no clear hook).
(window as unknown as { __update?: { on(): void } }).__update = { on: reportUpdateRequired };
// The mock never receives a real version signal from the edge, so the e2e drives them directly:
// __update.on() raises the hard-tier notice (netState.versionLocked), __update.recommend() raises the
// soft-tier nudge (versionRecommended → the setNudge latch, from online).
(window as unknown as { __update?: { on(): void; recommend(): void } }).__update = {
on: reportUpdateRequired,
recommend: reportUpdateRecommended,
};
// The mock has no real transport, so the net-state store's probe watcher is inert; the e2e drives the
// machine directly. __net.offline() rides past the connecting hysteresis (k failures) into the offline
// lobby; __net.online() heals back to online (the "back online" toast fires from the offline state).
(window as unknown as { __net?: { offline(): void; online(): void } }).__net = {
offline: () => {
emit('callFailed');
emit('probeFailed');
emit('probeFailed');
emit('probeFailed');
},
online: () => emit('callOk'),
};
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(
+8 -7
View File
@@ -7,12 +7,16 @@ export const en = {
'app.brand': 'Erudit',
'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…',
'net.offline': 'You are offline',
'net.online': 'Back online',
'maintenance.title': 'Under maintenance',
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
'maintenance.retry': 'Try again',
'update.title': 'Update required',
'update.body': 'This app is out of date and can no longer run. Download the update to keep playing — and winning.',
'update.action': 'Update',
'update.playOffline': 'Play offline',
'update.available': 'Update available',
'blocked.title': 'Account blocked',
'blocked.permanent': 'Your account is blocked.',
@@ -219,15 +223,8 @@ export const en = {
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'settings.zoomBoard': 'Zoom the board',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
'settings.offlineChecking': 'Loading dictionaries…',
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
'offline.promptTitle': 'No connection. Enable offline mode?',
'offline.promptYes': 'Enable',
'offline.promptNo': 'Keep trying',
// --- wallet ---
'wallet.title': 'Wallet',
@@ -378,6 +375,10 @@ export const en = {
'new.auto': 'Quick match',
'new.withFriends': 'Play with friends',
'new.playRemote': 'Invite a friend',
'new.playLocal': 'Pass and play',
'new.needsNetwork': 'Online play needs a connection.',
'new.dictUnavailable': "This dictionary isn't available offline yet.",
'new.pickFriends': 'Choose who to invite',
'new.searchFriends': 'Search friends',
'new.gameType': 'Variant',
+8 -7
View File
@@ -8,12 +8,16 @@ export const ru: Record<MessageKey, string> = {
'app.brand': 'Эрудит',
'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…',
'net.offline': 'Нет соединения',
'net.online': 'Соединение восстановлено',
'maintenance.title': 'Технические работы',
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
'maintenance.retry': 'Повторить',
'update.title': 'Требуется обновление',
'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.',
'update.action': 'Обновить',
'update.playOffline': 'Играть офлайн',
'update.available': 'Доступно обновление',
'blocked.title': 'Учётная запись заблокирована',
'blocked.permanent': 'Ваша учётная запись заблокирована.',
@@ -219,15 +223,8 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'settings.zoomBoard': 'Приближать доску',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
'settings.offlineChecking': 'Загрузка словарей…',
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
'offline.promptYes': 'Включить',
'offline.promptNo': 'Ждать сеть',
// --- wallet ---
'wallet.title': 'Кошелёк',
@@ -378,6 +375,10 @@ export const ru: Record<MessageKey, string> = {
'new.auto': 'Быстрая игра',
'new.withFriends': 'Игра с друзьями',
'new.playRemote': 'Пригласить друга',
'new.playLocal': 'На одном устройстве',
'new.needsNetwork': 'Для игры онлайн нужна сеть.',
'new.dictUnavailable': 'Этот словарь пока недоступен офлайн.',
'new.pickFriends': 'Кого пригласить',
'new.searchFriends': 'Поиск друзей',
'new.gameType': 'Вариант',
+28 -35
View File
@@ -44,10 +44,10 @@ beforeEach(() => clearLobby());
describe('patchLobbyGame', () => {
it('replaces the matching game by id and leaves the others untouched', () => {
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], offline: false });
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
// The player's own move flipped game "a" to the opponent's turn.
patchLobbyGame(gameView('a', 'active', 1));
const snap = getLobby(false);
const snap = getLobby();
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
@@ -55,27 +55,27 @@ describe('patchLobbyGame', () => {
it('preserves invitations and incoming when patching a game', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('a')], invitations: [], incoming, offline: false });
setLobby({ games: [gameView('a')], invitations: [], incoming });
patchLobbyGame(gameView('a', 'finished'));
expect(getLobby(false)?.incoming).toEqual(incoming);
expect(getLobby(false)?.games[0].status).toBe('finished');
expect(getLobby()?.incoming).toEqual(incoming);
expect(getLobby()?.games[0].status).toBe('finished');
});
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
setLobby({ games: [gameView('a')], invitations: [], incoming: [], offline: false });
setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
patchLobbyGame(gameView('z', 'active'));
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyGame(gameView('a'));
expect(getLobby(false)).toBeNull();
expect(getLobby()).toBeNull();
});
it('does not mutate the previous snapshot array', () => {
const games = [gameView('a', 'active', 0)];
setLobby({ games, invitations: [], incoming: [], offline: false });
setLobby({ games, invitations: [], incoming: [] });
patchLobbyGame(gameView('a', 'active', 1));
expect(games[0].toMove).toBe(0); // the original array/object is left intact
});
@@ -83,59 +83,52 @@ describe('patchLobbyGame', () => {
describe('patchLobbyInvitation', () => {
it('adds a new pending invitation to the cached lobby', () => {
setLobby({ games: [], invitations: [], incoming: [], offline: false });
setLobby({ games: [], invitations: [], incoming: [] });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
});
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
patchLobbyInvitation(updated);
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
expect(getLobby(false)?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
});
it('removes an invitation that reached a terminal status', () => {
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', terminal));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
}
});
it('is a no-op for a terminal invitation that is not in the list', () => {
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], offline: false });
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', 'declined'));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)).toBeNull();
expect(getLobby()).toBeNull();
});
it('preserves games and incoming when patching an invitation', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('g1')], invitations: [], incoming, offline: false });
setLobby({ games: [gameView('g1')], invitations: [], incoming });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby(false)?.incoming).toEqual(incoming);
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby()?.incoming).toEqual(incoming);
});
});
describe('getLobby is mode-aware (a mode flip must not render the other modes games)', () => {
it('returns the snapshot only for the mode it was stored under', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
expect(getLobby(true)).toBeNull();
});
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], offline: true });
expect(getLobby(false)).toBeNull();
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
describe('the unified snapshot (one lobby cache — offline reuses its server games, greyed)', () => {
it('replaces the whole snapshot on each store, so the latest merged lists win', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [] });
expect(getLobby()?.games.map((g) => g.id)).toEqual(['online1']);
setLobby({ games: [gameView('local1'), gameView('online2')], invitations: [], incoming: [] });
expect(getLobby()?.games.map((g) => g.id)).toEqual(['local1', 'online2']);
});
});
+5 -8
View File
@@ -10,18 +10,15 @@ interface LobbySnapshot {
games: GameView[];
invitations: Invitation[];
incoming: AccountRef[];
// Which mode the snapshot belongs to (offline = device-local games, online = server games). The
// lobby renders it instantly only when it matches the current mode, so a mode flip never flashes —
// or lets the player open — the other mode's games before the background refresh replaces them.
offline: boolean;
}
let snapshot: LobbySnapshot | null = null;
/** getLobby returns the last lobby lists for the given mode, or null before the first load or when
* the cached snapshot belongs to the other mode (so the lobby cold-renders after a mode flip). */
export function getLobby(offline: boolean): LobbySnapshot | null {
return snapshot && snapshot.offline === offline ? snapshot : null;
/** getLobby returns the last lobby lists, or null before the first load. The unified lobby caches one
* snapshot (device-local + server games merged); offline reuses its server games, greyed, and drops
* the local ones for a fresh device read. */
export function getLobby(): LobbySnapshot | null {
return snapshot;
}
/** setLobby stores the latest lobby lists. */
+46 -30
View File
@@ -3,6 +3,9 @@ import { gamePhase, groupGames, isMyTurn, orderedSeats, scoreStanding, shouldBli
import type { GameView, Seat } from './model';
const ME = 'me';
// The self-id set the lobby passes (server user id + device-local guest id); ME is used as a bare seat
// id where a single seat is built, MINE where a function takes the whole "you" set.
const MINE = [ME];
const seat = (s: number, accountId: string): Seat => ({
seat: s,
accountId,
@@ -41,7 +44,7 @@ describe('groupGames', () => {
game('b', 'active', 1, 100), // their turn
game('c', 'finished', 0, 100),
],
ME,
MINE,
{},
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
@@ -59,7 +62,7 @@ describe('groupGames', () => {
game('f_new', 'finished', 0, 200),
game('f_old', 'finished', 0, 100),
],
ME,
MINE,
{},
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
@@ -77,7 +80,7 @@ describe('groupGames', () => {
game('f_unread', 'finished', 0, 100),
game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first
],
ME,
MINE,
{ y_unread: true, t_unread: true, f_unread: true },
);
// Unread is the primary key, so it overrides the within-section activity order.
@@ -88,9 +91,9 @@ describe('groupGames', () => {
});
it('isMyTurn is false for a finished game even at my seat', () => {
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false);
expect(isMyTurn(game('x', 'finished', 0, 0), MINE)).toBe(false);
expect(isMyTurn(game('x', 'active', 0, 0), MINE)).toBe(true);
expect(isMyTurn(game('x', 'active', 1, 0), MINE)).toBe(false);
});
it('treats an open game (awaiting an opponent) as in progress, not finished', () => {
@@ -99,23 +102,36 @@ describe('groupGames', () => {
game('open_mine', 'open', 0, 100), // my turn while waiting
game('open_wait', 'open', 1, 100), // the empty seat's turn
],
ME,
MINE,
{},
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
expect(g.finished).toEqual([]);
expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true);
expect(isMyTurn(game('x', 'open', 0, 0), MINE)).toBe(true);
});
it('recognises "you" under any of the self ids (local game under localGuestId, server under userId)', () => {
// After reconciliation a device-local game seats you under the local guest id and a server game
// under the user id; the two differ (ids never collide) and both must count as your turn.
const localGame: GameView = { ...game('local', 'active', 0, 100), seats: [seat(0, 'guest'), seat(1, 'robot')] };
const serverGame: GameView = { ...game('server', 'active', 0, 100), seats: [seat(0, ME), seat(1, 'opp')] };
const both = ['guest', ME];
expect(isMyTurn(localGame, both)).toBe(true);
expect(isMyTurn(serverGame, both)).toBe(true);
expect(isMyTurn(localGame, [ME])).toBe(false); // a single id misses the other source's game
const g = groupGames([localGame, serverGame], both, {});
expect(g.yourTurn.map((x) => x.id).sort()).toEqual(['local', 'server']);
});
});
describe('gamePhase', () => {
it('maps each game to its lobby bucket (open folds into mine/theirs like active)', () => {
expect(gamePhase(game('x', 'active', 0, 0), ME)).toBe('mine');
expect(gamePhase(game('x', 'active', 1, 0), ME)).toBe('theirs');
expect(gamePhase(game('x', 'open', 0, 0), ME)).toBe('mine');
expect(gamePhase(game('x', 'open', 1, 0), ME)).toBe('theirs');
expect(gamePhase(game('x', 'finished', 0, 0), ME)).toBe('finished');
expect(gamePhase(game('x', 'active', 0, 0), MINE)).toBe('mine');
expect(gamePhase(game('x', 'active', 1, 0), MINE)).toBe('theirs');
expect(gamePhase(game('x', 'open', 0, 0), MINE)).toBe('mine');
expect(gamePhase(game('x', 'open', 1, 0), MINE)).toBe('theirs');
expect(gamePhase(game('x', 'finished', 0, 0), MINE)).toBe('finished');
});
});
@@ -131,38 +147,38 @@ describe('scoreStanding', () => {
it('greens the leading viewer and reds a trailing one; the opponent stays muted', () => {
const lead = withScores('active', 30, 10);
expect(scoreStanding(lead, ME, at(lead, ME))).toBe('win');
expect(scoreStanding(lead, ME, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured
expect(scoreStanding(lead, MINE, at(lead, ME))).toBe('win');
expect(scoreStanding(lead, MINE, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured
const behind = withScores('active', 5, 10);
expect(scoreStanding(behind, ME, at(behind, ME))).toBe('lose');
expect(scoreStanding(behind, ME, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured
expect(scoreStanding(behind, MINE, at(behind, ME))).toBe('lose');
expect(scoreStanding(behind, MINE, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured
});
it('greens both seats on an equal non-zero score', () => {
const tie = withScores('active', 10, 10);
expect(scoreStanding(tie, ME, at(tie, ME))).toBe('win');
expect(scoreStanding(tie, ME, at(tie, 'opp'))).toBe('win');
expect(scoreStanding(tie, MINE, at(tie, ME))).toBe('win');
expect(scoreStanding(tie, MINE, at(tie, 'opp'))).toBe('win');
});
it('leaves a fresh 0:0 board muted (nobody has scored yet)', () => {
const fresh = withScores('active', 0, 0);
expect(scoreStanding(fresh, ME, at(fresh, ME))).toBeNull();
expect(scoreStanding(fresh, ME, at(fresh, 'opp'))).toBeNull();
expect(scoreStanding(fresh, MINE, at(fresh, ME))).toBeNull();
expect(scoreStanding(fresh, MINE, at(fresh, 'opp'))).toBeNull();
});
it('is null for any non-active game (open or finished)', () => {
const fin = withScores('finished', 30, 10);
expect(scoreStanding(fin, ME, at(fin, ME))).toBeNull();
expect(scoreStanding(fin, MINE, at(fin, ME))).toBeNull();
const op = withScores('open', 0, 0);
expect(scoreStanding(op, ME, at(op, ME))).toBeNull();
expect(scoreStanding(op, MINE, at(op, ME))).toBeNull();
});
it('is null when the viewer is not seated or has no opponent', () => {
const g = withScores('active', 30, 10);
expect(scoreStanding(g, 'stranger', at(g, ME))).toBeNull();
expect(scoreStanding(g, ['stranger'], at(g, ME))).toBeNull();
const solo: GameView = { ...game('g', 'active', 0, 0), seats: [{ ...seat(0, ME), score: 9 }] };
expect(scoreStanding(solo, ME, solo.seats[0])).toBeNull();
expect(scoreStanding(solo, MINE, solo.seats[0])).toBeNull();
});
it('compares against the strongest opponent in a multiplayer game', () => {
@@ -175,8 +191,8 @@ describe('scoreStanding', () => {
{ ...seat(2, 'b'), score: 50 }, // b is ahead -> the viewer is losing
],
};
expect(scoreStanding(g3, ME, at(g3, ME))).toBe('lose');
expect(scoreStanding(g3, ME, at(g3, 'b'))).toBeNull(); // a leading opponent is not coloured
expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('lose');
expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull(); // a leading opponent is not coloured
});
it('greens every seat tied with the viewer for the lead in a multiplayer game', () => {
@@ -189,9 +205,9 @@ describe('scoreStanding', () => {
{ ...seat(2, 'b'), score: 20 }, // trailing -> muted
],
};
expect(scoreStanding(g3, ME, at(g3, ME))).toBe('win');
expect(scoreStanding(g3, ME, at(g3, 'a'))).toBe('win');
expect(scoreStanding(g3, ME, at(g3, 'b'))).toBeNull();
expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('win');
expect(scoreStanding(g3, MINE, at(g3, 'a'))).toBe('win');
expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull();
});
});
+15 -12
View File
@@ -5,9 +5,12 @@
import type { GameView, Seat } from './model';
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */
export function isMyTurn(game: GameView, myId: string): boolean {
const me = game.seats.find((s) => s.accountId === myId);
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. myIds carries every id
* that is "you" — the server user id and the device-local guest id — since after reconciliation a
* device-local game seats you under localGuestId while a server game seats you under the user id (the
* ids never collide). */
export function isMyTurn(game: GameView, myIds: readonly string[]): boolean {
const me = game.seats.find((s) => myIds.includes(s.accountId));
// 'open' (an auto-match game still awaiting an opponent) is in progress like 'active'.
return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat;
}
@@ -21,16 +24,16 @@ export function isMyTurn(game: GameView, myId: string): boolean {
* the result emoji instead), for a fresh 0:0 board where nobody has scored yet, and when myId is
* not seated against an opponent.
*/
export function scoreStanding(game: GameView, myId: string, seat: Seat): 'win' | 'lose' | null {
export function scoreStanding(game: GameView, myIds: readonly string[], seat: Seat): 'win' | 'lose' | null {
if (game.status !== 'active') return null;
const me = game.seats.find((s) => s.accountId === myId);
const me = game.seats.find((s) => myIds.includes(s.accountId));
if (!me) return null;
const others = game.seats.filter((s) => s.accountId && s.accountId !== myId).map((s) => s.score);
const others = game.seats.filter((s) => s.accountId && !myIds.includes(s.accountId)).map((s) => s.score);
if (!others.length) return null;
const top = Math.max(me.score, ...others);
if (top === 0) return null; // nobody has scored yet: leave the 0:0 board muted, like a finished game
const meLeads = me.score === top;
if (seat.accountId === myId) return meLeads ? 'win' : 'lose';
if (myIds.includes(seat.accountId)) return meLeads ? 'win' : 'lose';
// Another seat greens only when it ties the leading viewer at the top — the equal-score case.
return meLeads && seat.score === top ? 'win' : null;
}
@@ -47,13 +50,13 @@ export function orderedSeats(game: GameView): GameView['seats'] {
export type LobbyPhase = 'mine' | 'theirs' | 'finished';
/**
* gamePhase reports a game's lobby bucket for myId: 'finished' for any non-active/open status,
* gamePhase reports a game's lobby bucket for myIds: 'finished' for any non-active/open status,
* else 'mine' when it is the caller's turn, else 'theirs'. It mirrors groupGames' partition and
* drives the per-card blink, which fires when a card transitions into 'mine' or 'finished'.
*/
export function gamePhase(game: GameView, myId: string): LobbyPhase {
export function gamePhase(game: GameView, myIds: readonly string[]): LobbyPhase {
if (game.status !== 'active' && game.status !== 'open') return 'finished';
return isMyTurn(game, myId) ? 'mine' : 'theirs';
return isMyTurn(game, myIds) ? 'mine' : 'theirs';
}
/**
@@ -81,7 +84,7 @@ export interface LobbyGroups {
*/
export function groupGames(
games: GameView[],
myId: string,
myIds: readonly string[],
unread: Record<string, boolean>,
): LobbyGroups {
const yourTurn: GameView[] = [];
@@ -89,7 +92,7 @@ export function groupGames(
const finished: GameView[] = [];
for (const g of games) {
if (g.status !== 'active' && g.status !== 'open') finished.push(g);
else if (isMyTurn(g, myId)) yourTurn.push(g);
else if (isMyTurn(g, myIds)) yourTurn.push(g);
else theirTurn.push(g);
}
// Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break.
+23
View File
@@ -4,6 +4,7 @@
// statement is only ever reached inside the packaged native app.
import { clientChannel } from './channel';
import { emit } from './netstate.svelte';
/**
* initNativeShell wires native-shell behaviour that only applies inside the packaged
@@ -30,3 +31,25 @@ export async function initNativeShell(atNavigationRoot: () => boolean): Promise<
}
}
/**
* initNativeNetwork feeds the OS connectivity hint from @capacitor/network into the net-state machine
* (osOnline / osOffline) on the packaged native app. It is a hint only — the machine's probe still
* decides whether to actually go offline or heal to online — so on the web (which keeps navigator's
* online/offline events, wired by initNetSignals) this is a no-op and never loads the plugin. Tolerates
* a missing bridge (a WebView without the Network plugin) exactly like initNativeShell.
*/
export async function initNativeNetwork(): Promise<void> {
const ch = clientChannel();
if (ch !== 'android' && ch !== 'ios') return;
try {
const { Network } = await import('@capacitor/network');
await Network.addListener('networkStatusChange', (status) => {
emit(status.connected ? 'osOnline' : 'osOffline');
});
} catch {
// No @capacitor/network bridge behind window.Capacitor (an e2e simulating native in a plain
// browser, or a WebView without the plugin): the native hint is unavailable, which is harmless —
// the gateway probe still drives the machine.
}
}
+196
View File
@@ -0,0 +1,196 @@
// The reactive net-state store (O2): the single source of truth for the detected
// connectivity/version state, running the pure reducer (netstate.ts, O1) over a `$state` snapshot and
// owning the side-effects the reducer asks for — the probe/recovery watcher, the OS-signal listeners
// and the offline/online toast. connection.svelte.ts and offline.svelte.ts are now thin derived shims
// over this module, so their ~40 consumers keep reading `connection.online` / `offlineMode.active`
// unchanged while a single machine drives them underneath.
//
// The store is a leaf module: it never imports app.svelte.ts. The pieces that need app context — the
// smart recovery (reconcile-or-reachability) and the toast text (i18n) — are injected via
// register*() hooks the bootstrap wires, which also breaks what would otherwise be an import cycle.
import { INITIAL, reduce, type Effect, type NetConfig, type NetEvent, type NetSnapshot } from './netstate';
import { backoffMs } from './retry';
const isMock = import.meta.env.MODE === 'mock';
// Production hysteresis tuning (owner-confirmed). k = 3 consecutive probe failures ⇒ offline (a single
// blip is one failure, so it rides out in `connecting`); debounceMs is the wall-clock backstop for a
// slow, spaced-out failure sequence. Both sit on top of the transport's own retry/backoff.
const cfg: NetConfig = { k: 3, debounceMs: 15000 };
// While offline, re-check reachability on this cadence (mirrors the old app.svelte.ts recovery poll).
const OFFLINE_POLL_MS = 4000;
let snap = $state<NetSnapshot>({ ...INITIAL });
/** netState exposes the reactive detected state; read the getters in markup / `$derived`. */
export const netState = {
/** state is the raw machine state. */
get state() {
return snap.state;
},
/** online is true only when the gateway is reachable and the version accepted (full features). */
get online() {
return snap.state === 'online';
},
/** connecting is true while a call/probe is failing but the anti-flap window has not elapsed. */
get connecting() {
return snap.state === 'connecting';
},
/** offline is true in either offline state (the kill switch / blue chrome / local-only lobby). */
get offline() {
return snap.state === 'offlineNoNetwork' || snap.state === 'offlineVersionLocked';
},
/** versionLocked is true when the client is below the hard minimum version (the Update notice). */
get versionLocked() {
return snap.state === 'offlineVersionLocked';
},
};
// Injected hooks (dependency inversion — see the module comment).
let probeFn: (() => Promise<void>) | null = null;
let recoverFn: (() => Promise<void>) | null = null;
let toastFn: ((toast: 'offline' | 'online') => void) | null = null;
let nudgeFn: (() => void) | null = null;
/** registerProbe installs the low-level reachability probe (a cheap authenticated read); the transport
* wires it, and checkReachable / the smart recovery use it. It should reject when there is no session. */
export function registerProbe(fn: () => Promise<void>): void {
probeFn = fn;
}
/** registerRecovery installs the smart recovery the watcher runs while connecting/offline: reconcile a
* server guest when there is no session (native), otherwise a reachability check. It resolves when the
* gateway is reachable and rejects to stay offline. The bootstrap wires it. */
export function registerRecovery(fn: () => Promise<void>): void {
recoverFn = fn;
}
/** registerNetToast installs the offline/online toast renderer (i18n text lives in app scope). */
export function registerNetToast(fn: (toast: 'offline' | 'online') => void): void {
toastFn = fn;
}
/** registerNetNudge installs the soft "update available" latch the setNudge effect fires. It runs only
* when the machine accepts a versionRecommended from online (O1), so the nudge never shows offline. */
export function registerNetNudge(fn: () => void): void {
nudgeFn = fn;
}
// The probe/recovery watcher: one self-rescheduling timer whose cadence follows the current state —
// exponential backoff while `connecting`, a fixed poll while `offlineNoNetwork`, and idle otherwise. It
// is inert under the mock (no real transport); the e2e drives transitions through the window hooks.
let timer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0;
let probing = false;
function stopTimer(): void {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
function arm(delayMs: number): void {
if (isMock) return;
stopTimer();
timer = setTimeout(runProbe, delayMs);
}
async function runProbe(): Promise<void> {
timer = null;
const st = snap.state;
// Nothing to check when online, and the version lock only exits on an app update (a fresh boot).
if (st === 'online' || st === 'offlineVersionLocked') return;
if (probing || !recoverFn) return;
probing = true;
try {
await recoverFn();
emit('probeOk');
} catch {
emit('probeFailed');
// Reschedule by the (possibly just-changed) state: keep backing off while connecting, poll while
// offline; stop once online / version-locked (the guard at the top of the next run).
if (snap.state === 'connecting') arm(backoffMs(Math.min(++attempt, 6)));
else if (snap.state === 'offlineNoNetwork') arm(OFFLINE_POLL_MS);
} finally {
probing = false;
}
}
/**
* checkReachable runs the registered reachability probe once, bounded by timeoutMs, and reports whether
* the gateway answered — a single attempt for the cold-start / recovery decision. It reports false when
* no probe is registered or the timeout wins.
*/
export async function checkReachable(timeoutMs: number): Promise<boolean> {
if (!probeFn) return false;
try {
await Promise.race([
probeFn(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
]);
return true;
} catch {
return false;
}
}
/** emit applies one event to the machine (stamping it with the current time — the reducer chose
* time-on-the-event) and runs the resulting effects. It is the single entry point every signal source
* funnels through. */
export function emit(type: NetEvent): void {
const { next, effects } = reduce(snap, { type, at: Date.now() }, cfg);
snap = next;
for (const effect of effects) applyEffect(effect);
}
function applyEffect(effect: Effect): void {
switch (effect.kind) {
case 'toast':
toastFn?.(effect.toast);
break;
case 'startProbe':
// Kick the watcher from the top of the backoff (entering connecting, or an osOnline hint).
attempt = 0;
arm(backoffMs(1));
break;
case 'showVersionNotice':
// The Update/Play-offline notice (UpdateOverlay.svelte) reads netState.versionLocked directly, so
// this effect needs no side-effect here — the state transition is the signal.
break;
case 'setNudge':
// Latch the soft "update available" nudge (update.svelte.ts, wired via registerNetNudge). Fires
// only from online (O1), so the nudge is never raised while offline / version-locked.
nudgeFn?.();
break;
}
}
/**
* bootOffline drives the machine straight into offlineNoNetwork for a known-offline launch — a native
* device-local guest with no server session, or a web cached-session launch the reachability check
* could not confirm — and arms the recovery poll. It bypasses the connecting hysteresis on purpose:
* boot is not a mid-session flap, so there is nothing to ride out. Pass kickNow to start the recovery
* poll immediately (the native reconcile) rather than after the first interval.
*/
export function bootOffline(kickNow = false): void {
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
arm(kickNow ? 0 : OFFLINE_POLL_MS);
}
/** initNetSignals wires the web connectivity hints (navigator online/offline) to the machine. The
* native @capacitor/network hint is wired separately in native.ts. A no-op under SSR and the mock (the
* e2e drives connectivity through the window hooks). */
export function initNetSignals(): void {
if (typeof window === 'undefined' || isMock) return;
window.addEventListener('offline', () => emit('osOffline'));
window.addEventListener('online', () => emit('osOnline'));
}
/** resetNetState returns the machine to online and stops the watcher (e.g. on logout). */
export function resetNetState(): void {
stopTimer();
attempt = 0;
snap = { state: 'online', fails: 0, connectingSince: 0 };
}
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect } from 'vitest';
import {
reduce,
INITIAL,
type NetConfig,
type NetEvent,
type NetInput,
type NetSnapshot,
} from './netstate';
// A deterministic config for the assertions: k = 3 (a single blip rides out; three consecutive
// failures trip offline) and a 1 s debounce window (the wall-clock anti-flap branch).
const cfg: NetConfig = { k: 3, debounceMs: 1000 };
// Event and snapshot builders keep the cases terse; `at` defaults to 0 where the debounce branch is
// irrelevant.
const e = (type: NetEvent, at = 0): NetInput => ({ type, at });
const online: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
const offline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
const locked: NetSnapshot = { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 };
const connecting = (over: Partial<NetSnapshot> = {}): NetSnapshot => ({
state: 'connecting',
fails: 0,
connectingSince: 0,
...over,
});
// fold applies a sequence of inputs, threading the snapshot and collecting every emitted effect —
// the shape most edge cases assert against.
function fold(start: NetSnapshot, inputs: NetInput[], config: NetConfig) {
let snap = start;
const effects = [];
for (const ev of inputs) {
const r = reduce(snap, ev, config);
snap = r.next;
effects.push(...r.effects);
}
return { final: snap, effects };
}
describe('reduce — transition table', () => {
it('online + callFailed → connecting (starts a probe, counts the failure)', () => {
const r = reduce(online, e('callFailed', 100), cfg);
expect(r.next).toEqual({ state: 'connecting', fails: 1, connectingSince: 100 });
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
});
it('online + osOffline → connecting (starts a probe, no failure counted — a hint)', () => {
const r = reduce(online, e('osOffline', 100), cfg);
expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
});
it('connecting + probeOk → online (silent; a blip must not thrash the chrome)', () => {
const r = reduce(connecting({ fails: 2, connectingSince: 100 }), e('probeOk', 200), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([]);
});
it('connecting + callOk → online (silent)', () => {
const r = reduce(connecting({ fails: 1, connectingSince: 100 }), e('callOk', 200), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([]);
});
it('connecting + a failure reaching K → offlineNoNetwork (toast "offline")', () => {
const r = reduce(connecting({ fails: cfg.k - 1, connectingSince: 0 }), e('probeFailed', 10), cfg);
expect(r.next).toEqual({ state: 'offlineNoNetwork', fails: 0, connectingSince: 0 });
expect(r.effects).toEqual([{ kind: 'toast', toast: 'offline' }]);
});
it('offlineNoNetwork + osOnline → stays offline, triggers a probe (a hint never flips by itself)', () => {
const r = reduce(offline, e('osOnline', 100), cfg);
expect(r.next).toEqual(offline);
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
});
it('offlineNoNetwork + probeOk → online (toast "back online")', () => {
const r = reduce(offline, e('probeOk', 100), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
it('offlineNoNetwork + callOk → online (toast "back online")', () => {
const r = reduce(offline, e('callOk', 100), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
it('any state + versionRejected → offlineVersionLocked (shows the notice once)', () => {
for (const start of [online, connecting({ fails: 1, connectingSince: 5 }), offline]) {
const r = reduce(start, e('versionRejected', 100), cfg);
expect(r.next).toEqual({ state: 'offlineVersionLocked', fails: 0, connectingSince: 0 });
expect(r.effects).toEqual([{ kind: 'showVersionNotice' }]);
}
});
it('offlineVersionLocked + versionRejected (probe still rejected) → stays locked, no repeat notice', () => {
const r = reduce(locked, e('versionRejected', 100), cfg);
expect(r.next).toEqual(locked);
expect(r.effects).toEqual([]);
});
it('online + versionRecommended → online + a nudge', () => {
const r = reduce(online, e('versionRecommended', 100), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([{ kind: 'setNudge' }]);
});
it('boot → connecting + startProbe from any state (resets the sticky lock)', () => {
for (const start of [online, offline, locked, connecting({ fails: 2, connectingSince: 5 })]) {
const r = reduce(start, e('boot', 100), cfg);
expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
}
});
});
describe('reduce — hysteresis (blip vs sustained)', () => {
it('a single fail then a success rides out inside connecting (no offline toast)', () => {
const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 200)], cfg);
expect(final).toEqual(online);
expect(effects).toEqual([{ kind: 'startProbe' }]);
});
it('K consecutive failures trip offlineNoNetwork', () => {
const { final, effects } = fold(online, [e('callFailed', 0), e('probeFailed', 10), e('probeFailed', 20)], cfg);
expect(final.state).toBe('offlineNoNetwork');
expect(effects).toContainEqual({ kind: 'toast', toast: 'offline' });
});
it('the debounce window trips offlineNoNetwork even below K', () => {
const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs + 1)], cfg);
expect(final.state).toBe('offlineNoNetwork');
});
it('a failure inside the debounce window and below K stays connecting', () => {
const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs - 1)], cfg);
expect(final.state).toBe('connecting');
expect(final.fails).toBe(1);
});
});
describe('reduce — version gate (two tiers)', () => {
it('versionRecommended sets the nudge only from online', () => {
expect(reduce(online, e('versionRecommended', 0), cfg).effects).toEqual([{ kind: 'setNudge' }]);
for (const start of [connecting({ connectingSince: 0 }), offline, locked]) {
expect(reduce(start, e('versionRecommended', 0), cfg).effects).toEqual([]);
}
});
it('versionRejected from any state locks (the hard tier), superseding a pending soft nudge', () => {
const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
expect(final.state).toBe('offlineVersionLocked');
expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
});
});
describe('reduce — edge cases (#1#12)', () => {
it('#1 rapid flap (fail→ok inside the window) never leaves connecting for offline', () => {
const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 50)], cfg);
expect(final).toEqual(online);
expect(effects).not.toContainEqual({ kind: 'toast', toast: 'offline' });
});
it('#2 cold boot, no network → offlineNoNetwork', () => {
const { final } = fold(
INITIAL,
[e('boot', 0), e('osOffline', 1), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
cfg,
);
expect(final.state).toBe('offlineNoNetwork');
});
it('#3 cold boot, gateway up but version < min → offlineVersionLocked + notice', () => {
const { final, effects } = fold(INITIAL, [e('boot', 0), e('versionRejected', 20)], cfg);
expect(final.state).toBe('offlineVersionLocked');
expect(effects).toContainEqual({ kind: 'showVersionNotice' });
});
it('#4 cold boot, session-less guest: the reconcile IS the probe — ok→online, fail→offline', () => {
expect(fold(INITIAL, [e('boot', 0), e('callOk', 20)], cfg).final).toEqual(online);
const failed = fold(
INITIAL,
[e('boot', 0), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
cfg,
);
expect(failed.final.state).toBe('offlineNoNetwork');
});
it('#5 mid-session min-version bump → next call versionRejected → locked mid-play', () => {
const r = reduce(online, e('versionRejected', 100), cfg);
expect(r.next.state).toBe('offlineVersionLocked');
expect(r.effects).toContainEqual({ kind: 'showVersionNotice' });
});
it('#6 captive portal: osOnline fires but the gateway is down → probe fails → stays offline', () => {
const { final, effects } = fold(offline, [e('osOnline', 0), e('probeFailed', 10)], cfg);
expect(final.state).toBe('offlineNoNetwork');
expect(effects).toEqual([{ kind: 'startProbe' }]);
});
it('#7 recovery race: probeOk then a queued callOk → one idempotent transition to online', () => {
const { final, effects } = fold(offline, [e('probeOk', 0), e('callOk', 1)], cfg);
expect(final).toEqual(online);
expect(effects.filter((x) => x.kind === 'toast')).toEqual([{ kind: 'toast', toast: 'online' }]);
});
it('#8 soft nudge then a hard reject → escalates to locked (notice after the nudge)', () => {
const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
expect(final.state).toBe('offlineVersionLocked');
expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
});
it('#9 offlineVersionLocked is sticky — no connectivity event exits it; only boot does', () => {
const stuck: NetEvent[] = ['callOk', 'probeOk', 'osOnline', 'osOffline', 'callFailed', 'probeFailed', 'versionRecommended'];
for (const ev of stuck) {
expect(reduce(locked, e(ev, 100), cfg).next).toEqual(locked);
}
expect(reduce(locked, e('boot', 100), cfg).next.state).toBe('connecting');
});
it('#10 offline → back online transitions cleanly (local-game persistence is O5, not the machine)', () => {
expect(fold(offline, [e('probeOk', 0)], cfg).final).toEqual(online);
});
it('#11 nothing forces the machine offline without a real failure/version signal (a stale pref cannot stick)', () => {
expect(reduce(INITIAL, e('boot', 0), cfg).next.state).toBe('connecting');
const { final } = fold(
INITIAL,
[e('boot', 0), e('callOk', 10), e('osOnline', 20), e('versionRecommended', 30)],
cfg,
);
expect(final.state).toBe('online');
});
it('#12 TG/VK inertness: with only success/hint events the machine never leaves online', () => {
const { final, effects } = fold(online, [e('callOk', 0), e('probeOk', 10), e('osOnline', 20), e('callOk', 30)], cfg);
expect(final).toEqual(online);
expect(effects).toEqual([]);
});
});
describe('reduce — purity', () => {
it('does not mutate the previous snapshot', () => {
const prev = Object.freeze<NetSnapshot>({ state: 'connecting', fails: 1, connectingSince: 5 });
expect(() => reduce(prev, e('probeFailed', 10), cfg)).not.toThrow();
expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
});
});
+214
View File
@@ -0,0 +1,214 @@
// The pure net-state machine (O1 of the offline-model redesign). A single reducer replaces the old
// two-layer connection.svelte.ts / offline.svelte.ts split: connectivity and the client-version gate
// collapse into one detected `state`, with an explicit hysteresis buffer so a brief network blip does
// not thrash the chrome. It is a pure function of (previous snapshot, event, config) — no timers, no
// storage, no reactivity — so every transition and the whole hysteresis are unit-tested in the node
// env and the machine is reusable verbatim by a future iOS shell. The reactive store, the probe timer
// and the event wiring live in O2 (netstate.svelte.ts); the effects returned here tell that store what
// to do (raise a toast, kick a probe, show the version notice, set the soft nudge).
/**
* NetState is the single detected connectivity/version state.
* - `online` — the gateway is reachable and the client version is accepted; full features. It may
* additionally carry an orthogonal dismissible "update available" nudge (the soft tier) without
* leaving this state.
* - `connecting` — a call or probe is currently failing but the anti-flap window has not elapsed;
* the chrome stays online ("Connecting…"). Transient — never a kill switch.
* - `offlineNoNetwork` — sustained unreachable (hysteresis exceeded): offline mode (blue chrome,
* local-only active lobby, transport kill switch). Self-heals when a probe or call gets through.
* - `offlineVersionLocked` — the gateway is reachable but the version is below the hard minimum:
* offline mode plus the "Update / Play offline" notice. Sticky — it exits only via an app update
* (a fresh version on the next boot).
*/
export type NetState = 'online' | 'connecting' | 'offlineNoNetwork' | 'offlineVersionLocked';
/**
* NetEvent is every signal that can drive the machine.
* - `boot` — a fresh app start (the only exit from the sticky version lock).
* - `callOk` / `callFailed` — a foreground call succeeded / failed at the transport.
* - `probeOk` / `probeFailed` — the reachability probe succeeded / failed.
* - `osOnline` / `osOffline` — an OS connectivity hint (navigator / @capacitor/network); a hint only
* triggers a probe, it never decides the state by itself.
* - `versionRejected` — the hard gate refused the client as too old (update_required).
* - `versionRecommended` — the soft gate signalled an available (non-blocking) update.
*/
export type NetEvent =
| 'boot'
| 'callOk'
| 'callFailed'
| 'probeOk'
| 'probeFailed'
| 'osOnline'
| 'osOffline'
| 'versionRejected'
| 'versionRecommended';
/** NetConfig tunes the anti-flap hysteresis. */
export interface NetConfig {
/**
* k is the consecutive-failure threshold that trips `connecting` → `offlineNoNetwork`. It must be
* at least 2 so a single blip (one failure then a success) rides out inside `connecting`.
*/
k: number;
/**
* debounceMs is the wall-clock anti-flap window (the design's `OFFLINE_DEBOUNCE_MS`): a failure
* whose timestamp is at least this far past the moment `connecting` was entered also trips
* `offlineNoNetwork`, even below k. This bounds how long a slow, spaced-out failure sequence can
* hold the chrome in "Connecting…".
*/
debounceMs: number;
}
/**
* NetSnapshot is the machine's full memory. Beyond the visible `state` it carries the hysteresis
* bookkeeping (`fails`, `connectingSince`) so the reducer stays pure while still deciding both the
* count-based and the time-based anti-flap branches; both are zero outside `connecting`.
*/
export interface NetSnapshot {
/** state is the current detected state. */
state: NetState;
/** fails counts consecutive failure signals accrued while `connecting` (0 in every other state). */
fails: number;
/**
* connectingSince is the timestamp `connecting` was entered; the debounce window is measured from
* it (0 while not `connecting`).
*/
connectingSince: number;
}
/** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch
* reads `at`, and callers stamp `Date.now()`. */
export interface NetInput {
/** type is the event. */
type: NetEvent;
/** at is the event's timestamp in milliseconds. */
at: number;
}
/**
* Effect is a side-effect the reducer asks the O2 store to perform. It is data, not an action — the
* reducer never runs it.
* - `toast` — show the offline / back-online toast.
* - `startProbe` — kick the reachability probe (entering `connecting`, or on an `osOnline` hint).
* - `showVersionNotice` — raise the "Update / Play offline" notice (entering the version lock).
* - `setNudge` — latch the dismissible "update available" soft nudge.
*/
export type Effect =
| { kind: 'toast'; toast: 'offline' | 'online' }
| { kind: 'startProbe' }
| { kind: 'showVersionNotice' }
| { kind: 'setNudge' };
/** NetResult is a reduction: the next snapshot plus the effects the store should perform. */
export interface NetResult {
/** next is the snapshot after applying the event. */
next: NetSnapshot;
/** effects are the side-effects the O2 store should run, in order. */
effects: Effect[];
}
/**
* INITIAL is the optimistic pre-boot snapshot: `online` until proven otherwise. The O2 store emits a
* `boot` event on start, which immediately moves the machine to `connecting` and kicks a probe.
*/
export const INITIAL: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
function onlineSnapshot(): NetSnapshot {
return { state: 'online', fails: 0, connectingSince: 0 };
}
function enterConnecting(at: number, fails: number): NetResult {
return { next: { state: 'connecting', fails, connectingSince: at }, effects: [{ kind: 'startProbe' }] };
}
/**
* reduce applies one event to the machine and returns the next snapshot plus the effects the store
* should perform. It is a pure function: it never mutates `prev`, reads the clock, or runs an effect.
*
* `boot` and `versionRejected` are handled first because they fire from any state: `boot` resets the
* machine (the sole exit from the sticky version lock), and `versionRejected` (the hard tier) always
* supersedes into `offlineVersionLocked`.
*/
export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResult {
// A fresh app start: reachability is unknown, so enter `connecting` and kick a probe; the probe /
// version result then resolves online, offlineNoNetwork or offlineVersionLocked. This is the only
// transition that leaves the sticky version lock (a possibly-newer build).
if (ev.type === 'boot') {
return enterConnecting(ev.at, 0);
}
// The hard version gate supersedes everything and is sticky. The notice is raised once, on entry;
// a repeated rejection while already locked changes nothing.
if (ev.type === 'versionRejected') {
const effects: Effect[] = prev.state === 'offlineVersionLocked' ? [] : [{ kind: 'showVersionNotice' }];
return { next: { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 }, effects };
}
switch (prev.state) {
case 'offlineVersionLocked':
// Sticky: no connectivity event exits it. A probe may reach the gateway, but real calls stay
// version-gated, so only `boot` / `versionRejected` (both handled above) touch this state.
return { next: prev, effects: [] };
case 'online':
switch (ev.type) {
case 'callFailed':
case 'probeFailed':
// A real failure opens the hysteresis window (probe + timer); this first failure counts.
return enterConnecting(ev.at, 1);
case 'osOffline':
// An OS hint only opens the window and a probe — it never decides offline by itself, so it
// enters `connecting` with no failure counted.
return enterConnecting(ev.at, 0);
case 'versionRecommended':
// The soft tier nudges only from online; play continues.
return { next: prev, effects: [{ kind: 'setNudge' }] };
default:
// callOk / probeOk / osOnline: already online — idempotent, no effect.
return { next: prev, effects: [] };
}
case 'connecting':
switch (ev.type) {
case 'callOk':
case 'probeOk':
// Recovery is immediate on the first success — a blip resolves silently (no toast).
return { next: onlineSnapshot(), effects: [] };
case 'callFailed':
case 'probeFailed': {
const fails = prev.fails + 1;
const sustained = fails >= cfg.k || ev.at - prev.connectingSince >= cfg.debounceMs;
if (sustained) {
return {
next: { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 },
effects: [{ kind: 'toast', toast: 'offline' }],
};
}
return { next: { ...prev, fails }, effects: [] };
}
case 'osOnline':
// A positive hint triggers an immediate probe but never flips to online by itself.
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// osOffline (already probing) / versionRecommended (no nudge outside online): no-op.
return { next: prev, effects: [] };
}
case 'offlineNoNetwork':
switch (ev.type) {
case 'callOk':
case 'probeOk':
// The probe (or a reconcile call) won — self-heal to online with a "back online" toast.
return { next: onlineSnapshot(), effects: [{ kind: 'toast', toast: 'online' }] };
case 'osOnline':
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// callFailed / probeFailed / osOffline (still offline), versionRecommended (no nudge): no-op.
return { next: prev, effects: [] };
}
}
// Unreachable: the switch above is exhaustive over NetState. Kept so the function is total.
return { next: prev, effects: [] };
}
+31 -60
View File
@@ -1,68 +1,39 @@
// The deliberate offline MODE: a sticky, device-scoped reactive flag the app reads to gate the
// network, tint the chrome blue and show only local games. It is the player's own choice (the
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
// offline.svelte.ts: the offline-mode read surface (a thin shim over the net-state store,
// netstate.svelte.ts) plus the orthogonal dictionary-preload warning + background preload. Offline is
// implicit — detected by the net-state machine — so offlineMode.active just derives from netState; there
// is no deliberate toggle. The dict-preload pieces are unrelated to connectivity and stay live here.
import { loadOfflinePref, saveOfflinePref, offlinePreloadEligible } from './offline';
import { netState } from './netstate.svelte';
import { offlinePreloadEligible } from './offline';
import { isStandalone } from './pwa';
import { insideTelegram } from './telegram';
import { insideVK } from './vk';
import type { Profile } from './model';
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
let active = $state(loadOfflinePref());
// True while the current offline state was entered automatically (no network detected), not chosen
// by the player. An auto offline self-heals to online when the network returns; a deliberate one is
// left as the player's choice.
let auto = $state(false);
/**
* offlineCapable reports whether this channel supports the offline model — implicit offline mode,
* device-local games (vs_ai and hotseat) and the transport kill switch. Native builds and the plain web
* app do; the Telegram and VK mini-apps are online-only wrappers, so the whole offline model stays inert
* there (offlineMode.active is forced false regardless of the detected net state, and the create flow
* hides its device-local segment).
*/
export function offlineCapable(): boolean {
return !insideTelegram() && !insideVK();
}
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
export const offlineMode = {
/** active is true while the app is in offline mode (deliberate or auto-detected). */
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked) on a
* channel that supports offline play; it is always false in the online-only Telegram/VK mini-apps, so
* every offline-model consumer (chrome, lobby, kill switch, device-local create) stays inert there. */
get active(): boolean {
return active;
},
/** auto is true while offline was entered automatically (no network), not by the player. */
get auto(): boolean {
return auto;
return netState.offline && offlineCapable();
},
};
/**
* setOfflineMode enters or leaves offline mode. By default it persists the choice (device-scoped) —
* a deliberate choice (the Settings toggle, or the cold-start "no connection" dialog). Pass
* persist=false for a transient, auto-detected offline (a cold start with no network interface): the
* flag holds for the session but is not saved, so the next launch re-evaluates the network.
*/
export function setOfflineMode(on: boolean, persist = true): void {
active = on;
auto = on && !persist; // a non-persisted offline is auto-detected; a persisted one is deliberate
if (persist) saveOfflinePref(on);
}
/** The toggle-flip readiness wait: entering offline waits at most this long for the enabled
* variants' dictionaries before reverting to online (the fetch then continues in the background). */
export const TOGGLE_READY_BUDGET_MS = 5000;
/**
* requestOffline attempts to enter offline mode from the Settings toggle. It fetches the enabled
* variants' dictionaries cache-first and, if every one is available within the readiness budget,
* switches to a deliberate (persisted) offline mode and returns true. Otherwise it stays online and
* returns false — the caller shows the "needs internet" note — while the fetch keeps warming the
* cache so a later flip is instant. The readiness glue and the dict loader are imported dynamically,
* so neither is pulled into the main bundle. Returns false with no profile.
*/
export async function requestOffline(prof: Profile | null, budgetMs = TOGGLE_READY_BUDGET_MS): Promise<boolean> {
if (!prof) return false;
const m = await import('./dict/offlineready');
const ready = await m.ensureOfflineDicts(prof, budgetMs);
if (ready) setOfflineMode(true);
return ready;
}
// The dict-preload warning: true when a first-lobby background preload could not fetch every
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
// The lobby shows a notice in place of the ad banner while it holds.
// The dict-preload warning: true when a first-lobby background preload could not fetch every enabled
// variant's dictionary (typically a poor connection), so offline play may be incomplete. The lobby
// shows a notice in place of the ad banner while it holds.
let preloadWarn = $state(false);
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
@@ -78,18 +49,18 @@ export function setDictPreloadWarning(on: boolean): void {
preloadWarn = on;
}
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby mount or
// a variant-preference change) can start another. Module-scoped so mounts do not stack.
let preloadInFlight = false;
/**
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
* browser tab, without a confirmed email, while offline, or when a preload is already running;
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
* and generator are imported dynamically, so neither is pulled into the main bundle.
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later switch
* to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain browser tab,
* without a confirmed email, while offline, or when a preload is already running; getDawg's caching
* makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a fetch failure raises the
* in-lobby notice, and a later successful run clears it. The dict loader and generator are imported
* dynamically, so neither is pulled into the main bundle.
*/
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
if (preloadInFlight || !prof) return;
+9 -51
View File
@@ -1,8 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline';
import type { Variant } from './model';
import { clearOfflinePref, offlinePreloadEligible } from './offline';
// A minimal in-memory localStorage for the persistence tests (node has none).
// A minimal in-memory localStorage for the cleanup test (node has none).
beforeEach(() => {
const store = new Map<string, string>();
(globalThis as unknown as { localStorage: Storage }).localStorage = {
@@ -15,27 +14,7 @@ beforeEach(() => {
} as Storage;
});
describe('offline mode helpers', () => {
it('persists and reads the device-scoped offline flag', () => {
expect(loadOfflinePref()).toBe(false);
saveOfflinePref(true);
expect(loadOfflinePref()).toBe(true);
saveOfflinePref(false);
expect(loadOfflinePref()).toBe(false);
});
it('offlineReady requires every enabled variant to have a dictionary', () => {
const has = (v: Variant): boolean => v !== 'erudit_ru';
expect(offlineReady(['scrabble_en', 'scrabble_ru'], has)).toBe(true);
expect(offlineReady(['scrabble_en', 'erudit_ru'], has)).toBe(false);
expect(offlineReady([], has)).toBe(false);
});
it('missingDicts lists the enabled variants without a dictionary', () => {
const has = (v: Variant): boolean => v === 'scrabble_en';
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
});
describe('offline helpers', () => {
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
expect(offlinePreloadEligible(base)).toBe(true);
@@ -46,32 +25,11 @@ describe('offline mode helpers', () => {
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
});
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => {
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true);
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
});
});
describe('raceOfflineReady (toggle-flip readiness wait)', () => {
// A sleep that never elapses: the fetch always wins the race, exercising its resolution.
const never = (): Promise<void> => new Promise<void>(() => {});
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
const now = (): Promise<void> => Promise.resolve();
it('is ready when the fetch resolves with nothing failed before the budget', async () => {
const run = Promise.resolve({ failed: [] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(true);
});
it('is not ready when a variant is still missing after the fetch', async () => {
const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(false);
});
it('is not ready when the budget elapses before the fetch resolves', async () => {
const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves
expect(await raceOfflineReady(run, 5000, now)).toBe(false);
it('clearOfflinePref removes the retired deliberate-offline key (best-effort, idempotent)', () => {
localStorage.setItem('scrabble.offlineMode', '1');
clearOfflinePref();
expect(localStorage.getItem('scrabble.offlineMode')).toBeNull();
// Idempotent: a second call on an already-clear key is a no-op, not an error.
expect(() => clearOfflinePref()).not.toThrow();
});
});
+17 -68
View File
@@ -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;
}
+21 -13
View File
@@ -34,28 +34,36 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
}
describe('resultBadge', () => {
it('recognises "you" under any of the self ids (a device-local game seats you under the guest id)', () => {
// myIds carries both the server user id and the device-local guest id, so the badge resolves the
// right seat regardless of which one seated you.
const local = game([seat(0, 'guest', 5), seat(1, 'robot', 3)], 'active', 0);
expect(resultBadge(local, ['guest', 'me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...local, toMove: 1 }, ['guest', 'me']).key).toBe('result.oppMove');
});
it('active: your move vs opponent', () => {
const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
});
it('open (awaiting an opponent) reads as in-progress, not a finished result', () => {
const g = game([seat(0, 'me', 0), seat(1, '', 0)], 'open', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
});
it('finished two-player: victory / defeat / draw', () => {
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), ['me'])).toEqual({
key: 'result.victory',
emoji: '🏆',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), 'me')).toEqual({
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), ['me'])).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), 'me')).toEqual({
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), ['me'])).toEqual({
key: 'result.draw',
emoji: '🏅',
});
@@ -64,7 +72,7 @@ describe('resultBadge', () => {
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
// The opponent won by resignation (isWinner) although neither side scored — the lobby
// must read this as a loss, matching the game-detail screen (regression).
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), ['me'])).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
@@ -72,21 +80,21 @@ describe('resultBadge', () => {
it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
expect(resultBadge(last, ['me'])).toEqual({ key: 'result.place4', emoji: '🏅' });
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
expect(resultBadge(second, ['me'])).toEqual({ key: 'result.place2', emoji: '🥈' });
});
it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => {
// The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single
// winner(), so this must NOT read as a full draw for everyone.
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' });
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' });
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), ['me'])).toEqual({ key: 'result.victory', emoji: '🏆' });
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), ['me'])).toEqual({ key: 'result.place3', emoji: '🥉' });
});
it('an aborted game reads as a draw for everyone, whatever the scores', () => {
const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' };
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' });
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.draw', emoji: '🏅' });
});
});
+3 -3
View File
@@ -16,8 +16,8 @@ function placeBadge(rank: number, players: number): ResultBadge {
return { key: 'result.place4', emoji: '🏅' };
}
export function resultBadge(game: GameView, myId: string): ResultBadge {
const me = game.seats.find((s) => s.accountId === myId);
export function resultBadge(game: GameView, myIds: readonly string[]): ResultBadge {
const me = game.seats.find((s) => myIds.includes(s.accountId));
if (game.status === 'active' || game.status === 'open') {
return game.toMove === me?.seat
@@ -42,7 +42,7 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
// A winner exists but it is not me — even when scores are level (a win by resignation or timeout
// can leave the winner at or below my score). The winner takes rank 1; place me among the remaining
// (non-resigned) seats by score, starting at rank 2.
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && !myIds.includes(s.accountId) && s.score > (me?.score ?? 0)).length;
return placeBadge(2 + ahead, game.players);
}
+16 -2
View File
@@ -17,7 +17,7 @@ import { offlineMode } from './offline.svelte';
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
import { maintenanceRetryMs } from './maintenance';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
import { UPDATE_REQUIRED, reportUpdateRequired } from './update.svelte';
import { UPDATE_REQUIRED, reportUpdateRecommended, reportUpdateRequired } from './update.svelte';
const MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -31,7 +31,21 @@ function assertOnline(): void {
export function createTransport(baseUrl: string): GatewayClient {
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });
const transport = createConnectTransport({
baseUrl: origin,
useBinaryFormat: true,
// Observe the soft-tier version nudge: a served unary response may carry X-Update-Recommended (the
// additive header the edge sets for a client at or above the hard minimum but below the recommended
// version). Reading it in an interceptor keeps it off every call site; the stream path is not
// soft-gated. It never fails the call — the nudge is non-blocking.
interceptors: [
(next) => async (req) => {
const res = await next(req);
if (!res.stream && res.header.get('x-update-recommended') === '1') reportUpdateRecommended();
return res;
},
],
});
const client = createClient(Gateway, transport);
let token: string | null = null;
+65 -15
View File
@@ -1,26 +1,76 @@
// Global "client too old" signal. `active` latches true the first time the gateway answers a
// user-initiated online call with the update_required sentinel — the HTTP-header version gate the
// edge checks before decoding the payload (docs/ARCHITECTURE.md §2). It is terminal: unlike the
// maintenance signal there is no self-clearing poll, because an installed build cannot become
// compatible without an actual update. A non-dismissable overlay (UpdateOverlay.svelte) then covers
// the app; its one action sends the user to the store (native) or reloads (web). Offline play never
// trips it — the network kill switch refuses the call before it leaves the device.
// The client-version gate's two client signals (docs/ARCHITECTURE.md §2).
//
// HARD tier ("client too old"): the gateway refuses a foreground call with the update_required
// sentinel (the Execute result_code, the Subscribe FailedPrecondition). The transport reports it here,
// which drives the net-state machine into offlineVersionLocked; the notice (UpdateOverlay.svelte) reads
// netState.versionLocked and offers "Update" (store / reload) or "Play offline" (dismiss → stay
// offline). Offline play never trips it — the kill switch refuses the call before it leaves the device,
// and the background guest reconcile swallows it (stays a local guest, no notice).
//
// SOFT tier ("update available"): a served response carries the X-Update-Recommended header, which the
// transport reports here. It drives the machine (versionRecommended), whose setNudge effect — fired
// only from online — latches the dismissible nudge (UpdateNudge.svelte). The hard notice supersedes the
// nudge naturally: the nudge shows only while online, and version-locked is an offline state.
import { emit } from './netstate.svelte';
import { clientChannel } from './channel';
/** UPDATE_REQUIRED is the stable sentinel the gateway returns (the Execute result_code, and the
* GatewayError code the Subscribe FailedPrecondition maps to) for a client too old to be served. */
export const UPDATE_REQUIRED = 'update_required';
let required = $state(false);
/** openUpdate sends the user to the fix, shared by the hard-tier notice and the soft-tier nudge: the
* store listing on a native build (VITE_RUSTORE_URL, handed to the OS via '_system'), or a plain
* reload on the web (which fetches the current client). */
export function openUpdate(): void {
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') {
const url = import.meta.env.VITE_RUSTORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
}
}
export const updateRequired = {
/** active is true once a foreground online call has been refused as too old. Terminal. */
/** reportUpdateRequired drives the net-state machine into offlineVersionLocked (the hard tier). The
* transport calls it only for a foreground update_required — the background reconcile swallows it and
* stays offline. The notice reads netState.versionLocked; there is no separate latch. */
export function reportUpdateRequired(): void {
emit('versionRejected');
}
// The soft "update available" nudge. `active` latches when the machine accepts a versionRecommended
// from online (via the setNudge effect → latchUpdateNudge); `dismissed` hides the banner for the
// session once the user closes it. A fresh launch re-evaluates the version, so both reset on reload.
let recommended = $state(false);
let nudgeDismissed = $state(false);
export const updateRecommended = {
/** active is true once the gateway has signalled an available (non-blocking) update. */
get active(): boolean {
return required;
return recommended;
},
/** dismissed is true once the user has closed the nudge for this session. */
get dismissed(): boolean {
return nudgeDismissed;
},
};
/** reportUpdateRequired latches the terminal "update required" overlay. The transport calls it when
* a foreground call returns the update_required sentinel; idempotent. */
export function reportUpdateRequired(): void {
required = true;
/** latchUpdateNudge raises the soft-nudge latch. It is wired to the machine's setNudge effect
* (registerNetNudge), which fires only from online, so the nudge is never raised while offline or
* version-locked. */
export function latchUpdateNudge(): void {
recommended = true;
}
/** dismissUpdateNudge hides the nudge for the session (a fresh launch re-evaluates the version). */
export function dismissUpdateNudge(): void {
nudgeDismissed = true;
}
/** reportUpdateRecommended signals an available update (the soft tier). The transport calls it when a
* served response carries the X-Update-Recommended header; it drives the machine (versionRecommended),
* which latches the nudge from online. */
export function reportUpdateRecommended(): void {
emit('versionRecommended');
}