From fa4dc2412a2b0890a33b3b1826b725b1c79e7b33 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 13 Jul 2026 01:44:28 +0200 Subject: [PATCH] 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, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin 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 (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README). Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later. Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit). --- ...build.yaml => android-build.yaml.disabled} | 0 ANDROID_PLAN.md | 220 ++++++++++----- deploy/README.md | 3 +- docs/ARCHITECTURE.md | 110 ++++---- docs/FUNCTIONAL.md | 53 ++-- docs/FUNCTIONAL_ru.md | 52 ++-- docs/TESTING.md | 35 ++- gateway/cmd/gateway/main.go | 35 +-- gateway/internal/config/config.go | 36 ++- gateway/internal/config/config_test.go | 36 +++ gateway/internal/connectsrv/server.go | 65 ++++- gateway/internal/connectsrv/server_test.go | 75 ++++++ ui/android/app/capacitor.build.gradle | 1 + ui/android/capacitor.settings.gradle | 3 + ui/e2e/hotseat.spec.ts | 22 +- ui/e2e/native.spec.ts | 11 +- ui/e2e/offline.spec.ts | 98 +++++-- ui/e2e/update.spec.ts | 79 ++++-- ui/package.json | 1 + ui/pnpm-lock.yaml | 12 + ui/src/App.svelte | 60 +---- ui/src/components/UpdateNudge.svelte | 56 ++++ ui/src/components/UpdateOverlay.svelte | 53 ++-- ui/src/lib/app.svelte.ts | 186 ++++--------- ui/src/lib/connection.svelte.ts | 87 ++---- ui/src/lib/dict/offlineready.ts | 27 -- ui/src/lib/gateway.ts | 25 +- ui/src/lib/i18n/en.ts | 15 +- ui/src/lib/i18n/ru.ts | 15 +- ui/src/lib/lobbycache.test.ts | 63 ++--- ui/src/lib/lobbycache.ts | 13 +- ui/src/lib/lobbysort.test.ts | 76 +++--- ui/src/lib/lobbysort.ts | 27 +- ui/src/lib/native.ts | 23 ++ ui/src/lib/netstate.svelte.ts | 196 ++++++++++++++ ui/src/lib/netstate.test.ts | 250 ++++++++++++++++++ ui/src/lib/netstate.ts | 214 +++++++++++++++ ui/src/lib/offline.svelte.ts | 80 ++---- ui/src/lib/offline.test.ts | 60 +---- ui/src/lib/offline.ts | 85 ++---- ui/src/lib/result.test.ts | 34 ++- ui/src/lib/result.ts | 6 +- ui/src/lib/transport.ts | 18 +- ui/src/lib/update.svelte.ts | 80 ++++-- ui/src/screens/Lobby.svelte | 86 ++++-- ui/src/screens/NewGame.svelte | 71 ++++- ui/src/screens/Settings.svelte | 63 ----- 47 files changed, 1946 insertions(+), 970 deletions(-) rename .gitea/workflows/{android-build.yaml => android-build.yaml.disabled} (100%) create mode 100644 ui/src/components/UpdateNudge.svelte delete mode 100644 ui/src/lib/dict/offlineready.ts create mode 100644 ui/src/lib/netstate.svelte.ts create mode 100644 ui/src/lib/netstate.test.ts create mode 100644 ui/src/lib/netstate.ts diff --git a/.gitea/workflows/android-build.yaml b/.gitea/workflows/android-build.yaml.disabled similarity index 100% rename from .gitea/workflows/android-build.yaml rename to .gitea/workflows/android-build.yaml.disabled diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index f025fb4..a4ec135 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -695,12 +695,12 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`: ### G. Release + owner handoff -0. **Land the offline-model redesign (design below — "Offline-model redesign").** This resolves the deferred - native local-game-visibility decision (a native guest must see / resume their device-local games once online - — today the online lobby hides them, `Lobby.svelte:42/54`) as part of a broader, owner-approved change: - remove the explicit offline toggle, make offline an implicit **net-state machine**, unify the lobby, and add - the **two-tier** version gate. Cross-cutting (web + native + a small additive backend/wire bit), its own - staged work; the Android release waits on it. +0. **Land the offline-model redesign — ✅ DONE (O1–O7, 2026-07-13; staged detail below).** Resolved the deferred + native local-game-visibility decision — a native guest now sees / resumes their device-local games once online + (the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline + is an implicit **net-state machine** (O1/O2), the lobby is unified (O5), and the **two-tier** version gate is in + (O4). Cross-cutting (web + native + a small additive backend/wire bit); **contour-safe** (both version vars empty + ⇒ dormant; the wire add is additive). The remaining G steps below are the release chain. 1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant). 2. Promote `development` → `master`; tag `vX.Y.0`. 3. Dispatch `android-build`; watch green; retrieve the signed APK. @@ -863,73 +863,167 @@ untouched**; wire changes **additive-only**; **TG/VK inert** (always online); ** vars empty ⇒ dormant). Minimal-diff: keep `connection`/`offline` module paths as thin **derived shims** over `netState` so the ~14 `offlineMode.active` consumers are not mass-renamed (single source of truth underneath). -- **O1 — Pure net-state reducer + exhaustive unit tests (test-first).** +- **O1 — Pure net-state reducer + exhaustive unit tests (test-first). — ✅ DONE (2026-07-12).** - Files — Create: `ui/src/lib/netstate.ts`, `ui/src/lib/netstate.test.ts`. - - Interfaces — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`; - `type NetEvent` (`callFailed`/`callOk`/`probeOk`/`probeFailed`/`osOffline`/`osOnline`/`versionRejected`/ - `versionRecommended`/`boot`); `reduce(prev, event, cfg) => { state: NetState; effects: Effect[] }` - (effects: `toast`, `startProbe`, `showVersionNotice`, `setNudge`); `cfg` carries `K` + `OFFLINE_DEBOUNCE_MS`. - Consumes: nothing (pure). + - Interfaces (as built) — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`; + `type NetEvent` (`boot`/`callOk`/`callFailed`/`probeOk`/`probeFailed`/`osOnline`/`osOffline`/`versionRejected`/ + `versionRecommended`); `reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig) => NetResult` where + `NetInput = { type: NetEvent; at: number }` (**time-on-the-event** — owner-chosen option A: the reducer stays + a pure function of `(state, event)` and decides *both* hysteresis branches; O2 stamps `Date.now()`), + `NetSnapshot = { state; fails; connectingSince }` (the hysteresis bookkeeping is carried in the reduced value + so `reduce` is pure yet the whole anti-flap is unit-tested), `NetResult = { next: NetSnapshot; effects: Effect[] }`, + `Effect = {kind:'toast',toast:'offline'|'online'} | {kind:'startProbe'} | {kind:'showVersionNotice'} | {kind:'setNudge'}`, + `NetConfig = { k; debounceMs }` (K + `OFFLINE_DEBOUNCE_MS`), plus `INITIAL` (optimistic pre-boot `online`). + Consumes: nothing (pure). **As-built semantics:** entry via `callFailed`/`probeFailed` counts as `fails 1`; + `osOffline` is a hint (enters `connecting` with `fails 0`, only `startProbe`) — so a single blip stays `connecting` + for K≥2; `boot` resets from any state (the sole exit from the sticky lock) → `connecting` + `startProbe`; + `offlineVersionLocked` is sticky (no connectivity event exits it, notice shown once on entry); the soft nudge is + **effect-only** (`setNudge` from `online`), latched later in O4, not stored in the snapshot. - Acceptance: every transition-table row + all 12 edge cases pass as pure `reduce` assertions; the blip vs sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected` from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`. - - Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). + - Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). **31 assertions, green** (transition table, + hysteresis blip/K/debounce, two-tier version gate, edge cases #1–#12, purity). -- **O2 — Runtime store + wire the events; migrate the two modules.** - - Files — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`, the derived - `offline`/`connecting` getters, the probe timer). Modify: `ui/src/lib/connection.svelte.ts` + - `ui/src/lib/offline.svelte.ts` (become thin shims re-exporting the derived getters), `ui/src/lib/transport.ts` - (emit `callFailed`/`callOk`/`versionRejected`/`versionRecommended`), `ui/src/lib/native.ts` (wire - `@capacitor/network`), `ui/src/lib/app.svelte.ts` (boot event), `ui/package.json` (+`@capacitor/network`). - - Interfaces — Produces: `netState` store `.state`/`.offline`/`.connecting`, `emit(event)`. Consumes: `reduce` - (O1), the existing `registerProbe`. - - Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses - `@capacitor/network`, web `navigator`; the derived `offline` matches the old `offlineMode.active` for every - consumer (no visual/behaviour regression). - - Targeted tests: `e2e/offline.spec.ts` still green; `e2e/native.spec.ts` extended (a `$state` store ⇒ e2e, like `update.svelte.ts`). +- **O2 — Runtime store + wire the events; migrate the two modules. — ✅ DONE (2026-07-12).** + - Files (as built) — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`; the derived + `state`/`online`/`connecting`/`offline`/`versionLocked` getters; one self-rescheduling probe/recovery watcher — + backoff while `connecting`, a 4 s poll while `offlineNoNetwork`, idle otherwise; the OS-signal wiring). Modify: + `ui/src/lib/connection.svelte.ts` + `ui/src/lib/offline.svelte.ts` (thin shims over `netState` — `connection.online` + = the online state, `offlineMode.active` = the machine `offline`; `reportOffline`→`callFailed` **once** from online, + `reportOnline`→`callOk`), `ui/src/lib/update.svelte.ts` (`reportUpdateRequired` also emits `versionRejected`), + `ui/src/lib/native.ts` (+`initNativeNetwork` — `@capacitor/network` → `osOnline`/`osOffline`), + `ui/src/lib/app.svelte.ts` (the boot/recovery-seam rewrite), `ui/src/lib/gateway.ts` (the mock `__net` hook), + `ui/src/App.svelte` (dialog removal), `ui/src/lib/i18n/{en,ru}.ts` (`net.offline`/`net.online` toast), `ui/package.json` + (+`@capacitor/network@8.0.1`). **Owner-confirmed scope A (full migration):** the store subsumes the old + `scheduleRecovery` poll + `initNetworkReactivity` listeners + the cold-boot `promptOfflineChoice` **dialog** (removed — + "auto + toast, no dialog"); the native offline-first boot + the web unreachable-cached boot now enter via `bootOffline()`. + - Interfaces (as built) — Produces: `netState` (`.state`/`.online`/`.connecting`/`.offline`/`.versionLocked`), + `emit(type)` (stamps `Date.now()`), `bootOffline(kickNow?)`, `checkReachable`, `resetNetState`, `initNetSignals`, and a + **register-hook inversion** so the store stays a leaf module (no cycle with `app.svelte.ts`): `registerProbe` + (transport's reachability read), `registerRecovery` (the app's smart reconcile-or-reachability), `registerNetToast` + (i18n toast). `NetConfig = { k: 3, debounceMs: 15000 }` (owner-confirmed). **Wiring:** `reportOffline` enters + `connecting` once (only from online) and the **probe decides** offline via K/debounce; the session-less native + reconcile IS the probe (`recover()` → `reconcileServerGuest` → `emit('callOk')` before adopt). **`boot` is not + emitted** — a real relaunch reloads the bundle to `INITIAL` online, which also clears the sticky version lock; O1's + `boot` handling stays valid but unused here. **TG/VK inertness falls out for free:** the registrations run only past + the Telegram/VK early-returns in `bootstrap`, so those channels are never fed an offline signal (they can show + `connecting` but never reach `offline`). + - Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses `@capacitor/network`, + web `navigator`; the derived `offline` matches the old `offlineMode.active` for every consumer (no regression). **Verified.** + - Targeted tests: `e2e/offline.spec.ts` **rewritten** (implicit offline via the mock `__net` hook — no toggle — + the + toast + local play + persist + self-heal), `e2e/hotseat.spec.ts` migrated off the toggle, `e2e/native.spec.ts` green + (the native boot/offline/reconcile IS the store's e2e). **Full suite 119/119 on chromium + webkit; svelte-check 0/0; + vitest 622; `cap sync` registers `@capacitor/network`.** -- **O3 — Remove the explicit offline toggle + migrate the pref.** - - Files — Modify: `ui/src/screens/Settings.svelte` (delete the `offlineEligible` toggle + `goOffline`), - `ui/src/lib/offline.ts`/`offline.svelte.ts` (drop `requestOffline`/`loadOfflinePref`/`saveOfflinePref`; clear - the stale key on boot), `ui/src/screens/SettingsHub.svelte` (tab-disable from `netState`). - - Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck); offline - chrome/tab-gating still driven from `netState`. - - Targeted tests: `e2e` — Settings has no toggle; a stale pref does not force offline. +- **O3 — Remove the explicit offline toggle + migrate the pref. — ✅ DONE (2026-07-12).** + - Files (as built) — Modify: `ui/src/screens/Settings.svelte` (deleted the whole "Play mode" Online/Offline segment + + `offlineEligible` + `goOffline` + the `checking`/`needsData` state + the now-unused `isStandalone`/`insideVK`/offline + imports + the `.onote` CSS), `ui/src/lib/offline.svelte.ts` (dropped the inert `setOfflineMode`/`requestOffline`/ + `TOGGLE_READY_BUDGET_MS` stubs + the dead `offlineMode.auto` getter — `offlineMode.active` = `netState.offline` stays), + `ui/src/lib/offline.ts` (dropped `loadOfflinePref`/`saveOfflinePref`/`shouldBootOffline`/`offlineReady`/`missingDicts`/ + `raceOfflineReady`; **new** `clearOfflinePref`; only `offlinePreloadEligible` + the cleanup remain), `ui/src/lib/app.svelte.ts` + (calls `clearOfflinePref()` once at boot, before the Mini-App branches), `ui/src/lib/i18n/{en,ru}.ts` (pruned 7 orphaned + keys each: `settings.{offlineMode,online,offlineChecking,offlineNeedsData}` + `offline.prompt{Title,Yes,No}`). **Deleted** + `ui/src/lib/dict/offlineready.ts` (orphaned once `requestOffline` went). Tests: `ui/src/lib/offline.test.ts` slimmed to + `offlinePreloadEligible` + `clearOfflinePref`; `ui/e2e/offline.spec.ts` +1 case. + - **Kept, not changed:** `settings.offline` (the Header offline chip still uses it) + `offline.preloadWarning` (the lobby + dict-preload notice). **`SettingsHub.svelte` needed no change** — its tab-disable already reads `offlineMode.active`, + which is `netState.offline` (the O2 shim); the plan's "tab-disable from `netState`" is satisfied without a mass-rename. + - Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck) and is cleared; offline + chrome/tab-gating still driven from `netState`. **Verified.** + - Targeted tests: `e2e/offline.spec.ts` new case (a stale `scrabble.offlineMode` → boots online, key cleared, Settings has + no `Offline` toggle even in the eligible context); `offline.test.ts` (`clearOfflinePref`). **Full suite 120/120 on + chromium + webkit; svelte-check 0/0; vitest 616.** -- **O4 — Two-tier version gate.** - - Files — Modify: `gateway/internal/config/config.go` (+`RecommendedClientVersion`, validate ≥ `MinClientVersion`), - `gateway/internal/connectsrv/server.go` (soft band ⇒ `X-Update-Recommended: 1` response header; hard band - unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (read the header ⇒ - `versionRecommended`), `ui/src/lib/update.svelte.ts` (nudge state), rework `ui/src/components/UpdateOverlay.svelte` - from terminal → the `offlineVersionLocked` notice ("Update"/"Play offline"), new `ui/src/components/UpdateNudge.svelte`. - - Interfaces — Produces: `GATEWAY_RECOMMENDED_CLIENT_VERSION`, the `X-Update-Recommended` header, `updateRecommended` store. - - Acceptance: `v