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 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s

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, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline 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.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
This commit is contained in:
Ilia Denisov
2026-07-13 01:44:28 +02:00
parent 09e05eef18
commit 2d1fadb50c
55 changed files with 2065 additions and 976 deletions
+5
View File
@@ -353,6 +353,11 @@ jobs:
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini # for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret. # App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on # Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off. # test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
+6
View File
@@ -112,6 +112,12 @@ jobs:
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh. # derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }} VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }} GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm. # Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh. # Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }} GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
+157 -63
View File
@@ -695,12 +695,12 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`:
### G. Release + owner handoff ### G. Release + owner handoff
0. **Land the offline-model redesign (design below — "Offline-model redesign").** This resolves the deferred 0. **Land the offline-model redesign — ✅ DONE (O1O7, 2026-07-13; staged detail below).** Resolved the deferred
native local-game-visibility decision (a native guest must see / resume their device-local games once online native local-game-visibility decision a native guest now sees / resumes their device-local games once online
— today the online lobby hides them, `Lobby.svelte:42/54`) as part of a broader, owner-approved change: (the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline
remove the explicit offline toggle, make offline an implicit **net-state machine**, unify the lobby, and add is an implicit **net-state machine** (O1/O2), the lobby is unified (O5), and the **two-tier** version gate is in
the **two-tier** version gate. Cross-cutting (web + native + a small additive backend/wire bit), its own (O4). Cross-cutting (web + native + a small additive backend/wire bit); **contour-safe** (both version vars empty
staged work; the Android release waits on it. ⇒ dormant; the wire add is additive). The remaining G steps below are the release chain.
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.
@@ -863,73 +863,167 @@ untouched**; wire changes **additive-only**; **TG/VK inert** (always online); **
vars empty ⇒ dormant). Minimal-diff: keep `connection`/`offline` module paths as thin **derived shims** over vars empty ⇒ dormant). Minimal-diff: keep `connection`/`offline` module paths as thin **derived shims** over
`netState` so the ~14 `offlineMode.active` consumers are not mass-renamed (single source of truth underneath). `netState` so the ~14 `offlineMode.active` consumers are not mass-renamed (single source of truth underneath).
- **O1 — Pure net-state reducer + exhaustive unit tests (test-first).** - **O1 — Pure net-state reducer + exhaustive unit tests (test-first). — ✅ DONE (2026-07-12).**
- Files — Create: `ui/src/lib/netstate.ts`, `ui/src/lib/netstate.test.ts`. - Files — Create: `ui/src/lib/netstate.ts`, `ui/src/lib/netstate.test.ts`.
- Interfaces — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`; - Interfaces (as built) — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`;
`type NetEvent` (`callFailed`/`callOk`/`probeOk`/`probeFailed`/`osOffline`/`osOnline`/`versionRejected`/ `type NetEvent` (`boot`/`callOk`/`callFailed`/`probeOk`/`probeFailed`/`osOnline`/`osOffline`/`versionRejected`/
`versionRecommended`/`boot`); `reduce(prev, event, cfg) => { state: NetState; effects: Effect[] }` `versionRecommended`); `reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig) => NetResult` where
(effects: `toast`, `startProbe`, `showVersionNotice`, `setNudge`); `cfg` carries `K` + `OFFLINE_DEBOUNCE_MS`. `NetInput = { type: NetEvent; at: number }` (**time-on-the-event** — owner-chosen option A: the reducer stays
Consumes: nothing (pure). a pure function of `(state, event)` and decides *both* hysteresis branches; O2 stamps `Date.now()`),
`NetSnapshot = { state; fails; connectingSince }` (the hysteresis bookkeeping is carried in the reduced value
so `reduce` is pure yet the whole anti-flap is unit-tested), `NetResult = { next: NetSnapshot; effects: Effect[] }`,
`Effect = {kind:'toast',toast:'offline'|'online'} | {kind:'startProbe'} | {kind:'showVersionNotice'} | {kind:'setNudge'}`,
`NetConfig = { k; debounceMs }` (K + `OFFLINE_DEBOUNCE_MS`), plus `INITIAL` (optimistic pre-boot `online`).
Consumes: nothing (pure). **As-built semantics:** entry via `callFailed`/`probeFailed` counts as `fails 1`;
`osOffline` is a hint (enters `connecting` with `fails 0`, only `startProbe`) — so a single blip stays `connecting`
for K≥2; `boot` resets from any state (the sole exit from the sticky lock) → `connecting` + `startProbe`;
`offlineVersionLocked` is sticky (no connectivity event exits it, notice shown once on entry); the soft nudge is
**effect-only** (`setNudge` from `online`), latched later in O4, not stored in the snapshot.
- Acceptance: every transition-table row + all 12 edge cases pass as pure `reduce` assertions; the blip vs - Acceptance: every transition-table row + all 12 edge cases pass as pure `reduce` assertions; the blip vs
sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected` sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected`
from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`. from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`.
- Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). - Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). **31 assertions, green** (transition table,
hysteresis blip/K/debounce, two-tier version gate, edge cases #1#12, purity).
- **O2 — Runtime store + wire the events; migrate the two modules.** - **O2 — Runtime store + wire the events; migrate the two modules. — ✅ DONE (2026-07-12).**
- Files — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`, the derived - Files (as built) — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`; the derived
`offline`/`connecting` getters, the probe timer). Modify: `ui/src/lib/connection.svelte.ts` + `state`/`online`/`connecting`/`offline`/`versionLocked` getters; one self-rescheduling probe/recovery watcher —
`ui/src/lib/offline.svelte.ts` (become thin shims re-exporting the derived getters), `ui/src/lib/transport.ts` backoff while `connecting`, a 4 s poll while `offlineNoNetwork`, idle otherwise; the OS-signal wiring). Modify:
(emit `callFailed`/`callOk`/`versionRejected`/`versionRecommended`), `ui/src/lib/native.ts` (wire `ui/src/lib/connection.svelte.ts` + `ui/src/lib/offline.svelte.ts` (thin shims over `netState` — `connection.online`
`@capacitor/network`), `ui/src/lib/app.svelte.ts` (boot event), `ui/package.json` (+`@capacitor/network`). = the online state, `offlineMode.active` = the machine `offline`; `reportOffline`→`callFailed` **once** from online,
- Interfaces — Produces: `netState` store `.state`/`.offline`/`.connecting`, `emit(event)`. Consumes: `reduce` `reportOnline`→`callOk`), `ui/src/lib/update.svelte.ts` (`reportUpdateRequired` also emits `versionRejected`),
(O1), the existing `registerProbe`. `ui/src/lib/native.ts` (+`initNativeNetwork` — `@capacitor/network` → `osOnline`/`osOffline`),
- Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses `ui/src/lib/app.svelte.ts` (the boot/recovery-seam rewrite), `ui/src/lib/gateway.ts` (the mock `__net` hook),
`@capacitor/network`, web `navigator`; the derived `offline` matches the old `offlineMode.active` for every `ui/src/App.svelte` (dialog removal), `ui/src/lib/i18n/{en,ru}.ts` (`net.offline`/`net.online` toast), `ui/package.json`
consumer (no visual/behaviour regression). (+`@capacitor/network@8.0.1`). **Owner-confirmed scope A (full migration):** the store subsumes the old
- Targeted tests: `e2e/offline.spec.ts` still green; `e2e/native.spec.ts` extended (a `$state` store ⇒ e2e, like `update.svelte.ts`). `scheduleRecovery` poll + `initNetworkReactivity` listeners + the cold-boot `promptOfflineChoice` **dialog** (removed —
"auto + toast, no dialog"); the native offline-first boot + the web unreachable-cached boot now enter via `bootOffline()`.
- Interfaces (as built) — Produces: `netState` (`.state`/`.online`/`.connecting`/`.offline`/`.versionLocked`),
`emit(type)` (stamps `Date.now()`), `bootOffline(kickNow?)`, `checkReachable`, `resetNetState`, `initNetSignals`, and a
**register-hook inversion** so the store stays a leaf module (no cycle with `app.svelte.ts`): `registerProbe`
(transport's reachability read), `registerRecovery` (the app's smart reconcile-or-reachability), `registerNetToast`
(i18n toast). `NetConfig = { k: 3, debounceMs: 15000 }` (owner-confirmed). **Wiring:** `reportOffline` enters
`connecting` once (only from online) and the **probe decides** offline via K/debounce; the session-less native
reconcile IS the probe (`recover()` → `reconcileServerGuest` → `emit('callOk')` before adopt). **`boot` is not
emitted** — a real relaunch reloads the bundle to `INITIAL` online, which also clears the sticky version lock; O1's
`boot` handling stays valid but unused here. **TG/VK inertness falls out for free:** the registrations run only past
the Telegram/VK early-returns in `bootstrap`, so those channels are never fed an offline signal (they can show
`connecting` but never reach `offline`).
- Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses `@capacitor/network`,
web `navigator`; the derived `offline` matches the old `offlineMode.active` for every consumer (no regression). **Verified.**
- Targeted tests: `e2e/offline.spec.ts` **rewritten** (implicit offline via the mock `__net` hook — no toggle — + the
toast + local play + persist + self-heal), `e2e/hotseat.spec.ts` migrated off the toggle, `e2e/native.spec.ts` green
(the native boot/offline/reconcile IS the store's e2e). **Full suite 119/119 on chromium + webkit; svelte-check 0/0;
vitest 622; `cap sync` registers `@capacitor/network`.**
- **O3 — Remove the explicit offline toggle + migrate the pref.** - **O3 — Remove the explicit offline toggle + migrate the pref. — ✅ DONE (2026-07-12).**
- Files — Modify: `ui/src/screens/Settings.svelte` (delete the `offlineEligible` toggle + `goOffline`), - Files (as built) — Modify: `ui/src/screens/Settings.svelte` (deleted the whole "Play mode" Online/Offline segment +
`ui/src/lib/offline.ts`/`offline.svelte.ts` (drop `requestOffline`/`loadOfflinePref`/`saveOfflinePref`; clear `offlineEligible` + `goOffline` + the `checking`/`needsData` state + the now-unused `isStandalone`/`insideVK`/offline
the stale key on boot), `ui/src/screens/SettingsHub.svelte` (tab-disable from `netState`). imports + the `.onote` CSS), `ui/src/lib/offline.svelte.ts` (dropped the inert `setOfflineMode`/`requestOffline`/
- Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck); offline `TOGGLE_READY_BUDGET_MS` stubs + the dead `offlineMode.auto` getter — `offlineMode.active` = `netState.offline` stays),
chrome/tab-gating still driven from `netState`. `ui/src/lib/offline.ts` (dropped `loadOfflinePref`/`saveOfflinePref`/`shouldBootOffline`/`offlineReady`/`missingDicts`/
- Targeted tests: `e2e` — Settings has no toggle; a stale pref does not force offline. `raceOfflineReady`; **new** `clearOfflinePref`; only `offlinePreloadEligible` + the cleanup remain), `ui/src/lib/app.svelte.ts`
(calls `clearOfflinePref()` once at boot, before the Mini-App branches), `ui/src/lib/i18n/{en,ru}.ts` (pruned 7 orphaned
keys each: `settings.{offlineMode,online,offlineChecking,offlineNeedsData}` + `offline.prompt{Title,Yes,No}`). **Deleted**
`ui/src/lib/dict/offlineready.ts` (orphaned once `requestOffline` went). Tests: `ui/src/lib/offline.test.ts` slimmed to
`offlinePreloadEligible` + `clearOfflinePref`; `ui/e2e/offline.spec.ts` +1 case.
- **Kept, not changed:** `settings.offline` (the Header offline chip still uses it) + `offline.preloadWarning` (the lobby
dict-preload notice). **`SettingsHub.svelte` needed no change** — its tab-disable already reads `offlineMode.active`,
which is `netState.offline` (the O2 shim); the plan's "tab-disable from `netState`" is satisfied without a mass-rename.
- Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck) and is cleared; offline
chrome/tab-gating still driven from `netState`. **Verified.**
- Targeted tests: `e2e/offline.spec.ts` new case (a stale `scrabble.offlineMode` → boots online, key cleared, Settings has
no `Offline` toggle even in the eligible context); `offline.test.ts` (`clearOfflinePref`). **Full suite 120/120 on
chromium + webkit; svelte-check 0/0; vitest 616.**
- **O4 — Two-tier version gate.** - **O4 — Two-tier version gate. — ✅ DONE (2026-07-13).**
- Files — Modify: `gateway/internal/config/config.go` (+`RecommendedClientVersion`, validate ≥ `MinClientVersion`), - Files (as built) — Backend: `gateway/internal/config/config.go` (+`RecommendedClientVersion` from
`gateway/internal/connectsrv/server.go` (soft band ⇒ `X-Update-Recommended: 1` response header; hard band `GATEWAY_RECOMMENDED_CLIENT_VERSION`; validate parseable + `≥ MinClientVersion`, an empty min imposing no lower bound),
unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (read the header ⇒ `gateway/internal/connectsrv/server.go` (soft-tier fields `recClient`/`recOn`; `clientUpdateRecommended` = `min ≤ v <
`versionRecommended`), `ui/src/lib/update.svelte.ts` (nudge state), rework `ui/src/components/UpdateOverlay.svelte` recommended`, fail-open; Execute sets `X-Update-Recommended: 1` on any served response in the band via a named-return
from terminal → the `offlineVersionLocked` notice ("Update"/"Play offline"), new `ui/src/components/UpdateNudge.svelte`. `defer`; hard band + Subscribe unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (a
- Interfaces — Produces: `GATEWAY_RECOMMENDED_CLIENT_VERSION`, the `X-Update-Recommended` header, `updateRecommended` store. Connect-ES **interceptor** reads `x-update-recommended` on unary responses ⇒ `reportUpdateRecommended`),
- Acceptance: `v<min` → notice → "Play offline" plays local; `min≤v<recommended` → dismissible banner, online `ui/src/lib/update.svelte.ts` (dropped the terminal `updateRequired` latch — `reportUpdateRequired` now only emits
continues; `v≥recommended`/both empty → nothing; fail-open on a garbled header. `versionRejected`; new `updateRecommended` store + `latchUpdateNudge`/`dismissUpdateNudge`/`reportUpdateRecommended` +
- Targeted tests: Go `config_test`/`server_test` (three bands + ordering + fail-open); `e2e/update.spec.ts` shared `openUpdate`), `ui/src/lib/netstate.svelte.ts` (`registerNetNudge`; the `setNudge` effect fires it),
(notice → play offline; nudge banner). `ui/src/lib/app.svelte.ts` (`registerNetNudge(latchUpdateNudge)`), `ui/src/lib/gateway.ts` (mock `__update.recommend`),
`ui/src/lib/i18n/{en,ru}.ts` (`update.playOffline` + `update.available`). Rework: `ui/src/components/UpdateOverlay.svelte`
(terminal → dismissable notice reading `netState.versionLocked`, "Update" + "Play offline"). New:
`ui/src/components/UpdateNudge.svelte` (the soft banner, rendered **bottom of the lobby, above the tab bar** — shown only
while `online && updateRecommended.active && !dismissed`).
- Owner-chosen (interview): the hard notice keeps the **full-screen** form (now dismissable); the soft nudge is a
**bottom-of-lobby** banner (not in-game); the header rides **Execute only** (Subscribe untouched); read via a transport
interceptor; nudge dismiss is **per-session**; the nudge latches via `setNudge → registerNetNudge` (machine is the source,
"only from online"). `update.body` copy kept as-is (owner).
- Acceptance: `v<min` → notice → "Play offline" → offline lobby; `min≤v<recommended` → dismissible banner, online continues;
`v≥recommended`/both empty → nothing; fail-open on an absent/garbled header. **Verified.**
- Targeted tests: Go `config_test` (`TestLoadRecommendedClientVersion`) + `server_test` (`TestExecuteUpdateRecommended` — 3
bands + at-min/at-recommended boundaries + fail-open + recommended-alone + dormant); `e2e/update.spec.ts` rewritten (notice
+ both actions; Update reloads; Play offline → offline lobby; the soft nudge shows in the lobby + dismisses). **Full gateway
go-test/build/vet/gofmt clean; e2e 122/122 chromium + webkit; svelte-check 0/0; vitest 616.**
- **O5 — Unified lobby.** - **O5 — Unified lobby. — ✅ DONE (2026-07-13).**
- Files — Modify: `ui/src/screens/Lobby.svelte` (merge `localSource.list()` + `gateway.gamesList()`; offline ⇒ - Files (as built) — `ui/src/screens/Lobby.svelte` (`load()` merges `localSource.list()` + `gateway.gamesList()`;
local active + cached server greyed; drop the mode-exclusive `loadSeq` branch), `ui/src/lib/lobbysort.ts` online ⇒ `[...local, ...server]`; offline ⇒ `[...localFresh, ...cachedServer]` greyed; the mode-exclusive branch is
(sort the merged set), a self-identity helper (recognise `session.userId` **and** `localGuestId()`). gone; `grey(g) = offline && !isLocalGameId` drives a `.rowwrap.greyed` dim + an un-openable `openGame` that toasts
- Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache `net.offline`; **invitations hidden offline** — owner addition — via `{#if invitations.length && !offline}`), `ui/src/lib/lobbysort.ts`
(un-openable); a reconciled guest's local games stay visible online (**closes G-step-0**); TG/VK server-only. + `ui/src/lib/result.ts` (`myId: string` → `myIds: readonly string[]` self-set across `isMyTurn`/`scoreStanding`/
- Targeted tests: `e2e` unified lobby (greyed offline; both online); `lobbysort.test.ts` extended. `gamePhase`/`groupGames`/`resultBadge`; game seat-matching uses `myIds.includes(...)`), `ui/src/lib/lobbycache.ts`
(dropped the `offline` snapshot key — **one unified snapshot**; `getLobby()` no arg; offline reuses its server games),
`ui/src/screens/NewGame.svelte` (`getLobby()` + filter to server games — device-local games do not count toward the
server per-kind limit), `ui/src/lib/localguest`/`telegram`/`vk` imports in the lobby.
- **Identity split:** game functions take the self-set `myIds = [session.userId, localGuestId()]` (a reconciled
guest's local games seat you under `localGuestId`, server games under `userId`, ids never collide); **invitations
keep `session.userId`** (a server concept). **TG/VK server-only:** the local merge is gated on
`insideTelegram()`/`insideVK()` (the shared-origin local store must not leak into a mini-app).
- Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache (un-openable);
a reconciled guest's local games stay visible online (**closes G-step-0**); invitations hidden offline; TG/VK
server-only. **Verified.**
- Targeted tests: `lobbysort.test.ts` + `result.test.ts` extended (two-id "self" recognition), `lobbycache.test.ts`
reworked (unified snapshot, mode-aware tests removed); `e2e/native.spec.ts` proves the local vs_ai game stays listed
online (G-step-0); `e2e/offline.spec.ts` + `hotseat.spec.ts` + `update.spec.ts` updated for greyed-from-cache server
games offline. **Full suite 122/122 on chromium + webkit; svelte-check 0/0; vitest 617.**
- **O6 — Create flows.** - **O6 — Create flows. — ✅ DONE (2026-07-13).**
- Files — Modify: `ui/src/screens/NewGame.svelte` (with-friends online/offline segmented control, online - Files (as built) — `ui/src/screens/NewGame.svelte`: **with-friends** now carries a `friendsMode` segmented control
disabled when `netState.offline`; vs_ai already branches on the flag — verify; the dict-availability guard). (`new.playRemote` = online invite / `new.playLocal` = offline hotseat); the online segment is `disabled` when
- Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline `offlineMode.active` and an `$effect` forces `friendsMode='offline'` there (a `new.needsNetwork` note explains it);
preselected, create works; vs_ai offline→local / online→server unchanged; create disabled with a reason when the friends markup is restructured from the mode-exclusive `{:else if offlineMode.active}` chain into
the variant's dawg is unavailable offline. `{:else} <segment> {#if friendsMode==='offline'} hotseat {:else if !friends} noFriends {:else} invite`. **vs_ai
- Targeted tests: `e2e` with-friends toggle (online disabled offline); vs_ai local-vs-server by state. unchanged** (verified: `find()` still offline→`localSource.create`, online→`lobbyEnqueue`). **Dict guard** (owner-chosen
**async getDawg**): a `dawgReady`/`dictBlocked` pair — an `$effect` awaits `getDawg(guardVariant, version)` while
offline (`guardVariant` = the quick pick or the hotseat pick) and disables create + shows a `new.dictUnavailable` note
only on a **confirmed** miss (`dawgReady === false`, never while `null`/checking, so a still-warming dawg does not
false-block). `ui/src/lib/i18n/{en,ru}.ts` (`new.{playRemote,playLocal,needsNetwork,dictUnavailable}`).
- Owner-chosen (interview): the dict guard uses the **async getDawg** precheck (no reliable sync signal; native bundles
every variant so it mostly guards web offline); **guest gating unchanged** — an online guest still sees vs_ai only (no
new online-guest hotseat); a guest offline keeps its hotseat.
- Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline preselected,
create works; vs_ai offline→local / online→server unchanged; create disabled with a reason on an unavailable offline
dawg. **Verified.**
- Targeted tests: `e2e/hotseat.spec.ts` asserts the offline segment (online disabled, hotseat forced); `offline.spec.ts`
+ `native.spec.ts` exercise vs_ai-local + hotseat create through the new segment. **Full suite 122/122 on chromium +
webkit; svelte-check 0/0; vitest 617.**
- **O7 — Docs + native e2e.** - **O7 — Docs + native e2e. — ✅ DONE (2026-07-13).**
- Files — Modify: `docs/ARCHITECTURE.md` (§2 gate → two tiers + graceful degrade; §3 the net-state model), - Files (as built) — `docs/ARCHITECTURE.md`: **§2** rewrote the connectivity signal into the **net-state machine**
`docs/FUNCTIONAL.md` (+`_ru`), `docs/TESTING.md`, `deploy/README.md` (the two version vars), `ui/e2e/native.spec.ts`. (four states, implicit offline, hysteresis, self-heal, TG/VK-exempt), the version gate into the **two tiers** (hard →
- Acceptance: the F-baked docs describe both tiers + the implicit model + the unified lobby; `deploy/README` `offlineVersionLocked` + dismissable notice, *not* terminal; soft → `X-Update-Recommended` nudge), and the gate×offline
lists `GATEWAY_RECOMMENDED_CLIENT_VERSION`. rule; **§13** (PWA/offline) rewrote the offline model from the deliberate toggle/dialog to implicit detection + the
- Targeted tests: n/a (docs); the native e2e reflects the new boot/offline. **unified lobby** (greyed cached server games) + the with-friends **segment** + the dict guard. `docs/FUNCTIONAL.md`
(+`_ru` mirror): rewrote *Offline mode* (implicit, unified lobby, segment) and added a *Staying up to date* /
*«Актуальная версия»* story (Update/Play-offline notice + the soft nudge). `docs/TESTING.md`: the `netstate.test.ts`
reducer layer, the `__net`-driven offline spec, the two-tier `update.spec`, the soft-tier Go server/config tests, and
the native G-step-0 assertion. `deploy/README.md`: a **`GATEWAY_RECOMMENDED_CLIENT_VERSION`** row (soft tier;
validated ≥ `MIN`). `ui/e2e/native.spec.ts` already reflects the new boot/offline (done in O5).
- Acceptance: the docs describe both tiers + the implicit model + the unified lobby; `deploy/README` lists
`GATEWAY_RECOMMENDED_CLIENT_VERSION`. **Verified** (a full-doc sweep leaves no stale deliberate-offline / terminal /
toggle / dialog reference). Code unchanged from O6 (docs only): gateway Go green, svelte-check 0/0, vitest 617,
e2e 122/122 chromium + webkit.
**Offline-model redesign COMPLETE (O1O7).** G-step-0 is resolved — the redesign is contour-safe (both version vars empty
⇒ dormant; the wire add is additive). Next is the normal G chain: PR → `development` → contour verify → `master` → tag →
dispatch `android-build`.
--- ---
+7
View File
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
TELEGRAM_TEST_ENV=false TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL= TELEGRAM_API_BASE_URL=
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
# variables, shared across contours; in the test contour the stamped client version is a commit hash
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
GATEWAY_MIN_CLIENT_VERSION=
GATEWAY_RECOMMENDED_CLIENT_VERSION=
# --- VK Mini App ------------------------------------------------------------ # --- VK Mini App ------------------------------------------------------------
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App # The VK app's "protected key" (client_secret): the gateway verifies the Mini App
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call). # launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
+2 -1
View File
@@ -118,7 +118,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. | | `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. | | `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. | | `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | Minimum client build the gateway will serve — the native client-version gate (ARCHITECTURE.md §2). Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind. Validated at load; a non-empty unparseable value fails startup. | | `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | **Hard** tier of the client-version gate (ARCHITECTURE.md §2). Minimum client build the gateway will serve. Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind — the client then degrades to an offline "Update / Play offline" notice, not a hard lockout. Validated at load; a non-empty unparseable value fails startup. |
| `GATEWAY_RECOMMENDED_CLIENT_VERSION` | variable | _(empty)_ | **Soft** tier of the client-version gate (ARCHITECTURE.md §2). A build at or above `GATEWAY_MIN_CLIENT_VERSION` but **below** this is served normally, but every gated `Execute` response carries an additive `X-Update-Recommended: 1` header ⇒ the client shows a dismissable *update available* nudge (play continues). Empty ⇒ off. Validated at load: unparseable, or **below** `GATEWAY_MIN_CLIENT_VERSION`, fails startup. Bump it (ahead of `MIN`) to nudge upgrades before a hard cut-over. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). | | `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). | | `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
+6
View File
@@ -222,6 +222,12 @@ services:
GATEWAY_HTTP_ADDR: ":8081" GATEWAY_HTTP_ADDR: ":8081"
GATEWAY_BACKEND_HTTP_URL: http://backend:8080 GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090 GATEWAY_BACKEND_GRPC_ADDR: backend:9090
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
# Telegram auth validates against the home validator (plaintext, internal). # Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091 GATEWAY_VALIDATOR_ADDR: validator:9091
# VK Mini App auth verifies the launch-parameter signature in-process under the VK # VK Mini App auth verifies the launch-parameter signature in-process under the VK
+2
View File
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}' export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION' export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$APP_VERSION' export APP_VERSION='$APP_VERSION'
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET' export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
+65 -49
View File
@@ -100,15 +100,26 @@ dropped). Horizontal scaling is explicit future work.
auth operations are unauthenticated and return the minted token. A unary auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal) 200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes. The client treats a connectivity edge failure as surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
actions, and the transport **auto-retries with capped exponential backoff** — every op on a states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only** the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
response was lost — its button is disabled while offline and the player re-issues it on reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal. `@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: they are never fed an
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
local lobby, no transport kill switch and no device-local create paths.
**Edge hardening:** every request body on the public listener is capped at **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 `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 layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
@@ -140,18 +151,26 @@ dropped). Horizontal scaling is explicit future work.
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the (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 client's in-app stream while the app is open. Out-of-app delivery uses
platform-native push via the platform side-service. 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 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 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 — 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 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 that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
`GATEWAY_MIN_CLIENT_VERSION` at the top (before registry lookup / auth / decode), so the first online call before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
an app makes is already gated. A too-old client gets the domain envelope `result_code = "update_required"` open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
(HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`, and makes **zero** successful vars empty ⇒ off, web behaviour unchanged).
requests. The gate **fails open** (an absent or unparseable header passes) and is **dormant** until - **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
`GATEWAY_MIN_CLIENT_VERSION` is deliberately set (empty ⇒ off, so web behaviour is unchanged). The client `result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
raises a terminal *update* overlay — native → the store listing (`VITE_RUSTORE_URL`), web → reload. 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 - **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 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` reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
@@ -160,10 +179,12 @@ dropped). Horizontal scaling is explicit future work.
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that 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 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)). rollout (see [`../deploy/README.md`](../deploy/README.md)).
- **Gate × offline** (UX rule): deliberate offline uses the network kill switch, so the gate never fires - **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
while playing offline — an old install can always play local vs_ai / hotseat. The terminal *update* (implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
overlay is raised **only on a user-initiated online action**; the silent background guest reconciliation offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
(§3) swallows an `update_required` and stays a local guest rather than interrupting local play. **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 ## 3. Authentication & sessions
@@ -1369,13 +1390,18 @@ 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 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 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 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 for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with (§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those (the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
creates one through the in-browser engine — the same game screen then drives it, the robot replying **server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**. 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;
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. 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` + 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 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 (referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
@@ -1403,27 +1429,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 — 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 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 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 the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
mode** boots from the persisted session and on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session === false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag **hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
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
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served 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 `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 `no-cache` so a new deploy is picked up — both containers apply the same caching. An
+33 -23
View File
@@ -277,22 +277,28 @@ add-friend are off. AI games are **practice** — they never count toward a play
statistics. statistics.
### Offline mode ### Offline mode
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode** The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are native apps only). When it cannot reach the
disabled or hidden (the Stats tab; the *random opponent* option in New Game). server the header turns blue with an *Offline* chip and play is confined to games on the device; a
A New Game against the robot creates a device-local **vs_ai** game — it momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
plays entirely in the browser with no backend. Local games are kept on the device, visible only in connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
offline mode, and never sync to the account. So a game can be created and played with no connection, app never touches the network.
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
(once installed) launches from a precached shell even with no network. Switching **to** offline first
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
(the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches.
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one The **lobby stays unified**: your device-local games are active, while your server games are still
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
backend; local games are kept on the device and never sync to the account, so a game can be created and
played with no connection. The app quietly preloads the dictionaries for the player's enabled variants
while still online, and (once installed, or on the native app) launches from bundled/precached data even
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
a short note.
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too — keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**. rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
@@ -305,14 +311,18 @@ pass-and-play game. A game that finishes normally is saved to the offline lobby
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password, deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
so no one can wipe a game the moment they have moved. so no one can wipe a game the moment they have moved.
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch Going offline and coming back are both **automatic**. On a cold launch with no connection the app
with no connection it switches to offline for that session; when the device is online but the gateway starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with offline on its own, and restoring the network returns it online by itself. There is no dialog and no
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
the network — airplane mode — switches to offline automatically, and restoring it returns online by offline, so you are never interrupted for a hiccup.
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that ### Staying up to date
dialog — is the player's choice: it is kept, and never undone automatically. When a new client version is required, the app does not lock you out. On the **native** app a
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
required — version exists; you can update or dismiss it, and play continues either way.
### Native app (Android) ### Native app (Android)
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
+32 -23
View File
@@ -283,22 +283,27 @@ e-mail) либо ввод фразы. Активные игры форфейтя
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока. редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим ### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный **Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с **мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности приложениям). Когда оно не может достучаться
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре). до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение сети.
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек **Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле **затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает, экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
@@ -312,14 +317,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода. своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить** восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети — кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн. вас не прерывают из-за короткой икоты.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том ### Актуальная версия
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически. Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
### Нативное приложение (Android) ### Нативное приложение (Android)
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
+22 -13
View File
@@ -17,14 +17,17 @@ tests or touching CI.
- **UI** — Vitest (unit) + Playwright - **UI** — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
the FlatBuffers codecs (friend list, invitation, stats), the win-rate the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/copy/download choice, plus Playwright specs against the derivation, the GCG share/copy/download choice and the **net-state reducer**
(`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the export chooser's invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted). The finished-only visibility + its signed-URL download flow (route-intercepted). The
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game **offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
end-to-end: it forces the installed-PWA display mode, enters offline through the end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the Stats tab), self-heals back online, then creates and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
rack is deterministic and the human can tap out a precomputed opening), asserting the rack is deterministic and the human can tap out a precomputed opening), asserting the
robot's real reply and the IndexedDB replay after a reload. A local game needs a real robot's real reply and the IndexedDB replay after a reload. A local game needs a real
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
@@ -36,15 +39,21 @@ tests or touching CI.
`Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands `Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives (`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
`window.__native.reconcile()` to prove online lights up and Profile hides the Telegram/VK link buttons. `window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
The **update overlay** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove the terminal **unified lobby** (closing G-step-0) — and Profile hides the Telegram/VK link buttons.
update cover; `retry.test.ts` covers the `FailedPrecondition → update_required` mapping. The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
- **Client-version gate** (Go) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH` parse hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
(with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering; `connectsrv`'s server tests dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
assert a too-old `Execute` returns `result_code = "update_required"` (and the op handler never ran), a update_required` mapping.
too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min / unparseable header - **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
/ equal version all pass (fail-open); `config` tests reject a non-empty, unparseable parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
`GATEWAY_MIN_CLIENT_VERSION`. assert the **hard** tier — a too-old `Execute` returns `result_code = "update_required"` (and the op
handler never ran), a too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min /
unparseable header / equal version all pass (fail-open) — and the **soft** tier (`TestExecuteUpdateRecommended`):
a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
minimum.
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared - **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a `ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
real self-played 35-move game) must rasterize to a plausible PNG real self-played 35-move game) must rasterize to a plausible PNG
+8 -3
View File
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_APP_VERSION=$VITE_APP_VERSION \ VITE_APP_VERSION=$VITE_APP_VERSION \
VITE_ADS_STUB=$VITE_ADS_STUB VITE_ADS_STUB=$VITE_ADS_STUB
# Install with the lockfile first (the workspace file carries pnpm's build-script # Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
# approval for esbuild), then build. Committed src/gen/ means no codegen here. # --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
# installs (where android:assets does need sharp built).
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./ COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile --ignore-scripts
COPY ui ./ COPY ui ./
RUN pnpm build RUN pnpm build
+18 -17
View File
@@ -221,23 +221,24 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
} }
registry := transcode.NewRegistry(backend, validator, regOpts...) registry := transcode.NewRegistry(backend, validator, regOpts...)
edge := connectsrv.NewServer(connectsrv.Deps{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry, Registry: registry,
Sessions: sessions, Sessions: sessions,
Backend: backend, Backend: backend,
Limiter: limiter, Limiter: limiter,
Tracker: tracker, Tracker: tracker,
Banlist: banlist, Banlist: banlist,
Blocklist: blocklist, Blocklist: blocklist,
Honeytoken: cfg.Abuse.Honeytoken, Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret, VKAppSecret: cfg.VKAppSecret,
Hub: hub, Hub: hub,
RateLimit: cfg.RateLimit, RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval, Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger, Logger: logger,
AdminProxy: adminProxy, AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"), Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
MaxBodyBytes: cfg.MaxBodyBytes, MaxBodyBytes: cfg.MaxBodyBytes,
MinClientVersion: cfg.MinClientVersion, MinClientVersion: cfg.MinClientVersion,
RecommendedClientVersion: cfg.RecommendedClientVersion,
}) })
// Bridge the backend push stream into the fan-out hub (and the out-of-app // Bridge the backend push stream into the fan-out hub (and the out-of-app
+27 -9
View File
@@ -60,6 +60,11 @@ type Config struct {
// "update required" signal before its payload is decoded. Empty leaves the gate dormant // "update required" signal before its payload is decoded. Empty leaves the gate dormant
// (every client is served) — the default for web-only deployments. // (every client is served) — the default for web-only deployments.
MinClientVersion string MinClientVersion string
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
RecommendedClientVersion string
// RateLimit configures the in-memory anti-abuse limiter. // RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only). // Abuse configures the temporary IP ban and the honeytoken (prod-only).
@@ -232,15 +237,16 @@ func (c VKIDConfig) Enabled() bool {
func Load() (Config, error) { func Load() (Config, error) {
var err error var err error
c := Config{ c := Config{
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel), LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL), BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr), BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"), AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"), ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"), VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"), MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
VKID: VKIDConfig{ VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"), AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"), ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
@@ -344,6 +350,18 @@ func (c Config) validate() error {
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion) return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
} }
} }
if c.RecommendedClientVersion != "" {
rec, ok := clientver.Parse(c.RecommendedClientVersion)
if !ok {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
}
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
// misconfiguration (every client below min is already turned away). An empty/unparseable
// minimum imposes no lower bound, so the recommended may stand alone.
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
}
}
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) { if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED") return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
} }
+36
View File
@@ -70,6 +70,42 @@ func TestLoadMinClientVersion(t *testing.T) {
} }
} }
// TestLoadRecommendedClientVersion verifies the soft-tier config: dormant (empty) by default, a
// parseable version accepted (standing alone with no minimum, or at/above one), an unparseable one
// rejected, and a recommended below the minimum rejected.
func TestLoadRecommendedClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.RecommendedClientVersion != "" {
t.Errorf("RecommendedClientVersion = %q, want empty (soft tier dormant)", c.RecommendedClientVersion)
}
// Stands alone with no minimum configured.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.20.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid recommended version (no min): %v", err)
}
if c.RecommendedClientVersion != "v1.20.0" {
t.Errorf("RecommendedClientVersion = %q, want %q", c.RecommendedClientVersion, "v1.20.0")
}
// At or above the minimum is accepted.
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if _, err = Load(); err != nil {
t.Fatalf("Load with recommended >= min: %v", err)
}
// Below the minimum is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.10.0")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a recommended version below the minimum, got nil")
}
// Unparseable is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_RECOMMENDED_CLIENT_VERSION, got nil")
}
}
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only), // TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken. // the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) { func TestLoadAbuseDefaults(t *testing.T) {
+61 -4
View File
@@ -51,10 +51,15 @@ const honeypotHeader = "X-Scrabble-Honeypot"
// the edge can turn away a build too old to speak the current wire contract, before it decodes // the edge can turn away a build too old to speak the current wire contract, before it decodes
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe // the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as // counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
// "you must update". Both are part of the frozen wire contract (docs/ARCHITECTURE.md §2). // "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
// on a served Execute response when the client is at or above the hard minimum but below the
// recommended version, it nudges an update without failing the call. Headers are the
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
// contract (docs/ARCHITECTURE.md §2).
const ( const (
clientVersionHeader = "X-Client-Version" clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required" resultUpdateRequired = "update_required"
updateRecommendedHeader = "X-Update-Recommended"
) )
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the // Limiter classes, the `class` attribute of gateway_rate_limited_total and the
@@ -104,6 +109,12 @@ type Server struct {
minClient clientver.Version minClient clientver.Version
gateOn bool gateOn bool
// recClient is the version below which a served client is nudged to update (the soft tier);
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
// unparseable). It is independent of the hard gate.
recClient clientver.Version
recOn bool
publicPolicy ratelimit.Policy publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy emailPolicy ratelimit.Policy
@@ -148,6 +159,9 @@ type Deps struct {
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves // older X-Client-Version is turned away with "update required". Empty or unparseable leaves
// the gate dormant. // the gate dormant.
MinClientVersion string MinClientVersion string
// RecommendedClientVersion is the version below which a served client is nudged to update (the
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
RecommendedClientVersion string
} }
// NewServer constructs the edge service. // NewServer constructs the edge service.
@@ -192,6 +206,18 @@ func NewServer(d Deps) *Server {
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion)) log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
} }
} }
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
// construction; an empty value leaves the soft tier dormant.
var recClient clientver.Version
recOn := false
if d.RecommendedClientVersion != "" {
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
recClient, recOn = v, true
} else {
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
}
}
return &Server{ return &Server{
registry: d.Registry, registry: d.Registry,
sessions: d.Sessions, sessions: d.Sessions,
@@ -210,6 +236,8 @@ func NewServer(d Deps) *Server {
maxBodyBytes: maxBody, maxBodyBytes: maxBody,
minClient: minClient, minClient: minClient,
gateOn: gateOn, gateOn: gateOn,
recClient: recClient,
recOn: recOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst), publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst), userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst), emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
@@ -335,15 +363,44 @@ func (s *Server) clientTooOld(header string) bool {
return clientver.Less(v, s.minClient) return clientver.Less(v, s.minClient)
} }
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
// band — at or above the hard minimum but below the recommended version — so a served response can
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
// leaves minClient at the zero version, which no real version is below, so the band is simply
// "below recommended").
func (s *Server) clientUpdateRecommended(header string) bool {
if !s.recOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
}
// Execute runs one unary operation. Domain failures are returned in the envelope // Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing // (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors. // session, unknown type, internal) become Connect errors.
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) { func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
start := time.Now() start := time.Now()
msgType := req.Msg.GetMessageType() msgType := req.Msg.GetMessageType()
result := "internal" result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }() defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
// client at or above the hard minimum but below the recommended version is told an update is
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
// version yields no nudge.
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
defer func() {
if recommend && resp != nil {
resp.Header().Set(updateRecommendedHeader, "1")
}
}()
// The version gate rides the outermost stable layer (an HTTP header) and is checked before // The version gate rides the outermost stable layer (an HTTP header) and is checked before
// the payload is decoded, so a too-old client makes zero successful calls but sees the // the payload is decoded, so a too-old client makes zero successful calls but sees the
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2). // recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
@@ -53,6 +53,33 @@ func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc
} }
} }
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
RecommendedClientVersion: recommendedVersion,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
return client, func() {
edgeSrv.Close()
_ = backend.Close()
backendSrv.Close()
}
}
func TestExecuteGuestAuthOK(t *testing.T) { func TestExecuteGuestAuthOK(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) { client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`)) _, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
@@ -156,6 +183,54 @@ func TestExecuteVersionGate(t *testing.T) {
} }
} }
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
// and a dormant soft tier get no header.
func TestExecuteUpdateRecommended(t *testing.T) {
backend := func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
}
tests := []struct {
name string
min string
rec string
header string
wantResult string
wantHeader bool
}{
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
resp, err := client.Execute(context.Background(), req)
if err != nil {
t.Fatalf("execute: %v", err)
}
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
}
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
}
})
}
}
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with // TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which // FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
// fails Unauthenticated with no session, proving it passed the version gate). // fails Unauthenticated with no session, proving it passed the version gate).
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-network')
} }
+3
View File
@@ -4,3 +4,6 @@ project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capa
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.4.1/node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.4.1/node_modules/@capacitor/app/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/.pnpm/@capacitor+network@8.0.1_@capacitor+core@8.4.1/node_modules/@capacitor/network/android')
+12 -10
View File
@@ -1,8 +1,9 @@
import { test, expect, type Page } from './fixtures'; import { test, expect, type Page } from './fixtures';
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed // Offline pass-and-play (hotseat) is offered only in offline mode, which is now implicit (the net-state
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the // machine detects it — no toggle). The e2e drives the machine through the mock's __net hook. Standalone
// Settings offline toggle shows. // display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
// installed PWA.
async function forceStandalone(page: Page): Promise<void> { async function forceStandalone(page: Page): Promise<void> {
await page.addInitScript(() => { await page.addInitScript(() => {
const orig = window.matchMedia.bind(window); const orig = window.matchMedia.bind(window);
@@ -19,11 +20,9 @@ async function enterLobby(page: Page): Promise<void> {
} }
async function goOffline(page: Page): Promise<void> { async function goOffline(page: Page): Promise<void> {
await page.locator('button.tab').nth(2).click(); // Settings // The machine auto-detects offline (no toggle, no dialog); the lobby stays mounted and turns blue.
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click(); await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 }); await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
} }
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action). // typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
@@ -46,9 +45,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.) // randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2')); await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
// New game -> the offline mode selector now offers "with friends" (hotseat). // New game -> "with friends" offers an online (invite) / offline (hotseat) segment. Offline, the
// online segment is disabled and the offline (pass-and-play) one is forced, so the hotseat form shows.
await page.locator('button.tab').nth(0).click(); await page.locator('button.tab').nth(0).click();
await page.getByRole('button', { name: /With friends|друзьями/i }).click(); await page.getByRole('button', { name: /With friends|друзьями/i }).click();
await expect(page.getByRole('button', { name: /Invite a friend|Пригласить друга/i })).toBeDisabled();
await expect(page.locator('.hostpin')).toBeVisible();
// The player rows are disabled until the mandatory host (master) PIN is set. // The player rows are disabled until the mandatory host (master) PIN is set.
await expect(page.locator('.pname').first()).toBeDisabled(); await expect(page.locator('.pname').first()).toBeDisabled();
@@ -115,12 +117,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN. // Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
await page.getByRole('button', { name: /Back|Назад/i }).click(); await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible(); await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('.rowwrap')).toHaveCount(1); await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(1);
await page.locator('.kebab').first().click(); await page.locator('.kebab').first().click();
const del = page.locator('.rowwrap.revealed .del'); const del = page.locator('.rowwrap.revealed .del');
await expect(del).toBeVisible(); await expect(del).toBeVisible();
await del.click(); await del.click();
await typePin(page, '9999'); await typePin(page, '9999');
await expect(page.locator('.rowwrap')).toHaveCount(0); await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(0);
}); });
}); });
+7 -4
View File
@@ -63,15 +63,18 @@ test.describe('native offline-first', () => {
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3); expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
}).toPass({ timeout: 15000 }); }).toPass({ timeout: 15000 });
// Back to the lobby, then the network returns: reconciliation silently mints + adopts a server // Back to the lobby (still offline): the device-local vs_ai game (🤖) is listed. Then the network
// guest and clears the auto-offline, so online lights up — the offline tint drops, the seeded // returns: reconciliation silently mints + adopts a server guest and clears the auto-offline, so
// online game (vs Ann) appears and the Stats tab re-enables. (The local vs_ai game stays // online lights up — the offline tint drops, the seeded online game (vs Ann) appears and Stats
// device-only, so the online lobby list no longer shows it — that is the intended split.) // re-enables. The local game STAYS visible in the unified lobby alongside the server games — a
// reconciled guest keeps its device-local games (this closes G-step-0).
await page.getByRole('button', { name: /Back|Назад/i }).click(); await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible(); await expect(page.locator('button.tab').nth(2)).toBeVisible();
await expect(page.locator('.rowwrap').filter({ hasText: '🤖' })).toBeVisible(); // the local vs_ai game, listed while offline
await page.evaluate(() => (window as unknown as { __native: { reconcile(): void } }).__native.reconcile()); await page.evaluate(() => (window as unknown as { __native: { reconcile(): void } }).__native.reconcile());
await expect(page.locator('header.nav.offline')).toHaveCount(0); await expect(page.locator('header.nav.offline')).toHaveCount(0);
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible({ timeout: 15000 }); await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible({ timeout: 15000 });
await expect(page.locator('.rowwrap').filter({ hasText: '🤖' })).toBeVisible(); // still listed online — the reconciled guest keeps it (G-step-0)
await expect(page.locator('button.tab').nth(1)).toBeEnabled(); await expect(page.locator('button.tab').nth(1)).toBeEnabled();
// The native sign-in surface is guest + email only: on Profile the Telegram + VK LINK buttons are // The native sign-in surface is guest + email only: on Profile the Telegram + VK LINK buttons are
+71 -27
View File
@@ -1,8 +1,9 @@
import { test, expect, type Page } from './fixtures'; import { test, expect, type Page } from './fixtures';
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account // Offline is now implicit — the net-state machine detects it; there is no toggle. The e2e drives the
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the // machine through the mock's __net hook (the real probe watcher is inert under the mock). Standalone
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through. // display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
// installed PWA; only the `(display-mode: standalone)` query is overridden.
async function forceStandalone(page: Page): Promise<void> { async function forceStandalone(page: Page): Promise<void> {
await page.addInitScript(() => { await page.addInitScript(() => {
const orig = window.matchMedia.bind(window); const orig = window.matchMedia.bind(window);
@@ -15,25 +16,30 @@ async function forceStandalone(page: Page): Promise<void> {
// After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so // After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so
// click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes // click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes
// this deterministic: a count() sampled during the pre-login splash frame returned 0, skipped the // this deterministic.
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
async function enterLobby(page: Page): Promise<void> { async function enterLobby(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest|гост/i }).first().click(); await page.getByRole('button', { name: /guest|гост/i }).first().click();
// The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is // The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run // robust to locale, emoji variation selectors and the coachmark anchors.
// onboarding strips after it completes).
await expect(page.locator('button.tab').nth(2)).toBeVisible(); await expect(page.locator('button.tab').nth(2)).toBeVisible();
} }
// Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has // Drive the net-state machine offline / online through the mock hook (no toggle, no dialog).
// all-distinct letters, so the glyph is unambiguous). async function goOffline(page: Page): Promise<void> {
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
}
async function goOnline(page: Page): Promise<void> {
await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online());
}
// Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct).
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> { async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
await page.locator('.rack .tile', { hasText: glyph }).first().click(); await page.locator('.rack .tile', { hasText: glyph }).first().click();
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click(); await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
} }
test.describe('offline mode', () => { test.describe('offline mode (implicit)', () => {
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => { test('auto-detects offline, plays a local vs_ai game, persists across a reload', async ({ page }) => {
await forceStandalone(page); await forceStandalone(page);
await page.goto('/'); await page.goto('/');
await enterLobby(page); await enterLobby(page);
@@ -41,23 +47,22 @@ test.describe('offline mode', () => {
// Online: a seeded online game (vs Ann) is listed. // Online: a seeded online game (vs Ann) is listed.
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible(); await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
// Enter offline through the real Settings toggle (its readiness check fetches every enabled // The network drops: the machine auto-detects offline (no toggle, no dialog) — a toast announces it
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip. // and the header turns blue with the chip.
await page.locator('button.tab').nth(2).click(); await goOffline(page);
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click(); await expect(page.locator('.toast')).toContainText(/offline|соединени/i);
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 }); await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
// Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled. // The (now offline) lobby stays mounted: the server game (vs Ann) rides along GREYED from the cache
await page.getByRole('button', { name: /Back|Назад/i }).click(); // (un-openable), and the Stats tab disables.
await expect(page.locator('button.tab').nth(2)).toBeVisible(); await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
await expect(page.locator('button.tab').nth(1)).toBeDisabled(); await expect(page.locator('button.tab').nth(1)).toBeDisabled();
// Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). // Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO).
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
await page.locator('button.tab').nth(0).click(); await page.locator('button.tab').nth(0).click();
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian // The English variant's display name is "Scrabble" (Latin) in both locales — the Russian variants
// variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game. // are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
await page.locator('.variant', { hasText: 'Scrabble' }).click(); await page.locator('.variant', { hasText: 'Scrabble' }).click();
await page.locator('button.invite').click(); await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
@@ -72,8 +77,8 @@ test.describe('offline mode', () => {
await expect(page.locator('[data-cell].pending')).toHaveCount(3); await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click(); await page.locator('.make').click();
// The play commits and the local robot replies with a real move, so the board carries more than // The play commits and the local robot replies with a real move (which needs the bundled
// WAY's three tiles. // dictionary), so the board carries more than WAY's three tiles.
await expect(async () => { await expect(async () => {
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3); expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
}).toPass({ timeout: 15000 }); }).toPass({ timeout: 15000 });
@@ -83,13 +88,52 @@ test.describe('offline mode', () => {
// button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here). // button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here).
await expect(page.locator('.lock')).toBeVisible(); await expect(page.locator('.lock')).toBeVisible();
// Reload: the hash router restores the /game/<id> route and the local game replays from // Reload: the hash router restores the /game/<id> route and the local game replays from IndexedDB
// IndexedDB with every committed tile intact. // with every committed tile intact.
await page.reload(); await page.reload();
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('[data-cell].filled')).toHaveCount(filled); await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload // The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload.
// the wait was not reset by relaunching.
await expect(page.locator('.lock')).toBeVisible(); await expect(page.locator('.lock')).toBeVisible();
}); });
test('self-heals to online when the network returns', async ({ page }) => {
await forceStandalone(page);
await page.goto('/');
await enterLobby(page);
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
// Offline, then the network returns: the machine heals to online on its own (a "back online" toast),
// the offline tint drops and the online games return — no user action.
await goOffline(page);
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
await goOnline(page);
await expect(page.locator('header.nav.offline')).toHaveCount(0, { timeout: 15000 });
// Back online, the server game is active again (no longer greyed).
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
await expect(page.locator('button.tab').nth(1)).toBeEnabled();
});
test('a stale deliberate-offline pref is ignored and cleared; Settings has no toggle', async ({ page }) => {
await forceStandalone(page);
// Seed a pre-redesign deliberate-offline flag: offline is implicit now, so it must be ignored
// (nobody stuck offline) and cleared on boot.
await page.addInitScript(() => localStorage.setItem('scrabble.offlineMode', '1'));
await page.goto('/');
await enterLobby(page);
// Booted online despite the stale pref: the online game (vs Ann) shows and the header is not blue.
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('header.nav.offline')).toHaveCount(0);
// The orphaned key was cleared at boot.
expect(await page.evaluate(() => localStorage.getItem('scrabble.offlineMode'))).toBeNull();
// Settings no longer offers the offline toggle (the "Play mode" Online/Offline segment is gone),
// even in the eligible (standalone + email) context where it used to appear.
await page.locator('button.tab').nth(2).click();
await expect(page.getByRole('button', { name: /^(Offline|Оффлайн)$/ })).toHaveCount(0);
});
}); });
+48
View File
@@ -122,6 +122,54 @@ test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
}); });
test('inside Telegram (online-only), an offline signal never switches the app to offline mode', async ({
page,
}) => {
// The Telegram mini-app is online-only: the offline model (implicit offline, device-local games,
// the transport kill switch) must stay inert there. Even when the net-state machine is driven to an
// offline state, offlineMode must remain false — otherwise the chrome turns blue, the lobby greys the
// server games and, critically, a vs_ai start would create a device-local game instead of enqueuing.
await page.addInitScript((stub) => {
Object.assign(window, stub);
}, webAppStub());
await page.goto('/');
await expect(page.getByText('Your turn')).toBeVisible(); // the durable mini-app session, in the lobby
// Drive the net-state machine to an offline state through the mock hook.
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
// Because the channel is online-only, offlineMode stays inert: on the lobby the chrome never turns blue
// and no server game is greyed…
await expect(page.locator('header.nav.offline')).toHaveCount(0);
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
// …and New Game's quick match still offers the opponent choice (AI / random), which is hidden only in
// real offline mode. Its presence is the positive proof that offlineMode is false — so the vs_ai start,
// whose device-local branch is gated on offlineMode, enqueues on the server instead of creating a
// device-local game.
await page.locator('button.tab').nth(0).click();
await expect(page.locator('button.opt', { hasText: '🤖' })).toBeVisible();
});
test('inside Telegram (online-only), New Game with friends offers remote invites only', async ({
page,
}) => {
// A Telegram launch authenticates a durable account, so New Game shows the auto/with-friends selector.
// "Play with friends" must offer only the remote invite — never the online/offline segment, whose
// "Pass and play" is the device-local hotseat flow that has no place in an online-only mini-app.
await page.addInitScript((stub) => {
Object.assign(window, stub);
}, webAppStub());
await page.goto('/');
await expect(page.getByText('Your turn')).toBeVisible();
await page.locator('button.tab').nth(0).click(); // the New tab opens the create screen
await page.getByRole('button', { name: 'Play with friends' }).click();
// The online/offline segment (Invite a friend / Pass and play) is absent — only the invite form shows.
await expect(page.getByRole('button', { name: 'Pass and play' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Invite a friend' })).toHaveCount(0);
});
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => { test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA // Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked // loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
+61 -18
View File
@@ -1,34 +1,77 @@
import { expect, test } from './fixtures'; import { expect, test, type Page } from './fixtures';
// The "update required" overlay is raised in prod when the edge's client-version gate turns away a // The two-tier client-version gate (docs/ARCHITECTURE.md §2). The mock transport never produces a real
// too-old build (the update_required result_code on Execute, a Subscribe FailedPrecondition). The // version signal, so — like the maintenance overlay — the e2e drives both tiers through the
// mock transport never produces one, so — like the maintenance overlay — the e2e drives it through // window.__update hook (gateway.ts, mock-only): __update.on() raises the HARD "update required" notice
// the window.__update hook (gateway.ts, mock-only). It is app-global (mounted outside the route // (the net-state machine's offlineVersionLocked), __update.recommend() raises the SOFT "update
// blocks in App.svelte), so it shows without a session, and it is terminal (no self-clearing poll). // available" nudge.
test('update overlay covers the app when the client is too old', async ({ page }) => {
async function enterLobby(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest|гост/i }).first().click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
test('the update-required notice covers the app and offers Update or Play offline', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await expect(page.getByRole('alertdialog')).toHaveCount(0); await expect(page.getByRole('alertdialog')).toHaveCount(0);
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on()); await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const overlay = page.getByRole('alertdialog'); const notice = page.getByRole('alertdialog');
await expect(overlay).toBeVisible(); await expect(notice).toBeVisible();
// One action button (EN "Update" / RU "Обновить" depending on locale). await expect(notice.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
await expect(overlay.getByRole('button', { name: /Update|Обновить/i })).toBeVisible(); await expect(notice.getByRole('button', { name: /Play offline|Играть офлайн/i })).toBeVisible();
}); });
test('the update action reloads the web client', async ({ page }) => { test('the Update action reloads the web client', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on()); await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const overlay = page.getByRole('alertdialog'); const notice = page.getByRole('alertdialog');
await expect(overlay).toBeVisible(); await expect(notice).toBeVisible();
// On the web the action reloads the page to fetch the current client (on a native build it opens // On the web the action reloads the page to fetch the current client (a native build opens the store
// the store instead). The reload resets the terminal store, so the app re-bootstraps: the overlay // instead). The reload re-bootstraps to INITIAL online, so the notice is gone and the login is back.
// is gone and the login is back.
await Promise.all([ await Promise.all([
page.waitForEvent('load'), page.waitForEvent('load'),
overlay.getByRole('button', { name: /Update|Обновить/i }).click(), notice.getByRole('button', { name: /Update|Обновить/i }).click(),
]); ]);
await expect(page.getByRole('alertdialog')).toHaveCount(0); await expect(page.getByRole('alertdialog')).toHaveCount(0);
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
}); });
test('Play offline dismisses the notice into the offline lobby', async ({ page }) => {
await page.goto('/');
await enterLobby(page);
// Online first: the seeded online game (vs Ann) is listed.
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
// A too-old foreground call locks the version: the notice covers the (now offline) app.
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const notice = page.getByRole('alertdialog');
await expect(notice).toBeVisible();
// "Play offline" dismisses the notice and leaves the app in the offline lobby (the version lock is an
// offline state): the header is blue and the online game is gone.
await notice.getByRole('button', { name: /Play offline|Играть офлайн/i }).click();
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await expect(page.locator('header.nav.offline')).toBeVisible();
// The server game (vs Ann) rides along greyed from the cache (offline, un-openable).
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
});
test('the soft update-available nudge shows in the lobby and dismisses', async ({ page }) => {
await page.goto('/');
await enterLobby(page);
await expect(page.locator('.nudge')).toHaveCount(0);
// A served response below the recommended version carries X-Update-Recommended: the non-blocking
// nudge appears in the lobby (above the tab bar); play is unaffected — no overlay.
await page.evaluate(() => (window as unknown as { __update: { recommend(): void } }).__update.recommend());
const nudge = page.locator('.nudge');
await expect(nudge).toBeVisible();
await expect(nudge).toContainText(/Update available|Доступно обновление/i);
await expect(page.getByRole('alertdialog')).toHaveCount(0);
// Dismissing it (the ×) hides it for the session.
await nudge.getByRole('button', { name: /Close|Закрыть/i }).click();
await expect(page.locator('.nudge')).toHaveCount(0);
});
+1
View File
@@ -22,6 +22,7 @@
"@capacitor/android": "^8.4.1", "@capacitor/android": "^8.4.1",
"@capacitor/app": "^8.1.0", "@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.4.1", "@capacitor/core": "^8.4.1",
"@capacitor/network": "8.0.1",
"@connectrpc/connect": "^2.1.0", "@connectrpc/connect": "^2.1.0",
"@connectrpc/connect-web": "^2.1.0", "@connectrpc/connect-web": "^2.1.0",
"@vkontakte/vk-bridge": "^3.0.2", "@vkontakte/vk-bridge": "^3.0.2",
+12
View File
@@ -20,6 +20,9 @@ importers:
'@capacitor/core': '@capacitor/core':
specifier: ^8.4.1 specifier: ^8.4.1
version: 8.4.1 version: 8.4.1
'@capacitor/network':
specifier: 8.0.1
version: 8.0.1(@capacitor/core@8.4.1)
'@connectrpc/connect': '@connectrpc/connect':
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.1(@bufbuild/protobuf@2.12.0) version: 2.1.1(@bufbuild/protobuf@2.12.0)
@@ -638,6 +641,11 @@ packages:
'@capacitor/core@8.4.1': '@capacitor/core@8.4.1':
resolution: {integrity: sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg==} resolution: {integrity: sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg==}
'@capacitor/network@8.0.1':
resolution: {integrity: sha512-9xK/FHFmzKGanB6BdoSZOzXk8vF0OFVQSQ4PAsIrzAzLuXHryO317qy8dcHVpgxYeuZq2noI0My9z1DvVDi/9w==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@connectrpc/connect-web@2.1.1': '@connectrpc/connect-web@2.1.1':
resolution: {integrity: sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==} resolution: {integrity: sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==}
peerDependencies: peerDependencies:
@@ -4333,6 +4341,10 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@capacitor/network@8.0.1(@capacitor/core@8.4.1)':
dependencies:
'@capacitor/core': 8.4.1
'@connectrpc/connect-web@2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))': '@connectrpc/connect-web@2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))':
dependencies: dependencies:
'@bufbuild/protobuf': 2.12.0 '@bufbuild/protobuf': 2.12.0
+7 -3
View File
@@ -36,9 +36,13 @@ const DIST = 'dist';
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom // highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront // wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront
// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same // redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload // always-loaded Wallet screen — then to 130 for the offline-model redesign: the net-state machine
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget. // (`netstate`), the two-tier version gate (the transport interceptor + the Update/Play-offline notice
const BUDGET = { app: 127, shared: 31, landing: 5 }; // + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded
// transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The
// heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy
// chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 130, shared: 31, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing. // local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+1 -59
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte'; import { app, bootstrap } from './lib/app.svelte';
import { router, type RouteName } from './lib/router.svelte'; import { router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte'; import { t } from './lib/i18n/index.svelte';
import { initNativeShell } from './lib/native'; import { initNativeShell } from './lib/native';
@@ -145,21 +145,6 @@
<MaintenanceOverlay /> <MaintenanceOverlay />
<UpdateOverlay /> <UpdateOverlay />
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
keep trying to connect. bootstrap awaits this choice (app.offlinePrompt). -->
{#if app.offlinePrompt}
<div class="offprompt" role="dialog" aria-modal="true">
<div class="card">
<p class="msg">{t('offline.promptTitle')}</p>
<div class="acts">
<button class="opt primary" onclick={() => resolveOfflinePrompt(true)}>{t('offline.promptYes')}</button>
<button class="opt" onclick={() => resolveOfflinePrompt(false)}>{t('offline.promptNo')}</button>
</div>
</div>
</div>
{/if}
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
<Splash /> <Splash />
{/if} {/if}
@@ -169,49 +154,6 @@
{/if} {/if}
<style> <style>
/* The cold-start "no connection" dialog — a centred card over a scrim, above the splash. */
.offprompt {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: var(--pad);
}
.offprompt .card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 20px;
max-width: 320px;
width: 100%;
text-align: center;
}
.offprompt .msg {
margin: 0 0 16px;
font-size: 1rem;
color: var(--text);
}
.offprompt .acts {
display: flex;
flex-direction: column;
gap: 8px;
}
.offprompt .opt {
padding: 11px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 0.95rem;
}
.offprompt .opt.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.splash { .splash {
height: 100%; height: 100%;
display: grid; display: grid;
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
// The soft "update available" nudge (the soft version tier). A non-blocking banner: play continues.
// Shown only while online — the hard "update required" notice is an offline state, so it naturally
// supersedes this — and only until the user dismisses it for the session. Rendered at the bottom of
// the lobby (above the tab bar), so it never covers a game in progress.
import { netState } from '../lib/netstate.svelte';
import { updateRecommended, dismissUpdateNudge, openUpdate } from '../lib/update.svelte';
import { t } from '../lib/i18n/index.svelte';
const show = $derived(netState.online && updateRecommended.active && !updateRecommended.dismissed);
</script>
{#if show}
<div class="nudge" role="status">
<span class="msg">{t('update.available')}</span>
<button class="upd" type="button" onclick={openUpdate}>{t('update.action')}</button>
<button class="x" type="button" onclick={dismissUpdateNudge} aria-label={t('common.close')}>×</button>
</div>
{/if}
<style>
.nudge {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.9rem;
border-top: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 0.9rem;
}
.msg {
flex: 1 1 auto;
min-width: 0;
}
.upd {
flex: 0 0 auto;
font: inherit;
padding: 0.3rem 0.8rem;
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-text);
cursor: pointer;
}
.x {
flex: 0 0 auto;
font-size: 1.2rem;
line-height: 1;
padding: 0.2rem 0.4rem;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
</style>
+31 -22
View File
@@ -1,33 +1,29 @@
<script lang="ts"> <script lang="ts">
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call // The "update required" notice (the hard version tier). Shown when the gateway has refused a
// as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is // foreground call as too old, driving the net-state machine into offlineVersionLocked. Unlike the
// terminal: an installed build cannot become compatible without an actual update, so its one // old terminal cover it is dismissable: "Update" takes the user to the fix (the store on native,
// action takes the user to the fix — the store listing on a native build (VITE_RUSTORE_URL, opened // a reload on the web), while "Play offline" dismisses the notice and leaves the app in the offline
// in the system browser / store app) or a plain reload on the web (which fetches the current // lobby (the version lock is an offline state, sticky until an actual update on the next launch).
// client). Mirrors MaintenanceOverlay.svelte's look. // Mirrors MaintenanceOverlay.svelte's look.
import { updateRequired } from '../lib/update.svelte'; import { netState } from '../lib/netstate.svelte';
import { clientChannel } from '../lib/channel'; import { openUpdate } from '../lib/update.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
function onAction(): void { // Dismissed for the session once the user chooses "Play offline". The lock is sticky until a fresh
const ch = clientChannel(); // launch, so the notice does not reappear this session; the app stays in the offline lobby.
if (ch === 'android' || ch === 'ios') { let dismissed = $state(false);
// '_system' hands the URL to the OS (the store app / external browser) rather than the WebView.
const url = import.meta.env.VITE_RUSTORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
}
}
</script> </script>
{#if updateRequired.active} {#if netState.versionLocked && !dismissed}
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="update-title" aria-describedby="update-body"> <div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="update-title" aria-describedby="update-body">
<div class="card"> <div class="card">
<div class="tile" aria-hidden="true">Э</div> <div class="tile" aria-hidden="true">Э</div>
<h1 id="update-title">{t('update.title')}</h1> <h1 id="update-title">{t('update.title')}</h1>
<p id="update-body">{t('update.body')}</p> <p id="update-body">{t('update.body')}</p>
<button type="button" onclick={onAction}>{t('update.action')}</button> <div class="acts">
<button type="button" class="primary" onclick={openUpdate}>{t('update.action')}</button>
<button type="button" onclick={() => (dismissed = true)}>{t('update.playOffline')}</button>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@@ -36,8 +32,7 @@
.scrim { .scrim {
position: fixed; position: fixed;
inset: 0; inset: 0;
/* Above the game (z 60) and toasts (z 50) — a terminal update block covers everything the user /* Above the game (z 60) and toasts (z 50); below the dev DebugPanel (z 10000). */
could otherwise interact with; below the dev DebugPanel (z 10000). */
z-index: 100; z-index: 100;
background: rgba(0, 0, 0, 0.55); background: rgba(0, 0, 0, 0.55);
display: grid; display: grid;
@@ -79,6 +74,11 @@
color: var(--text-muted); color: var(--text-muted);
line-height: 1.5; line-height: 1.5;
} }
.acts {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
button { button {
font: inherit; font: inherit;
padding: 0.55rem 1.4rem; padding: 0.55rem 1.4rem;
@@ -92,4 +92,13 @@
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
} }
.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.primary:hover {
color: var(--accent-text);
opacity: 0.9;
}
</style> </style>
+55 -131
View File
@@ -56,8 +56,10 @@ import {
} from './session'; } from './session';
import type { CoachSeries } from './coachmark'; import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte'; import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
import { offlineMode, setOfflineMode } from './offline.svelte'; import { bootOffline, emit, initNetSignals, registerNetNudge, registerNetToast, registerRecovery } from './netstate.svelte';
import { shouldBootOffline } from './offline'; import { latchUpdateNudge } from './update.svelte';
import { clearOfflinePref } from './offline';
import { initNativeNetwork } from './native';
import { clientChannel } from './channel'; import { clientChannel } from './channel';
import { localGuestId } from './localguest'; import { localGuestId } from './localguest';
import { isConnectionCode } from './retry'; import { isConnectionCode } from './retry';
@@ -80,10 +82,6 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen * backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login — a Mini App has no manual sign-in to fall back to. */ * instead of the web login — a Mini App has no manual sign-in to fall back to. */
bootError: boolean; bootError: boolean;
/** True while the cold-start "no connection — go offline?" dialog is up (the reachability check
* timed out with the network interface reportedly online). App.svelte renders it over the splash;
* bootstrap awaits the choice via resolveOfflinePrompt. */
offlinePrompt: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App /** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact * launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram * launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
@@ -160,7 +158,6 @@ export const app = $state<{
}>({ }>({
ready: false, ready: false,
bootError: false, bootError: false,
offlinePrompt: false,
launchError: null, launchError: null,
debugOpen: false, debugOpen: false,
lobbyReady: false, lobbyReady: false,
@@ -574,82 +571,25 @@ async function adoptSession(s: Session): Promise<void> {
void refreshNotifications(); void refreshNotifications();
} }
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice // The cold-start reachability probe budget: how long the boot waits for the gateway to answer before
// here; App.svelte's dialog buttons call resolveOfflinePrompt. // launching from the cache into the implicit offline lobby (offline is auto-detected now — no dialog).
const BOOT_REACHABILITY_TIMEOUT_MS = 3000; const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
let offlinePromptResolve: ((goOffline: boolean) => void) | null = null;
/** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */
export function resolveOfflinePrompt(goOffline: boolean): void {
app.offlinePrompt = false;
const resolve = offlinePromptResolve;
offlinePromptResolve = null;
resolve?.(goOffline);
}
/** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */
function promptOfflineChoice(): Promise<boolean> {
app.offlinePrompt = true;
return new Promise<boolean>((resolve) => {
offlinePromptResolve = resolve;
});
}
/** /**
* initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode): * recover is the smart recovery the net-state store's watcher runs while connecting/offline: a
* losing the network interface enters offline mode automatically (session-only); regaining it * session-less native local guest reconciles a server guest (the reconcile attempt IS the reachability
* verifies the gateway is really reachable (an interface being up does not guarantee it) and returns * probe — it needs no prior session), while any cached-session launch does a cheap authenticated read.
* to online — but only if the offline was auto. A deliberate offline (the Settings toggle or the * It resolves when the gateway is reachable (the store then heals to online) and rejects to stay offline.
* cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no * bootstrap wires it to the store, which owns the poll/backoff timer and the OS-signal listeners.
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
*/ */
// While in auto-offline, poll for the network to return so the app comes back online — robust to the async function recover(): Promise<void> {
// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it const channel = clientChannel();
// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the if ((channel === 'android' || channel === 'ios') && !app.session) {
// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in await reconcileServerGuest();
// auto-offline (a deliberate offline is the player's choice) and stops on returning online. if (!app.session) throw new Error('still a local guest'); // reconcile did not adopt — stay offline
let recoveryTimer: ReturnType<typeof setTimeout> | null = null; return;
export function scheduleRecovery(delayMs: number): void {
if (recoveryTimer) {
clearTimeout(recoveryTimer);
recoveryTimer = null;
} }
// Web / native only; under the mock the e2e drives connectivity + reconciliation directly (cf. if (!(await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) throw new Error('gateway unreachable');
// initNetworkReactivity), so the poll must not race a mock reconcile to online before the spec can
// observe the offline lobby.
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock' || !offlineMode.active || !offlineMode.auto) return;
recoveryTimer = setTimeout(async () => {
recoveryTimer = null;
if (!offlineMode.active || !offlineMode.auto) return;
const interfaceUp = typeof navigator === 'undefined' || navigator.onLine;
if (interfaceUp) {
if (!app.session) {
// A native local guest has no session token for checkReachable's authenticated probe, so the
// reconciliation attempt itself is the reachability check: it mints + adopts a server guest and
// clears the auto-offline on success (a no-op off the native channel or once a session exists).
await reconcileServerGuest();
} else if (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS)) {
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
}
if (!offlineMode.active) return; // came back online
}
scheduleRecovery(4000);
}, delayMs);
}
export function initNetworkReactivity(): void {
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
window.addEventListener('offline', () => {
if (!offlineMode.active) {
setOfflineMode(true, false); // auto (session)
scheduleRecovery(4000); // poll for the network to return
}
});
// The online event, when it fires, just kicks an immediate reachability check; the poll above is
// the reliable fallback for platforms where it does not fire.
window.addEventListener('online', () => {
if (offlineMode.active && offlineMode.auto) scheduleRecovery(0);
});
} }
// A concurrency guard: the boot kick, the recovery poll and the online event can all try to reconcile // A concurrency guard: the boot kick, the recovery poll and the online event can all try to reconcile
@@ -672,9 +612,10 @@ async function reconcileServerGuest(): Promise<void> {
reconcileInFlight = true; reconcileInFlight = true;
try { try {
const s = await gateway.authGuestSilent(app.locale); const s = await gateway.authGuestSilent(app.locale);
// The gateway answered, so leave auto-offline BEFORE adopting: adoptSession's profile fetch and live // The gateway answered, so heal the machine to online BEFORE adopting: adoptSession's profile fetch
// stream ride the normal (online) transport, which the offline kill switch would otherwise refuse. // and live stream ride the normal (online) transport, which the offline kill switch would otherwise
if (offlineMode.active && offlineMode.auto) setOfflineMode(false); // refuse. emit('callOk') is idempotent when already online (a re-entrant recover after adoption).
emit('callOk');
await adoptSession(s); await adoptSession(s);
} catch { } catch {
// A too-old client (update_required, swallowed — stay a local guest, no overlay) or an unreachable // A too-old client (update_required, swallowed — stay a local guest, no overlay) or an unreachable
@@ -857,6 +798,9 @@ export async function bootstrap(): Promise<void> {
// has no real backend). Deliberately before any await, so even an early-return launch path // has no real backend). Deliberately before any await, so even an early-return launch path
// (e.g. the Telegram launch-error screen) still counts as an app open. // (e.g. the Telegram launch-error screen) still counts as an app open.
if (import.meta.env.MODE !== 'mock') startLocalEvalMetrics(); if (import.meta.env.MODE !== 'mock') startLocalEvalMetrics();
// One-time cleanup of the retired deliberate-offline preference (offline is implicit now — the key is
// never read again). Runs for every channel, before the Mini-App branches return.
clearOfflinePref();
const prefs = await loadPrefs(); const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto'; app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false; app.reduceMotion = prefs.reduceMotion ?? false;
@@ -966,37 +910,27 @@ export async function bootstrap(): Promise<void> {
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only // worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
// and skipped in the mock build; see lib/pwa.svelte. // and skipped in the mock build; see lib/pwa.svelte.
registerServiceWorker(); registerServiceWorker();
// React to mid-session network changes (e.g. flight mode) for the rest of the session. // Wire the net-state machine's signal sources for the rest of the session: the OS connectivity hints
initNetworkReactivity(); // (web navigator + native @capacitor/network), the smart recovery the store's watcher runs, and the
// offline/online toast. Reached only on web / native — the Telegram/VK branches returned above, so
// Deliberate offline mode carried over from a prior session: with no network the session adoption // those channels are never fed an offline signal and the machine stays inert (always online) there.
// + profile fetch below would hang the splash, so skip them and launch straight from the cached registerNetToast((toast) => showToast(t(toast === 'offline' ? 'net.offline' : 'net.online')));
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall registerNetNudge(latchUpdateNudge);
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and registerRecovery(recover);
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch initNetSignals();
// with no cached profile is unreachable: enabling offline requires a prior online session.) void initNativeNetwork();
if (offlineMode.active) {
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
gateway.setToken(offlineSession!.token);
app.session = offlineSession!;
app.profile = offlineProfile!;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
const saved = await loadSession(); const saved = await loadSession();
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back // A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
// from VK; capture and clear it here (it needs the restored session to link against). // from VK; capture and clear it here (it needs the restored session to link against).
const vkcb = pendingVKLink(); const vkcb = pendingVKLink();
if (saved) { if (saved) {
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to // Cold-start network auto-detect. With a cached profile to fall back on and no pending VK-link, do
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when // not hang on adoptSession's retrying profile fetch when the network is down: if the interface is
// the network is down: a device with no network interface goes offline for the session (no // down, or the gateway does not answer within a short window, launch straight from the cache into
// dialog); an interface that is up but cannot reach the gateway within a short window asks the // the implicit offline lobby (no dialog — offline is detected now). The store's recovery poll heals
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it. // to online when the network returns. Web-only reaches here (the Mini-App branches returned above),
// so isStandalone gates it.
const cachedProfile = await loadProfile(); const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email; const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
if (canOffline) { if (canOffline) {
@@ -1004,21 +938,12 @@ export async function bootstrap(): Promise<void> {
gateway.setToken(saved.token); gateway.setToken(saved.token);
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS); const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
if (!reachable) { if (!reachable) {
// No network interface → go offline for the session (no dialog); interface up but the bootOffline();
// gateway is unreachable → ambiguous, so ask. app.session = saved;
const goOffline = interfaceOffline ? true : await promptOfflineChoice(); app.profile = cachedProfile;
if (goOffline) { if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only. app.ready = true;
setOfflineMode(true, !interfaceOffline); return;
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
// watcher shows "Connecting…").
} }
} }
await adoptSession(saved); await adoptSession(saved);
@@ -1031,15 +956,14 @@ export async function bootstrap(): Promise<void> {
navigate('/'); navigate('/');
} }
} else if (clientChannel() === 'android' || clientChannel() === 'ios') { } else if (clientChannel() === 'android' || clientChannel() === 'ios') {
// Native offline-first cold boot: no cached server session, so enter as a device-local guest in a // Native offline-first cold boot: no cached server session, so enter as a device-local guest in the
// non-sticky (auto) offline mode and land straight in the lobby — never /login. reconcileServerGuest // implicit offline lobby and land straight there — never /login. bootOffline(true) drops into offline
// (kicked here and by the recovery poll) mints a server guest and clears the auto-offline once the // and kicks the recovery poll now; recover() mints + adopts a server guest and heals to online once
// gateway is reachable; until then the guest plays local vs_ai / hotseat with the APK's bundled // the gateway is reachable, and until then the guest plays local vs_ai / hotseat with the APK's
// dictionaries. Web / PWA / Telegram / VK keep the online-session rule below. // bundled dictionaries. Web / PWA / Telegram / VK keep the online-session rule below.
localGuestId(); // establish + persist the device-local identity from the very first launch localGuestId(); // establish + persist the device-local identity from the very first launch
setOfflineMode(true, false); bootOffline(true); // offline-first + kick reconciliation now, then poll until the network returns
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/'); if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
scheduleRecovery(0); // kick reconciliation now, then poll until the network returns
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') { } else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login'); navigate('/login');
} }
@@ -1424,9 +1348,9 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
navigate, navigate,
route: () => router.route.name, route: () => router.route.name,
}; };
// Drive the native offline-first reconciliation from the e2e: the recovery poll that fires it in a // Drive the native offline-first reconciliation from the e2e: the net-state store's recovery poll that
// real build is skipped under the mock (scheduleRecovery), so the spec calls this to simulate "the // fires it in a real build is inert under the mock, so the spec calls this to simulate "the network
// network returned" and prove a server guest is minted + adopted and the auto-offline clears. // returned" and prove a server guest is minted + adopted and the machine heals to online.
(window as unknown as { __native?: { reconcile(): void } }).__native = { (window as unknown as { __native?: { reconcile(): void } }).__native = {
reconcile: () => void reconcileServerGuest(), reconcile: () => void reconcileServerGuest(),
}; };
+28 -59
View File
@@ -1,80 +1,49 @@
// Global connectivity signal. `online` is false while the app is actively failing to // connection.svelte.ts is now a thin shim over the net-state store (netstate.svelte.ts, O2). It keeps
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live // the historical surface — `connection.online` plus the report/probe helpers the transport and the live
// stream dropped. The transport and the live-stream owner report transitions; the UI reads // stream call — so those consumers are unchanged while the single net-state machine drives everything
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive // underneath. `online` is true only in the fully-online machine state; a failing call or dropped stream
// actions. In mock mode nothing ever reports trouble, so it simply stays online. // reports via reportOffline, which enters the connecting hysteresis buffer once, and the store's probe
// // then decides whether it becomes offline — so a transient blip never flips the chrome.
// Recovery is guaranteed by a reachability watcher: while offline it periodically fires a
// registered probe (a lightweight read) until one succeeds, so the indicator clears even when no
// other traffic is in flight.
import { backoffMs } from './retry'; import {
import { offlineMode } from './offline.svelte'; netState,
emit,
let online = $state(true); registerProbe as registerNetProbe,
let watchTimer: ReturnType<typeof setTimeout> | null = null; checkReachable as checkNetReachable,
let probe: (() => Promise<void>) | null = null; resetNetState,
} from './netstate.svelte';
export const connection = { export const connection = {
/** online is true when the app believes it can reach the gateway. */ /** online is true when the app can reach the gateway (the fully-online machine state). */
get online(): boolean { get online(): boolean {
return online; return netState.online;
}, },
}; };
/** registerProbe installs the reachability probe the watcher fires while offline. The transport /** registerProbe installs the reachability probe the store's watcher fires while connecting/offline.
* wires a cheap authenticated read; it should reject when there is no session. */ * The transport wires a cheap authenticated read; it should reject when there is no session. */
export function registerProbe(fn: () => Promise<void>): void { export function registerProbe(fn: () => Promise<void>): void {
probe = fn; registerNetProbe(fn);
} }
/** /** checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the * gateway answered — a single attempt for the cold-start / recovery decision. */
* gateway answered — a single attempt (no retry loop), for the cold-start network decision — while export function checkReachable(timeoutMs: number): Promise<boolean> {
* updating the online signal. It reports false when no probe is registered or the timeout wins. return checkNetReachable(timeoutMs);
*/
export async function checkReachable(timeoutMs: number): Promise<boolean> {
if (!probe) return false;
try {
await Promise.race([
probe(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
]);
return true;
} catch {
return false;
}
} }
/** reportOnline marks the gateway reachable and stops the watcher. */ /** reportOnline marks the gateway reachable — a successful round-trip heals the machine to online. */
export function reportOnline(): void { export function reportOnline(): void {
online = true; emit('callOk');
if (watchTimer) {
clearTimeout(watchTimer);
watchTimer = null;
}
} }
/** reportOffline marks the gateway unreachable and starts the reachability watcher (once). */ /** reportOffline reports a failing call or dropped stream. It opens the connecting hysteresis buffer
* once (only from online); the store's probe then decides whether it becomes offline. */
export function reportOffline(): void { export function reportOffline(): void {
online = false; if (netState.online) emit('callFailed');
if (!watchTimer && probe) scheduleProbe(1);
} }
/** resetConnection restores the online state and stops the watcher (e.g. on logout). */ /** resetConnection restores the online state and stops the watcher (e.g. on logout). */
export function resetConnection(): void { export function resetConnection(): void {
reportOnline(); resetNetState();
}
function scheduleProbe(attempt: number): void {
watchTimer = setTimeout(
() => {
watchTimer = null;
// Never probe the network in offline mode (the kill switch); the online/offline events drive
// recovery there instead.
if (online || !probe || offlineMode.active) return;
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
},
backoffMs(attempt),
);
} }
-27
View File
@@ -1,27 +0,0 @@
// Browser orchestration for the offline-toggle readiness wait. Lazily imported by
// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main
// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by
// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the
// fetch running on a timeout so a later flip is instant.
import type { Profile } from '../model';
import { preloadDicts } from './preload';
import { getDawg, dictLoadingDisabled } from '../dict';
import { raceOfflineReady } from '../offline';
/**
* ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when
* already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is
* available — the offline toggle's readiness gate. With no enabled variant there is nothing to play
* offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the
* on-device cache in the background so a later flip to offline succeeds immediately.
*/
export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise<boolean> {
if (prof.variantPreferences.length === 0) return false;
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
getDawg,
disabled: dictLoadingDisabled,
retries: 1,
});
return raceOfflineReady(run, budgetMs);
}
+21 -4
View File
@@ -7,8 +7,9 @@ import { MockGateway } from './mock/client';
import { createTransport } from './transport'; import { createTransport } from './transport';
import { setForcedSeed } from './localgame/id'; import { setForcedSeed } from './localgame/id';
import { reportOffline, reportOnline } from './connection.svelte'; import { reportOffline, reportOnline } from './connection.svelte';
import { emit } from './netstate.svelte';
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte'; import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
import { reportUpdateRequired } from './update.svelte'; import { reportUpdateRecommended, reportUpdateRequired } from './update.svelte';
const isMock = import.meta.env.MODE === 'mock'; const isMock = import.meta.env.MODE === 'mock';
@@ -32,9 +33,25 @@ if (isMock && typeof window !== 'undefined') {
off: clearMaintenance, off: clearMaintenance,
recover: maintenanceRecovered, recover: maintenanceRecovered,
}; };
// The mock never receives a real update_required from the edge, so the e2e drives the terminal // The mock never receives a real version signal from the edge, so the e2e drives them directly:
// "update required" overlay directly (it is terminal — there is no clear hook). // __update.on() raises the hard-tier notice (netState.versionLocked), __update.recommend() raises the
(window as unknown as { __update?: { on(): void } }).__update = { on: reportUpdateRequired }; // soft-tier nudge (versionRecommended → the setNudge latch, from online).
(window as unknown as { __update?: { on(): void; recommend(): void } }).__update = {
on: reportUpdateRequired,
recommend: reportUpdateRecommended,
};
// The mock has no real transport, so the net-state store's probe watcher is inert; the e2e drives the
// machine directly. __net.offline() rides past the connecting hysteresis (k failures) into the offline
// lobby; __net.online() heals back to online (the "back online" toast fires from the offline state).
(window as unknown as { __net?: { offline(): void; online(): void } }).__net = {
offline: () => {
emit('callFailed');
emit('probeFailed');
emit('probeFailed');
emit('probeFailed');
},
online: () => emit('callOk'),
};
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise // Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer). // attaches a robot on a timer).
( (
+8 -7
View File
@@ -7,12 +7,16 @@ export const en = {
'app.brand': 'Erudit', 'app.brand': 'Erudit',
'dict.loading': 'Loading…', 'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…', 'connection.connecting': 'Connecting…',
'net.offline': 'You are offline',
'net.online': 'Back online',
'maintenance.title': 'Under maintenance', 'maintenance.title': 'Under maintenance',
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.', 'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
'maintenance.retry': 'Try again', 'maintenance.retry': 'Try again',
'update.title': 'Update required', 'update.title': 'Update required',
'update.body': 'This app is out of date and can no longer run. Download the update to keep playing — and winning.', 'update.body': 'This app is out of date and can no longer run. Download the update to keep playing — and winning.',
'update.action': 'Update', 'update.action': 'Update',
'update.playOffline': 'Play offline',
'update.available': 'Update available',
'blocked.title': 'Account blocked', 'blocked.title': 'Account blocked',
'blocked.permanent': 'Your account is blocked.', 'blocked.permanent': 'Your account is blocked.',
@@ -219,15 +223,8 @@ export const en = {
'settings.labelsNone': 'None', 'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion', 'settings.reduceMotion': 'Reduce motion',
'settings.zoomBoard': 'Zoom the board', 'settings.zoomBoard': 'Zoom the board',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline', 'settings.offline': 'Offline',
'settings.offlineChecking': 'Loading dictionaries…',
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.', 'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
'offline.promptTitle': 'No connection. Enable offline mode?',
'offline.promptYes': 'Enable',
'offline.promptNo': 'Keep trying',
// --- wallet --- // --- wallet ---
'wallet.title': 'Wallet', 'wallet.title': 'Wallet',
@@ -378,6 +375,10 @@ export const en = {
'new.auto': 'Quick match', 'new.auto': 'Quick match',
'new.withFriends': 'Play with friends', 'new.withFriends': 'Play with friends',
'new.playRemote': 'Invite a friend',
'new.playLocal': 'Pass and play',
'new.needsNetwork': 'Online play needs a connection.',
'new.dictUnavailable': "This dictionary isn't available offline yet.",
'new.pickFriends': 'Choose who to invite', 'new.pickFriends': 'Choose who to invite',
'new.searchFriends': 'Search friends', 'new.searchFriends': 'Search friends',
'new.gameType': 'Variant', 'new.gameType': 'Variant',
+8 -7
View File
@@ -8,12 +8,16 @@ export const ru: Record<MessageKey, string> = {
'app.brand': 'Эрудит', 'app.brand': 'Эрудит',
'dict.loading': 'Загрузка…', 'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…', 'connection.connecting': 'Подключение…',
'net.offline': 'Нет соединения',
'net.online': 'Соединение восстановлено',
'maintenance.title': 'Технические работы', 'maintenance.title': 'Технические работы',
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.', 'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
'maintenance.retry': 'Повторить', 'maintenance.retry': 'Повторить',
'update.title': 'Требуется обновление', 'update.title': 'Требуется обновление',
'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.', 'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.',
'update.action': 'Обновить', 'update.action': 'Обновить',
'update.playOffline': 'Играть офлайн',
'update.available': 'Доступно обновление',
'blocked.title': 'Учётная запись заблокирована', 'blocked.title': 'Учётная запись заблокирована',
'blocked.permanent': 'Ваша учётная запись заблокирована.', 'blocked.permanent': 'Ваша учётная запись заблокирована.',
@@ -219,15 +223,8 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsNone': 'Без текста', 'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций', 'settings.reduceMotion': 'Меньше анимаций',
'settings.zoomBoard': 'Приближать доску', 'settings.zoomBoard': 'Приближать доску',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн', 'settings.offline': 'Оффлайн',
'settings.offlineChecking': 'Загрузка словарей…',
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.', 'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
'offline.promptYes': 'Включить',
'offline.promptNo': 'Ждать сеть',
// --- wallet --- // --- wallet ---
'wallet.title': 'Кошелёк', 'wallet.title': 'Кошелёк',
@@ -378,6 +375,10 @@ export const ru: Record<MessageKey, string> = {
'new.auto': 'Быстрая игра', 'new.auto': 'Быстрая игра',
'new.withFriends': 'Игра с друзьями', 'new.withFriends': 'Игра с друзьями',
'new.playRemote': 'Пригласить друга',
'new.playLocal': 'На одном устройстве',
'new.needsNetwork': 'Для игры онлайн нужна сеть.',
'new.dictUnavailable': 'Этот словарь пока недоступен офлайн.',
'new.pickFriends': 'Кого пригласить', 'new.pickFriends': 'Кого пригласить',
'new.searchFriends': 'Поиск друзей', 'new.searchFriends': 'Поиск друзей',
'new.gameType': 'Вариант', 'new.gameType': 'Вариант',
+28 -35
View File
@@ -44,10 +44,10 @@ beforeEach(() => clearLobby());
describe('patchLobbyGame', () => { describe('patchLobbyGame', () => {
it('replaces the matching game by id and leaves the others untouched', () => { it('replaces the matching game by id and leaves the others untouched', () => {
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], offline: false }); setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
// The player's own move flipped game "a" to the opponent's turn. // The player's own move flipped game "a" to the opponent's turn.
patchLobbyGame(gameView('a', 'active', 1)); patchLobbyGame(gameView('a', 'active', 1));
const snap = getLobby(false); const snap = getLobby();
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']); expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1); expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0); expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
@@ -55,27 +55,27 @@ describe('patchLobbyGame', () => {
it('preserves invitations and incoming when patching a game', () => { it('preserves invitations and incoming when patching a game', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }]; const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('a')], invitations: [], incoming, offline: false }); setLobby({ games: [gameView('a')], invitations: [], incoming });
patchLobbyGame(gameView('a', 'finished')); patchLobbyGame(gameView('a', 'finished'));
expect(getLobby(false)?.incoming).toEqual(incoming); expect(getLobby()?.incoming).toEqual(incoming);
expect(getLobby(false)?.games[0].status).toBe('finished'); expect(getLobby()?.games[0].status).toBe('finished');
}); });
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => { it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
setLobby({ games: [gameView('a')], invitations: [], incoming: [], offline: false }); setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
patchLobbyGame(gameView('z', 'active')); patchLobbyGame(gameView('z', 'active'));
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership. // Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']); expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
}); });
it('is a no-op when there is no cached lobby yet', () => { it('is a no-op when there is no cached lobby yet', () => {
patchLobbyGame(gameView('a')); patchLobbyGame(gameView('a'));
expect(getLobby(false)).toBeNull(); expect(getLobby()).toBeNull();
}); });
it('does not mutate the previous snapshot array', () => { it('does not mutate the previous snapshot array', () => {
const games = [gameView('a', 'active', 0)]; const games = [gameView('a', 'active', 0)];
setLobby({ games, invitations: [], incoming: [], offline: false }); setLobby({ games, invitations: [], incoming: [] });
patchLobbyGame(gameView('a', 'active', 1)); patchLobbyGame(gameView('a', 'active', 1));
expect(games[0].toMove).toBe(0); // the original array/object is left intact expect(games[0].toMove).toBe(0); // the original array/object is left intact
}); });
@@ -83,59 +83,52 @@ describe('patchLobbyGame', () => {
describe('patchLobbyInvitation', () => { describe('patchLobbyInvitation', () => {
it('adds a new pending invitation to the cached lobby', () => { it('adds a new pending invitation to the cached lobby', () => {
setLobby({ games: [], invitations: [], incoming: [], offline: false }); setLobby({ games: [], invitations: [], incoming: [] });
patchLobbyInvitation(invitation('i1', 'pending')); patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']); expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
}); });
it('replaces a pending invitation already in the list (e.g. an updated response)', () => { it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false }); setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 }; const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
patchLobbyInvitation(updated); patchLobbyInvitation(updated);
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']); expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
expect(getLobby(false)?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999); expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
}); });
it('removes an invitation that reached a terminal status', () => { it('removes an invitation that reached a terminal status', () => {
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) { for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false }); setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', terminal)); patchLobbyInvitation(invitation('i1', terminal));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']); expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
} }
}); });
it('is a no-op for a terminal invitation that is not in the list', () => { it('is a no-op for a terminal invitation that is not in the list', () => {
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], offline: false }); setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', 'declined')); patchLobbyInvitation(invitation('i1', 'declined'));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']); expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
}); });
it('is a no-op when there is no cached lobby yet', () => { it('is a no-op when there is no cached lobby yet', () => {
patchLobbyInvitation(invitation('i1', 'pending')); patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)).toBeNull(); expect(getLobby()).toBeNull();
}); });
it('preserves games and incoming when patching an invitation', () => { it('preserves games and incoming when patching an invitation', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }]; const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('g1')], invitations: [], incoming, offline: false }); setLobby({ games: [gameView('g1')], invitations: [], incoming });
patchLobbyInvitation(invitation('i1', 'pending')); patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']); expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby(false)?.incoming).toEqual(incoming); expect(getLobby()?.incoming).toEqual(incoming);
}); });
}); });
describe('getLobby is mode-aware (a mode flip must not render the other modes games)', () => { describe('the unified snapshot (one lobby cache — offline reuses its server games, greyed)', () => {
it('returns the snapshot only for the mode it was stored under', () => { it('replaces the whole snapshot on each store, so the latest merged lists win', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false }); setLobby({ games: [gameView('online1')], invitations: [], incoming: [] });
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']); expect(getLobby()?.games.map((g) => g.id)).toEqual(['online1']);
// Reading it as the OTHER (offline) mode must not surface the online snapshot. setLobby({ games: [gameView('local1'), gameView('online2')], invitations: [], incoming: [] });
expect(getLobby(true)).toBeNull(); expect(getLobby()?.games.map((g) => g.id)).toEqual(['local1', 'online2']);
});
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], offline: true });
expect(getLobby(false)).toBeNull();
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
}); });
}); });
+5 -8
View File
@@ -10,18 +10,15 @@ interface LobbySnapshot {
games: GameView[]; games: GameView[];
invitations: Invitation[]; invitations: Invitation[];
incoming: AccountRef[]; incoming: AccountRef[];
// Which mode the snapshot belongs to (offline = device-local games, online = server games). The
// lobby renders it instantly only when it matches the current mode, so a mode flip never flashes —
// or lets the player open — the other mode's games before the background refresh replaces them.
offline: boolean;
} }
let snapshot: LobbySnapshot | null = null; let snapshot: LobbySnapshot | null = null;
/** getLobby returns the last lobby lists for the given mode, or null before the first load or when /** getLobby returns the last lobby lists, or null before the first load. The unified lobby caches one
* the cached snapshot belongs to the other mode (so the lobby cold-renders after a mode flip). */ * snapshot (device-local + server games merged); offline reuses its server games, greyed, and drops
export function getLobby(offline: boolean): LobbySnapshot | null { * the local ones for a fresh device read. */
return snapshot && snapshot.offline === offline ? snapshot : null; export function getLobby(): LobbySnapshot | null {
return snapshot;
} }
/** setLobby stores the latest lobby lists. */ /** setLobby stores the latest lobby lists. */
+46 -30
View File
@@ -3,6 +3,9 @@ import { gamePhase, groupGames, isMyTurn, orderedSeats, scoreStanding, shouldBli
import type { GameView, Seat } from './model'; import type { GameView, Seat } from './model';
const ME = 'me'; const ME = 'me';
// The self-id set the lobby passes (server user id + device-local guest id); ME is used as a bare seat
// id where a single seat is built, MINE where a function takes the whole "you" set.
const MINE = [ME];
const seat = (s: number, accountId: string): Seat => ({ const seat = (s: number, accountId: string): Seat => ({
seat: s, seat: s,
accountId, accountId,
@@ -41,7 +44,7 @@ describe('groupGames', () => {
game('b', 'active', 1, 100), // their turn game('b', 'active', 1, 100), // their turn
game('c', 'finished', 0, 100), game('c', 'finished', 0, 100),
], ],
ME, MINE,
{}, {},
); );
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']); expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
@@ -59,7 +62,7 @@ describe('groupGames', () => {
game('f_new', 'finished', 0, 200), game('f_new', 'finished', 0, 200),
game('f_old', 'finished', 0, 100), game('f_old', 'finished', 0, 100),
], ],
ME, MINE,
{}, {},
); );
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']); expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
@@ -77,7 +80,7 @@ describe('groupGames', () => {
game('f_unread', 'finished', 0, 100), game('f_unread', 'finished', 0, 100),
game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first
], ],
ME, MINE,
{ y_unread: true, t_unread: true, f_unread: true }, { y_unread: true, t_unread: true, f_unread: true },
); );
// Unread is the primary key, so it overrides the within-section activity order. // Unread is the primary key, so it overrides the within-section activity order.
@@ -88,9 +91,9 @@ describe('groupGames', () => {
}); });
it('isMyTurn is false for a finished game even at my seat', () => { it('isMyTurn is false for a finished game even at my seat', () => {
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false); expect(isMyTurn(game('x', 'finished', 0, 0), MINE)).toBe(false);
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true); expect(isMyTurn(game('x', 'active', 0, 0), MINE)).toBe(true);
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false); expect(isMyTurn(game('x', 'active', 1, 0), MINE)).toBe(false);
}); });
it('treats an open game (awaiting an opponent) as in progress, not finished', () => { it('treats an open game (awaiting an opponent) as in progress, not finished', () => {
@@ -99,23 +102,36 @@ describe('groupGames', () => {
game('open_mine', 'open', 0, 100), // my turn while waiting game('open_mine', 'open', 0, 100), // my turn while waiting
game('open_wait', 'open', 1, 100), // the empty seat's turn game('open_wait', 'open', 1, 100), // the empty seat's turn
], ],
ME, MINE,
{}, {},
); );
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']); expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']); expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
expect(g.finished).toEqual([]); expect(g.finished).toEqual([]);
expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true); expect(isMyTurn(game('x', 'open', 0, 0), MINE)).toBe(true);
});
it('recognises "you" under any of the self ids (local game under localGuestId, server under userId)', () => {
// After reconciliation a device-local game seats you under the local guest id and a server game
// under the user id; the two differ (ids never collide) and both must count as your turn.
const localGame: GameView = { ...game('local', 'active', 0, 100), seats: [seat(0, 'guest'), seat(1, 'robot')] };
const serverGame: GameView = { ...game('server', 'active', 0, 100), seats: [seat(0, ME), seat(1, 'opp')] };
const both = ['guest', ME];
expect(isMyTurn(localGame, both)).toBe(true);
expect(isMyTurn(serverGame, both)).toBe(true);
expect(isMyTurn(localGame, [ME])).toBe(false); // a single id misses the other source's game
const g = groupGames([localGame, serverGame], both, {});
expect(g.yourTurn.map((x) => x.id).sort()).toEqual(['local', 'server']);
}); });
}); });
describe('gamePhase', () => { describe('gamePhase', () => {
it('maps each game to its lobby bucket (open folds into mine/theirs like active)', () => { it('maps each game to its lobby bucket (open folds into mine/theirs like active)', () => {
expect(gamePhase(game('x', 'active', 0, 0), ME)).toBe('mine'); expect(gamePhase(game('x', 'active', 0, 0), MINE)).toBe('mine');
expect(gamePhase(game('x', 'active', 1, 0), ME)).toBe('theirs'); expect(gamePhase(game('x', 'active', 1, 0), MINE)).toBe('theirs');
expect(gamePhase(game('x', 'open', 0, 0), ME)).toBe('mine'); expect(gamePhase(game('x', 'open', 0, 0), MINE)).toBe('mine');
expect(gamePhase(game('x', 'open', 1, 0), ME)).toBe('theirs'); expect(gamePhase(game('x', 'open', 1, 0), MINE)).toBe('theirs');
expect(gamePhase(game('x', 'finished', 0, 0), ME)).toBe('finished'); expect(gamePhase(game('x', 'finished', 0, 0), MINE)).toBe('finished');
}); });
}); });
@@ -131,38 +147,38 @@ describe('scoreStanding', () => {
it('greens the leading viewer and reds a trailing one; the opponent stays muted', () => { it('greens the leading viewer and reds a trailing one; the opponent stays muted', () => {
const lead = withScores('active', 30, 10); const lead = withScores('active', 30, 10);
expect(scoreStanding(lead, ME, at(lead, ME))).toBe('win'); expect(scoreStanding(lead, MINE, at(lead, ME))).toBe('win');
expect(scoreStanding(lead, ME, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured expect(scoreStanding(lead, MINE, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured
const behind = withScores('active', 5, 10); const behind = withScores('active', 5, 10);
expect(scoreStanding(behind, ME, at(behind, ME))).toBe('lose'); expect(scoreStanding(behind, MINE, at(behind, ME))).toBe('lose');
expect(scoreStanding(behind, ME, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured expect(scoreStanding(behind, MINE, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured
}); });
it('greens both seats on an equal non-zero score', () => { it('greens both seats on an equal non-zero score', () => {
const tie = withScores('active', 10, 10); const tie = withScores('active', 10, 10);
expect(scoreStanding(tie, ME, at(tie, ME))).toBe('win'); expect(scoreStanding(tie, MINE, at(tie, ME))).toBe('win');
expect(scoreStanding(tie, ME, at(tie, 'opp'))).toBe('win'); expect(scoreStanding(tie, MINE, at(tie, 'opp'))).toBe('win');
}); });
it('leaves a fresh 0:0 board muted (nobody has scored yet)', () => { it('leaves a fresh 0:0 board muted (nobody has scored yet)', () => {
const fresh = withScores('active', 0, 0); const fresh = withScores('active', 0, 0);
expect(scoreStanding(fresh, ME, at(fresh, ME))).toBeNull(); expect(scoreStanding(fresh, MINE, at(fresh, ME))).toBeNull();
expect(scoreStanding(fresh, ME, at(fresh, 'opp'))).toBeNull(); expect(scoreStanding(fresh, MINE, at(fresh, 'opp'))).toBeNull();
}); });
it('is null for any non-active game (open or finished)', () => { it('is null for any non-active game (open or finished)', () => {
const fin = withScores('finished', 30, 10); const fin = withScores('finished', 30, 10);
expect(scoreStanding(fin, ME, at(fin, ME))).toBeNull(); expect(scoreStanding(fin, MINE, at(fin, ME))).toBeNull();
const op = withScores('open', 0, 0); const op = withScores('open', 0, 0);
expect(scoreStanding(op, ME, at(op, ME))).toBeNull(); expect(scoreStanding(op, MINE, at(op, ME))).toBeNull();
}); });
it('is null when the viewer is not seated or has no opponent', () => { it('is null when the viewer is not seated or has no opponent', () => {
const g = withScores('active', 30, 10); const g = withScores('active', 30, 10);
expect(scoreStanding(g, 'stranger', at(g, ME))).toBeNull(); expect(scoreStanding(g, ['stranger'], at(g, ME))).toBeNull();
const solo: GameView = { ...game('g', 'active', 0, 0), seats: [{ ...seat(0, ME), score: 9 }] }; const solo: GameView = { ...game('g', 'active', 0, 0), seats: [{ ...seat(0, ME), score: 9 }] };
expect(scoreStanding(solo, ME, solo.seats[0])).toBeNull(); expect(scoreStanding(solo, MINE, solo.seats[0])).toBeNull();
}); });
it('compares against the strongest opponent in a multiplayer game', () => { it('compares against the strongest opponent in a multiplayer game', () => {
@@ -175,8 +191,8 @@ describe('scoreStanding', () => {
{ ...seat(2, 'b'), score: 50 }, // b is ahead -> the viewer is losing { ...seat(2, 'b'), score: 50 }, // b is ahead -> the viewer is losing
], ],
}; };
expect(scoreStanding(g3, ME, at(g3, ME))).toBe('lose'); expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('lose');
expect(scoreStanding(g3, ME, at(g3, 'b'))).toBeNull(); // a leading opponent is not coloured expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull(); // a leading opponent is not coloured
}); });
it('greens every seat tied with the viewer for the lead in a multiplayer game', () => { it('greens every seat tied with the viewer for the lead in a multiplayer game', () => {
@@ -189,9 +205,9 @@ describe('scoreStanding', () => {
{ ...seat(2, 'b'), score: 20 }, // trailing -> muted { ...seat(2, 'b'), score: 20 }, // trailing -> muted
], ],
}; };
expect(scoreStanding(g3, ME, at(g3, ME))).toBe('win'); expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('win');
expect(scoreStanding(g3, ME, at(g3, 'a'))).toBe('win'); expect(scoreStanding(g3, MINE, at(g3, 'a'))).toBe('win');
expect(scoreStanding(g3, ME, at(g3, 'b'))).toBeNull(); expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull();
}); });
}); });
+15 -12
View File
@@ -5,9 +5,12 @@
import type { GameView, Seat } from './model'; import type { GameView, Seat } from './model';
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */ /** isMyTurn reports whether an active game's seat-to-move belongs to the caller. myIds carries every id
export function isMyTurn(game: GameView, myId: string): boolean { * that is "you" — the server user id and the device-local guest id — since after reconciliation a
const me = game.seats.find((s) => s.accountId === myId); * device-local game seats you under localGuestId while a server game seats you under the user id (the
* ids never collide). */
export function isMyTurn(game: GameView, myIds: readonly string[]): boolean {
const me = game.seats.find((s) => myIds.includes(s.accountId));
// 'open' (an auto-match game still awaiting an opponent) is in progress like 'active'. // 'open' (an auto-match game still awaiting an opponent) is in progress like 'active'.
return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat; return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat;
} }
@@ -21,16 +24,16 @@ export function isMyTurn(game: GameView, myId: string): boolean {
* the result emoji instead), for a fresh 0:0 board where nobody has scored yet, and when myId is * the result emoji instead), for a fresh 0:0 board where nobody has scored yet, and when myId is
* not seated against an opponent. * not seated against an opponent.
*/ */
export function scoreStanding(game: GameView, myId: string, seat: Seat): 'win' | 'lose' | null { export function scoreStanding(game: GameView, myIds: readonly string[], seat: Seat): 'win' | 'lose' | null {
if (game.status !== 'active') return null; if (game.status !== 'active') return null;
const me = game.seats.find((s) => s.accountId === myId); const me = game.seats.find((s) => myIds.includes(s.accountId));
if (!me) return null; if (!me) return null;
const others = game.seats.filter((s) => s.accountId && s.accountId !== myId).map((s) => s.score); const others = game.seats.filter((s) => s.accountId && !myIds.includes(s.accountId)).map((s) => s.score);
if (!others.length) return null; if (!others.length) return null;
const top = Math.max(me.score, ...others); const top = Math.max(me.score, ...others);
if (top === 0) return null; // nobody has scored yet: leave the 0:0 board muted, like a finished game if (top === 0) return null; // nobody has scored yet: leave the 0:0 board muted, like a finished game
const meLeads = me.score === top; const meLeads = me.score === top;
if (seat.accountId === myId) return meLeads ? 'win' : 'lose'; if (myIds.includes(seat.accountId)) return meLeads ? 'win' : 'lose';
// Another seat greens only when it ties the leading viewer at the top — the equal-score case. // Another seat greens only when it ties the leading viewer at the top — the equal-score case.
return meLeads && seat.score === top ? 'win' : null; return meLeads && seat.score === top ? 'win' : null;
} }
@@ -47,13 +50,13 @@ export function orderedSeats(game: GameView): GameView['seats'] {
export type LobbyPhase = 'mine' | 'theirs' | 'finished'; export type LobbyPhase = 'mine' | 'theirs' | 'finished';
/** /**
* gamePhase reports a game's lobby bucket for myId: 'finished' for any non-active/open status, * gamePhase reports a game's lobby bucket for myIds: 'finished' for any non-active/open status,
* else 'mine' when it is the caller's turn, else 'theirs'. It mirrors groupGames' partition and * else 'mine' when it is the caller's turn, else 'theirs'. It mirrors groupGames' partition and
* drives the per-card blink, which fires when a card transitions into 'mine' or 'finished'. * drives the per-card blink, which fires when a card transitions into 'mine' or 'finished'.
*/ */
export function gamePhase(game: GameView, myId: string): LobbyPhase { export function gamePhase(game: GameView, myIds: readonly string[]): LobbyPhase {
if (game.status !== 'active' && game.status !== 'open') return 'finished'; if (game.status !== 'active' && game.status !== 'open') return 'finished';
return isMyTurn(game, myId) ? 'mine' : 'theirs'; return isMyTurn(game, myIds) ? 'mine' : 'theirs';
} }
/** /**
@@ -81,7 +84,7 @@ export interface LobbyGroups {
*/ */
export function groupGames( export function groupGames(
games: GameView[], games: GameView[],
myId: string, myIds: readonly string[],
unread: Record<string, boolean>, unread: Record<string, boolean>,
): LobbyGroups { ): LobbyGroups {
const yourTurn: GameView[] = []; const yourTurn: GameView[] = [];
@@ -89,7 +92,7 @@ export function groupGames(
const finished: GameView[] = []; const finished: GameView[] = [];
for (const g of games) { for (const g of games) {
if (g.status !== 'active' && g.status !== 'open') finished.push(g); if (g.status !== 'active' && g.status !== 'open') finished.push(g);
else if (isMyTurn(g, myId)) yourTurn.push(g); else if (isMyTurn(g, myIds)) yourTurn.push(g);
else theirTurn.push(g); else theirTurn.push(g);
} }
// Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break. // Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break.
+23
View File
@@ -4,6 +4,7 @@
// statement is only ever reached inside the packaged native app. // statement is only ever reached inside the packaged native app.
import { clientChannel } from './channel'; import { clientChannel } from './channel';
import { emit } from './netstate.svelte';
/** /**
* initNativeShell wires native-shell behaviour that only applies inside the packaged * initNativeShell wires native-shell behaviour that only applies inside the packaged
@@ -30,3 +31,25 @@ export async function initNativeShell(atNavigationRoot: () => boolean): Promise<
} }
} }
/**
* initNativeNetwork feeds the OS connectivity hint from @capacitor/network into the net-state machine
* (osOnline / osOffline) on the packaged native app. It is a hint only — the machine's probe still
* decides whether to actually go offline or heal to online — so on the web (which keeps navigator's
* online/offline events, wired by initNetSignals) this is a no-op and never loads the plugin. Tolerates
* a missing bridge (a WebView without the Network plugin) exactly like initNativeShell.
*/
export async function initNativeNetwork(): Promise<void> {
const ch = clientChannel();
if (ch !== 'android' && ch !== 'ios') return;
try {
const { Network } = await import('@capacitor/network');
await Network.addListener('networkStatusChange', (status) => {
emit(status.connected ? 'osOnline' : 'osOffline');
});
} catch {
// No @capacitor/network bridge behind window.Capacitor (an e2e simulating native in a plain
// browser, or a WebView without the plugin): the native hint is unavailable, which is harmless —
// the gateway probe still drives the machine.
}
}
+196
View File
@@ -0,0 +1,196 @@
// The reactive net-state store (O2): the single source of truth for the detected
// connectivity/version state, running the pure reducer (netstate.ts, O1) over a `$state` snapshot and
// owning the side-effects the reducer asks for — the probe/recovery watcher, the OS-signal listeners
// and the offline/online toast. connection.svelte.ts and offline.svelte.ts are now thin derived shims
// over this module, so their ~40 consumers keep reading `connection.online` / `offlineMode.active`
// unchanged while a single machine drives them underneath.
//
// The store is a leaf module: it never imports app.svelte.ts. The pieces that need app context — the
// smart recovery (reconcile-or-reachability) and the toast text (i18n) — are injected via
// register*() hooks the bootstrap wires, which also breaks what would otherwise be an import cycle.
import { INITIAL, reduce, type Effect, type NetConfig, type NetEvent, type NetSnapshot } from './netstate';
import { backoffMs } from './retry';
const isMock = import.meta.env.MODE === 'mock';
// Production hysteresis tuning (owner-confirmed). k = 3 consecutive probe failures ⇒ offline (a single
// blip is one failure, so it rides out in `connecting`); debounceMs is the wall-clock backstop for a
// slow, spaced-out failure sequence. Both sit on top of the transport's own retry/backoff.
const cfg: NetConfig = { k: 3, debounceMs: 15000 };
// While offline, re-check reachability on this cadence (mirrors the old app.svelte.ts recovery poll).
const OFFLINE_POLL_MS = 4000;
let snap = $state<NetSnapshot>({ ...INITIAL });
/** netState exposes the reactive detected state; read the getters in markup / `$derived`. */
export const netState = {
/** state is the raw machine state. */
get state() {
return snap.state;
},
/** online is true only when the gateway is reachable and the version accepted (full features). */
get online() {
return snap.state === 'online';
},
/** connecting is true while a call/probe is failing but the anti-flap window has not elapsed. */
get connecting() {
return snap.state === 'connecting';
},
/** offline is true in either offline state (the kill switch / blue chrome / local-only lobby). */
get offline() {
return snap.state === 'offlineNoNetwork' || snap.state === 'offlineVersionLocked';
},
/** versionLocked is true when the client is below the hard minimum version (the Update notice). */
get versionLocked() {
return snap.state === 'offlineVersionLocked';
},
};
// Injected hooks (dependency inversion — see the module comment).
let probeFn: (() => Promise<void>) | null = null;
let recoverFn: (() => Promise<void>) | null = null;
let toastFn: ((toast: 'offline' | 'online') => void) | null = null;
let nudgeFn: (() => void) | null = null;
/** registerProbe installs the low-level reachability probe (a cheap authenticated read); the transport
* wires it, and checkReachable / the smart recovery use it. It should reject when there is no session. */
export function registerProbe(fn: () => Promise<void>): void {
probeFn = fn;
}
/** registerRecovery installs the smart recovery the watcher runs while connecting/offline: reconcile a
* server guest when there is no session (native), otherwise a reachability check. It resolves when the
* gateway is reachable and rejects to stay offline. The bootstrap wires it. */
export function registerRecovery(fn: () => Promise<void>): void {
recoverFn = fn;
}
/** registerNetToast installs the offline/online toast renderer (i18n text lives in app scope). */
export function registerNetToast(fn: (toast: 'offline' | 'online') => void): void {
toastFn = fn;
}
/** registerNetNudge installs the soft "update available" latch the setNudge effect fires. It runs only
* when the machine accepts a versionRecommended from online (O1), so the nudge never shows offline. */
export function registerNetNudge(fn: () => void): void {
nudgeFn = fn;
}
// The probe/recovery watcher: one self-rescheduling timer whose cadence follows the current state —
// exponential backoff while `connecting`, a fixed poll while `offlineNoNetwork`, and idle otherwise. It
// is inert under the mock (no real transport); the e2e drives transitions through the window hooks.
let timer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0;
let probing = false;
function stopTimer(): void {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
function arm(delayMs: number): void {
if (isMock) return;
stopTimer();
timer = setTimeout(runProbe, delayMs);
}
async function runProbe(): Promise<void> {
timer = null;
const st = snap.state;
// Nothing to check when online, and the version lock only exits on an app update (a fresh boot).
if (st === 'online' || st === 'offlineVersionLocked') return;
if (probing || !recoverFn) return;
probing = true;
try {
await recoverFn();
emit('probeOk');
} catch {
emit('probeFailed');
// Reschedule by the (possibly just-changed) state: keep backing off while connecting, poll while
// offline; stop once online / version-locked (the guard at the top of the next run).
if (snap.state === 'connecting') arm(backoffMs(Math.min(++attempt, 6)));
else if (snap.state === 'offlineNoNetwork') arm(OFFLINE_POLL_MS);
} finally {
probing = false;
}
}
/**
* checkReachable runs the registered reachability probe once, bounded by timeoutMs, and reports whether
* the gateway answered — a single attempt for the cold-start / recovery decision. It reports false when
* no probe is registered or the timeout wins.
*/
export async function checkReachable(timeoutMs: number): Promise<boolean> {
if (!probeFn) return false;
try {
await Promise.race([
probeFn(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
]);
return true;
} catch {
return false;
}
}
/** emit applies one event to the machine (stamping it with the current time — the reducer chose
* time-on-the-event) and runs the resulting effects. It is the single entry point every signal source
* funnels through. */
export function emit(type: NetEvent): void {
const { next, effects } = reduce(snap, { type, at: Date.now() }, cfg);
snap = next;
for (const effect of effects) applyEffect(effect);
}
function applyEffect(effect: Effect): void {
switch (effect.kind) {
case 'toast':
toastFn?.(effect.toast);
break;
case 'startProbe':
// Kick the watcher from the top of the backoff (entering connecting, or an osOnline hint).
attempt = 0;
arm(backoffMs(1));
break;
case 'showVersionNotice':
// The Update/Play-offline notice (UpdateOverlay.svelte) reads netState.versionLocked directly, so
// this effect needs no side-effect here — the state transition is the signal.
break;
case 'setNudge':
// Latch the soft "update available" nudge (update.svelte.ts, wired via registerNetNudge). Fires
// only from online (O1), so the nudge is never raised while offline / version-locked.
nudgeFn?.();
break;
}
}
/**
* bootOffline drives the machine straight into offlineNoNetwork for a known-offline launch — a native
* device-local guest with no server session, or a web cached-session launch the reachability check
* could not confirm — and arms the recovery poll. It bypasses the connecting hysteresis on purpose:
* boot is not a mid-session flap, so there is nothing to ride out. Pass kickNow to start the recovery
* poll immediately (the native reconcile) rather than after the first interval.
*/
export function bootOffline(kickNow = false): void {
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
arm(kickNow ? 0 : OFFLINE_POLL_MS);
}
/** initNetSignals wires the web connectivity hints (navigator online/offline) to the machine. The
* native @capacitor/network hint is wired separately in native.ts. A no-op under SSR and the mock (the
* e2e drives connectivity through the window hooks). */
export function initNetSignals(): void {
if (typeof window === 'undefined' || isMock) return;
window.addEventListener('offline', () => emit('osOffline'));
window.addEventListener('online', () => emit('osOnline'));
}
/** resetNetState returns the machine to online and stops the watcher (e.g. on logout). */
export function resetNetState(): void {
stopTimer();
attempt = 0;
snap = { state: 'online', fails: 0, connectingSince: 0 };
}
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect } from 'vitest';
import {
reduce,
INITIAL,
type NetConfig,
type NetEvent,
type NetInput,
type NetSnapshot,
} from './netstate';
// A deterministic config for the assertions: k = 3 (a single blip rides out; three consecutive
// failures trip offline) and a 1 s debounce window (the wall-clock anti-flap branch).
const cfg: NetConfig = { k: 3, debounceMs: 1000 };
// Event and snapshot builders keep the cases terse; `at` defaults to 0 where the debounce branch is
// irrelevant.
const e = (type: NetEvent, at = 0): NetInput => ({ type, at });
const online: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
const offline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
const locked: NetSnapshot = { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 };
const connecting = (over: Partial<NetSnapshot> = {}): NetSnapshot => ({
state: 'connecting',
fails: 0,
connectingSince: 0,
...over,
});
// fold applies a sequence of inputs, threading the snapshot and collecting every emitted effect —
// the shape most edge cases assert against.
function fold(start: NetSnapshot, inputs: NetInput[], config: NetConfig) {
let snap = start;
const effects = [];
for (const ev of inputs) {
const r = reduce(snap, ev, config);
snap = r.next;
effects.push(...r.effects);
}
return { final: snap, effects };
}
describe('reduce — transition table', () => {
it('online + callFailed → connecting (starts a probe, counts the failure)', () => {
const r = reduce(online, e('callFailed', 100), cfg);
expect(r.next).toEqual({ state: 'connecting', fails: 1, connectingSince: 100 });
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
});
it('online + osOffline → connecting (starts a probe, no failure counted — a hint)', () => {
const r = reduce(online, e('osOffline', 100), cfg);
expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
});
it('connecting + probeOk → online (silent; a blip must not thrash the chrome)', () => {
const r = reduce(connecting({ fails: 2, connectingSince: 100 }), e('probeOk', 200), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([]);
});
it('connecting + callOk → online (silent)', () => {
const r = reduce(connecting({ fails: 1, connectingSince: 100 }), e('callOk', 200), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([]);
});
it('connecting + a failure reaching K → offlineNoNetwork (toast "offline")', () => {
const r = reduce(connecting({ fails: cfg.k - 1, connectingSince: 0 }), e('probeFailed', 10), cfg);
expect(r.next).toEqual({ state: 'offlineNoNetwork', fails: 0, connectingSince: 0 });
expect(r.effects).toEqual([{ kind: 'toast', toast: 'offline' }]);
});
it('offlineNoNetwork + osOnline → stays offline, triggers a probe (a hint never flips by itself)', () => {
const r = reduce(offline, e('osOnline', 100), cfg);
expect(r.next).toEqual(offline);
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
});
it('offlineNoNetwork + probeOk → online (toast "back online")', () => {
const r = reduce(offline, e('probeOk', 100), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
it('offlineNoNetwork + callOk → online (toast "back online")', () => {
const r = reduce(offline, e('callOk', 100), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
it('any state + versionRejected → offlineVersionLocked (shows the notice once)', () => {
for (const start of [online, connecting({ fails: 1, connectingSince: 5 }), offline]) {
const r = reduce(start, e('versionRejected', 100), cfg);
expect(r.next).toEqual({ state: 'offlineVersionLocked', fails: 0, connectingSince: 0 });
expect(r.effects).toEqual([{ kind: 'showVersionNotice' }]);
}
});
it('offlineVersionLocked + versionRejected (probe still rejected) → stays locked, no repeat notice', () => {
const r = reduce(locked, e('versionRejected', 100), cfg);
expect(r.next).toEqual(locked);
expect(r.effects).toEqual([]);
});
it('online + versionRecommended → online + a nudge', () => {
const r = reduce(online, e('versionRecommended', 100), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([{ kind: 'setNudge' }]);
});
it('boot → connecting + startProbe from any state (resets the sticky lock)', () => {
for (const start of [online, offline, locked, connecting({ fails: 2, connectingSince: 5 })]) {
const r = reduce(start, e('boot', 100), cfg);
expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
}
});
});
describe('reduce — hysteresis (blip vs sustained)', () => {
it('a single fail then a success rides out inside connecting (no offline toast)', () => {
const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 200)], cfg);
expect(final).toEqual(online);
expect(effects).toEqual([{ kind: 'startProbe' }]);
});
it('K consecutive failures trip offlineNoNetwork', () => {
const { final, effects } = fold(online, [e('callFailed', 0), e('probeFailed', 10), e('probeFailed', 20)], cfg);
expect(final.state).toBe('offlineNoNetwork');
expect(effects).toContainEqual({ kind: 'toast', toast: 'offline' });
});
it('the debounce window trips offlineNoNetwork even below K', () => {
const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs + 1)], cfg);
expect(final.state).toBe('offlineNoNetwork');
});
it('a failure inside the debounce window and below K stays connecting', () => {
const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs - 1)], cfg);
expect(final.state).toBe('connecting');
expect(final.fails).toBe(1);
});
});
describe('reduce — version gate (two tiers)', () => {
it('versionRecommended sets the nudge only from online', () => {
expect(reduce(online, e('versionRecommended', 0), cfg).effects).toEqual([{ kind: 'setNudge' }]);
for (const start of [connecting({ connectingSince: 0 }), offline, locked]) {
expect(reduce(start, e('versionRecommended', 0), cfg).effects).toEqual([]);
}
});
it('versionRejected from any state locks (the hard tier), superseding a pending soft nudge', () => {
const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
expect(final.state).toBe('offlineVersionLocked');
expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
});
});
describe('reduce — edge cases (#1#12)', () => {
it('#1 rapid flap (fail→ok inside the window) never leaves connecting for offline', () => {
const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 50)], cfg);
expect(final).toEqual(online);
expect(effects).not.toContainEqual({ kind: 'toast', toast: 'offline' });
});
it('#2 cold boot, no network → offlineNoNetwork', () => {
const { final } = fold(
INITIAL,
[e('boot', 0), e('osOffline', 1), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
cfg,
);
expect(final.state).toBe('offlineNoNetwork');
});
it('#3 cold boot, gateway up but version < min → offlineVersionLocked + notice', () => {
const { final, effects } = fold(INITIAL, [e('boot', 0), e('versionRejected', 20)], cfg);
expect(final.state).toBe('offlineVersionLocked');
expect(effects).toContainEqual({ kind: 'showVersionNotice' });
});
it('#4 cold boot, session-less guest: the reconcile IS the probe — ok→online, fail→offline', () => {
expect(fold(INITIAL, [e('boot', 0), e('callOk', 20)], cfg).final).toEqual(online);
const failed = fold(
INITIAL,
[e('boot', 0), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
cfg,
);
expect(failed.final.state).toBe('offlineNoNetwork');
});
it('#5 mid-session min-version bump → next call versionRejected → locked mid-play', () => {
const r = reduce(online, e('versionRejected', 100), cfg);
expect(r.next.state).toBe('offlineVersionLocked');
expect(r.effects).toContainEqual({ kind: 'showVersionNotice' });
});
it('#6 captive portal: osOnline fires but the gateway is down → probe fails → stays offline', () => {
const { final, effects } = fold(offline, [e('osOnline', 0), e('probeFailed', 10)], cfg);
expect(final.state).toBe('offlineNoNetwork');
expect(effects).toEqual([{ kind: 'startProbe' }]);
});
it('#7 recovery race: probeOk then a queued callOk → one idempotent transition to online', () => {
const { final, effects } = fold(offline, [e('probeOk', 0), e('callOk', 1)], cfg);
expect(final).toEqual(online);
expect(effects.filter((x) => x.kind === 'toast')).toEqual([{ kind: 'toast', toast: 'online' }]);
});
it('#8 soft nudge then a hard reject → escalates to locked (notice after the nudge)', () => {
const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
expect(final.state).toBe('offlineVersionLocked');
expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
});
it('#9 offlineVersionLocked is sticky — no connectivity event exits it; only boot does', () => {
const stuck: NetEvent[] = ['callOk', 'probeOk', 'osOnline', 'osOffline', 'callFailed', 'probeFailed', 'versionRecommended'];
for (const ev of stuck) {
expect(reduce(locked, e(ev, 100), cfg).next).toEqual(locked);
}
expect(reduce(locked, e('boot', 100), cfg).next.state).toBe('connecting');
});
it('#10 offline → back online transitions cleanly (local-game persistence is O5, not the machine)', () => {
expect(fold(offline, [e('probeOk', 0)], cfg).final).toEqual(online);
});
it('#11 nothing forces the machine offline without a real failure/version signal (a stale pref cannot stick)', () => {
expect(reduce(INITIAL, e('boot', 0), cfg).next.state).toBe('connecting');
const { final } = fold(
INITIAL,
[e('boot', 0), e('callOk', 10), e('osOnline', 20), e('versionRecommended', 30)],
cfg,
);
expect(final.state).toBe('online');
});
it('#12 TG/VK inertness: with only success/hint events the machine never leaves online', () => {
const { final, effects } = fold(online, [e('callOk', 0), e('probeOk', 10), e('osOnline', 20), e('callOk', 30)], cfg);
expect(final).toEqual(online);
expect(effects).toEqual([]);
});
});
describe('reduce — purity', () => {
it('does not mutate the previous snapshot', () => {
const prev = Object.freeze<NetSnapshot>({ state: 'connecting', fails: 1, connectingSince: 5 });
expect(() => reduce(prev, e('probeFailed', 10), cfg)).not.toThrow();
expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
});
});
+214
View File
@@ -0,0 +1,214 @@
// The pure net-state machine (O1 of the offline-model redesign). A single reducer replaces the old
// two-layer connection.svelte.ts / offline.svelte.ts split: connectivity and the client-version gate
// collapse into one detected `state`, with an explicit hysteresis buffer so a brief network blip does
// not thrash the chrome. It is a pure function of (previous snapshot, event, config) — no timers, no
// storage, no reactivity — so every transition and the whole hysteresis are unit-tested in the node
// env and the machine is reusable verbatim by a future iOS shell. The reactive store, the probe timer
// and the event wiring live in O2 (netstate.svelte.ts); the effects returned here tell that store what
// to do (raise a toast, kick a probe, show the version notice, set the soft nudge).
/**
* NetState is the single detected connectivity/version state.
* - `online` — the gateway is reachable and the client version is accepted; full features. It may
* additionally carry an orthogonal dismissible "update available" nudge (the soft tier) without
* leaving this state.
* - `connecting` — a call or probe is currently failing but the anti-flap window has not elapsed;
* the chrome stays online ("Connecting…"). Transient — never a kill switch.
* - `offlineNoNetwork` — sustained unreachable (hysteresis exceeded): offline mode (blue chrome,
* local-only active lobby, transport kill switch). Self-heals when a probe or call gets through.
* - `offlineVersionLocked` — the gateway is reachable but the version is below the hard minimum:
* offline mode plus the "Update / Play offline" notice. Sticky — it exits only via an app update
* (a fresh version on the next boot).
*/
export type NetState = 'online' | 'connecting' | 'offlineNoNetwork' | 'offlineVersionLocked';
/**
* NetEvent is every signal that can drive the machine.
* - `boot` — a fresh app start (the only exit from the sticky version lock).
* - `callOk` / `callFailed` — a foreground call succeeded / failed at the transport.
* - `probeOk` / `probeFailed` — the reachability probe succeeded / failed.
* - `osOnline` / `osOffline` — an OS connectivity hint (navigator / @capacitor/network); a hint only
* triggers a probe, it never decides the state by itself.
* - `versionRejected` — the hard gate refused the client as too old (update_required).
* - `versionRecommended` — the soft gate signalled an available (non-blocking) update.
*/
export type NetEvent =
| 'boot'
| 'callOk'
| 'callFailed'
| 'probeOk'
| 'probeFailed'
| 'osOnline'
| 'osOffline'
| 'versionRejected'
| 'versionRecommended';
/** NetConfig tunes the anti-flap hysteresis. */
export interface NetConfig {
/**
* k is the consecutive-failure threshold that trips `connecting` → `offlineNoNetwork`. It must be
* at least 2 so a single blip (one failure then a success) rides out inside `connecting`.
*/
k: number;
/**
* debounceMs is the wall-clock anti-flap window (the design's `OFFLINE_DEBOUNCE_MS`): a failure
* whose timestamp is at least this far past the moment `connecting` was entered also trips
* `offlineNoNetwork`, even below k. This bounds how long a slow, spaced-out failure sequence can
* hold the chrome in "Connecting…".
*/
debounceMs: number;
}
/**
* NetSnapshot is the machine's full memory. Beyond the visible `state` it carries the hysteresis
* bookkeeping (`fails`, `connectingSince`) so the reducer stays pure while still deciding both the
* count-based and the time-based anti-flap branches; both are zero outside `connecting`.
*/
export interface NetSnapshot {
/** state is the current detected state. */
state: NetState;
/** fails counts consecutive failure signals accrued while `connecting` (0 in every other state). */
fails: number;
/**
* connectingSince is the timestamp `connecting` was entered; the debounce window is measured from
* it (0 while not `connecting`).
*/
connectingSince: number;
}
/** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch
* reads `at`, and callers stamp `Date.now()`. */
export interface NetInput {
/** type is the event. */
type: NetEvent;
/** at is the event's timestamp in milliseconds. */
at: number;
}
/**
* Effect is a side-effect the reducer asks the O2 store to perform. It is data, not an action — the
* reducer never runs it.
* - `toast` — show the offline / back-online toast.
* - `startProbe` — kick the reachability probe (entering `connecting`, or on an `osOnline` hint).
* - `showVersionNotice` — raise the "Update / Play offline" notice (entering the version lock).
* - `setNudge` — latch the dismissible "update available" soft nudge.
*/
export type Effect =
| { kind: 'toast'; toast: 'offline' | 'online' }
| { kind: 'startProbe' }
| { kind: 'showVersionNotice' }
| { kind: 'setNudge' };
/** NetResult is a reduction: the next snapshot plus the effects the store should perform. */
export interface NetResult {
/** next is the snapshot after applying the event. */
next: NetSnapshot;
/** effects are the side-effects the O2 store should run, in order. */
effects: Effect[];
}
/**
* INITIAL is the optimistic pre-boot snapshot: `online` until proven otherwise. The O2 store emits a
* `boot` event on start, which immediately moves the machine to `connecting` and kicks a probe.
*/
export const INITIAL: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
function onlineSnapshot(): NetSnapshot {
return { state: 'online', fails: 0, connectingSince: 0 };
}
function enterConnecting(at: number, fails: number): NetResult {
return { next: { state: 'connecting', fails, connectingSince: at }, effects: [{ kind: 'startProbe' }] };
}
/**
* reduce applies one event to the machine and returns the next snapshot plus the effects the store
* should perform. It is a pure function: it never mutates `prev`, reads the clock, or runs an effect.
*
* `boot` and `versionRejected` are handled first because they fire from any state: `boot` resets the
* machine (the sole exit from the sticky version lock), and `versionRejected` (the hard tier) always
* supersedes into `offlineVersionLocked`.
*/
export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResult {
// A fresh app start: reachability is unknown, so enter `connecting` and kick a probe; the probe /
// version result then resolves online, offlineNoNetwork or offlineVersionLocked. This is the only
// transition that leaves the sticky version lock (a possibly-newer build).
if (ev.type === 'boot') {
return enterConnecting(ev.at, 0);
}
// The hard version gate supersedes everything and is sticky. The notice is raised once, on entry;
// a repeated rejection while already locked changes nothing.
if (ev.type === 'versionRejected') {
const effects: Effect[] = prev.state === 'offlineVersionLocked' ? [] : [{ kind: 'showVersionNotice' }];
return { next: { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 }, effects };
}
switch (prev.state) {
case 'offlineVersionLocked':
// Sticky: no connectivity event exits it. A probe may reach the gateway, but real calls stay
// version-gated, so only `boot` / `versionRejected` (both handled above) touch this state.
return { next: prev, effects: [] };
case 'online':
switch (ev.type) {
case 'callFailed':
case 'probeFailed':
// A real failure opens the hysteresis window (probe + timer); this first failure counts.
return enterConnecting(ev.at, 1);
case 'osOffline':
// An OS hint only opens the window and a probe — it never decides offline by itself, so it
// enters `connecting` with no failure counted.
return enterConnecting(ev.at, 0);
case 'versionRecommended':
// The soft tier nudges only from online; play continues.
return { next: prev, effects: [{ kind: 'setNudge' }] };
default:
// callOk / probeOk / osOnline: already online — idempotent, no effect.
return { next: prev, effects: [] };
}
case 'connecting':
switch (ev.type) {
case 'callOk':
case 'probeOk':
// Recovery is immediate on the first success — a blip resolves silently (no toast).
return { next: onlineSnapshot(), effects: [] };
case 'callFailed':
case 'probeFailed': {
const fails = prev.fails + 1;
const sustained = fails >= cfg.k || ev.at - prev.connectingSince >= cfg.debounceMs;
if (sustained) {
return {
next: { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 },
effects: [{ kind: 'toast', toast: 'offline' }],
};
}
return { next: { ...prev, fails }, effects: [] };
}
case 'osOnline':
// A positive hint triggers an immediate probe but never flips to online by itself.
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// osOffline (already probing) / versionRecommended (no nudge outside online): no-op.
return { next: prev, effects: [] };
}
case 'offlineNoNetwork':
switch (ev.type) {
case 'callOk':
case 'probeOk':
// The probe (or a reconcile call) won — self-heal to online with a "back online" toast.
return { next: onlineSnapshot(), effects: [{ kind: 'toast', toast: 'online' }] };
case 'osOnline':
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// callFailed / probeFailed / osOffline (still offline), versionRecommended (no nudge): no-op.
return { next: prev, effects: [] };
}
}
// Unreachable: the switch above is exhaustive over NetState. Kept so the function is total.
return { next: prev, effects: [] };
}
+31 -60
View File
@@ -1,68 +1,39 @@
// The deliberate offline MODE: a sticky, device-scoped reactive flag the app reads to gate the // offline.svelte.ts: the offline-mode read surface (a thin shim over the net-state store,
// network, tint the chrome blue and show only local games. It is the player's own choice (the // netstate.svelte.ts) plus the orthogonal dictionary-preload warning + background preload. Offline is
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient // implicit — detected by the net-state machine — so offlineMode.active just derives from netState; there
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts. // is no deliberate toggle. The dict-preload pieces are unrelated to connectivity and stay live here.
import { loadOfflinePref, saveOfflinePref, offlinePreloadEligible } from './offline'; import { netState } from './netstate.svelte';
import { offlinePreloadEligible } from './offline';
import { isStandalone } from './pwa'; import { isStandalone } from './pwa';
import { insideTelegram } from './telegram'; import { insideTelegram } from './telegram';
import { insideVK } from './vk'; import { insideVK } from './vk';
import type { Profile } from './model'; import type { Profile } from './model';
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription). /**
let active = $state(loadOfflinePref()); * offlineCapable reports whether this channel supports the offline model — implicit offline mode,
// True while the current offline state was entered automatically (no network detected), not chosen * device-local games (vs_ai and hotseat) and the transport kill switch. Native builds and the plain web
// by the player. An auto offline self-heals to online when the network returns; a deliberate one is * app do; the Telegram and VK mini-apps are online-only wrappers, so the whole offline model stays inert
// left as the player's choice. * there (offlineMode.active is forced false regardless of the detected net state, and the create flow
let auto = $state(false); * hides its device-local segment).
*/
export function offlineCapable(): boolean {
return !insideTelegram() && !insideVK();
}
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */ /** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
export const offlineMode = { export const offlineMode = {
/** active is true while the app is in offline mode (deliberate or auto-detected). */ /** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked) on a
* channel that supports offline play; it is always false in the online-only Telegram/VK mini-apps, so
* every offline-model consumer (chrome, lobby, kill switch, device-local create) stays inert there. */
get active(): boolean { get active(): boolean {
return active; return netState.offline && offlineCapable();
},
/** auto is true while offline was entered automatically (no network), not by the player. */
get auto(): boolean {
return auto;
}, },
}; };
/** // The dict-preload warning: true when a first-lobby background preload could not fetch every enabled
* setOfflineMode enters or leaves offline mode. By default it persists the choice (device-scoped) — // variant's dictionary (typically a poor connection), so offline play may be incomplete. The lobby
* a deliberate choice (the Settings toggle, or the cold-start "no connection" dialog). Pass // shows a notice in place of the ad banner while it holds.
* persist=false for a transient, auto-detected offline (a cold start with no network interface): the
* flag holds for the session but is not saved, so the next launch re-evaluates the network.
*/
export function setOfflineMode(on: boolean, persist = true): void {
active = on;
auto = on && !persist; // a non-persisted offline is auto-detected; a persisted one is deliberate
if (persist) saveOfflinePref(on);
}
/** The toggle-flip readiness wait: entering offline waits at most this long for the enabled
* variants' dictionaries before reverting to online (the fetch then continues in the background). */
export const TOGGLE_READY_BUDGET_MS = 5000;
/**
* requestOffline attempts to enter offline mode from the Settings toggle. It fetches the enabled
* variants' dictionaries cache-first and, if every one is available within the readiness budget,
* switches to a deliberate (persisted) offline mode and returns true. Otherwise it stays online and
* returns false — the caller shows the "needs internet" note — while the fetch keeps warming the
* cache so a later flip is instant. The readiness glue and the dict loader are imported dynamically,
* so neither is pulled into the main bundle. Returns false with no profile.
*/
export async function requestOffline(prof: Profile | null, budgetMs = TOGGLE_READY_BUDGET_MS): Promise<boolean> {
if (!prof) return false;
const m = await import('./dict/offlineready');
const ready = await m.ensureOfflineDicts(prof, budgetMs);
if (ready) setOfflineMode(true);
return ready;
}
// The dict-preload warning: true when a first-lobby background preload could not fetch every
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
// The lobby shows a notice in place of the ad banner while it holds.
let preloadWarn = $state(false); let preloadWarn = $state(false);
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */ /** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
@@ -78,18 +49,18 @@ export function setDictPreloadWarning(on: boolean): void {
preloadWarn = on; preloadWarn = on;
} }
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby // A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby mount or
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack. // a variant-preference change) can start another. Module-scoped so mounts do not stack.
let preloadInFlight = false; let preloadInFlight = false;
/** /**
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an * kickDictPreload starts a background preload of the enabled variants' dictionaries for an
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later * offline-capable install (a standalone web PWA with a confirmed email) while online, so a later switch
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain * to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain browser tab,
* browser tab, without a confirmed email, while offline, or when a preload is already running; * without a confirmed email, while offline, or when a preload is already running; getDawg's caching
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a * makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a fetch failure raises the
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader * in-lobby notice, and a later successful run clears it. The dict loader and generator are imported
* and generator are imported dynamically, so neither is pulled into the main bundle. * dynamically, so neither is pulled into the main bundle.
*/ */
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void { export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
if (preloadInFlight || !prof) return; if (preloadInFlight || !prof) return;
+9 -51
View File
@@ -1,8 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline'; import { clearOfflinePref, offlinePreloadEligible } from './offline';
import type { Variant } from './model';
// A minimal in-memory localStorage for the persistence tests (node has none). // A minimal in-memory localStorage for the cleanup test (node has none).
beforeEach(() => { beforeEach(() => {
const store = new Map<string, string>(); const store = new Map<string, string>();
(globalThis as unknown as { localStorage: Storage }).localStorage = { (globalThis as unknown as { localStorage: Storage }).localStorage = {
@@ -15,27 +14,7 @@ beforeEach(() => {
} as Storage; } as Storage;
}); });
describe('offline mode helpers', () => { describe('offline helpers', () => {
it('persists and reads the device-scoped offline flag', () => {
expect(loadOfflinePref()).toBe(false);
saveOfflinePref(true);
expect(loadOfflinePref()).toBe(true);
saveOfflinePref(false);
expect(loadOfflinePref()).toBe(false);
});
it('offlineReady requires every enabled variant to have a dictionary', () => {
const has = (v: Variant): boolean => v !== 'erudit_ru';
expect(offlineReady(['scrabble_en', 'scrabble_ru'], has)).toBe(true);
expect(offlineReady(['scrabble_en', 'erudit_ru'], has)).toBe(false);
expect(offlineReady([], has)).toBe(false);
});
it('missingDicts lists the enabled variants without a dictionary', () => {
const has = (v: Variant): boolean => v === 'scrabble_en';
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
});
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => { it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true }; const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
expect(offlinePreloadEligible(base)).toBe(true); expect(offlinePreloadEligible(base)).toBe(true);
@@ -46,32 +25,11 @@ describe('offline mode helpers', () => {
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false); expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
}); });
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => { it('clearOfflinePref removes the retired deliberate-offline key (best-effort, idempotent)', () => {
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true); localStorage.setItem('scrabble.offlineMode', '1');
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false); clearOfflinePref();
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false); expect(localStorage.getItem('scrabble.offlineMode')).toBeNull();
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false); // Idempotent: a second call on an already-clear key is a no-op, not an error.
}); expect(() => clearOfflinePref()).not.toThrow();
});
describe('raceOfflineReady (toggle-flip readiness wait)', () => {
// A sleep that never elapses: the fetch always wins the race, exercising its resolution.
const never = (): Promise<void> => new Promise<void>(() => {});
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
const now = (): Promise<void> => Promise.resolve();
it('is ready when the fetch resolves with nothing failed before the budget', async () => {
const run = Promise.resolve({ failed: [] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(true);
});
it('is not ready when a variant is still missing after the fetch', async () => {
const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(false);
});
it('is not ready when the budget elapses before the fetch resolves', async () => {
const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves
expect(await raceOfflineReady(run, 5000, now)).toBe(false);
}); });
}); });
+17 -68
View File
@@ -1,70 +1,29 @@
// Pure helpers for the deliberate offline MODE — its device-scoped persistence and the readiness // Pure helpers for offline-mode support, kept out of the reactive module (offline.svelte.ts) so they
// decision — kept out of the reactive module (offline.svelte.ts) so they unit-test in the node env. // unit-test in the node env. Offline became implicit — the net-state machine detects connectivity, and
// The deliberate offline mode is distinct from connection.svelte.ts's transient "can we reach the // there is no deliberate toggle — so only the background dict-preload eligibility check and a one-time
// gateway" signal: it is the player's own sticky choice, and it gates the network, tints the chrome // cleanup of the retired deliberate-offline preference remain here.
// and shows only local games.
import type { Variant } from './model';
const STORAGE_KEY = 'scrabble.offlineMode'; const STORAGE_KEY = 'scrabble.offlineMode';
/** loadOfflinePref reads the persisted offline-mode flag (device-scoped); false when unset or when /**
* storage is unavailable, so a device that cannot persist simply starts online. */ * clearOfflinePref removes the retired deliberate-offline preference key. The offline model is implicit
export function loadOfflinePref(): boolean { * now — the app boots online and the machine detects offline — so a persisted flag from a pre-redesign
* install is orphaned data that is never read again; boot clears it once (best-effort) so nobody is left
* with a dead key.
*/
export function clearOfflinePref(): void {
try { try {
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'; if (typeof localStorage !== 'undefined') localStorage.removeItem(STORAGE_KEY);
} catch { } catch {
return false; /* best-effort — a storage failure just leaves the orphaned key, which is never read anyway */
}
}
/** saveOfflinePref persists the offline-mode flag (best-effort). */
export function saveOfflinePref(on: boolean): void {
try {
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
} catch {
/* best-effort — a failed persist just reverts to online on the next launch */
} }
} }
/** /**
* offlineReady reports whether the device can play offline right now: at least one variant is * offlinePreloadEligible reports whether a background dictionary preload should run in this context: an
* enabled and every enabled variant's dictionary is already available on the device (hasDict). The * installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab) with a confirmed
* offline toggle uses it to decide whether flipping to offline can succeed immediately. * email, currently online. Elsewhere the preload is wasted bandwidth — the context has no offline play
*/ * to prepare for — so kickDictPreload skips it.
export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): boolean {
return enabled.length > 0 && enabled.every((v) => hasDict(v));
}
/** missingDicts lists the enabled variants whose dictionary is not yet available — the ones the
* toggle must fetch (or wait on) before offline mode can be entered. */
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
return enabled.filter((v) => !hasDict(v));
}
/**
* raceOfflineReady runs the dictionary fetch `run` against a `budgetMs` wait and reports whether
* offline mode can be entered now: ready only when the fetch resolves with nothing still failed
* before the budget elapses. On a timeout the caller stops waiting but does NOT abort `run` — it
* keeps warming the on-device cache so a later flip to offline is instant. The sleep is injected so
* the logic stays pure and unit-tests in the node env.
*/
export async function raceOfflineReady(
run: Promise<{ failed: readonly unknown[] }>,
budgetMs: number,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
): Promise<boolean> {
const elapsed = sleep(budgetMs).then(() => null);
const res = await Promise.race([run, elapsed]);
return res !== null && res.failed.length === 0;
}
/**
* offlinePreloadEligible reports whether a background dictionary preload should run in this
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
* with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth — the context
* has no offline mode to prepare for — so kickDictPreload skips it. Mirrors the Settings offline
* toggle's eligibility so the two never disagree.
*/ */
export function offlinePreloadEligible(opts: { export function offlinePreloadEligible(opts: {
hasEmail: boolean; hasEmail: boolean;
@@ -75,13 +34,3 @@ export function offlinePreloadEligible(opts: {
}): boolean { }): boolean {
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online; return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
} }
/**
* shouldBootOffline decides whether a cold start skips the network and launches straight into
* offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached
* session and profile to start from. Without both (e.g. cleared storage, or a never-online install),
* the app must boot online once first — the caller then drops the sticky flag and continues online.
*/
export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean {
return opts.offlineActive && opts.hasSession && opts.hasProfile;
}
+21 -13
View File
@@ -34,28 +34,36 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
} }
describe('resultBadge', () => { describe('resultBadge', () => {
it('recognises "you" under any of the self ids (a device-local game seats you under the guest id)', () => {
// myIds carries both the server user id and the device-local guest id, so the badge resolves the
// right seat regardless of which one seated you.
const local = game([seat(0, 'guest', 5), seat(1, 'robot', 3)], 'active', 0);
expect(resultBadge(local, ['guest', 'me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...local, toMove: 1 }, ['guest', 'me']).key).toBe('result.oppMove');
});
it('active: your move vs opponent', () => { it('active: your move vs opponent', () => {
const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0); const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' }); expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove'); expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
}); });
it('open (awaiting an opponent) reads as in-progress, not a finished result', () => { it('open (awaiting an opponent) reads as in-progress, not a finished result', () => {
const g = game([seat(0, 'me', 0), seat(1, '', 0)], 'open', 0); const g = game([seat(0, 'me', 0), seat(1, '', 0)], 'open', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' }); expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove'); expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
}); });
it('finished two-player: victory / defeat / draw', () => { it('finished two-player: victory / defeat / draw', () => {
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({ expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), ['me'])).toEqual({
key: 'result.victory', key: 'result.victory',
emoji: '🏆', emoji: '🏆',
}); });
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), 'me')).toEqual({ expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), ['me'])).toEqual({
key: 'result.defeat', key: 'result.defeat',
emoji: '🥈', emoji: '🥈',
}); });
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), 'me')).toEqual({ expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), ['me'])).toEqual({
key: 'result.draw', key: 'result.draw',
emoji: '🏅', emoji: '🏅',
}); });
@@ -64,7 +72,7 @@ describe('resultBadge', () => {
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => { it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
// The opponent won by resignation (isWinner) although neither side scored — the lobby // The opponent won by resignation (isWinner) although neither side scored — the lobby
// must read this as a loss, matching the game-detail screen (regression). // must read this as a loss, matching the game-detail screen (regression).
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({ expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), ['me'])).toEqual({
key: 'result.defeat', key: 'result.defeat',
emoji: '🥈', emoji: '🥈',
}); });
@@ -72,21 +80,21 @@ describe('resultBadge', () => {
it('finished four-player: places by score', () => { it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]); const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' }); expect(resultBadge(last, ['me'])).toEqual({ key: 'result.place4', emoji: '🏅' });
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]); const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' }); expect(resultBadge(second, ['me'])).toEqual({ key: 'result.place2', emoji: '🥈' });
}); });
it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => { it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => {
// The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single // The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single
// winner(), so this must NOT read as a full draw for everyone. // winner(), so this must NOT read as a full draw for everyone.
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' }); expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), ['me'])).toEqual({ key: 'result.victory', emoji: '🏆' });
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' }); expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), ['me'])).toEqual({ key: 'result.place3', emoji: '🥉' });
}); });
it('an aborted game reads as a draw for everyone, whatever the scores', () => { it('an aborted game reads as a draw for everyone, whatever the scores', () => {
const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' }; const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' };
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' }); expect(resultBadge(g, ['me'])).toEqual({ key: 'result.draw', emoji: '🏅' });
}); });
}); });
+3 -3
View File
@@ -16,8 +16,8 @@ function placeBadge(rank: number, players: number): ResultBadge {
return { key: 'result.place4', emoji: '🏅' }; return { key: 'result.place4', emoji: '🏅' };
} }
export function resultBadge(game: GameView, myId: string): ResultBadge { export function resultBadge(game: GameView, myIds: readonly string[]): ResultBadge {
const me = game.seats.find((s) => s.accountId === myId); const me = game.seats.find((s) => myIds.includes(s.accountId));
if (game.status === 'active' || game.status === 'open') { if (game.status === 'active' || game.status === 'open') {
return game.toMove === me?.seat return game.toMove === me?.seat
@@ -42,7 +42,7 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
// A winner exists but it is not me — even when scores are level (a win by resignation or timeout // A winner exists but it is not me — even when scores are level (a win by resignation or timeout
// can leave the winner at or below my score). The winner takes rank 1; place me among the remaining // can leave the winner at or below my score). The winner takes rank 1; place me among the remaining
// (non-resigned) seats by score, starting at rank 2. // (non-resigned) seats by score, starting at rank 2.
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && s.accountId !== myId && s.score > (me?.score ?? 0)).length; const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && !myIds.includes(s.accountId) && s.score > (me?.score ?? 0)).length;
return placeBadge(2 + ahead, game.players); return placeBadge(2 + ahead, game.players);
} }
+16 -2
View File
@@ -17,7 +17,7 @@ import { offlineMode } from './offline.svelte';
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte'; import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
import { maintenanceRetryMs } from './maintenance'; import { maintenanceRetryMs } from './maintenance';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
import { UPDATE_REQUIRED, reportUpdateRequired } from './update.svelte'; import { UPDATE_REQUIRED, reportUpdateRecommended, reportUpdateRequired } from './update.svelte';
const MAX_RETRIES = 6; const MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -31,7 +31,21 @@ function assertOnline(): void {
export function createTransport(baseUrl: string): GatewayClient { export function createTransport(baseUrl: string): GatewayClient {
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : ''); const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true }); const transport = createConnectTransport({
baseUrl: origin,
useBinaryFormat: true,
// Observe the soft-tier version nudge: a served unary response may carry X-Update-Recommended (the
// additive header the edge sets for a client at or above the hard minimum but below the recommended
// version). Reading it in an interceptor keeps it off every call site; the stream path is not
// soft-gated. It never fails the call — the nudge is non-blocking.
interceptors: [
(next) => async (req) => {
const res = await next(req);
if (!res.stream && res.header.get('x-update-recommended') === '1') reportUpdateRecommended();
return res;
},
],
});
const client = createClient(Gateway, transport); const client = createClient(Gateway, transport);
let token: string | null = null; let token: string | null = null;
+65 -15
View File
@@ -1,26 +1,76 @@
// Global "client too old" signal. `active` latches true the first time the gateway answers a // The client-version gate's two client signals (docs/ARCHITECTURE.md §2).
// user-initiated online call with the update_required sentinel — the HTTP-header version gate the //
// edge checks before decoding the payload (docs/ARCHITECTURE.md §2). It is terminal: unlike the // HARD tier ("client too old"): the gateway refuses a foreground call with the update_required
// maintenance signal there is no self-clearing poll, because an installed build cannot become // sentinel (the Execute result_code, the Subscribe FailedPrecondition). The transport reports it here,
// compatible without an actual update. A non-dismissable overlay (UpdateOverlay.svelte) then covers // which drives the net-state machine into offlineVersionLocked; the notice (UpdateOverlay.svelte) reads
// the app; its one action sends the user to the store (native) or reloads (web). Offline play never // netState.versionLocked and offers "Update" (store / reload) or "Play offline" (dismiss → stay
// trips it — the network kill switch refuses the call before it leaves the device. // offline). Offline play never trips it — the kill switch refuses the call before it leaves the device,
// and the background guest reconcile swallows it (stays a local guest, no notice).
//
// SOFT tier ("update available"): a served response carries the X-Update-Recommended header, which the
// transport reports here. It drives the machine (versionRecommended), whose setNudge effect — fired
// only from online — latches the dismissible nudge (UpdateNudge.svelte). The hard notice supersedes the
// nudge naturally: the nudge shows only while online, and version-locked is an offline state.
import { emit } from './netstate.svelte';
import { clientChannel } from './channel';
/** UPDATE_REQUIRED is the stable sentinel the gateway returns (the Execute result_code, and the /** UPDATE_REQUIRED is the stable sentinel the gateway returns (the Execute result_code, and the
* GatewayError code the Subscribe FailedPrecondition maps to) for a client too old to be served. */ * GatewayError code the Subscribe FailedPrecondition maps to) for a client too old to be served. */
export const UPDATE_REQUIRED = 'update_required'; export const UPDATE_REQUIRED = 'update_required';
let required = $state(false); /** openUpdate sends the user to the fix, shared by the hard-tier notice and the soft-tier nudge: the
* store listing on a native build (VITE_RUSTORE_URL, handed to the OS via '_system'), or a plain
* reload on the web (which fetches the current client). */
export function openUpdate(): void {
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') {
const url = import.meta.env.VITE_RUSTORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
}
}
export const updateRequired = { /** reportUpdateRequired drives the net-state machine into offlineVersionLocked (the hard tier). The
/** active is true once a foreground online call has been refused as too old. Terminal. */ * transport calls it only for a foreground update_required — the background reconcile swallows it and
* stays offline. The notice reads netState.versionLocked; there is no separate latch. */
export function reportUpdateRequired(): void {
emit('versionRejected');
}
// The soft "update available" nudge. `active` latches when the machine accepts a versionRecommended
// from online (via the setNudge effect → latchUpdateNudge); `dismissed` hides the banner for the
// session once the user closes it. A fresh launch re-evaluates the version, so both reset on reload.
let recommended = $state(false);
let nudgeDismissed = $state(false);
export const updateRecommended = {
/** active is true once the gateway has signalled an available (non-blocking) update. */
get active(): boolean { get active(): boolean {
return required; return recommended;
},
/** dismissed is true once the user has closed the nudge for this session. */
get dismissed(): boolean {
return nudgeDismissed;
}, },
}; };
/** reportUpdateRequired latches the terminal "update required" overlay. The transport calls it when /** latchUpdateNudge raises the soft-nudge latch. It is wired to the machine's setNudge effect
* a foreground call returns the update_required sentinel; idempotent. */ * (registerNetNudge), which fires only from online, so the nudge is never raised while offline or
export function reportUpdateRequired(): void { * version-locked. */
required = true; export function latchUpdateNudge(): void {
recommended = true;
}
/** dismissUpdateNudge hides the nudge for the session (a fresh launch re-evaluates the version). */
export function dismissUpdateNudge(): void {
nudgeDismissed = true;
}
/** reportUpdateRecommended signals an available update (the soft tier). The transport calls it when a
* served response carries the X-Update-Recommended header; it drives the machine (versionRecommended),
* which latches the nudge from online. */
export function reportUpdateRecommended(): void {
emit('versionRecommended');
} }
+59 -27
View File
@@ -3,9 +3,10 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import UpdateNudge from '../components/UpdateNudge.svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte'; import PinPad from '../components/PinPad.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte'; import { app, handleError, refreshFeedbackBadge, seedChatUnread, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
@@ -16,6 +17,9 @@
import { preloadGames } from '../lib/preload'; import { preloadGames } from '../lib/preload';
import { kickDictPreload, offlineMode } from '../lib/offline.svelte'; import { kickDictPreload, offlineMode } from '../lib/offline.svelte';
import { localSource, isLocalGameId } from '../lib/gamesource'; import { localSource, isLocalGameId } from '../lib/gamesource';
import { localGuestId } from '../lib/localguest';
import { insideTelegram } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model'; import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants'; import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
@@ -36,27 +40,37 @@
let loadSeq = 0; let loadSeq = 0;
async function load() { async function load() {
const seq = ++loadSeq; const seq = ++loadSeq;
// Deliberate offline mode: never touch the network. The lobby shows only the device-local const offline = offlineMode.active;
// vs_ai games (reconstructed from the store) plus the New-vs-AI entry; there are no online // Device-local games (vs_ai / hotseat) merge into the unified lobby everywhere except the
// games, invitations or incoming requests to fetch. // always-online Telegram/VK mini-apps, which are server-only (the shared-origin local store must
if (offlineMode.active) { // not leak device-local games into a mini-app).
const local = await localSource.list(); const serverOnly = insideTelegram() || insideVK();
if (seq !== loadSeq) return; const local = serverOnly ? [] : await localSource.list();
games = local; if (seq !== loadSeq) return;
if (offline) {
// Offline: device-local games are active; the last-known server games ride along greyed from the
// cache (un-openable). No network — and no invitations/incoming (a server concept, hidden until
// back online).
const cachedServer = (getLobby()?.games ?? []).filter((g) => !isLocalGameId(g.id));
games = [...local, ...cachedServer];
invitations = []; invitations = [];
incoming = []; incoming = [];
app.notifications = 0; app.notifications = 0;
setLobby({ games, invitations, incoming, offline: offlineMode.active }); setLobby({ games, invitations, incoming });
app.lobbyReady = true; app.lobbyReady = true;
return; return;
} }
try { try {
const list = await gateway.gamesList(); const list = await gateway.gamesList();
if (seq !== loadSeq) return; if (seq !== loadSeq) return;
games = list.games; const server = list.games;
// Seed the per-game unread badges from the authoritative list. The live stream only raises // Seed the per-game unread badges from the authoritative server list. The live stream only raises
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here. // unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages); for (const g of server) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
// The unified list: device-local games alongside the server ones (so a reconciled guest's local
// games stay visible online). Ids never collide.
games = [...local, ...server];
if (!guest) { if (!guest) {
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
if (seq !== loadSeq) return; if (seq !== loadSeq) return;
@@ -67,12 +81,12 @@
app.notifications = incoming.length; app.notifications = incoming.length;
void refreshFeedbackBadge(); void refreshFeedbackBadge();
} }
setLobby({ games, invitations, incoming, offline: offlineMode.active }); setLobby({ games, invitations, incoming });
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list // Warm the cache for the ongoing SERVER games so opening one from the lobby is instant (local
// just loaded, so the connection is up; the call is non-blocking and re-running it on each // games open from the device). The list just loaded, so the connection is up; non-blocking and
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped). // cheap (already-cached ones are skipped).
if (connection.online) { if (connection.online) {
void preloadGames(games); void preloadGames(server);
// Warm the offline dictionaries for an eligible install (PWA + email) so a later switch to // Warm the offline dictionaries for an eligible install (PWA + email) so a later switch to
// offline mode already has the data; a no-op elsewhere, and cheap once cached. The first // offline mode already has the data; a no-op elsewhere, and cheap once cached. The first
// lobby entry surfaces a notice in place of the ad banner if a fetch fails. // lobby entry surfaces a notice in place of the ad banner if a fetch fails.
@@ -90,7 +104,7 @@
onMount(() => { onMount(() => {
// Render instantly from the cached lists (so the screen does not "draw in" during // Render instantly from the cached lists (so the screen does not "draw in" during
// the back-slide), then refresh in the background. // the back-slide), then refresh in the background.
const cached = getLobby(offlineMode.active); const cached = getLobby();
if (cached) { if (cached) {
games = cached.games; games = cached.games;
invitations = cached.invitations; invitations = cached.invitations;
@@ -118,8 +132,16 @@
} }
}); });
// myId is the server user id, used for the (server-only) invitation "from me" checks. myIds is the
// full "you" set — the server user id plus the device-local guest id — used for game seat matching,
// since a reconciled guest's device-local games are seated under localGuestId while server games are
// seated under the user id (the ids never collide). guestId is read once (it mints on first launch).
const guestId = localGuestId();
const myId = $derived(app.session?.userId ?? ''); const myId = $derived(app.session?.userId ?? '');
const groups = $derived(groupGames(games, myId, app.chatUnread)); const myIds = $derived([app.session?.userId, guestId].filter((x): x is string => !!x));
// grey marks a server game shown only from the offline cache: dimmed and un-openable until online.
const grey = (g: GameView): boolean => offlineMode.active && !isLocalGameId(g.id);
const groups = $derived(groupGames(games, myIds, app.chatUnread));
// Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished" // Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished"
// while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is // while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is
@@ -152,7 +174,7 @@
const seen = new Set<string>(); const seen = new Set<string>();
for (const g of games) { for (const g of games) {
seen.add(g.id); seen.add(g.id);
const phase = gamePhase(g, myId); const phase = gamePhase(g, myIds);
if (shouldBlink(prevPhase.get(g.id), phase)) blink(g.id); if (shouldBlink(prevPhase.get(g.id), phase)) blink(g.id);
prevPhase.set(g.id, phase); prevPhase.set(g.id, phase);
} }
@@ -169,7 +191,7 @@
// An honest-AI game shows the robot opponent as 🤖, never its (pooled) name. // An honest-AI game shows the robot opponent as 🤖, never its (pooled) name.
if (g.vsAi) return '🤖'; if (g.vsAi) return '🤖';
return g.seats return g.seats
.filter((s) => s.accountId !== myId) .filter((s) => !myIds.includes(s.accountId))
.map((s) => s.displayName) .map((s) => s.displayName)
.join(', '); .join(', ');
} }
@@ -213,6 +235,11 @@
revealedId = null; revealedId = null;
return; return;
} }
// A server game shown only from the offline cache is un-openable — a toast explains why.
if (grey(g)) {
showToast(t('net.offline'));
return;
}
navigate(`/game/${g.id}`); navigate(`/game/${g.id}`);
} }
function toggleReveal(id: string): void { function toggleReveal(id: string): void {
@@ -222,7 +249,7 @@
revealedId = null; revealedId = null;
const prev = games; const prev = games;
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
setLobby({ games, invitations, incoming, offline: offlineMode.active }); setLobby({ games, invitations, incoming });
try { try {
// A local (offline) game is deleted from the device store; an online game is hidden on the // A local (offline) game is deleted from the device store; an online game is hidden on the
// backend. Routing by id keeps the delete off the network for a local game (the transport // backend. Routing by id keeps the delete off the network for a local game (the transport
@@ -231,7 +258,7 @@
else await gateway.hideGame(id); else await gateway.hideGame(id);
} catch (e) { } catch (e) {
games = prev; games = prev;
setLobby({ games, invitations, incoming, offline: offlineMode.active }); setLobby({ games, invitations, incoming });
handleError(e); handleError(e);
} }
} }
@@ -276,7 +303,7 @@
<Screen title={app.profile?.displayName ?? t('app.title')}> <Screen title={app.profile?.displayName ?? t('app.title')}>
<div class="lobby"> <div class="lobby">
{#if invitations.length} {#if invitations.length && !offlineMode.active}
<section> <section>
<h2>{t('lobby.invitations')}</h2> <h2>{t('lobby.invitations')}</h2>
{#each invitations as inv (inv.id)} {#each invitations as inv (inv.id)}
@@ -319,7 +346,7 @@
<div class="list"> <div class="list">
{#each group.list as g (g.id)} {#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)} {@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}> <div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id} class:greyed={grey(g)}>
{#if group.finished || g.hotseat} {#if group.finished || g.hotseat}
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button> <button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if} {/if}
@@ -336,7 +363,7 @@
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if} {#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
</span> </span>
<span class="sub scoreline" <span class="sub scoreline"
>{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myId, s)}{#if i > 0}<span class="sep">{' : '}</span >{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myIds, s)}{#if i > 0}<span class="sep">{' : '}</span
>{/if}<span >{/if}<span
class="num" class="num"
class:win={standing === 'win'} class:win={standing === 'win'}
@@ -349,7 +376,7 @@
plaques, and resultBadge is viewer-centric (no seat matches the account). A plaques, and resultBadge is viewer-centric (no seat matches the account). A
vs_ai game keeps its medal (one human, a straightforward win/loss). --> vs_ai game keeps its medal (one human, a straightforward win/loss). -->
{#key blinkNonce.get(g.id) ?? 0} {#key blinkNonce.get(g.id) ?? 0}
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myId).emoji}</span> <span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myIds).emoji}</span>
{/key} {/key}
</button> </button>
{#if group.finished || g.hotseat} {#if group.finished || g.hotseat}
@@ -397,6 +424,7 @@
{/if} {/if}
{#snippet tabbar()} {#snippet tabbar()}
<UpdateNudge />
<TabBar> <TabBar>
<button class="tab" onclick={() => navigate('/new')}> <button class="tab" onclick={() => navigate('/new')}>
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span> <span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
@@ -495,6 +523,10 @@
.rowwrap + .rowwrap { .rowwrap + .rowwrap {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
/* A server game shown only from the offline cache: dimmed and un-openable (openGame toasts instead). */
.rowwrap.greyed {
opacity: 0.5;
}
.del { .del {
position: absolute; position: absolute;
inset: 0 0 0 auto; inset: 0 0 0 auto;
+74 -6
View File
@@ -10,8 +10,8 @@
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte'; import { offlineMode, offlineCapable } from '../lib/offline.svelte';
import { localSource } from '../lib/gamesource'; import { localSource, isLocalGameId } from '../lib/gamesource';
import { newLocalGameId, randomSeed } from '../lib/localgame/id'; import { newLocalGameId, randomSeed } from '../lib/localgame/id';
import { localGuestId } from '../lib/localguest'; import { localGuestId } from '../lib/localguest';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
@@ -51,7 +51,18 @@
]; ];
const guest = $derived(app.profile?.isGuest ?? true); const guest = $derived(app.profile?.isGuest ?? true);
// Whether this channel supports device-local play. Native and plain web do; the Telegram/VK mini-apps
// are online-only, so they offer no offline/hotseat segment and never create a device-local game. The
// channel is fixed for the session, so a plain const (not reactive) is enough.
const canPlayOffline = offlineCapable();
let mode = $state<'auto' | 'friends'>('auto'); let mode = $state<'auto' | 'friends'>('auto');
// Within "with friends" (offline-capable channels only): online = a remote invite, offline = a
// pass-and-play (hotseat) game on this device. With no network the online segment is disabled and
// offline is forced — so the create flow always works.
let friendsMode = $state<'online' | 'offline'>('online');
$effect(() => {
if (offlineMode.active) friendsMode = 'offline';
});
// The quick-game opponent: an honest AI (a robot that joins and moves at once, shown as 🤖) // The quick-game opponent: an honest AI (a robot that joins and moves at once, shown as 🤖)
// or a random human via auto-match. AI is the default. // or a random human via auto-match. AI is the default.
let opponent = $state<'ai' | 'random'>('ai'); let opponent = $state<'ai' | 'random'>('ai');
@@ -63,7 +74,10 @@
const autoKind = $derived(opponent === 'ai' ? GameKind.vsAi : GameKind.random); const autoKind = $derived(opponent === 'ai' ? GameKind.vsAi : GameKind.random);
// The player's current games for the per-kind lock: seeded from the lobby snapshot for an instant // The player's current games for the per-kind lock: seeded from the lobby snapshot for an instant
// render, then refreshed on mount (below) so a game created since the last lobby visit is counted. // render, then refreshed on mount (below) so a game created since the last lobby visit is counted.
let lobbyGames = $state<GameView[]>(getLobby(false)?.games ?? []); // Only server games count toward the per-kind server limit (device-local games are unlimited), so
// the merged snapshot is filtered to the server ones here (the on-mount refresh below already reads
// the server list directly).
let lobbyGames = $state<GameView[]>(getLobby()?.games.filter((g) => !isLocalGameId(g.id)) ?? []);
const autoLocked = $derived(!offlineMode.active && isKindLocked(lobbyGames, app.profile?.gameLimits, autoKind)); const autoLocked = $derived(!offlineMode.active && isKindLocked(lobbyGames, app.profile?.gameLimits, autoKind));
let limitOpen = $state(false); let limitOpen = $state(false);
@@ -125,6 +139,37 @@
$effect(() => { $effect(() => {
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id; if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
}); });
// The offline dictionary guard: creating a device-local game (vs_ai or hotseat) needs the variant's
// dawg to be loadable without the network — bundled (native, always) or IndexedDB-cached (web). A
// native build bundles every variant, so this only ever bites a web install whose dawg was not
// cached. dawgReady is null while the async check runs, true/false once resolved; online it stays
// true (the guard is inert).
let dawgReady = $state<boolean | null>(true);
const guardVariant = $derived(mode === 'auto' ? selectedAuto : friendsMode === 'offline' ? inviteVariant : '');
$effect(() => {
const v = guardVariant;
if (!offlineMode.active || !v) {
dawgReady = true;
return;
}
dawgReady = null; // checking
const version = app.profile?.dictVersions?.[v] ?? __DICT_VERSION__;
let cancelled = false;
// getDawg resolves from IndexedDB / the native bundle offline (the network tier is refused by the
// kill switch), so a null result means the dictionary is genuinely unavailable on this device.
void import('../lib/dict')
.then((m) => m.getDawg(v, version))
.then((d) => {
if (!cancelled) dawgReady = !!d;
});
return () => {
cancelled = true;
};
});
// dictBlocked gates the offline create on a genuinely-missing dictionary (never while still checking).
const dictBlocked = $derived(offlineMode.active && dawgReady === false);
let timeoutSecs = $state(86400); let timeoutSecs = $state(86400);
let hints = $state(1); let hints = $state(1);
@@ -355,10 +400,11 @@
{/if} {/if}
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p> <p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if} {#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
{#if dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
<button <button
class="invite" class="invite"
class:locked={autoLocked} class:locked={autoLocked}
disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || starting} disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || dictBlocked || starting}
onclick={() => (autoLocked ? (limitOpen = true) : selectedAuto && find(selectedAuto))} onclick={() => (autoLocked ? (limitOpen = true) : selectedAuto && find(selectedAuto))}
>{#if autoLocked}🔒 {/if}{t('new.start')}</button> >{#if autoLocked}🔒 {/if}{t('new.start')}</button>
<GameLimitModal <GameLimitModal
@@ -367,7 +413,19 @@
onclose={() => (limitOpen = false)} onclose={() => (limitOpen = false)}
onlogin={() => { limitOpen = false; navigate('/profile'); }} onlogin={() => { limitOpen = false; navigate('/profile'); }}
/> />
{:else if offlineMode.active} {:else}
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this device.
The online/offline segment is offered only on offline-capable channels (native / plain web);
the online-only Telegram/VK mini-apps skip it and show the remote invite directly. With no
network the online segment is disabled and offline is forced (friendsMode). -->
{#if canPlayOffline}
<div class="seg modes">
<button class="opt" class:active={friendsMode === 'online'} disabled={offlineMode.active} onclick={() => (friendsMode = 'online')}>{t('new.playRemote')}</button>
<button class="opt" class:active={friendsMode === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button>
</div>
{#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if}
{/if}
{#if friendsMode === 'offline'}
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master <!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
PIN first (it gates the roster); each seat may add its own PIN. --> PIN first (it gates the roster); each seat may add its own PIN. -->
<div class="hostpin"> <div class="hostpin">
@@ -434,9 +492,10 @@
+ {t('hotseat.addPlayer')} + {t('hotseat.addPlayer')}
</button> </button>
{/if} {/if}
{#if dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
<button <button
class="invite" class="invite"
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting} disabled={!hostPin || !inviteVariant || !rosterReady(rows) || dictBlocked || starting}
onclick={startHotseat} onclick={startHotseat}
>{t('new.start')}</button> >{t('new.start')}</button>
{:else if friends.length === 0} {:else if friends.length === 0}
@@ -485,6 +544,7 @@
{/if} {/if}
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button> <button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div> </div>
{/if}
{/if} {/if}
{#if pad} {#if pad}
@@ -708,6 +768,14 @@
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.4; line-height: 1.4;
} }
/* The offline-blocker reason (a missing dictionary, or the online segment needing a connection). */
.dictnote {
margin: 0;
text-align: center;
color: var(--warn);
font-size: 0.85rem;
line-height: 1.4;
}
/* --- offline hotseat roster --- */ /* --- offline hotseat roster --- */
.hostpin { .hostpin {
display: flex; display: flex;
-63
View File
@@ -11,22 +11,12 @@
import type { ThemePref } from '../lib/theme'; import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels'; import type { BoardLabelMode } from '../lib/boardlabels';
import { insideTelegram } from '../lib/telegram'; import { insideTelegram } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { offlineMode, setOfflineMode, requestOffline } from '../lib/offline.svelte';
import { isStandalone } from '../lib/pwa';
import InstallApp from '../components/InstallApp.svelte'; import InstallApp from '../components/InstallApp.svelte';
// The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit // The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit
// the whole board and never auto-zoom), so it is shown only for a coarse pointer. // the whole board and never auto-zoom), so it is shown only for a coarse pointer.
const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
const offlineEligible = $derived(
isStandalone() && !insideTelegram() && !insideVK() && !!app.profile?.email,
);
const themes: ThemePref[] = ['auto', 'light', 'dark']; const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = { const themeLabel: Record<ThemePref, MessageKey> = {
auto: 'settings.themeAuto', auto: 'settings.themeAuto',
@@ -41,24 +31,6 @@
none: 'settings.labelsNone', none: 'settings.labelsNone',
}; };
// The offline toggle gates entry on dictionary readiness: flipping to offline fetches the enabled
// variants' dictionaries (cache-first) and waits at most a few seconds; if they cannot be made
// available it stays online and shows the "needs internet" note, while the fetch keeps warming the
// cache in the background so a later flip is instant. Leaving offline (-> online) is never gated.
let checking = $state(false);
let needsData = $state(false);
async function goOffline(): Promise<void> {
if (offlineMode.active || checking) return;
needsData = false;
checking = true;
try {
const ready = await requestOffline(app.profile);
if (!ready) needsData = true;
} finally {
checking = false;
}
}
</script> </script>
<div class="page"> <div class="page">
@@ -119,33 +91,6 @@
{/if} {/if}
</section> </section>
{#if offlineEligible}
<section>
<h3>{t('settings.offlineMode')}</h3>
<div class="seg">
<button
class="opt"
class:active={!offlineMode.active}
disabled={checking}
onclick={() => {
needsData = false;
setOfflineMode(false);
}}
>
{t('settings.online')}
</button>
<button class="opt" class:active={offlineMode.active} disabled={checking} onclick={goOffline}>
{t('settings.offline')}
</button>
</div>
{#if checking}
<p class="onote">{t('settings.offlineChecking')}</p>
{:else if needsData}
<p class="onote onote--warn" role="alert">{t('settings.offlineNeedsData')}</p>
{/if}
</section>
{/if}
<!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app <!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app
is installable here — see components/InstallApp.svelte / lib/pwa). --> is installable here — see components/InstallApp.svelte / lib/pwa). -->
<InstallApp /> <InstallApp />
@@ -190,14 +135,6 @@
opacity: 0.55; opacity: 0.55;
cursor: default; cursor: default;
} }
.onote {
font-size: 0.85rem;
color: var(--text-muted);
margin: 8px 0 0;
}
.onote--warn {
color: var(--warn);
}
.row { .row {
display: flex; display: flex;
align-items: center; align-items: center;