feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 13s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Failing after 15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 13s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Failing after 15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README). Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later. Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
This commit is contained in:
+61
-49
@@ -100,15 +100,24 @@ dropped). Horizontal scaling is explicit future work.
|
||||
auth operations are unauthenticated and return the minted token. A unary
|
||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||
surface as Connect error codes. The client treats a connectivity edge failure as
|
||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
||||
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
|
||||
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
|
||||
response was lost — its button is disabled while offline and the player re-issues it on
|
||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
|
||||
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
|
||||
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
|
||||
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
|
||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
||||
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
||||
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
||||
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
||||
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
|
||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online, never fed an offline
|
||||
signal, so those channels never enter an offline state or show a local lobby.
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
@@ -140,18 +149,26 @@ dropped). Horizontal scaling is explicit future work.
|
||||
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
||||
client's in-app stream while the app is open. Out-of-app delivery uses
|
||||
platform-native push via the platform side-service.
|
||||
- **Client-version gate** (native long-tail protection): a bundled native install (§13) can be
|
||||
- **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
|
||||
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
|
||||
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
|
||||
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
|
||||
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
|
||||
that breaks. Enforcement is **per-call, not a Hello RPC**: `Execute` and `Subscribe` compare the header to
|
||||
`GATEWAY_MIN_CLIENT_VERSION` at the top (before registry lookup / auth / decode), so the first online call
|
||||
an app makes is already gated. A too-old client gets the domain envelope `result_code = "update_required"`
|
||||
(HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`, and makes **zero** successful
|
||||
requests. The gate **fails open** (an absent or unparseable header passes) and is **dormant** until
|
||||
`GATEWAY_MIN_CLIENT_VERSION` is deliberately set (empty ⇒ off, so web behaviour is unchanged). The client
|
||||
raises a terminal *update* overlay — native → the store listing (`VITE_RUSTORE_URL`), web → reload.
|
||||
that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
|
||||
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
|
||||
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
|
||||
vars empty ⇒ off, web behaviour unchanged).
|
||||
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
|
||||
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
|
||||
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
|
||||
`offlineVersionLocked` (offline mode) and shows a **dismissable notice** — **"Update"** (native → the
|
||||
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
|
||||
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
|
||||
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
|
||||
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
|
||||
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
|
||||
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **≥ `min`**
|
||||
(validated at config load); empty ⇒ the soft tier is off.
|
||||
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
|
||||
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
|
||||
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
|
||||
@@ -160,10 +177,12 @@ dropped). Horizontal scaling is explicit future work.
|
||||
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
|
||||
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
|
||||
rollout (see [`../deploy/README.md`](../deploy/README.md)).
|
||||
- **Gate × offline** (UX rule): deliberate offline uses the network kill switch, so the gate never fires
|
||||
while playing offline — an old install can always play local vs_ai / hotseat. The terminal *update*
|
||||
overlay is raised **only on a user-initiated online action**; the silent background guest reconciliation
|
||||
(§3) swallows an `update_required` and stays a local guest rather than interrupting local play.
|
||||
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
|
||||
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
|
||||
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
|
||||
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
|
||||
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
|
||||
rather than interrupting local play.
|
||||
|
||||
## 3. Authentication & sessions
|
||||
|
||||
@@ -1369,13 +1388,16 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
|
||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
|
||||
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
|
||||
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
||||
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
||||
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
||||
invitations are hidden while offline. New Game's *with friends* carries an **online/offline segmented
|
||||
control** — online = a friend invite, offline = **local pass-and-play (hotseat)** — with the online
|
||||
segment disabled and offline forced when there is no network; a `vs_ai` or hotseat create is guarded when
|
||||
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
|
||||
the in-browser engine and driven by the same game screen, the robot replying locally.
|
||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
||||
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||
@@ -1403,27 +1425,17 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
|
||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||
mode** boots from the persisted session and
|
||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
||||
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
||||
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
||||
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
||||
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
||||
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
||||
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
||||
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
||||
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
||||
while offline. The gateway registers the `.webmanifest` MIME type
|
||||
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
|
||||
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
|
||||
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
|
||||
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
|
||||
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
|
||||
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
|
||||
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
|
||||
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
|
||||
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
|
||||
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
|
||||
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
|
||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||
|
||||
Reference in New Issue
Block a user