feat(offline): implicit net-state model, two-tier version gate, unified lobby #249

Merged
developer merged 18 commits from feature/android-native into development 2026-07-13 00:59:16 +00:00
Showing only changes of commit 09e05eef18 - Show all commits
+84 -1
View File
@@ -173,7 +173,13 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
`GATEWAY_MIN_CLIENT_VERSION` var + the wire-break bump discipline + the Android build/release runbook —
keystore, secrets, dispatch, RuStore upload); `ui/README.md` (the native `VITE_*` build vars). Every
referenced test file was confirmed to exist.
- **G — pending.**
- **G — pending; gated on the offline-model redesign (was G-step-0).** The deferred native
local-game-visibility decision grew into an owner-approved cross-cutting change (web + native + a small
additive backend/wire bit): remove the explicit offline toggle → one `netState` machine, unify the lobby,
two-tier version gate. **Designed + staged (O1O7)** in the "Offline-model redesign" section below.
**➡ Next actionable work: implement O1** (the pure `netState` reducer, test-first) via `stage-implementation`,
then O2O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
O1O7 is started. Branch `feature/android-native`, local/unpushed.
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
@@ -848,6 +854,83 @@ chain (PR → development → contour verify → master → tag → dispatch `an
Dropping server vs_ai (kept). TG/VK offline. In-app store-update SDK. The iOS shell (the machine is written to
be reusable for it; iOS is not built here).
### Implementation stages
Executed via `stage-implementation` (interview on every fork, tests at each layer, bake docs). Ordered so each
stage leaves the app working and independently testable. **TDD:** the pure machine (O1) is written test-first.
Standing constraints (from the design): one `netState` machine; hysteresis explicit; **server vs_ai
untouched**; wire changes **additive-only**; **TG/VK inert** (always online); **contour-safe** (both version
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).**
- 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).
- 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).
- **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`).
- **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.
- **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<min` → notice → "Play offline" plays local; `min≤v<recommended` → dismissible banner, online
continues; `v≥recommended`/both empty → nothing; fail-open on a garbled header.
- Targeted tests: Go `config_test`/`server_test` (three bands + ordering + fail-open); `e2e/update.spec.ts`
(notice → play offline; nudge banner).
- **O5 — Unified lobby.**
- Files — Modify: `ui/src/screens/Lobby.svelte` (merge `localSource.list()` + `gateway.gamesList()`; offline ⇒
local active + cached server greyed; drop the mode-exclusive `loadSeq` branch), `ui/src/lib/lobbysort.ts`
(sort the merged set), a self-identity helper (recognise `session.userId` **and** `localGuestId()`).
- Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache
(un-openable); a reconciled guest's local games stay visible online (**closes G-step-0**); TG/VK server-only.
- Targeted tests: `e2e` unified lobby (greyed offline; both online); `lobbysort.test.ts` extended.
- **O6 — Create flows.**
- Files — Modify: `ui/src/screens/NewGame.svelte` (with-friends online/offline segmented control, online
disabled when `netState.offline`; vs_ai already branches on the flag — verify; the dict-availability guard).
- Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline
preselected, create works; vs_ai offline→local / online→server unchanged; create disabled with a reason when
the variant's dawg is unavailable offline.
- Targeted tests: `e2e` with-friends toggle (online disabled offline); vs_ai local-vs-server by state.
- **O7 — Docs + native e2e.**
- Files — Modify: `docs/ARCHITECTURE.md` (§2 gate → two tiers + graceful degrade; §3 the net-state model),
`docs/FUNCTIONAL.md` (+`_ru`), `docs/TESTING.md`, `deploy/README.md` (the two version vars), `ui/e2e/native.spec.ts`.
- Acceptance: the F-baked docs describe both tiers + the implicit model + the unified lobby; `deploy/README`
lists `GATEWAY_RECOMMENDED_CLIENT_VERSION`.
- Targeted tests: n/a (docs); the native e2e reflects the new boot/offline.
---
## Google Play variant (later — design now so we don't repaint)