Compare commits
1 Commits
v1.17.0
...
14918cc94f
| Author | SHA1 | Date | |
|---|---|---|---|
| 14918cc94f |
@@ -353,6 +353,11 @@ jobs:
|
||||
# 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.
|
||||
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
|
||||
# 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 }}
|
||||
|
||||
@@ -112,6 +112,12 @@ jobs:
|
||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||
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.
|
||||
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||
|
||||
+157
-63
@@ -695,12 +695,12 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
||||
|
||||
### G. Release + owner handoff
|
||||
|
||||
0. **Land the offline-model redesign (design below — "Offline-model redesign").** This resolves the deferred
|
||||
native local-game-visibility decision (a native guest must see / resume their device-local games once online
|
||||
— today the online lobby hides them, `Lobby.svelte:42/54`) as part of a broader, owner-approved change:
|
||||
remove the explicit offline toggle, make offline an implicit **net-state machine**, unify the lobby, and add
|
||||
the **two-tier** version gate. Cross-cutting (web + native + a small additive backend/wire bit), its own
|
||||
staged work; the Android release waits on it.
|
||||
0. **Land the offline-model redesign — ✅ DONE (O1–O7, 2026-07-13; staged detail below).** Resolved the deferred
|
||||
native local-game-visibility decision — a native guest now sees / resumes their device-local games once online
|
||||
(the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline
|
||||
is an implicit **net-state machine** (O1/O2), the lobby is unified (O5), and the **two-tier** version gate is in
|
||||
(O4). Cross-cutting (web + native + a small additive backend/wire bit); **contour-safe** (both version vars empty
|
||||
⇒ 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).
|
||||
2. Promote `development` → `master`; tag `vX.Y.0`.
|
||||
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
|
||||
`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`.
|
||||
- Interfaces — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`;
|
||||
`type NetEvent` (`callFailed`/`callOk`/`probeOk`/`probeFailed`/`osOffline`/`osOnline`/`versionRejected`/
|
||||
`versionRecommended`/`boot`); `reduce(prev, event, cfg) => { state: NetState; effects: Effect[] }`
|
||||
(effects: `toast`, `startProbe`, `showVersionNotice`, `setNudge`); `cfg` carries `K` + `OFFLINE_DEBOUNCE_MS`.
|
||||
Consumes: nothing (pure).
|
||||
- Interfaces (as built) — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`;
|
||||
`type NetEvent` (`boot`/`callOk`/`callFailed`/`probeOk`/`probeFailed`/`osOnline`/`osOffline`/`versionRejected`/
|
||||
`versionRecommended`); `reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig) => NetResult` where
|
||||
`NetInput = { type: NetEvent; at: number }` (**time-on-the-event** — owner-chosen option A: the reducer stays
|
||||
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
|
||||
sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected`
|
||||
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.**
|
||||
- Files — 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` +
|
||||
`ui/src/lib/offline.svelte.ts` (become thin shims re-exporting the derived getters), `ui/src/lib/transport.ts`
|
||||
(emit `callFailed`/`callOk`/`versionRejected`/`versionRecommended`), `ui/src/lib/native.ts` (wire
|
||||
`@capacitor/network`), `ui/src/lib/app.svelte.ts` (boot event), `ui/package.json` (+`@capacitor/network`).
|
||||
- Interfaces — Produces: `netState` store `.state`/`.offline`/`.connecting`, `emit(event)`. Consumes: `reduce`
|
||||
(O1), the existing `registerProbe`.
|
||||
- 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 visual/behaviour regression).
|
||||
- Targeted tests: `e2e/offline.spec.ts` still green; `e2e/native.spec.ts` extended (a `$state` store ⇒ e2e, like `update.svelte.ts`).
|
||||
- **O2 — Runtime store + wire the events; migrate the two modules. — ✅ DONE (2026-07-12).**
|
||||
- Files (as built) — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`; the derived
|
||||
`state`/`online`/`connecting`/`offline`/`versionLocked` getters; one self-rescheduling probe/recovery watcher —
|
||||
backoff while `connecting`, a 4 s poll while `offlineNoNetwork`, idle otherwise; the OS-signal wiring). Modify:
|
||||
`ui/src/lib/connection.svelte.ts` + `ui/src/lib/offline.svelte.ts` (thin shims over `netState` — `connection.online`
|
||||
= the online state, `offlineMode.active` = the machine `offline`; `reportOffline`→`callFailed` **once** from online,
|
||||
`reportOnline`→`callOk`), `ui/src/lib/update.svelte.ts` (`reportUpdateRequired` also emits `versionRejected`),
|
||||
`ui/src/lib/native.ts` (+`initNativeNetwork` — `@capacitor/network` → `osOnline`/`osOffline`),
|
||||
`ui/src/lib/app.svelte.ts` (the boot/recovery-seam rewrite), `ui/src/lib/gateway.ts` (the mock `__net` hook),
|
||||
`ui/src/App.svelte` (dialog removal), `ui/src/lib/i18n/{en,ru}.ts` (`net.offline`/`net.online` toast), `ui/package.json`
|
||||
(+`@capacitor/network@8.0.1`). **Owner-confirmed scope A (full migration):** the store subsumes the old
|
||||
`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.**
|
||||
- Files — Modify: `ui/src/screens/Settings.svelte` (delete the `offlineEligible` toggle + `goOffline`),
|
||||
`ui/src/lib/offline.ts`/`offline.svelte.ts` (drop `requestOffline`/`loadOfflinePref`/`saveOfflinePref`; clear
|
||||
the stale key on boot), `ui/src/screens/SettingsHub.svelte` (tab-disable from `netState`).
|
||||
- Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck); offline
|
||||
chrome/tab-gating still driven from `netState`.
|
||||
- Targeted tests: `e2e` — Settings has no toggle; a stale pref does not force offline.
|
||||
- **O3 — Remove the explicit offline toggle + migrate the pref. — ✅ DONE (2026-07-12).**
|
||||
- Files (as built) — Modify: `ui/src/screens/Settings.svelte` (deleted the whole "Play mode" Online/Offline segment +
|
||||
`offlineEligible` + `goOffline` + the `checking`/`needsData` state + the now-unused `isStandalone`/`insideVK`/offline
|
||||
imports + the `.onote` CSS), `ui/src/lib/offline.svelte.ts` (dropped the inert `setOfflineMode`/`requestOffline`/
|
||||
`TOGGLE_READY_BUDGET_MS` stubs + the dead `offlineMode.auto` getter — `offlineMode.active` = `netState.offline` stays),
|
||||
`ui/src/lib/offline.ts` (dropped `loadOfflinePref`/`saveOfflinePref`/`shouldBootOffline`/`offlineReady`/`missingDicts`/
|
||||
`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.**
|
||||
- Files — Modify: `gateway/internal/config/config.go` (+`RecommendedClientVersion`, validate ≥ `MinClientVersion`),
|
||||
`gateway/internal/connectsrv/server.go` (soft band ⇒ `X-Update-Recommended: 1` response header; hard band
|
||||
unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (read the header ⇒
|
||||
`versionRecommended`), `ui/src/lib/update.svelte.ts` (nudge state), rework `ui/src/components/UpdateOverlay.svelte`
|
||||
from terminal → the `offlineVersionLocked` notice ("Update"/"Play offline"), new `ui/src/components/UpdateNudge.svelte`.
|
||||
- Interfaces — Produces: `GATEWAY_RECOMMENDED_CLIENT_VERSION`, the `X-Update-Recommended` header, `updateRecommended` store.
|
||||
- Acceptance: `v<min` → notice → "Play offline" plays local; `min≤v<recommended` → dismissible banner, online
|
||||
continues; `v≥recommended`/both empty → nothing; fail-open on a garbled header.
|
||||
- Targeted tests: Go `config_test`/`server_test` (three bands + ordering + fail-open); `e2e/update.spec.ts`
|
||||
(notice → play offline; nudge banner).
|
||||
- **O4 — Two-tier version gate. — ✅ DONE (2026-07-13).**
|
||||
- Files (as built) — Backend: `gateway/internal/config/config.go` (+`RecommendedClientVersion` from
|
||||
`GATEWAY_RECOMMENDED_CLIENT_VERSION`; validate parseable + `≥ MinClientVersion`, an empty min imposing no lower bound),
|
||||
`gateway/internal/connectsrv/server.go` (soft-tier fields `recClient`/`recOn`; `clientUpdateRecommended` = `min ≤ v <
|
||||
recommended`, fail-open; Execute sets `X-Update-Recommended: 1` on any served response in the band via a named-return
|
||||
`defer`; hard band + Subscribe unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (a
|
||||
Connect-ES **interceptor** reads `x-update-recommended` on unary responses ⇒ `reportUpdateRecommended`),
|
||||
`ui/src/lib/update.svelte.ts` (dropped the terminal `updateRequired` latch — `reportUpdateRequired` now only emits
|
||||
`versionRejected`; new `updateRecommended` store + `latchUpdateNudge`/`dismissUpdateNudge`/`reportUpdateRecommended` +
|
||||
shared `openUpdate`), `ui/src/lib/netstate.svelte.ts` (`registerNetNudge`; the `setNudge` effect fires it),
|
||||
`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.**
|
||||
- Files — Modify: `ui/src/screens/Lobby.svelte` (merge `localSource.list()` + `gateway.gamesList()`; offline ⇒
|
||||
local active + cached server greyed; drop the mode-exclusive `loadSeq` branch), `ui/src/lib/lobbysort.ts`
|
||||
(sort the merged set), a self-identity helper (recognise `session.userId` **and** `localGuestId()`).
|
||||
- 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**); TG/VK server-only.
|
||||
- Targeted tests: `e2e` unified lobby (greyed offline; both online); `lobbysort.test.ts` extended.
|
||||
- **O5 — Unified lobby. — ✅ DONE (2026-07-13).**
|
||||
- Files (as built) — `ui/src/screens/Lobby.svelte` (`load()` merges `localSource.list()` + `gateway.gamesList()`;
|
||||
online ⇒ `[...local, ...server]`; offline ⇒ `[...localFresh, ...cachedServer]` greyed; the mode-exclusive branch is
|
||||
gone; `grey(g) = offline && !isLocalGameId` drives a `.rowwrap.greyed` dim + an un-openable `openGame` that toasts
|
||||
`net.offline`; **invitations hidden offline** — owner addition — via `{#if invitations.length && !offline}`), `ui/src/lib/lobbysort.ts`
|
||||
+ `ui/src/lib/result.ts` (`myId: string` → `myIds: readonly string[]` self-set across `isMyTurn`/`scoreStanding`/
|
||||
`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.**
|
||||
- Files — Modify: `ui/src/screens/NewGame.svelte` (with-friends online/offline segmented control, online
|
||||
disabled when `netState.offline`; vs_ai already branches on the flag — verify; the dict-availability guard).
|
||||
- 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 when
|
||||
the variant's dawg is unavailable offline.
|
||||
- Targeted tests: `e2e` with-friends toggle (online disabled offline); vs_ai local-vs-server by state.
|
||||
- **O6 — Create flows. — ✅ DONE (2026-07-13).**
|
||||
- Files (as built) — `ui/src/screens/NewGame.svelte`: **with-friends** now carries a `friendsMode` segmented control
|
||||
(`new.playRemote` = online invite / `new.playLocal` = offline hotseat); the online segment is `disabled` when
|
||||
`offlineMode.active` and an `$effect` forces `friendsMode='offline'` there (a `new.needsNetwork` note explains it);
|
||||
the friends markup is restructured from the mode-exclusive `{:else if offlineMode.active}` chain into
|
||||
`{:else} <segment> {#if friendsMode==='offline'} hotseat {:else if !friends} noFriends {:else} invite`. **vs_ai
|
||||
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.**
|
||||
- Files — Modify: `docs/ARCHITECTURE.md` (§2 gate → two tiers + graceful degrade; §3 the net-state model),
|
||||
`docs/FUNCTIONAL.md` (+`_ru`), `docs/TESTING.md`, `deploy/README.md` (the two version vars), `ui/e2e/native.spec.ts`.
|
||||
- Acceptance: the F-baked docs describe both tiers + the implicit model + the unified lobby; `deploy/README`
|
||||
lists `GATEWAY_RECOMMENDED_CLIENT_VERSION`.
|
||||
- Targeted tests: n/a (docs); the native e2e reflects the new boot/offline.
|
||||
- **O7 — Docs + native e2e. — ✅ DONE (2026-07-13).**
|
||||
- Files (as built) — `docs/ARCHITECTURE.md`: **§2** rewrote the connectivity signal into the **net-state machine**
|
||||
(four states, implicit offline, hysteresis, self-heal, TG/VK-exempt), the version gate into the **two tiers** (hard →
|
||||
`offlineVersionLocked` + dismissable notice, *not* terminal; soft → `X-Update-Recommended` nudge), and the gate×offline
|
||||
rule; **§13** (PWA/offline) rewrote the offline model from the deliberate toggle/dialog to implicit detection + the
|
||||
**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 (O1–O7).** 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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
|
||||
TELEGRAM_TEST_ENV=false
|
||||
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 ------------------------------------------------------------
|
||||
# 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).
|
||||
|
||||
+2
-1
@@ -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_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_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). |
|
||||
| `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). |
|
||||
|
||||
@@ -222,6 +222,12 @@ services:
|
||||
GATEWAY_HTTP_ADDR: ":8081"
|
||||
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||
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).
|
||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||
|
||||
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||
export DICT_VERSION='$DICT_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_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
|
||||
+61
-49
@@ -100,15 +100,24 @@ dropped). Horizontal scaling is explicit future work.
|
||||
auth operations are unauthenticated and return the minted token. A unary
|
||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||
surface as Connect error codes. The client treats a connectivity edge failure as
|
||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
||||
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
|
||||
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
|
||||
response was lost — its button is disabled while offline and the player re-issues it on
|
||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
|
||||
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
|
||||
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
|
||||
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
|
||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
||||
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
||||
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
||||
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
||||
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
|
||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online, never fed an offline
|
||||
signal, so those channels never enter an offline state or show a local lobby.
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
@@ -140,18 +149,26 @@ dropped). Horizontal scaling is explicit future work.
|
||||
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
||||
client's in-app stream while the app is open. Out-of-app delivery uses
|
||||
platform-native push via the platform side-service.
|
||||
- **Client-version gate** (native long-tail protection): a bundled native install (§13) can be
|
||||
- **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
|
||||
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
|
||||
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
|
||||
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
|
||||
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
|
||||
that breaks. Enforcement is **per-call, not a Hello RPC**: `Execute` and `Subscribe` compare the header to
|
||||
`GATEWAY_MIN_CLIENT_VERSION` at the top (before registry lookup / auth / decode), so the first online call
|
||||
an app makes is already gated. A too-old client gets the domain envelope `result_code = "update_required"`
|
||||
(HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`, and makes **zero** successful
|
||||
requests. The gate **fails open** (an absent or unparseable header passes) and is **dormant** until
|
||||
`GATEWAY_MIN_CLIENT_VERSION` is deliberately set (empty ⇒ off, so web behaviour is unchanged). The client
|
||||
raises a terminal *update* overlay — native → the store listing (`VITE_RUSTORE_URL`), web → reload.
|
||||
that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
|
||||
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
|
||||
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
|
||||
vars empty ⇒ off, web behaviour unchanged).
|
||||
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
|
||||
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
|
||||
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
|
||||
`offlineVersionLocked` (offline mode) and shows a **dismissable notice** — **"Update"** (native → the
|
||||
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
|
||||
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
|
||||
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
|
||||
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
|
||||
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
|
||||
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **≥ `min`**
|
||||
(validated at config load); empty ⇒ the soft tier is off.
|
||||
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
|
||||
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
|
||||
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
|
||||
@@ -160,10 +177,12 @@ dropped). Horizontal scaling is explicit future work.
|
||||
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
|
||||
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
|
||||
rollout (see [`../deploy/README.md`](../deploy/README.md)).
|
||||
- **Gate × offline** (UX rule): deliberate offline uses the network kill switch, so the gate never fires
|
||||
while playing offline — an old install can always play local vs_ai / hotseat. The terminal *update*
|
||||
overlay is raised **only on a user-initiated online action**; the silent background guest reconciliation
|
||||
(§3) swallows an `update_required` and stays a local guest rather than interrupting local play.
|
||||
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
|
||||
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
|
||||
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
|
||||
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
|
||||
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
|
||||
rather than interrupting local play.
|
||||
|
||||
## 3. Authentication & sessions
|
||||
|
||||
@@ -1369,13 +1388,16 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
|
||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
|
||||
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
|
||||
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
||||
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
||||
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
||||
invitations are hidden while offline. New Game's *with friends* carries an **online/offline segmented
|
||||
control** — online = a friend invite, offline = **local pass-and-play (hotseat)** — with the online
|
||||
segment disabled and offline forced when there is no network; a `vs_ai` or hotseat create is guarded when
|
||||
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
|
||||
the in-browser engine and driven by the same game screen, the robot replying locally.
|
||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
||||
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||
@@ -1403,27 +1425,17 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
|
||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||
mode** boots from the persisted session and
|
||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
||||
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
||||
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
||||
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
||||
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
||||
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
||||
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
||||
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
||||
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
||||
while offline. The gateway registers the `.webmanifest` MIME type
|
||||
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
|
||||
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
|
||||
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
|
||||
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
|
||||
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
|
||||
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
|
||||
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
|
||||
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
|
||||
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
|
||||
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
|
||||
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
|
||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||
|
||||
+30
-23
@@ -277,22 +277,25 @@ add-friend are off. AI games are **practice** — they never count toward a play
|
||||
statistics.
|
||||
|
||||
### Offline mode
|
||||
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
||||
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
||||
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
||||
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
||||
A New Game against the robot creates a device-local **vs_ai** game — it
|
||||
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
||||
offline mode, 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) 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.
|
||||
The app plays **offline automatically** — there is **no on/off switch**. When it cannot reach the
|
||||
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
|
||||
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
|
||||
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
|
||||
app never touches the network.
|
||||
|
||||
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
|
||||
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||
The **lobby stays unified**: your device-local games are active, while your server games are still
|
||||
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 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 —
|
||||
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**.
|
||||
@@ -305,14 +308,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,
|
||||
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
|
||||
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
||||
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
||||
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
||||
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
|
||||
dialog — is the player's choice: it is kept, and never undone automatically.
|
||||
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
|
||||
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
|
||||
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
|
||||
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||||
offline, so you are never interrupted for a hiccup.
|
||||
|
||||
### Staying up to date
|
||||
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)
|
||||
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
|
||||
|
||||
+29
-23
@@ -283,22 +283,24 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||
сохраняется между запусками.
|
||||
Приложение играет **офлайн автоматически** — никакого переключателя нет. Когда оно не может достучаться
|
||||
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
||||
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
||||
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
||||
сети.
|
||||
|
||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
|
||||
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
|
||||
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
|
||||
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
|
||||
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
|
||||
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
|
||||
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
|
||||
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
||||
|
||||
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди. В
|
||||
игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||
@@ -312,14 +314,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||
своего хода.
|
||||
|
||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
|
||||
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
|
||||
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
|
||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||
вас не прерывают из-за короткой икоты.
|
||||
|
||||
### Актуальная версия
|
||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
|
||||
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
|
||||
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
|
||||
|
||||
### Нативное приложение (Android)
|
||||
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
|
||||
|
||||
+22
-13
@@ -17,14 +17,17 @@ tests or touching CI.
|
||||
- **UI** — Vitest (unit) + Playwright
|
||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
||||
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
|
||||
invitations section, the stats screen, profile editing, and the export chooser's
|
||||
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
|
||||
end-to-end: it forces the installed-PWA display mode, enters offline through the
|
||||
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
|
||||
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
||||
end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
|
||||
auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
`window.__native.reconcile()` to prove online lights up and Profile hides the Telegram/VK link buttons.
|
||||
The **update overlay** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove the terminal
|
||||
update cover; `retry.test.ts` covers the `FailedPrecondition → update_required` mapping.
|
||||
- **Client-version gate** (Go) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH` parse
|
||||
(with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering; `connectsrv`'s server tests
|
||||
assert 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); `config` tests reject a non-empty, unparseable
|
||||
`GATEWAY_MIN_CLIENT_VERSION`.
|
||||
`window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
|
||||
**unified lobby** (closing G-step-0) — and Profile hides the Telegram/VK link buttons.
|
||||
The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
|
||||
hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
|
||||
dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
|
||||
update_required` mapping.
|
||||
- **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
|
||||
parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
|
||||
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
|
||||
`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
|
||||
|
||||
+8
-3
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||
VITE_APP_VERSION=$VITE_APP_VERSION \
|
||||
VITE_ADS_STUB=$VITE_ADS_STUB
|
||||
|
||||
# Install with the lockfile first (the workspace file carries pnpm's build-script
|
||||
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
||||
# Install with the lockfile first, 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 ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY ui ./
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
+18
-17
@@ -221,23 +221,24 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
}
|
||||
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
Backend: backend,
|
||||
Limiter: limiter,
|
||||
Tracker: tracker,
|
||||
Banlist: banlist,
|
||||
Blocklist: blocklist,
|
||||
Honeytoken: cfg.Abuse.Honeytoken,
|
||||
VKAppSecret: cfg.VKAppSecret,
|
||||
Hub: hub,
|
||||
RateLimit: cfg.RateLimit,
|
||||
Heartbeat: cfg.PushHeartbeatInterval,
|
||||
Logger: logger,
|
||||
AdminProxy: adminProxy,
|
||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||
MinClientVersion: cfg.MinClientVersion,
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
Backend: backend,
|
||||
Limiter: limiter,
|
||||
Tracker: tracker,
|
||||
Banlist: banlist,
|
||||
Blocklist: blocklist,
|
||||
Honeytoken: cfg.Abuse.Honeytoken,
|
||||
VKAppSecret: cfg.VKAppSecret,
|
||||
Hub: hub,
|
||||
RateLimit: cfg.RateLimit,
|
||||
Heartbeat: cfg.PushHeartbeatInterval,
|
||||
Logger: logger,
|
||||
AdminProxy: adminProxy,
|
||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||
MinClientVersion: cfg.MinClientVersion,
|
||||
RecommendedClientVersion: cfg.RecommendedClientVersion,
|
||||
})
|
||||
|
||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||
|
||||
@@ -60,6 +60,11 @@ type Config struct {
|
||||
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
|
||||
// (every client is served) — the default for web-only deployments.
|
||||
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 RateLimitConfig
|
||||
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||
@@ -232,15 +237,16 @@ func (c VKIDConfig) Enabled() bool {
|
||||
func Load() (Config, error) {
|
||||
var err error
|
||||
c := Config{
|
||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
|
||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
|
||||
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
|
||||
VKID: VKIDConfig{
|
||||
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||
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)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
// the agreed thresholds, and no honeytoken.
|
||||
func TestLoadAbuseDefaults(t *testing.T) {
|
||||
|
||||
@@ -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 payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
|
||||
// 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 (
|
||||
clientVersionHeader = "X-Client-Version"
|
||||
resultUpdateRequired = "update_required"
|
||||
clientVersionHeader = "X-Client-Version"
|
||||
resultUpdateRequired = "update_required"
|
||||
updateRecommendedHeader = "X-Update-Recommended"
|
||||
)
|
||||
|
||||
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||
@@ -104,6 +109,12 @@ type Server struct {
|
||||
minClient clientver.Version
|
||||
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
|
||||
userPolicy 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
|
||||
// the gate dormant.
|
||||
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.
|
||||
@@ -192,6 +206,18 @@ func NewServer(d Deps) *Server {
|
||||
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{
|
||||
registry: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
@@ -210,6 +236,8 @@ func NewServer(d Deps) *Server {
|
||||
maxBodyBytes: maxBody,
|
||||
minClient: minClient,
|
||||
gateOn: gateOn,
|
||||
recClient: recClient,
|
||||
recOn: recOn,
|
||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||
// 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()
|
||||
msgType := req.Msg.GetMessageType()
|
||||
result := "internal"
|
||||
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 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).
|
||||
|
||||
@@ -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) {
|
||||
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"}`))
|
||||
@@ -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
|
||||
// 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).
|
||||
|
||||
@@ -10,6 +10,7 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-network')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -4,3 +4,6 @@ project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capa
|
||||
|
||||
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')
|
||||
|
||||
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
@@ -1,8 +1,9 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
|
||||
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
|
||||
// Settings offline toggle shows.
|
||||
// Offline pass-and-play (hotseat) is offered only in offline mode, which is now implicit (the net-state
|
||||
// machine detects it — no toggle). The e2e drives the machine through the mock's __net hook. Standalone
|
||||
// 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> {
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
@@ -19,11 +20,9 @@ async function enterLobby(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goOffline(page: Page): Promise<void> {
|
||||
await page.locator('button.tab').nth(2).click(); // Settings
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
// The machine auto-detects offline (no toggle, no dialog); the lobby stays mounted and turns blue.
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
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).
|
||||
@@ -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.)
|
||||
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.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.
|
||||
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.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
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();
|
||||
const del = page.locator('.rowwrap.revealed .del');
|
||||
await expect(del).toBeVisible();
|
||||
await del.click();
|
||||
await typePin(page, '9999');
|
||||
await expect(page.locator('.rowwrap')).toHaveCount(0);
|
||||
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,15 +63,18 @@ test.describe('native offline-first', () => {
|
||||
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// Back to the lobby, then the network returns: reconciliation silently mints + adopts a server
|
||||
// guest and clears the auto-offline, so online lights up — the offline tint drops, the seeded
|
||||
// online game (vs Ann) appears and the Stats tab re-enables. (The local vs_ai game stays
|
||||
// device-only, so the online lobby list no longer shows it — that is the intended split.)
|
||||
// Back to the lobby (still offline): the device-local vs_ai game (🤖) is listed. Then the network
|
||||
// returns: reconciliation silently mints + adopts a server guest and clears the auto-offline, so
|
||||
// online lights up — the offline tint drops, the seeded online game (vs Ann) appears and Stats
|
||||
// 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 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 expect(page.locator('header.nav.offline')).toHaveCount(0);
|
||||
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();
|
||||
|
||||
// The native sign-in surface is guest + email only: on Profile the Telegram + VK LINK buttons are
|
||||
|
||||
+71
-27
@@ -1,8 +1,9 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account
|
||||
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the
|
||||
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through.
|
||||
// Offline is now implicit — the net-state machine detects it; there is no toggle. The e2e drives the
|
||||
// machine through the mock's __net hook (the real probe watcher is inert under the mock). Standalone
|
||||
// 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> {
|
||||
await page.addInitScript(() => {
|
||||
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
|
||||
// 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
|
||||
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
|
||||
// this deterministic.
|
||||
async function enterLobby(page: Page): Promise<void> {
|
||||
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
|
||||
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run
|
||||
// onboarding strips after it completes).
|
||||
// robust to locale, emoji variation selectors and the coachmark anchors.
|
||||
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
|
||||
// all-distinct letters, so the glyph is unambiguous).
|
||||
// Drive the net-state machine offline / online through the mock hook (no toggle, no dialog).
|
||||
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> {
|
||||
await page.locator('.rack .tile', { hasText: glyph }).first().click();
|
||||
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
|
||||
}
|
||||
|
||||
test.describe('offline mode', () => {
|
||||
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => {
|
||||
test.describe('offline mode (implicit)', () => {
|
||||
test('auto-detects offline, plays a local vs_ai game, persists across a reload', async ({ page }) => {
|
||||
await forceStandalone(page);
|
||||
await page.goto('/');
|
||||
await enterLobby(page);
|
||||
@@ -41,23 +47,22 @@ test.describe('offline mode', () => {
|
||||
// Online: a seeded online game (vs Ann) is listed.
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
|
||||
// Enter offline through the real Settings toggle (its readiness check fetches every enabled
|
||||
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip.
|
||||
await page.locator('button.tab').nth(2).click();
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
// The network drops: the machine auto-detects offline (no toggle, no dialog) — a toast announces it
|
||||
// and the header turns blue with the chip.
|
||||
await goOffline(page);
|
||||
await expect(page.locator('.toast')).toContainText(/offline|соединени/i);
|
||||
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.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
|
||||
// The (now offline) lobby stays mounted: the server game (vs Ann) rides along GREYED from the cache
|
||||
// (un-openable), and the Stats tab disables.
|
||||
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
|
||||
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).
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian
|
||||
// variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
|
||||
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian variants
|
||||
// are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
|
||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||
await page.locator('button.invite').click();
|
||||
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 page.locator('.make').click();
|
||||
|
||||
// The play commits and the local robot replies with a real move, so the board carries more than
|
||||
// WAY's three tiles.
|
||||
// The play commits and the local robot replies with a real move (which needs the bundled
|
||||
// dictionary), so the board carries more than WAY's three tiles.
|
||||
await expect(async () => {
|
||||
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
||||
}).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).
|
||||
await expect(page.locator('.lock')).toBeVisible();
|
||||
|
||||
// Reload: the hash router restores the /game/<id> route and the local game replays from
|
||||
// IndexedDB with every committed tile intact.
|
||||
// Reload: the hash router restores the /game/<id> route and the local game replays from IndexedDB
|
||||
// with every committed tile intact.
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
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 wait was not reset by relaunching.
|
||||
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload.
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+61
-18
@@ -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
|
||||
// too-old build (the update_required result_code on Execute, a Subscribe FailedPrecondition). The
|
||||
// mock transport never produces one, so — like the maintenance overlay — the e2e drives it through
|
||||
// the window.__update hook (gateway.ts, mock-only). It is app-global (mounted outside the route
|
||||
// blocks in App.svelte), so it shows without a session, and it is terminal (no self-clearing poll).
|
||||
test('update overlay covers the app when the client is too old', async ({ page }) => {
|
||||
// The two-tier client-version gate (docs/ARCHITECTURE.md §2). The mock transport never produces a real
|
||||
// version signal, so — like the maintenance overlay — the e2e drives both tiers through the
|
||||
// window.__update hook (gateway.ts, mock-only): __update.on() raises the HARD "update required" notice
|
||||
// (the net-state machine's offlineVersionLocked), __update.recommend() raises the SOFT "update
|
||||
// available" nudge.
|
||||
|
||||
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 expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||
|
||||
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
|
||||
const overlay = page.getByRole('alertdialog');
|
||||
await expect(overlay).toBeVisible();
|
||||
// One action button (EN "Update" / RU "Обновить" depending on locale).
|
||||
await expect(overlay.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
|
||||
const notice = page.getByRole('alertdialog');
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice.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.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
|
||||
const overlay = page.getByRole('alertdialog');
|
||||
await expect(overlay).toBeVisible();
|
||||
const notice = page.getByRole('alertdialog');
|
||||
await expect(notice).toBeVisible();
|
||||
|
||||
// On the web the action reloads the page to fetch the current client (on a native build it opens
|
||||
// the store instead). The reload resets the terminal store, so the app re-bootstraps: the overlay
|
||||
// is gone and the login is back.
|
||||
// On the web the action reloads the page to fetch the current client (a native build opens the store
|
||||
// instead). The reload re-bootstraps to INITIAL online, so the notice is gone and the login is back.
|
||||
await Promise.all([
|
||||
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('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);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@capacitor/android": "^8.4.1",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/network": "8.0.1",
|
||||
"@connectrpc/connect": "^2.1.0",
|
||||
"@connectrpc/connect-web": "^2.1.0",
|
||||
"@vkontakte/vk-bridge": "^3.0.2",
|
||||
|
||||
Generated
+12
@@ -20,6 +20,9 @@ importers:
|
||||
'@capacitor/core':
|
||||
specifier: ^8.4.1
|
||||
version: 8.4.1
|
||||
'@capacitor/network':
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1(@capacitor/core@8.4.1)
|
||||
'@connectrpc/connect':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.1(@bufbuild/protobuf@2.12.0)
|
||||
@@ -638,6 +641,11 @@ packages:
|
||||
'@capacitor/core@8.4.1':
|
||||
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':
|
||||
resolution: {integrity: sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==}
|
||||
peerDependencies:
|
||||
@@ -4333,6 +4341,10 @@ snapshots:
|
||||
dependencies:
|
||||
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))':
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 2.12.0
|
||||
|
||||
@@ -36,9 +36,13 @@ const DIST = 'dist';
|
||||
// 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
|
||||
// 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
|
||||
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 127, shared: 31, landing: 5 };
|
||||
// always-loaded Wallet screen — then to 130 for the offline-model redesign: the net-state machine
|
||||
// (`netstate`), the two-tier version gate (the transport interceptor + the Update/Play-offline notice
|
||||
// + 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
|
||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||
|
||||
+1
-59
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
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 { t } from './lib/i18n/index.svelte';
|
||||
import { initNativeShell } from './lib/native';
|
||||
@@ -145,21 +145,6 @@
|
||||
<MaintenanceOverlay />
|
||||
<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}
|
||||
<Splash />
|
||||
{/if}
|
||||
@@ -169,49 +154,6 @@
|
||||
{/if}
|
||||
|
||||
<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 {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
|
||||
@@ -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>
|
||||
@@ -1,33 +1,29 @@
|
||||
<script lang="ts">
|
||||
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call
|
||||
// as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is
|
||||
// terminal: an installed build cannot become compatible without an actual update, so its one
|
||||
// action takes the user to the fix — the store listing on a native build (VITE_RUSTORE_URL, opened
|
||||
// in the system browser / store app) or a plain reload on the web (which fetches the current
|
||||
// client). Mirrors MaintenanceOverlay.svelte's look.
|
||||
import { updateRequired } from '../lib/update.svelte';
|
||||
import { clientChannel } from '../lib/channel';
|
||||
// The "update required" notice (the hard version tier). Shown when the gateway has refused a
|
||||
// foreground call as too old, driving the net-state machine into offlineVersionLocked. Unlike the
|
||||
// old terminal cover it is dismissable: "Update" takes the user to the fix (the store on native,
|
||||
// a reload on the web), while "Play offline" dismisses the notice and leaves the app in the offline
|
||||
// lobby (the version lock is an offline state, sticky until an actual update on the next launch).
|
||||
// Mirrors MaintenanceOverlay.svelte's look.
|
||||
import { netState } from '../lib/netstate.svelte';
|
||||
import { openUpdate } from '../lib/update.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
|
||||
function onAction(): void {
|
||||
const ch = clientChannel();
|
||||
if (ch === 'android' || ch === 'ios') {
|
||||
// '_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();
|
||||
}
|
||||
}
|
||||
// Dismissed for the session once the user chooses "Play offline". The lock is sticky until a fresh
|
||||
// launch, so the notice does not reappear this session; the app stays in the offline lobby.
|
||||
let dismissed = $state(false);
|
||||
</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="card">
|
||||
<div class="tile" aria-hidden="true">Э</div>
|
||||
<h1 id="update-title">{t('update.title')}</h1>
|
||||
<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>
|
||||
{/if}
|
||||
@@ -36,8 +32,7 @@
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
/* Above the game (z 60) and toasts (z 50) — a terminal update block covers everything the user
|
||||
could otherwise interact with; below the dev DebugPanel (z 10000). */
|
||||
/* Above the game (z 60) and toasts (z 50); below the dev DebugPanel (z 10000). */
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
@@ -79,6 +74,11 @@
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.acts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.55rem 1.4rem;
|
||||
@@ -92,4 +92,13 @@
|
||||
border-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>
|
||||
|
||||
+55
-131
@@ -56,8 +56,10 @@ import {
|
||||
} from './session';
|
||||
import type { CoachSeries } from './coachmark';
|
||||
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
|
||||
import { offlineMode, setOfflineMode } from './offline.svelte';
|
||||
import { shouldBootOffline } from './offline';
|
||||
import { bootOffline, emit, initNetSignals, registerNetNudge, registerNetToast, registerRecovery } from './netstate.svelte';
|
||||
import { latchUpdateNudge } from './update.svelte';
|
||||
import { clearOfflinePref } from './offline';
|
||||
import { initNativeNetwork } from './native';
|
||||
import { clientChannel } from './channel';
|
||||
import { localGuestId } from './localguest';
|
||||
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
|
||||
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
||||
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
|
||||
* 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
|
||||
@@ -160,7 +158,6 @@ export const app = $state<{
|
||||
}>({
|
||||
ready: false,
|
||||
bootError: false,
|
||||
offlinePrompt: false,
|
||||
launchError: null,
|
||||
debugOpen: false,
|
||||
lobbyReady: false,
|
||||
@@ -574,82 +571,25 @@ async function adoptSession(s: Session): Promise<void> {
|
||||
void refreshNotifications();
|
||||
}
|
||||
|
||||
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice
|
||||
// here; App.svelte's dialog buttons call resolveOfflinePrompt.
|
||||
// The cold-start reachability probe budget: how long the boot waits for the gateway to answer before
|
||||
// launching from the cache into the implicit offline lobby (offline is auto-detected now — no dialog).
|
||||
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):
|
||||
* losing the network interface enters offline mode automatically (session-only); regaining it
|
||||
* verifies the gateway is really reachable (an interface being up does not guarantee it) and returns
|
||||
* to online — but only if the offline was auto. A deliberate offline (the Settings toggle or the
|
||||
* cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no
|
||||
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
|
||||
* recover is the smart recovery the net-state store's watcher runs while connecting/offline: a
|
||||
* session-less native local guest reconciles a server guest (the reconcile attempt IS the reachability
|
||||
* probe — it needs no prior session), while any cached-session launch does a cheap authenticated read.
|
||||
* It resolves when the gateway is reachable (the store then heals to online) and rejects to stay offline.
|
||||
* bootstrap wires it to the store, which owns the poll/backoff timer and the OS-signal listeners.
|
||||
*/
|
||||
// While in auto-offline, poll for the network to return so the app comes back online — robust to the
|
||||
// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it
|
||||
// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the
|
||||
// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in
|
||||
// auto-offline (a deliberate offline is the player's choice) and stops on returning online.
|
||||
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
export function scheduleRecovery(delayMs: number): void {
|
||||
if (recoveryTimer) {
|
||||
clearTimeout(recoveryTimer);
|
||||
recoveryTimer = null;
|
||||
async function recover(): Promise<void> {
|
||||
const channel = clientChannel();
|
||||
if ((channel === 'android' || channel === 'ios') && !app.session) {
|
||||
await reconcileServerGuest();
|
||||
if (!app.session) throw new Error('still a local guest'); // reconcile did not adopt — stay offline
|
||||
return;
|
||||
}
|
||||
// Web / native only; under the mock the e2e drives connectivity + reconciliation directly (cf.
|
||||
// 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);
|
||||
});
|
||||
if (!(await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) throw new Error('gateway unreachable');
|
||||
}
|
||||
|
||||
// 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;
|
||||
try {
|
||||
const s = await gateway.authGuestSilent(app.locale);
|
||||
// The gateway answered, so leave auto-offline BEFORE adopting: adoptSession's profile fetch and live
|
||||
// stream ride the normal (online) transport, which the offline kill switch would otherwise refuse.
|
||||
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
|
||||
// The gateway answered, so heal the machine to online BEFORE adopting: adoptSession's profile fetch
|
||||
// and live stream ride the normal (online) transport, which the offline kill switch would otherwise
|
||||
// refuse. emit('callOk') is idempotent when already online (a re-entrant recover after adoption).
|
||||
emit('callOk');
|
||||
await adoptSession(s);
|
||||
} catch {
|
||||
// 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
|
||||
// (e.g. the Telegram launch-error screen) still counts as an app open.
|
||||
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();
|
||||
app.theme = prefs.theme ?? 'auto';
|
||||
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
|
||||
// and skipped in the mock build; see lib/pwa.svelte.
|
||||
registerServiceWorker();
|
||||
// React to mid-session network changes (e.g. flight mode) for the rest of the session.
|
||||
initNetworkReactivity();
|
||||
|
||||
// Deliberate offline mode carried over from a prior session: with no network the session adoption
|
||||
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
|
||||
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
|
||||
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
|
||||
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
|
||||
// with no cached profile is unreachable: enabling offline requires a prior online session.)
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Wire the net-state machine's signal sources for the rest of the session: the OS connectivity hints
|
||||
// (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
|
||||
// those channels are never fed an offline signal and the machine stays inert (always online) there.
|
||||
registerNetToast((toast) => showToast(t(toast === 'offline' ? 'net.offline' : 'net.online')));
|
||||
registerNetNudge(latchUpdateNudge);
|
||||
registerRecovery(recover);
|
||||
initNetSignals();
|
||||
void initNativeNetwork();
|
||||
|
||||
const saved = await loadSession();
|
||||
// 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).
|
||||
const vkcb = pendingVKLink();
|
||||
if (saved) {
|
||||
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
|
||||
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
|
||||
// the network is down: a device with no network interface goes offline for the session (no
|
||||
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
|
||||
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
|
||||
// Cold-start network auto-detect. With a cached profile to fall back on and no pending VK-link, do
|
||||
// not hang on adoptSession's retrying profile fetch when the network is down: if the interface is
|
||||
// down, or the gateway does not answer within a short window, launch straight from the cache into
|
||||
// the implicit offline lobby (no dialog — offline is detected now). The store's recovery poll heals
|
||||
// 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 canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
|
||||
if (canOffline) {
|
||||
@@ -1004,21 +938,12 @@ export async function bootstrap(): Promise<void> {
|
||||
gateway.setToken(saved.token);
|
||||
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
|
||||
if (!reachable) {
|
||||
// No network interface → go offline for the session (no dialog); interface up but the
|
||||
// gateway is unreachable → ambiguous, so ask.
|
||||
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
|
||||
if (goOffline) {
|
||||
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
|
||||
setOfflineMode(true, !interfaceOffline);
|
||||
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…").
|
||||
bootOffline();
|
||||
app.session = saved;
|
||||
app.profile = cachedProfile;
|
||||
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
||||
app.ready = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
await adoptSession(saved);
|
||||
@@ -1031,15 +956,14 @@ export async function bootstrap(): Promise<void> {
|
||||
navigate('/');
|
||||
}
|
||||
} else if (clientChannel() === 'android' || clientChannel() === 'ios') {
|
||||
// Native offline-first cold boot: no cached server session, so enter as a device-local guest in a
|
||||
// non-sticky (auto) offline mode and land straight in the lobby — never /login. reconcileServerGuest
|
||||
// (kicked here and by the recovery poll) mints a server guest and clears the auto-offline once the
|
||||
// gateway is reachable; until then the guest plays local vs_ai / hotseat with the APK's bundled
|
||||
// dictionaries. Web / PWA / Telegram / VK keep the online-session rule below.
|
||||
// Native offline-first cold boot: no cached server session, so enter as a device-local guest in the
|
||||
// implicit offline lobby and land straight there — never /login. bootOffline(true) drops into offline
|
||||
// and kicks the recovery poll now; recover() mints + adopts a server guest and heals to online once
|
||||
// the gateway is reachable, and until then the guest plays local vs_ai / hotseat with the APK's
|
||||
// bundled dictionaries. Web / PWA / Telegram / VK keep the online-session rule below.
|
||||
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('/');
|
||||
scheduleRecovery(0); // kick reconciliation now, then poll until the network returns
|
||||
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
|
||||
navigate('/login');
|
||||
}
|
||||
@@ -1424,9 +1348,9 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
|
||||
navigate,
|
||||
route: () => router.route.name,
|
||||
};
|
||||
// Drive the native offline-first reconciliation from the e2e: the recovery poll that fires it in a
|
||||
// real build is skipped under the mock (scheduleRecovery), so the spec calls this to simulate "the
|
||||
// network returned" and prove a server guest is minted + adopted and the auto-offline clears.
|
||||
// Drive the native offline-first reconciliation from the e2e: the net-state store's recovery poll that
|
||||
// fires it in a real build is inert under the mock, so the spec calls this to simulate "the network
|
||||
// returned" and prove a server guest is minted + adopted and the machine heals to online.
|
||||
(window as unknown as { __native?: { reconcile(): void } }).__native = {
|
||||
reconcile: () => void reconcileServerGuest(),
|
||||
};
|
||||
|
||||
@@ -1,80 +1,49 @@
|
||||
// Global connectivity signal. `online` is false while the app is actively failing to
|
||||
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live
|
||||
// stream dropped. The transport and the live-stream owner report transitions; the UI reads
|
||||
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive
|
||||
// actions. In mock mode nothing ever reports trouble, so it simply stays online.
|
||||
//
|
||||
// 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.
|
||||
// connection.svelte.ts is now a thin shim over the net-state store (netstate.svelte.ts, O2). It keeps
|
||||
// the historical surface — `connection.online` plus the report/probe helpers the transport and the live
|
||||
// stream call — so those consumers are unchanged while the single net-state machine drives everything
|
||||
// underneath. `online` is true only in the fully-online machine state; a failing call or dropped stream
|
||||
// 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.
|
||||
|
||||
import { backoffMs } from './retry';
|
||||
import { offlineMode } from './offline.svelte';
|
||||
|
||||
let online = $state(true);
|
||||
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let probe: (() => Promise<void>) | null = null;
|
||||
import {
|
||||
netState,
|
||||
emit,
|
||||
registerProbe as registerNetProbe,
|
||||
checkReachable as checkNetReachable,
|
||||
resetNetState,
|
||||
} from './netstate.svelte';
|
||||
|
||||
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 {
|
||||
return online;
|
||||
return netState.online;
|
||||
},
|
||||
};
|
||||
|
||||
/** registerProbe installs the reachability probe the watcher fires while offline. The transport
|
||||
* wires a cheap authenticated read; it should reject when there is no session. */
|
||||
/** registerProbe installs the reachability probe the store's watcher fires while connecting/offline.
|
||||
* The transport wires a cheap authenticated read; it should reject when there is no session. */
|
||||
export function registerProbe(fn: () => Promise<void>): void {
|
||||
probe = fn;
|
||||
registerNetProbe(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
|
||||
* gateway answered — a single attempt (no retry loop), for the cold-start network decision — while
|
||||
* updating the online signal. It reports false when no probe is registered or the timeout wins.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
/** checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
|
||||
* gateway answered — a single attempt for the cold-start / recovery decision. */
|
||||
export function checkReachable(timeoutMs: number): Promise<boolean> {
|
||||
return checkNetReachable(timeoutMs);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
online = true;
|
||||
if (watchTimer) {
|
||||
clearTimeout(watchTimer);
|
||||
watchTimer = null;
|
||||
}
|
||||
emit('callOk');
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
online = false;
|
||||
if (!watchTimer && probe) scheduleProbe(1);
|
||||
if (netState.online) emit('callFailed');
|
||||
}
|
||||
|
||||
/** resetConnection restores the online state and stops the watcher (e.g. on logout). */
|
||||
export function resetConnection(): void {
|
||||
reportOnline();
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
resetNetState();
|
||||
}
|
||||
|
||||
@@ -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
@@ -7,8 +7,9 @@ import { MockGateway } from './mock/client';
|
||||
import { createTransport } from './transport';
|
||||
import { setForcedSeed } from './localgame/id';
|
||||
import { reportOffline, reportOnline } from './connection.svelte';
|
||||
import { emit } from './netstate.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';
|
||||
|
||||
@@ -32,9 +33,25 @@ if (isMock && typeof window !== 'undefined') {
|
||||
off: clearMaintenance,
|
||||
recover: maintenanceRecovered,
|
||||
};
|
||||
// The mock never receives a real update_required from the edge, so the e2e drives the terminal
|
||||
// "update required" overlay directly (it is terminal — there is no clear hook).
|
||||
(window as unknown as { __update?: { on(): void } }).__update = { on: reportUpdateRequired };
|
||||
// The mock never receives a real version signal from the edge, so the e2e drives them directly:
|
||||
// __update.on() raises the hard-tier notice (netState.versionLocked), __update.recommend() raises the
|
||||
// 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
|
||||
// attaches a robot on a timer).
|
||||
(
|
||||
|
||||
@@ -7,12 +7,16 @@ export const en = {
|
||||
'app.brand': 'Erudit',
|
||||
'dict.loading': 'Loading…',
|
||||
'connection.connecting': 'Connecting…',
|
||||
'net.offline': 'You are offline',
|
||||
'net.online': 'Back online',
|
||||
'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.retry': 'Try again',
|
||||
'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.action': 'Update',
|
||||
'update.playOffline': 'Play offline',
|
||||
'update.available': 'Update available',
|
||||
|
||||
'blocked.title': 'Account blocked',
|
||||
'blocked.permanent': 'Your account is blocked.',
|
||||
@@ -219,15 +223,8 @@ export const en = {
|
||||
'settings.labelsNone': 'None',
|
||||
'settings.reduceMotion': 'Reduce motion',
|
||||
'settings.zoomBoard': 'Zoom the board',
|
||||
'settings.offlineMode': 'Play mode',
|
||||
'settings.online': 'Online',
|
||||
'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.promptTitle': 'No connection. Enable offline mode?',
|
||||
'offline.promptYes': 'Enable',
|
||||
'offline.promptNo': 'Keep trying',
|
||||
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Wallet',
|
||||
@@ -378,6 +375,10 @@ export const en = {
|
||||
|
||||
'new.auto': 'Quick match',
|
||||
'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.searchFriends': 'Search friends',
|
||||
'new.gameType': 'Variant',
|
||||
|
||||
@@ -8,12 +8,16 @@ export const ru: Record<MessageKey, string> = {
|
||||
'app.brand': 'Эрудит',
|
||||
'dict.loading': 'Загрузка…',
|
||||
'connection.connecting': 'Подключение…',
|
||||
'net.offline': 'Нет соединения',
|
||||
'net.online': 'Соединение восстановлено',
|
||||
'maintenance.title': 'Технические работы',
|
||||
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
||||
'maintenance.retry': 'Повторить',
|
||||
'update.title': 'Требуется обновление',
|
||||
'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.',
|
||||
'update.action': 'Обновить',
|
||||
'update.playOffline': 'Играть офлайн',
|
||||
'update.available': 'Доступно обновление',
|
||||
|
||||
'blocked.title': 'Учётная запись заблокирована',
|
||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||
@@ -219,15 +223,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'settings.labelsNone': 'Без текста',
|
||||
'settings.reduceMotion': 'Меньше анимаций',
|
||||
'settings.zoomBoard': 'Приближать доску',
|
||||
'settings.offlineMode': 'Режим игры',
|
||||
'settings.online': 'Онлайн',
|
||||
'settings.offline': 'Оффлайн',
|
||||
'settings.offlineChecking': 'Загрузка словарей…',
|
||||
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
|
||||
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
||||
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
|
||||
'offline.promptYes': 'Включить',
|
||||
'offline.promptNo': 'Ждать сеть',
|
||||
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Кошелёк',
|
||||
@@ -378,6 +375,10 @@ export const ru: Record<MessageKey, string> = {
|
||||
|
||||
'new.auto': 'Быстрая игра',
|
||||
'new.withFriends': 'Игра с друзьями',
|
||||
'new.playRemote': 'Пригласить друга',
|
||||
'new.playLocal': 'На одном устройстве',
|
||||
'new.needsNetwork': 'Для игры онлайн нужна сеть.',
|
||||
'new.dictUnavailable': 'Этот словарь пока недоступен офлайн.',
|
||||
'new.pickFriends': 'Кого пригласить',
|
||||
'new.searchFriends': 'Поиск друзей',
|
||||
'new.gameType': 'Вариант',
|
||||
|
||||
@@ -44,10 +44,10 @@ beforeEach(() => clearLobby());
|
||||
|
||||
describe('patchLobbyGame', () => {
|
||||
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.
|
||||
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.find((g) => g.id === 'a')?.toMove).toBe(1);
|
||||
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', () => {
|
||||
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'));
|
||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||
expect(getLobby(false)?.games[0].status).toBe('finished');
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
expect(getLobby()?.games[0].status).toBe('finished');
|
||||
});
|
||||
|
||||
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'));
|
||||
// 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', () => {
|
||||
patchLobbyGame(gameView('a'));
|
||||
expect(getLobby(false)).toBeNull();
|
||||
expect(getLobby()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mutate the previous snapshot array', () => {
|
||||
const games = [gameView('a', 'active', 0)];
|
||||
setLobby({ games, invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games, invitations: [], incoming: [] });
|
||||
patchLobbyGame(gameView('a', 'active', 1));
|
||||
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
||||
});
|
||||
@@ -83,59 +83,52 @@ describe('patchLobbyGame', () => {
|
||||
|
||||
describe('patchLobbyInvitation', () => {
|
||||
it('adds a new pending invitation to the cached lobby', () => {
|
||||
setLobby({ games: [], invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [], incoming: [] });
|
||||
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)', () => {
|
||||
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 };
|
||||
patchLobbyInvitation(updated);
|
||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||
expect(getLobby(false)?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
||||
});
|
||||
|
||||
it('removes an invitation that reached a terminal status', () => {
|
||||
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));
|
||||
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', () => {
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
|
||||
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', () => {
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby(false)).toBeNull();
|
||||
expect(getLobby()).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves games and incoming when patching an invitation', () => {
|
||||
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'));
|
||||
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
|
||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLobby is mode-aware (a mode flip must not render the other mode’s games)', () => {
|
||||
it('returns the snapshot only for the mode it was stored under', () => {
|
||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
|
||||
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
|
||||
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
|
||||
expect(getLobby(true)).toBeNull();
|
||||
});
|
||||
|
||||
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']);
|
||||
describe('the unified snapshot (one lobby cache — offline reuses its server games, greyed)', () => {
|
||||
it('replaces the whole snapshot on each store, so the latest merged lists win', () => {
|
||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [] });
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['online1']);
|
||||
setLobby({ games: [gameView('local1'), gameView('online2')], invitations: [], incoming: [] });
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['local1', 'online2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,18 +10,15 @@ interface LobbySnapshot {
|
||||
games: GameView[];
|
||||
invitations: Invitation[];
|
||||
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;
|
||||
|
||||
/** getLobby returns the last lobby lists for the given mode, or null before the first load or when
|
||||
* the cached snapshot belongs to the other mode (so the lobby cold-renders after a mode flip). */
|
||||
export function getLobby(offline: boolean): LobbySnapshot | null {
|
||||
return snapshot && snapshot.offline === offline ? snapshot : null;
|
||||
/** getLobby returns the last lobby lists, or null before the first load. The unified lobby caches one
|
||||
* snapshot (device-local + server games merged); offline reuses its server games, greyed, and drops
|
||||
* the local ones for a fresh device read. */
|
||||
export function getLobby(): LobbySnapshot | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** setLobby stores the latest lobby lists. */
|
||||
|
||||
@@ -3,6 +3,9 @@ import { gamePhase, groupGames, isMyTurn, orderedSeats, scoreStanding, shouldBli
|
||||
import type { GameView, Seat } from './model';
|
||||
|
||||
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 => ({
|
||||
seat: s,
|
||||
accountId,
|
||||
@@ -41,7 +44,7 @@ describe('groupGames', () => {
|
||||
game('b', 'active', 1, 100), // their turn
|
||||
game('c', 'finished', 0, 100),
|
||||
],
|
||||
ME,
|
||||
MINE,
|
||||
{},
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
|
||||
@@ -59,7 +62,7 @@ describe('groupGames', () => {
|
||||
game('f_new', 'finished', 0, 200),
|
||||
game('f_old', 'finished', 0, 100),
|
||||
],
|
||||
ME,
|
||||
MINE,
|
||||
{},
|
||||
);
|
||||
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_new', 'finished', 0, 300), // finished ignores unread, stays newest-first
|
||||
],
|
||||
ME,
|
||||
MINE,
|
||||
{ y_unread: true, t_unread: true, f_unread: true },
|
||||
);
|
||||
// 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', () => {
|
||||
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
|
||||
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
|
||||
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false);
|
||||
expect(isMyTurn(game('x', 'finished', 0, 0), MINE)).toBe(false);
|
||||
expect(isMyTurn(game('x', 'active', 0, 0), MINE)).toBe(true);
|
||||
expect(isMyTurn(game('x', 'active', 1, 0), MINE)).toBe(false);
|
||||
});
|
||||
|
||||
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_wait', 'open', 1, 100), // the empty seat's turn
|
||||
],
|
||||
ME,
|
||||
MINE,
|
||||
{},
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
|
||||
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
|
||||
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', () => {
|
||||
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', 1, 0), ME)).toBe('theirs');
|
||||
expect(gamePhase(game('x', 'open', 0, 0), ME)).toBe('mine');
|
||||
expect(gamePhase(game('x', 'open', 1, 0), ME)).toBe('theirs');
|
||||
expect(gamePhase(game('x', 'finished', 0, 0), ME)).toBe('finished');
|
||||
expect(gamePhase(game('x', 'active', 0, 0), MINE)).toBe('mine');
|
||||
expect(gamePhase(game('x', 'active', 1, 0), MINE)).toBe('theirs');
|
||||
expect(gamePhase(game('x', 'open', 0, 0), MINE)).toBe('mine');
|
||||
expect(gamePhase(game('x', 'open', 1, 0), MINE)).toBe('theirs');
|
||||
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', () => {
|
||||
const lead = withScores('active', 30, 10);
|
||||
expect(scoreStanding(lead, ME, 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, ME))).toBe('win');
|
||||
expect(scoreStanding(lead, MINE, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured
|
||||
|
||||
const behind = withScores('active', 5, 10);
|
||||
expect(scoreStanding(behind, ME, 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, ME))).toBe('lose');
|
||||
expect(scoreStanding(behind, MINE, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured
|
||||
});
|
||||
|
||||
it('greens both seats on an equal non-zero score', () => {
|
||||
const tie = withScores('active', 10, 10);
|
||||
expect(scoreStanding(tie, ME, at(tie, ME))).toBe('win');
|
||||
expect(scoreStanding(tie, ME, at(tie, 'opp'))).toBe('win');
|
||||
expect(scoreStanding(tie, MINE, at(tie, ME))).toBe('win');
|
||||
expect(scoreStanding(tie, MINE, at(tie, 'opp'))).toBe('win');
|
||||
});
|
||||
|
||||
it('leaves a fresh 0:0 board muted (nobody has scored yet)', () => {
|
||||
const fresh = withScores('active', 0, 0);
|
||||
expect(scoreStanding(fresh, ME, at(fresh, ME))).toBeNull();
|
||||
expect(scoreStanding(fresh, ME, at(fresh, 'opp'))).toBeNull();
|
||||
expect(scoreStanding(fresh, MINE, at(fresh, ME))).toBeNull();
|
||||
expect(scoreStanding(fresh, MINE, at(fresh, 'opp'))).toBeNull();
|
||||
});
|
||||
|
||||
it('is null for any non-active game (open or finished)', () => {
|
||||
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);
|
||||
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', () => {
|
||||
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 }] };
|
||||
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', () => {
|
||||
@@ -175,8 +191,8 @@ describe('scoreStanding', () => {
|
||||
{ ...seat(2, 'b'), score: 50 }, // b is ahead -> the viewer is losing
|
||||
],
|
||||
};
|
||||
expect(scoreStanding(g3, ME, 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, ME))).toBe('lose');
|
||||
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', () => {
|
||||
@@ -189,9 +205,9 @@ describe('scoreStanding', () => {
|
||||
{ ...seat(2, 'b'), score: 20 }, // trailing -> muted
|
||||
],
|
||||
};
|
||||
expect(scoreStanding(g3, ME, at(g3, ME))).toBe('win');
|
||||
expect(scoreStanding(g3, ME, at(g3, 'a'))).toBe('win');
|
||||
expect(scoreStanding(g3, ME, at(g3, 'b'))).toBeNull();
|
||||
expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('win');
|
||||
expect(scoreStanding(g3, MINE, at(g3, 'a'))).toBe('win');
|
||||
expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-12
@@ -5,9 +5,12 @@
|
||||
|
||||
import type { GameView, Seat } from './model';
|
||||
|
||||
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */
|
||||
export function isMyTurn(game: GameView, myId: string): boolean {
|
||||
const me = game.seats.find((s) => s.accountId === myId);
|
||||
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. myIds carries every id
|
||||
* that is "you" — the server user id and the device-local guest id — since after reconciliation a
|
||||
* 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'.
|
||||
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
|
||||
* 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;
|
||||
const me = game.seats.find((s) => s.accountId === myId);
|
||||
const me = game.seats.find((s) => myIds.includes(s.accountId));
|
||||
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;
|
||||
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
|
||||
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.
|
||||
return meLeads && seat.score === top ? 'win' : null;
|
||||
}
|
||||
@@ -47,13 +50,13 @@ export function orderedSeats(game: GameView): GameView['seats'] {
|
||||
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
|
||||
* 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';
|
||||
return isMyTurn(game, myId) ? 'mine' : 'theirs';
|
||||
return isMyTurn(game, myIds) ? 'mine' : 'theirs';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +84,7 @@ export interface LobbyGroups {
|
||||
*/
|
||||
export function groupGames(
|
||||
games: GameView[],
|
||||
myId: string,
|
||||
myIds: readonly string[],
|
||||
unread: Record<string, boolean>,
|
||||
): LobbyGroups {
|
||||
const yourTurn: GameView[] = [];
|
||||
@@ -89,7 +92,7 @@ export function groupGames(
|
||||
const finished: GameView[] = [];
|
||||
for (const g of games) {
|
||||
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);
|
||||
}
|
||||
// Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// statement is only ever reached inside the packaged native app.
|
||||
|
||||
import { clientChannel } from './channel';
|
||||
import { emit } from './netstate.svelte';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -1,68 +1,26 @@
|
||||
// The deliberate offline MODE: a sticky, device-scoped reactive flag the app reads to gate the
|
||||
// network, tint the chrome blue and show only local games. It is the player's own choice (the
|
||||
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
|
||||
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
|
||||
// offline.svelte.ts: the offline-mode read surface (a thin shim over the net-state store,
|
||||
// netstate.svelte.ts) plus the orthogonal dictionary-preload warning + background preload. Offline is
|
||||
// implicit — detected by the net-state machine — so offlineMode.active just derives from netState; there
|
||||
// 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 { insideTelegram } from './telegram';
|
||||
import { insideVK } from './vk';
|
||||
import type { Profile } from './model';
|
||||
|
||||
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
|
||||
let active = $state(loadOfflinePref());
|
||||
// True while the current offline state was entered automatically (no network detected), not chosen
|
||||
// by the player. An auto offline self-heals to online when the network returns; a deliberate one is
|
||||
// left as the player's choice.
|
||||
let auto = $state(false);
|
||||
|
||||
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
|
||||
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). */
|
||||
get active(): boolean {
|
||||
return active;
|
||||
},
|
||||
/** auto is true while offline was entered automatically (no network), not by the player. */
|
||||
get auto(): boolean {
|
||||
return auto;
|
||||
return netState.offline;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* setOfflineMode enters or leaves offline mode. By default it persists the choice (device-scoped) —
|
||||
* a deliberate choice (the Settings toggle, or the cold-start "no connection" dialog). Pass
|
||||
* 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.
|
||||
// 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 play may be incomplete. The lobby
|
||||
// shows a notice in place of the ad banner while it holds.
|
||||
let preloadWarn = $state(false);
|
||||
|
||||
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
|
||||
@@ -78,18 +36,18 @@ export function setDictPreloadWarning(on: boolean): void {
|
||||
preloadWarn = on;
|
||||
}
|
||||
|
||||
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
|
||||
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
|
||||
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby mount or
|
||||
// a variant-preference change) can start another. Module-scoped so mounts do not stack.
|
||||
let preloadInFlight = false;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
|
||||
* browser tab, without a confirmed email, while offline, or when a preload is already running;
|
||||
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
|
||||
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
|
||||
* and generator are imported dynamically, so neither is pulled into the main bundle.
|
||||
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later switch
|
||||
* to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain browser tab,
|
||||
* without a confirmed email, while offline, or when a preload is already running; getDawg's caching
|
||||
* makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a fetch failure raises the
|
||||
* in-lobby notice, and a later successful run clears it. The dict loader and generator are imported
|
||||
* dynamically, so neither is pulled into the main bundle.
|
||||
*/
|
||||
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
|
||||
if (preloadInFlight || !prof) return;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline';
|
||||
import type { Variant } from './model';
|
||||
import { clearOfflinePref, offlinePreloadEligible } from './offline';
|
||||
|
||||
// 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(() => {
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = {
|
||||
@@ -15,27 +14,7 @@ beforeEach(() => {
|
||||
} as Storage;
|
||||
});
|
||||
|
||||
describe('offline mode 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']);
|
||||
});
|
||||
|
||||
describe('offline helpers', () => {
|
||||
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
|
||||
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
|
||||
expect(offlinePreloadEligible(base)).toBe(true);
|
||||
@@ -46,32 +25,11 @@ describe('offline mode helpers', () => {
|
||||
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => {
|
||||
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true);
|
||||
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false);
|
||||
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false);
|
||||
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
it('clearOfflinePref removes the retired deliberate-offline key (best-effort, idempotent)', () => {
|
||||
localStorage.setItem('scrabble.offlineMode', '1');
|
||||
clearOfflinePref();
|
||||
expect(localStorage.getItem('scrabble.offlineMode')).toBeNull();
|
||||
// Idempotent: a second call on an already-clear key is a no-op, not an error.
|
||||
expect(() => clearOfflinePref()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+17
-68
@@ -1,70 +1,29 @@
|
||||
// Pure helpers for the deliberate offline MODE — its device-scoped persistence and the readiness
|
||||
// decision — kept out of the reactive module (offline.svelte.ts) so they unit-test in the node env.
|
||||
// The deliberate offline mode is distinct from connection.svelte.ts's transient "can we reach the
|
||||
// gateway" signal: it is the player's own sticky choice, and it gates the network, tints the chrome
|
||||
// and shows only local games.
|
||||
|
||||
import type { Variant } from './model';
|
||||
// Pure helpers for offline-mode support, kept out of the reactive module (offline.svelte.ts) so they
|
||||
// unit-test in the node env. Offline became implicit — the net-state machine detects connectivity, and
|
||||
// there is no deliberate toggle — so only the background dict-preload eligibility check and a one-time
|
||||
// cleanup of the retired deliberate-offline preference remain here.
|
||||
|
||||
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. */
|
||||
export function loadOfflinePref(): boolean {
|
||||
/**
|
||||
* clearOfflinePref removes the retired deliberate-offline preference key. The offline model is implicit
|
||||
* 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 {
|
||||
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||
if (typeof localStorage !== 'undefined') localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
/* best-effort — a storage failure just leaves the orphaned key, which is never read anyway */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* offlineReady reports whether the device can play offline right now: at least one variant is
|
||||
* enabled and every enabled variant's dictionary is already available on the device (hasDict). The
|
||||
* offline toggle uses it to decide whether flipping to offline can succeed immediately.
|
||||
*/
|
||||
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.
|
||||
* 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 play
|
||||
* to prepare for — so kickDictPreload skips it.
|
||||
*/
|
||||
export function offlinePreloadEligible(opts: {
|
||||
hasEmail: boolean;
|
||||
@@ -75,13 +34,3 @@ export function offlinePreloadEligible(opts: {
|
||||
}): boolean {
|
||||
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
@@ -34,28 +34,36 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
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, toMove: 1 }, 'me').key).toBe('result.oppMove');
|
||||
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
|
||||
expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
|
||||
});
|
||||
|
||||
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);
|
||||
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
|
||||
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
|
||||
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
|
||||
expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
|
||||
});
|
||||
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
emoji: '🏅',
|
||||
});
|
||||
@@ -64,7 +72,7 @@ describe('resultBadge', () => {
|
||||
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
|
||||
// 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',
|
||||
emoji: '🥈',
|
||||
});
|
||||
@@ -72,21 +80,21 @@ describe('resultBadge', () => {
|
||||
|
||||
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)]);
|
||||
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)]);
|
||||
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', () => {
|
||||
// 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.
|
||||
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, '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: '🥉' });
|
||||
});
|
||||
|
||||
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' };
|
||||
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' });
|
||||
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.draw', emoji: '🏅' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ function placeBadge(rank: number, players: number): ResultBadge {
|
||||
return { key: 'result.place4', emoji: '🏅' };
|
||||
}
|
||||
|
||||
export function resultBadge(game: GameView, myId: string): ResultBadge {
|
||||
const me = game.seats.find((s) => s.accountId === myId);
|
||||
export function resultBadge(game: GameView, myIds: readonly string[]): ResultBadge {
|
||||
const me = game.seats.find((s) => myIds.includes(s.accountId));
|
||||
|
||||
if (game.status === 'active' || game.status === 'open') {
|
||||
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
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+16
-2
@@ -17,7 +17,7 @@ import { offlineMode } from './offline.svelte';
|
||||
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
|
||||
import { maintenanceRetryMs } from './maintenance';
|
||||
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 sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -31,7 +31,21 @@ function assertOnline(): void {
|
||||
|
||||
export function createTransport(baseUrl: string): GatewayClient {
|
||||
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);
|
||||
let token: string | null = null;
|
||||
|
||||
|
||||
+65
-15
@@ -1,26 +1,76 @@
|
||||
// Global "client too old" signal. `active` latches true the first time the gateway answers a
|
||||
// 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
|
||||
// maintenance signal there is no self-clearing poll, because an installed build cannot become
|
||||
// compatible without an actual update. A non-dismissable overlay (UpdateOverlay.svelte) then covers
|
||||
// the app; its one action sends the user to the store (native) or reloads (web). Offline play never
|
||||
// trips it — the network kill switch refuses the call before it leaves the device.
|
||||
// The client-version gate's two client signals (docs/ARCHITECTURE.md §2).
|
||||
//
|
||||
// HARD tier ("client too old"): the gateway refuses a foreground call with the update_required
|
||||
// sentinel (the Execute result_code, the Subscribe FailedPrecondition). The transport reports it here,
|
||||
// which drives the net-state machine into offlineVersionLocked; the notice (UpdateOverlay.svelte) reads
|
||||
// netState.versionLocked and offers "Update" (store / reload) or "Play offline" (dismiss → stay
|
||||
// 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
|
||||
* GatewayError code the Subscribe FailedPrecondition maps to) for a client too old to be served. */
|
||||
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 = {
|
||||
/** active is true once a foreground online call has been refused as too old. Terminal. */
|
||||
/** reportUpdateRequired drives the net-state machine into offlineVersionLocked (the hard tier). The
|
||||
* 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 {
|
||||
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
|
||||
* a foreground call returns the update_required sentinel; idempotent. */
|
||||
export function reportUpdateRequired(): void {
|
||||
required = true;
|
||||
/** latchUpdateNudge raises the soft-nudge latch. It is wired to the machine's setNudge effect
|
||||
* (registerNetNudge), which fires only from online, so the nudge is never raised while offline or
|
||||
* version-locked. */
|
||||
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
@@ -3,9 +3,10 @@
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import UpdateNudge from '../components/UpdateNudge.svelte';
|
||||
import Modal from '../components/Modal.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 { gateway } from '../lib/gateway';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
@@ -16,6 +17,9 @@
|
||||
import { preloadGames } from '../lib/preload';
|
||||
import { kickDictPreload, offlineMode } from '../lib/offline.svelte';
|
||||
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 type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
|
||||
@@ -36,27 +40,37 @@
|
||||
let loadSeq = 0;
|
||||
async function load() {
|
||||
const seq = ++loadSeq;
|
||||
// Deliberate offline mode: never touch the network. The lobby shows only the device-local
|
||||
// vs_ai games (reconstructed from the store) plus the New-vs-AI entry; there are no online
|
||||
// games, invitations or incoming requests to fetch.
|
||||
if (offlineMode.active) {
|
||||
const local = await localSource.list();
|
||||
if (seq !== loadSeq) return;
|
||||
games = local;
|
||||
const offline = offlineMode.active;
|
||||
// Device-local games (vs_ai / hotseat) merge into the unified lobby everywhere except the
|
||||
// always-online Telegram/VK mini-apps, which are server-only (the shared-origin local store must
|
||||
// not leak device-local games into a mini-app).
|
||||
const serverOnly = insideTelegram() || insideVK();
|
||||
const local = serverOnly ? [] : await localSource.list();
|
||||
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 = [];
|
||||
incoming = [];
|
||||
app.notifications = 0;
|
||||
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||
setLobby({ games, invitations, incoming });
|
||||
app.lobbyReady = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = await gateway.gamesList();
|
||||
if (seq !== loadSeq) return;
|
||||
games = list.games;
|
||||
// Seed the per-game unread badges from the authoritative list. The live stream only raises
|
||||
const server = list.games;
|
||||
// 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.
|
||||
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) {
|
||||
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
||||
if (seq !== loadSeq) return;
|
||||
@@ -67,12 +81,12 @@
|
||||
app.notifications = incoming.length;
|
||||
void refreshFeedbackBadge();
|
||||
}
|
||||
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
|
||||
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
|
||||
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
|
||||
setLobby({ games, invitations, incoming });
|
||||
// Warm the cache for the ongoing SERVER games so opening one from the lobby is instant (local
|
||||
// games open from the device). The list just loaded, so the connection is up; non-blocking and
|
||||
// cheap (already-cached ones are skipped).
|
||||
if (connection.online) {
|
||||
void preloadGames(games);
|
||||
void preloadGames(server);
|
||||
// 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
|
||||
// lobby entry surfaces a notice in place of the ad banner if a fetch fails.
|
||||
@@ -90,7 +104,7 @@
|
||||
onMount(() => {
|
||||
// Render instantly from the cached lists (so the screen does not "draw in" during
|
||||
// the back-slide), then refresh in the background.
|
||||
const cached = getLobby(offlineMode.active);
|
||||
const cached = getLobby();
|
||||
if (cached) {
|
||||
games = cached.games;
|
||||
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 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"
|
||||
// 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>();
|
||||
for (const g of games) {
|
||||
seen.add(g.id);
|
||||
const phase = gamePhase(g, myId);
|
||||
const phase = gamePhase(g, myIds);
|
||||
if (shouldBlink(prevPhase.get(g.id), phase)) blink(g.id);
|
||||
prevPhase.set(g.id, phase);
|
||||
}
|
||||
@@ -169,7 +191,7 @@
|
||||
// An honest-AI game shows the robot opponent as 🤖, never its (pooled) name.
|
||||
if (g.vsAi) return '🤖';
|
||||
return g.seats
|
||||
.filter((s) => s.accountId !== myId)
|
||||
.filter((s) => !myIds.includes(s.accountId))
|
||||
.map((s) => s.displayName)
|
||||
.join(', ');
|
||||
}
|
||||
@@ -213,6 +235,11 @@
|
||||
revealedId = null;
|
||||
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}`);
|
||||
}
|
||||
function toggleReveal(id: string): void {
|
||||
@@ -222,7 +249,7 @@
|
||||
revealedId = null;
|
||||
const prev = games;
|
||||
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 {
|
||||
// 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
|
||||
@@ -231,7 +258,7 @@
|
||||
else await gateway.hideGame(id);
|
||||
} catch (e) {
|
||||
games = prev;
|
||||
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||
setLobby({ games, invitations, incoming });
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -276,7 +303,7 @@
|
||||
|
||||
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||||
<div class="lobby">
|
||||
{#if invitations.length}
|
||||
{#if invitations.length && !offlineMode.active}
|
||||
<section>
|
||||
<h2>{t('lobby.invitations')}</h2>
|
||||
{#each invitations as inv (inv.id)}
|
||||
@@ -319,7 +346,7 @@
|
||||
<div class="list">
|
||||
{#each group.list as g (g.id)}
|
||||
{@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}
|
||||
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||||
{/if}
|
||||
@@ -336,7 +363,7 @@
|
||||
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||
</span>
|
||||
<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
|
||||
class="num"
|
||||
class:win={standing === 'win'}
|
||||
@@ -349,7 +376,7 @@
|
||||
plaques, and resultBadge is viewer-centric (no seat matches the account). A
|
||||
vs_ai game keeps its medal (one human, a straightforward win/loss). -->
|
||||
{#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}
|
||||
</button>
|
||||
{#if group.finished || g.hotseat}
|
||||
@@ -397,6 +424,7 @@
|
||||
{/if}
|
||||
|
||||
{#snippet tabbar()}
|
||||
<UpdateNudge />
|
||||
<TabBar>
|
||||
<button class="tab" onclick={() => navigate('/new')}>
|
||||
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||
@@ -495,6 +523,10 @@
|
||||
.rowwrap + .rowwrap {
|
||||
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 {
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { offlineMode } from '../lib/offline.svelte';
|
||||
import { localSource } from '../lib/gamesource';
|
||||
import { localSource, isLocalGameId } from '../lib/gamesource';
|
||||
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
|
||||
import { localGuestId } from '../lib/localguest';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
@@ -52,6 +52,13 @@
|
||||
|
||||
const guest = $derived(app.profile?.isGuest ?? true);
|
||||
let mode = $state<'auto' | 'friends'>('auto');
|
||||
// Within "with friends": online = a remote invite, offline = a pass-and-play (hotseat) game on this
|
||||
// device. Both exist regardless of connectivity, but 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 🤖)
|
||||
// or a random human via auto-match. AI is the default.
|
||||
let opponent = $state<'ai' | 'random'>('ai');
|
||||
@@ -63,7 +70,10 @@
|
||||
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
|
||||
// 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));
|
||||
let limitOpen = $state(false);
|
||||
|
||||
@@ -125,6 +135,37 @@
|
||||
$effect(() => {
|
||||
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 hints = $state(1);
|
||||
|
||||
@@ -355,10 +396,11 @@
|
||||
{/if}
|
||||
<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 dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
|
||||
<button
|
||||
class="invite"
|
||||
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))}
|
||||
>{#if autoLocked}🔒 {/if}{t('new.start')}</button>
|
||||
<GameLimitModal
|
||||
@@ -367,7 +409,16 @@
|
||||
onclose={() => (limitOpen = false)}
|
||||
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. Both exist regardless of connectivity, but with no network the online segment is
|
||||
disabled and offline is forced (friendsMode). -->
|
||||
<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 friendsMode === 'offline'}
|
||||
<!-- 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. -->
|
||||
<div class="hostpin">
|
||||
@@ -434,9 +485,10 @@
|
||||
+ {t('hotseat.addPlayer')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
|
||||
<button
|
||||
class="invite"
|
||||
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
|
||||
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || dictBlocked || starting}
|
||||
onclick={startHotseat}
|
||||
>{t('new.start')}</button>
|
||||
{:else if friends.length === 0}
|
||||
@@ -485,6 +537,7 @@
|
||||
{/if}
|
||||
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if pad}
|
||||
@@ -708,6 +761,14 @@
|
||||
font-size: 0.85rem;
|
||||
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 --- */
|
||||
.hostpin {
|
||||
display: flex;
|
||||
|
||||
@@ -11,22 +11,12 @@
|
||||
import type { ThemePref } from '../lib/theme';
|
||||
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||
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';
|
||||
|
||||
// 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.
|
||||
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 themeLabel: Record<ThemePref, MessageKey> = {
|
||||
auto: 'settings.themeAuto',
|
||||
@@ -41,24 +31,6 @@
|
||||
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>
|
||||
|
||||
<div class="page">
|
||||
@@ -119,33 +91,6 @@
|
||||
{/if}
|
||||
</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
|
||||
is installable here — see components/InstallApp.svelte / lib/pwa). -->
|
||||
<InstallApp />
|
||||
@@ -190,14 +135,6 @@
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.onote {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.onote--warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user