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 73baf58002 - Show all commits
+153 -3
View File
@@ -689,9 +689,12 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`:
### G. Release + owner handoff
0. **Resolve the deferred native local-game-visibility decision (Progress §D):** a native guest must still
see / resume their device-local vs_ai / hotseat games once reconciled online (today the online lobby hides
them — `Lobby.svelte:42/54`). Design + implement, or explicitly accept the split for the MVP, before release.
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.
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.
@@ -700,6 +703,153 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`:
---
## Offline-model redesign (design — resolves G-step-0)
> Owner-approved design (brainstormed 2026-07-12). Cross-cutting web + native + a small **additive**
> backend/wire bit. This is the Android release's G-step-0 and its own staged work; `writing-plans` turns it
> into the implementation plan. English throughout; the FBS change is additive-only (§frozen contract).
### Goal & locked decisions
Remove the explicit online/offline toggle. Offline becomes an **implicit, detected** state driven by a single
**net-state machine**. vs_ai stays as-is (offline → local, online → server; the app decides by the detected
state). The lobby becomes **unified**. The version gate gains a **soft** tier and its hard tier degrades to
offline instead of a terminal lockout. TG/VK stay **always-online**. Client-only except the additive
soft-tier threshold + signal.
| Decision | Choice |
|---|---|
| Offline trigger | Implicit — detected connectivity + version, **no user toggle** |
| State model | **Single net-state machine** (replaces the two-layer `connection` / `offlineMode` split) |
| Switch UX | **Auto + a toast**, no dialog; hysteresis against flap; self-heal to online |
| Server vs_ai | **Kept** — offline → local, online → server; identical create flow, app decides by state |
| With-friends create | Explicit online/offline choice; offline **disabled (greyed)** when no network; create always works (→ local hotseat) |
| Lobby offline | Unified; server games **greyed from the last cache**, local games active |
| Version — critical (hard) | Terminal lockout → now a **notice "Update / Play offline"**, then forced offline |
| Version — recommended (soft) | **Built now** — notify but still play online (`GATEWAY_RECOMMENDED_CLIENT_VERSION` + an additive signal) |
| TG/VK | **Always online** (exempt); no local lobby / offline states |
| Native net signal | Add **`@capacitor/network`** as a hint; web keeps `navigator` + gateway probes |
| Migration | Clear the persisted `offlinePref` (nobody stuck without a toggle) |
### The net-state machine
One reactive `netState` absorbs `connection.svelte.ts` + `offline.svelte.ts`; the ~14 readers of
`offlineMode.active` migrate to a derived `offline` boolean and the "Connecting…" readers to `connecting`.
Written as a pure reducer (reusable for a future iOS shell).
**States**
- `online` — gateway reachable, version accepted. Full features. May carry an orthogonal dismissible
**`updateRecommended`** nudge (soft tier); play continues.
- `connecting` — a call/probe is currently failing but the hysteresis window has not elapsed. "Connecting…";
chrome stays online. Transient — the anti-flap buffer. **Not** offline (no kill-switch).
- `offlineNoNetwork` — sustained unreachable (hysteresis exceeded). Offline mode (blue chrome, local-only
active lobby, transport kill-switch). Self-heals.
- `offlineVersionLocked` — gateway reachable but version < min (critical). Offline mode + the
"Update / Play offline" notice. **Sticky** — exits only via an app update (fresh version next boot).
`offline` (kill-switch / blue chrome / local play) = `offlineNoNetwork || offlineVersionLocked`.
**Events:** `callFailed` · `callOk`/`probeOk` · `probeFailed` · `osOffline`/`osOnline` (navigator /
`@capacitor/network` hints) · `versionRejected` (update_required) · `versionRecommended` (soft) · `boot`.
**Transitions**
| From | Event | To | Side-effect |
|---|---|---|---|
| online | callFailed / osOffline | connecting | start hysteresis (probe + timer) |
| connecting | probeOk / callOk | online | — |
| connecting | hysteresis exceeded (K fails / `OFFLINE_DEBOUNCE_MS`) | offlineNoNetwork | toast "offline" |
| offlineNoNetwork | osOnline | (probe) | trigger a probe; stay until it wins |
| offlineNoNetwork | probeOk / callOk | online | toast "back online" |
| **any** | versionRejected | offlineVersionLocked | show the Update/Offline notice (supersedes the soft nudge) |
| offlineVersionLocked | probe (still rejected) | offlineVersionLocked | stays; only an app update exits |
| online | versionRecommended | online (+ nudge) | dismissible "update available" banner |
| boot (no net) | — | connecting → offlineNoNetwork | offline-first |
| boot (version too old) | versionRejected | offlineVersionLocked | notice |
**Hysteresis / anti-flap (explicit):** `online → offline` only after the probe fails **and** (K consecutive
failures **or** `connecting` held ≥ `OFFLINE_DEBOUNCE_MS`); a single blip lives entirely in `connecting`
(spinner only). Recovery to `online` is immediate on the first `probeOk`. `osOnline` never flips to online by
itself — it only **triggers** a probe (navigator / `@capacitor/network` can lie; the probe decides). Reuses
the existing probe watcher + backoff.
**Session-less guest (from D):** the probe is authenticated, so a not-yet-reconciled local guest cannot probe;
there the **reconcile attempt (`auth.guest`) IS the connectivity signal** — success → online + adopt;
failure / `update_required` → stays offline (swallowed, as D already does).
### Edge cases (each gets a test)
1. Rapid flap (fail→ok inside the window) → never leaves `connecting`; no chrome thrash.
2. Cold boot, no network → `offlineNoNetwork`, offline-first lobby.
3. Cold boot, gateway up but version < min → `offlineVersionLocked` + notice.
4. Cold boot, session-less guest → reconcile = probe; ok→online, fail→offline.
5. Mid-session min-version bump → next call `versionRejected` → `offlineVersionLocked` mid-play.
6. Captive portal: `osOnline` fires but the gateway is down → probe fails → stays offline.
7. Recovery race: `probeOk` + a queued `callOk` together → one idempotent transition to online.
8. Soft nudge, then a hard reject → escalate to `offlineVersionLocked` (notice supersedes the banner).
9. `offlineVersionLocked` on plain web with no cached dicts → offline but create disabled → the notice is the only action.
10. Offline → create local vs_ai → back online → the local game persists and shows in the unified lobby.
11. Persisted stale `offlinePref` on upgrade → ignored/cleared; never stuck offline.
12. TG/VK → the machine is inert (always `online`); no offline states, no local lobby.
### Version gate — two tiers (backend + wire, additive)
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`** (exists): `version < min` → `update_required` (Execute
result_code / Subscribe `FailedPrecondition`). Client reaction **changes**: no terminal overlay →
`offlineVersionLocked` + a **notice** with "Update" (native → `VITE_RUSTORE_URL`, web → reload) and
"Play offline" (dismiss → stay offline). Background reconcile stays silent (D).
- **Soft (recommended) — NEW `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` → a
**non-blocking** signal. Wire: **additive** — a **response header** `X-Update-Recommended: 1` on gated
responses (headers are the version-tolerant layer, like `X-Client-Version`); the call still succeeds and
never carries a breaking payload change. Client shows a **dismissible** "update available" banner and plays
online. Empty ⇒ soft tier off.
- Config: `recommended` must be ≥ `min` (validated at load); both empty ⇒ the gate is fully dormant (web
unchanged). Frozen-contract compliance: the soft signal is additive/trailing; the `update_required`
sentinel is unchanged.
### Unified lobby
Merge `localSource.list()` + `gateway.gamesList()`. Offline: local games active + **server games greyed from
the last `lobbycache` snapshot** (un-openable, an "offline" hint). Identity: recognise **both** the
`server user id` and the `localGuestId` as "you" (they differ after reconciliation) for turn / medal logic.
One `lobbysort`; ids never collide. Removes the `loadSeq` mode-exclusive branch — both sources feed one list.
TG/VK: server-only (unchanged).
### Create flows
- **vs_ai (quick):** unchanged logic — offline → `localSource.create`, online → `lobbyEnqueue` — driven by
`netState`. **Dict guard:** if the variant's dawg is unavailable offline, disable create with a reason.
- **With-friends:** a new **online/offline segmented control** (online = friend invite, offline = hotseat;
both forms exist). No network → the online segment is disabled ("needs network"), offline preselected.
### Settings / chrome / migration / platform
Remove the offline-toggle section from `Settings.svelte` (+ `requestOffline`, the `offlineEligible` gate);
`SettingsHub` keeps disabling Profile/Friends/Wallet while offline (now from `netState`). On upgrade,
ignore/clear the persisted `offlinePref` (never strand a deliberate-offline user). Implicit offline applies to
**native (always) + web (plain tab + PWA)**; **TG/VK exempt**. Plain-web offline is best-effort (local play
only for variants whose dawg is cached). No server / data migration (server vs_ai + all server games
untouched).
### Test matrix (exhaustive — owner requirement)
- **vitest (pure):** the net-state machine as a pure reducer — **every** transition, the hysteresis/debounce
(blip vs sustained), the session-less-guest reconcile-as-probe branch, the two-tier version decision
(min / recommended ordering + all three bands), the dict guard, the identity "self" test (both ids). Every
edge case #1#12 gets a case.
- **Go:** `clientver`/config extended for `GATEWAY_RECOMMENDED_CLIENT_VERSION` (parse + ordering validation);
`connectsrv` for the soft signal (`v < min` ⇒ hard, `min ≤ v < recommended` ⇒ soft, `v ≥ recommended` ⇒
none, absent/garbled ⇒ fail-open).
- **Playwright (mock):** auto online→offline (toast) + self-heal; `offlineVersionLocked` notice → "Play
offline" → local play; unified lobby (greyed server games offline; both active online); with-friends toggle
(online disabled offline); the soft nudge banner; update `native.spec.ts`.
- **Docs (revise the F bake):** ARCHITECTURE §2 gate → two tiers + graceful degrade; §3 offline model;
FUNCTIONAL (+`_ru`) offline + "update required"; TESTING; deploy/README the two version vars.
### Scope / sequencing
Client refactor + a small **additive** backend/wire bit; **contour-safe** (both version vars empty ⇒ dormant;
the wire add is additive). It is **G-step-0** and gates the Android release. After it lands: the normal G
chain (PR → development → contour verify → master → tag → dispatch `android-build`).
### Out of scope
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).
---
## Google Play variant (later — design now so we don't repaint)
One codebase, two build variants selected by flags: