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
|
### G. Release + owner handoff
|
||||||
|
|
||||||
0. **Resolve the deferred native local-game-visibility decision (Progress §D):** a native guest must still
|
0. **Land the offline-model redesign (design below — "Offline-model redesign").** This resolves the deferred
|
||||||
see / resume their device-local vs_ai / hotseat games once reconciled online (today the online lobby hides
|
native local-game-visibility decision (a native guest must see / resume their device-local games once online
|
||||||
them — `Lobby.svelte:42/54`). Design + implement, or explicitly accept the split for the MVP, before release.
|
— 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).
|
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
|
||||||
2. Promote `development` → `master`; tag `vX.Y.0`.
|
2. Promote `development` → `master`; tag `vX.Y.0`.
|
||||||
3. Dispatch `android-build`; watch green; retrieve the signed APK.
|
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)
|
## Google Play variant (later — design now so we don't repaint)
|
||||||
|
|
||||||
One codebase, two build variants selected by flags:
|
One codebase, two build variants selected by flags:
|
||||||
|
|||||||
Reference in New Issue
Block a user