Files
scrabble-game/ui/src/lib/netstate.ts
T
Ilia Denisov 2d1fadb50c
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
feat(offline): implicit net-state model, two-tier version gate, unified lobby
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.
2026-07-13 02:50:41 +02:00

215 lines
9.9 KiB
TypeScript

// 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: [] };
}