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