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