From 73baf580026bdfbb4f0ef740d6ce43d19203925f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 12 Jul 2026 21:57:40 +0200 Subject: [PATCH] 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. --- ANDROID_PLAN.md | 156 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 3 deletions(-) diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index 109ce24..f1f8798 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -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: