feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs. Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match. Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds. Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
This commit is contained in:
@@ -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: [] };
|
||||
}
|
||||
Reference in New Issue
Block a user