docs(offline): design the offline-model redesign (net-state machine + two-tier gate)
Owner-approved brainstormed design in ANDROID_PLAN.md, resolving G-step-0: remove the explicit offline toggle -> a single net-state machine (online / connecting / offlineNoNetwork / offlineVersionLocked) with explicit hysteresis + 12 enumerated edge cases; unify the lobby (server games greyed from cache offline; identity recognises both ids); two-tier version gate (hard degrades to forced offline with an "Update / Play offline" notice; new soft recommended tier via an additive X-Update-Recommended header); keep server vs_ai (offline->local, online->server, app decides by state); TG/VK always-online. Exhaustive test matrix. Client + a small additive backend/wire bit; contour-safe.
This commit is contained in:
+153
-3
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user