Compare commits

..

10 Commits

Author SHA1 Message Date
Ilia Denisov fa4dc2412a feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 13s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Failing after 15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
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).
2026-07-13 01:44:28 +02:00
Ilia Denisov 09e05eef18 docs(offline): stage the offline-model redesign (O1-O7 implementation plan)
Append the O1-O7 implementation stages to ANDROID_PLAN.md's "Offline-model
redesign" section (exact files, produced/consumed interfaces, acceptance
criteria, targeted tests; executed via stage-implementation, TDD from the pure
netState reducer in O1). Flag it in Progress as the next actionable work
(gated G-step-0), self-contained for a fresh session to resume from.
2026-07-12 22:07:49 +02:00
Ilia Denisov 73baf58002 docs(offline): design the offline-model redesign (net-state machine + two-tier gate)
Owner-approved brainstormed design in ANDROID_PLAN.md, resolving G-step-0:
remove the explicit offline toggle -> a single net-state machine
(online / connecting / offlineNoNetwork / offlineVersionLocked) with explicit
hysteresis + 12 enumerated edge cases; unify the lobby (server games greyed
from cache offline; identity recognises both ids); two-tier version gate (hard
degrades to forced offline with an "Update / Play offline" notice; new soft
recommended tier via an additive X-Update-Recommended header); keep server
vs_ai (offline->local, online->server, app decides by state); TG/VK
always-online. Exhaustive test matrix. Client + a small additive backend/wire
bit; contour-safe.
2026-07-12 21:57:40 +02:00
Ilia Denisov eec225c4ee docs(android): bake the version gate, identity model + native build into the docs
ARCHITECTURE §2 (client-version gate + frozen wire contract + gate x offline),
§3 (local-guest / server-guest / reconciliation identity), §13 (native Capacitor
build). FUNCTIONAL (+_ru) a new "Native app (Android)" domain. TESTING (the
clientver/gate Go tests, the native/update e2e + retry mapping, and the manual
on-device Android smoke checklist). deploy/README (GATEWAY_MIN_CLIENT_VERSION +
the wire-break bump discipline + the Android build/release runbook). ui/README
native VITE_* vars. Mark F done in ANDROID_PLAN.md.
2026-07-12 20:38:58 +02:00
Ilia Denisov ca2c6487cf feat(android): manual signed-APK CI workflow
Add .gitea/workflows/android-build.yaml (workflow_dispatch + confirm=build,
master-only; mirrors prod-deploy): builds the native SPA, bundles the offline
dicts, assembles a release APK as a run artifact. JDK 21 via setup-java; the
Android SDK is host-provisioned (host-executor runner) with a fail-fast verify
that also checks the runner user's access. Signing degrades gracefully — no
keystore => unsigned release APK, not a failure.

Wire ui/android/app/build.gradle: versionCode/versionName from the release tag
(-P props; '=' assignment, not the command form that binds .toInteger() to the
DSL setter's null return), plus a guarded signingConfigs.release from env.

Rename VITE_STORE_URL -> VITE_RUSTORE_URL (empty until publish).

Bake the E as-built into ANDROID_PLAN.md.
2026-07-12 20:27:07 +02:00
Ilia Denisov e7cb60c996 docs(android): record D on-device smoke + edge-to-edge fix as-built
Reconcile the Progress/§D status after this session: D is code-complete,
e2e-verified AND on-device-smoke-verified (Pixel_10 / API 37, airplane mode);
the smoke surfaced + fixed the edge-to-edge safe-area bug (top + bottom).
Mark D.5 done (verified by the e2e + the on-device vs_ai turn). Remaining for
D: the reconcile-online leg on-device (hits prod) + the deferred local-game-
visibility decision.
2026-07-12 19:31:22 +02:00
Ilia Denisov 49c53794f4 fix(ui): native header top safe-area under edge-to-edge
The earlier safe-area fix reached the bottom bars (they apply
--tg-safe-bottom) but not the header: its top inset lived only in a
Telegram-fullscreen-scoped rule, so on the native build the header .bar had
just 5px top padding and its content sat under the status bar (measured: the
title at y=11px behind the 54px bar; the back button untappable). Give .bar a
top inset from the SystemBars plugin's --safe-area-inset-top (native-only via
the plugin var; unset -> 0 on web/PWA/Telegram/VK, so no regression;
tg-fullscreen still overrides via specificity). Verified on-device (Pixel_10 /
API 37): the title moved from y=11 to y=65, clear of the status bar.
2026-07-12 19:22:12 +02:00
Ilia Denisov 4a0689a4ac fix(ui): native safe-area under Android 15+ edge-to-edge (WebView < 140)
targetSdk 36 forces edge-to-edge, so the WebView draws behind the system
bars. On Android WebView < 140, env(safe-area-inset-*) wrongly reports 0, so
the app chrome (which only read env()) drew under the status bar (top nav
untappable) and the gesture-nav home indicator (the game's centre Hint button
intercepted; side buttons fine). Capacitor 8's SystemBars core plugin (built
into @capacitor/core, insetsHandling:'css' by default) injects the correct
--safe-area-inset-* values on every WebView; consume them ahead of env():

  --tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))

Web / PWA / Telegram / VK are unchanged (--safe-area-inset-* is unset there,
so it falls back to env()). Verified on-device (Pixel_10 / API 37) via the
injected var — the emulator's auto-updated WebView 149 hides the env() bug,
so the visual alone won't reproduce it.
2026-07-12 18:58:07 +02:00
Ilia Denisov 0eb72ba955 feat(ui): hide Telegram/VK link buttons on the native build
The native (Capacitor) sign-in surface is guest + email only: VK ID
web-login is a full-page redirect to id.vk.com that cannot return into the
WebView, and the Telegram Login Widget is unreliable there. Profile now
gates telegramLinkable/vkLinkable on !nativeShell (clientChannel android/
ios), hiding both LINK buttons on native; email and account management —
including an existing link's redirect-free UNLINK — stay. Native tg/vk
login (native SDKs / deep-link OAuth) is a separate later stage.

Covered by e2e/native.spec.ts (the reconcile test opens Profile and asserts
no Link Telegram / Link VK buttons, email present).
2026-07-12 18:09:11 +02:00
Ilia Denisov e077258567 feat(ui): offline-first native boot + lazy server-guest reconciliation
Native (Capacitor) cold boot with no cached session now enters as a
device-local guest in auto-offline mode and lands straight in the lobby
(never /login), so the app opens and plays local vs_ai / hotseat with the
APK's bundled dictionaries and zero network. When the gateway becomes
reachable, reconcileServerGuest silently mints + adopts a server guest and
clears the auto-offline, lighting up online features. Web / PWA / Telegram /
VK are byte-for-byte unchanged.

- transport: exec gains { silent, allowOffline } so background reconciliation
  bypasses the offline kill switch (like the reachability probe) and never
  raises the terminal update overlay (a too-old client stays a local guest);
  new authGuestSilent on the client interface, the real transport and the mock.
- app: native no-session boot branch; reconcileServerGuest fired at boot, by
  the recovery poll and the online event; the poll routes the session-less
  guest through reconciliation, since checkReachable needs a token it lacks.
- native: initNativeShell tolerates a missing Capacitor bridge.
- e2e: new native.spec.ts (inject window.androidBridge; boot -> offline lobby,
  local vs_ai move, reconcile -> online, hotseat start) + playwright.config
  bundles the dawgs into dist-e2e/dict for the loader's bundled tier.
2026-07-12 18:03:52 +02:00
58 changed files with 2892 additions and 979 deletions
+34 -3
View File
@@ -51,7 +51,28 @@ The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui`
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
→ run it as a **background** Bash task (foreground `sleep` is blocked).
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
@@ -114,10 +135,20 @@ The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui`
eyeball per-variant tiles.
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
plugin-config gotcha in wiring them).
- **The `playwright test` runner can't fetch browsers in this sandbox** — `playwright install` dies
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
browser against a local `vite --mode mock` server for visual verification.
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
Use **testcontainers** for container-backed tests.
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
@@ -0,0 +1,172 @@
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
#
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the
# runner host needs nothing pre-installed.
name: android-build
run-name: "android build ${{ github.sha }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "build" to confirm an APK build from master.'
required: true
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
DICT_VERSION: ${{ vars.DICT_VERSION }}
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
VITE_PAYMENTS_DISABLED: "1"
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
# lives elsewhere (the default matches deploy/README.md's install path).
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
jobs:
build:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
# missing — this doubles as the runner-access check (deploy/README.md).
- name: Verify the host Android SDK
run: |
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sm" ]; then
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
fi
for pkg in "platforms/android-36" "build-tools"; do
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
fi
done
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
- name: Compute version + native build env
id: prep
env:
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
run: |
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
# rather than derive a versionCode from a "-N-gSHA" describe.
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
case "$desc" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
esac
v="${desc#v}"
IFS=. read -r MA MI PA <<< "$v"
# 10# forces base-10 so a zero-padded part is never read as octal.
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
# trailing slash so the Connect endpoint never doubles it.
gateway="${PUBLIC_BASE_URL%/}"
{
echo "tag=$desc"
echo "name=$v"
echo "code=$code"
echo "gateway=$gateway"
} >> "$GITHUB_OUTPUT"
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build the SPA (native flavour)
working-directory: ui
env:
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
run: pnpm run build
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
# (after the build, before cap sync copies dist/ into the native assets).
- name: Bundle the dictionaries
working-directory: ui
env:
DICT_DIR: ${{ github.workspace }}/dawg
run: node scripts/bundle-dicts.mjs
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
- name: Sync the native project
working-directory: ui
run: node_modules/.bin/cap sync android
- name: Decode the release keystore
id: keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
echo "keystore decoded -> signed release build"
else
echo "file=" >> "$GITHUB_OUTPUT"
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
fi
- name: Assemble the release APK
working-directory: ui/android
env:
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
- name: Upload the APK artifact
uses: actions/upload-artifact@v4
with:
name: erudit-${{ steps.prep.outputs.name }}-apk
path: ui/android/app/build/outputs/apk/release/*.apk
if-no-files-found: error
+525 -90
View File
@@ -86,22 +86,100 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
gate in `connectsrv``Execute` returns `result_code="update_required"` before the registry lookup,
`Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
`X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store +
`UpdateOverlay.svelte` (native → `VITE_STORE_URL`, web → reload), `retry.ts` maps
`UpdateOverlay.svelte` (native → `VITE_RUSTORE_URL`, web → reload), `retry.ts` maps
`FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
`clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
`update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
store-update SDK noted Out of scope.
- **D. Offline-first — 🚧 IN PROGRESS (foundations done & committed `bcd5a1d`, 2026-07-12).** The
additive, web-inert groundwork landed; the boot rewrite + reconciliation + Profile soft-sign-in +
tests remain (see §D for the detailed remaining path and the session decisions). Done:
`ui/scripts/bundle-dicts.mjs` (release DAWGs → `dist/dict/<variant>@<version>.dawg`, keyed on
`VITE_DICT_VERSION`, `OUT_DIR` override for the e2e); the `dict/loader.ts` **bundled tier** (between
IndexedDB and network, **native-gated** — see the §D.1 correction); `__DICT_VERSION__` vite define +
declaration; `lib/localguest.ts` (persisted device-local guest id, no DB row) + `common.guest` i18n;
`NewGame.svelte` offline vs_ai/hotseat creates fall back to `__DICT_VERSION__` and
`localGuestId()`/`t('common.guest')` when there is no session (inert until the boot lands).
`svelte-check` 0, `vitest` 591, native + web builds clean.
- **EG — pending.**
- **D. Offline-first — ✅ CODE-COMPLETE, e2e-verified & on-device-smoke-verified (D.1D.6 done, 2026-07-12).**
The offline path is proven on-device (Pixel_10 / API 37, airplane mode): cold-boot → offline guest lobby, a
full local vs_ai turn (bundled dawg — Hint placed "FEZ", the robot replied "NEEDFIRE"), New Game offering
both modes; the smoke also surfaced + fixed a native-chrome edge-to-edge safe-area bug (below). Remaining for
D: the **reconcile-online leg on-device** (deferred — it hits prod, minting a guest) and the **deferred
local-game-visibility decision** (below). D.5 is also covered by `e2e/native.spec.ts` (vs_ai move + hotseat).
- **D.1 + D.2 (foundations) — ✅ DONE & committed `bcd5a1d` (2026-07-12).** `ui/scripts/bundle-dicts.mjs`
(release DAWGs → `dist/dict/<variant>@<version>.dawg`, keyed on `VITE_DICT_VERSION`, `OUT_DIR` override
for the e2e); the `dict/loader.ts` **bundled tier** (between IndexedDB and network, **native-gated**
see the §D.1 correction); `__DICT_VERSION__` vite define + declaration; `lib/localguest.ts` (persisted
device-local guest id, no DB row) + `common.guest` i18n; `NewGame.svelte` offline vs_ai/hotseat creates
fall back to `__DICT_VERSION__` and `localGuestId()`/`t('common.guest')` when there is no session.
- **D.3 (cold boot) + D.4 (reconciliation + silent seam) — ✅ DONE & verified (2026-07-12).** The native
no-session cold boot lands in the offline lobby as a device-local guest (`app.svelte.ts` bootstrap
`else if (native)` branch — `localGuestId()` + `setOfflineMode(true,false)` + `scheduleRecovery(0)`);
lazy `reconcileServerGuest()` mints + adopts a server guest and clears the auto-offline when the gateway
is reachable (kicked at boot + by the recovery poll + the online event). Silent seam:
`transport.ts` `exec` gained `{ silent, allowOffline }` (suppress the update overlay **and** bypass the
kill switch), plus `authGuestSilent(locale)` on the `GatewayClient` interface, the real transport and the
mock. `native.ts` `initNativeShell` made bridge-tolerant. New `e2e/native.spec.ts` (Capacitor injected;
boot→offline-lobby, local vs_ai move, hotseat start, reconcile→online) + `playwright.config.ts` bundles
the dawgs into `dist-e2e/dict/`. `svelte-check` 0, `vitest` 591, web + native builds clean; e2e runs in CI.
See §D.3/§D.4 for the as-built corrections (shouldBootOffline **not** changed; `allowOffline` +
checkReachable-token findings; the `__native.reconcile` e2e hook).
- **D.6 (Profile tg/vk hide on native) — ✅ DONE & verified (2026-07-12).** `Profile.svelte` gates
`telegramLinkable`/`vkLinkable` on `!nativeShell` (`clientChannel()` android/ios), so the Telegram + VK
LINK buttons are hidden on the native build; email + account management (incl. any existing link's UNLINK,
a redirect-free gateway call) stay. Covered by the `e2e/native.spec.ts` reconcile test (Profile → no Link
Telegram / Link VK buttons, email present).
- **Native chrome: Android 15+ edge-to-edge safe-area — ✅ FIXED & on-device-verified (2026-07-12, owner-confirmed).**
targetSdk 36 forces edge-to-edge, so the WebView draws behind the system bars; the app chrome overlapped the
status bar (top nav, untappable) and the gesture-nav home indicator (the game's centre Hint button — side
buttons fine). Two-part CSS fix consuming the SystemBars core plugin's injected `--safe-area-inset-*`:
the **bottom** via the `--tg-safe-*` token (`app.css`), the **header top** via a native-only `.bar` rule
(`Header.svelte` — its top inset had been Telegram-fullscreen-scoped only, so the token alone didn't reach
it). No dep/config (SystemBars ships in `@capacitor/core` v8). Verified on Pixel_10 / API 37 by live-WebView
CDP measurement (header title y=11→65, `--safe-area-inset-*` = 54/24px) **and** owner re-test on their device.
Full gotcha + CDP recipe in `.claude/CLAUDE.md`. Commits `4a0689a` (bottom) + `49c5379` (top).
- **DEFERRED DECISION (owner-agreed 2026-07-12) — native local-game visibility.** The online lobby lists
only server games (`Lobby.svelte:42/54`), so a native guest's **device-local vs_ai/hotseat games hide from
the lobby once reconciled online** (the pre-existing offline↔online split). For native this IS the primary
flow, so the behaviour **must change** — a native guest should still see / resume their local games when
online. Owner agreed it needs changing but wants to design it **at the very end of the Android work (before
release — see §G)**; out of D.3/D.4 scope, tracked here so it is not lost.
- **E. CI — signed APK artifact — ✅ CODE-COMPLETE & locally-proven (2026-07-12).** New manual workflow
`.gitea/workflows/android-build.yaml` (`workflow_dispatch` + `confirm=build`, `if` gated to `master`
mirrors `prod-deploy.yaml`; never on a PR) builds the native-flavoured SPA, bundles the offline dicts, and
assembles a release APK uploaded as a run artifact. Like `prod-deploy`, it is **provable only by a dispatch
from `master`** (that is §G), so E was verified as far as possible now: locally via `assembleDebug` + a
**keyless** `assembleRelease` (→ `app-release-unsigned.apk`, `versionCode 1017000` / `versionName 1.17.0`
for a `v1.17.0` tag — confirmed in `output-metadata.json`), `svelte-check` 0, YAML structure checked.
As-built:
- **`ui/android/app/build.gradle`:** `versionCode`/`versionName` read `-PversionCode`/`-PversionName` (defaults
keep local `assembleDebug` building). **CORRECTION vs the plan snippet — use `=` assignment**
(`versionCode = (…).toInteger()`), NOT the command form `versionCode (…).toInteger()`: the latter binds
`.toInteger()` to the DSL setter's null return (`> Value is null`). A guarded `signingConfigs.release` reads
the keystore from env and is attached **only when the keystore file exists** — so **no keystore ⇒ an UNSIGNED
release APK, not a failure** (a dispatch proves the pipeline before the keystore exists).
- **Toolchain:** the **CI runner is a host-executor on a remote Debian host**. JDK 21 comes from
`actions/setup-java`; the **Android SDK is host-provisioned** (pre-installed at `ANDROID_SDK_DIR`, default
`/opt/android-sdk`), with a fail-fast **Verify the host Android SDK** pre-flight that also checks the `runner`
user's read/exec access (`platforms;android-36` + `build-tools`). Unit tests need no new tooling (they ride
the existing `unit`/`ui` jobs).
- **Env:** `VITE_GATEWAY_URL` reuses `vars.PROD_PUBLIC_BASE_URL` (trailing slash stripped); `VITE_DICT_VERSION` /
`DICT_VERSION` = the `DICT_VERSION` var; `VITE_PAYMENTS_DISABLED=1`. **`VITE_STORE_URL` was renamed to
`VITE_RUSTORE_URL`** (owner) — left **empty** via the (unset) `vars.ANDROID_RUSTORE_URL`, so the update button
no-ops until publication (`UpdateOverlay.svelte` already guards `if (url)`; the gate is dormant in the MVP).
- **versionCode from an exact tag:** the workflow refuses anything but a clean `git describe --exact-match`
`vX.Y.Z` (deterministic + monotonic store versionCode); §G tags before dispatch.
- **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch).
Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`,
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
- **F. Docs — ✅ DONE (2026-07-12).** Baked the Android work into the live docs: `docs/ARCHITECTURE.md` §2
(the client-version gate + the frozen wire contract + the gate × offline rule), §3 (the local-guest /
server-guest / reconciliation identity model) and §13 (the native Capacitor build — bundle model, bundled
dicts, `versionCode` scheme, `file://` origin, RuStore); `docs/FUNCTIONAL.md` (+ `_ru` mirror) a new **Native
app (Android)** domain (offline-first guest launch, email soft-registration, no TG/VK login, hidden
purchases, update-required); `docs/TESTING.md` (the `clientver`/gate Go tests, the `native`/`update` e2e +
the `retry` mapping, and the manual **on-device Android smoke checklist**); `deploy/README.md` (the
`GATEWAY_MIN_CLIENT_VERSION` var + the wire-break bump discipline + the Android build/release runbook —
keystore, secrets, dispatch, RuStore upload); `ui/README.md` (the native `VITE_*` build vars). Every
referenced test file was confirmed to exist.
- **G — pending; gated on the offline-model redesign (was G-step-0).** The deferred native
local-game-visibility decision grew into an owner-approved cross-cutting change (web + native + a small
additive backend/wire bit): remove the explicit offline toggle → one `netState` machine, unify the lobby,
two-tier version gate. **Designed + staged (O1O7)** in the "Offline-model redesign" section below.
**➡ Next actionable work: implement O1** (the pure `netState` reducer, test-first) via `stage-implementation`,
then O2O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
O1O7 is started. Branch `feature/android-native`, local/unpushed.
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
@@ -300,7 +378,7 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
same note can replace the RuStore stub in the later Google Play anti-steering variant.
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_RUSTORE_URL?: string`,
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
4. **Native env matrix** — see Build & env.
@@ -392,7 +470,7 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys
`window.open(import.meta.env.VITE_RUSTORE_URL, '_system')`; web → `location.reload()`. i18n keys
`update.title/body/action` (add siblings to the maintenance keys).
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
@@ -404,16 +482,26 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — 🚧 IN PROGRESS
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — ✅ CODE-COMPLETE
Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player
hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts`
and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch
with no network**.
**Status:** the additive foundations (D.1, D.2) are **done & committed (`bcd5a1d`)** and inert on the
web; the boot rewrite (D.3), reconciliation + the silent seam (D.4), the Profile soft-sign-in (D.6) and
the tests remain. **Verify every line ref below against current code** (they are as of 2026-07-12).
**Status:** D.1D.6 are **done & verified** (foundations committed `bcd5a1d`; boot + reconciliation +
Profile + the Android edge-to-edge safe-area fix committed on `feature/android-native`, 2026-07-12), with
`e2e/native.spec.ts` green (chromium + webkit) AND an **on-device emulator smoke** (Pixel_10 / API 37,
airplane mode): cold-boot → offline guest lobby, offline vs_ai played end-to-end (bundled dawg — Hint
placed "FEZ", the robot replied "NEEDFIRE"), New Game offers both modes. The smoke surfaced a native-chrome
bug on **Android 15+ edge-to-edge** (WebView < 140 reports `env(safe-area-inset-*)`=0 → the top nav draws
under the status bar and the game's centre Hint button under the gesture-nav home indicator) — **fixed** by
consuming the SystemBars core plugin's injected `--safe-area-inset-*`: the bottom via the `--tg-safe-*` token
(`app.css`), and the header's top inset via a native-only `.bar` rule (`Header.svelte` — its top inset was
Telegram-fullscreen-scoped only, so the token alone did not reach it). No dep/config (the plugin ships in
`@capacitor/core` v8); full story in `.claude/CLAUDE.md`. Remaining for D: the reconcile-online leg
on-device (hits prod — a guest mint) and the deferred local-game-visibility decision (§G). **Verify every
line ref below against current code.**
**Decisions locked this session (owner-approved) — bake these before implementing:**
- **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current
@@ -456,72 +544,84 @@ the tests remain. **Verify every line ref below against current code** (they are
(`~line 99`) now uses `accountId: app.session?.userId ?? localGuestId()` and
`name: app.profile?.displayName ?? t('common.guest')`. Hotseat seats stay independent local identities
(`buildSeats` — unchanged).
3. **Cold offline-first boot — ▢ TODO** (`ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`). **High
blast-radius: this is app startup for every platform — gate every change on the native channel and
leave the web / PWA / TG / VK paths byte-for-byte.**
- The blocking Login is **`bootstrap()` app.svelte.ts ~line 989-991**:
`} else if (router.route.name !== 'login' && router.route.name !== 'confirm') { navigate('/login'); }`
— the `else` of `if (saved)` (no cached session). **On native, replace this branch** with the
offline-first entry: establish the local guest (`localGuestId()`), `setOfflineMode(true, false)`
(**non-sticky** auto-offline, so reconciliation may clear it — cf. the sticky arg at ~line 968), and
land in the **lobby** (leave the route / `navigate('/')`), `app.ready = true`. Never navigate to
`/login` on native.
- The native no-session boot **always lands in the lobby**, online or offline. If reachable at boot,
also kick reconciliation (D.4); if not, stay a local guest until the network returns. The lobby
renders without a session — offline mode shows local games and `NewGame` gates its offline flows on
`guest || offlineMode.active`.
- `shouldBootOffline` (offline.ts:85, currently `offlineActive && hasSession && hasProfile`): add a
**native-no-session** path so a native cold start can boot offline as the local guest with no cached
session/profile. Keep the web rule intact.
- `offlinePreloadEligible` (offline.ts:69): **bypass the server preload on native** — the dicts are
bundled, so there is nothing to preload; keep the web `standalone && hasEmail` gate.
4. **Lazy server-guest reconciliation + the silent seam — ▢ TODO** (the seam was deferred from C).
- **Silent seam:** the C transport calls `reportUpdateRequired()` in three places — `exec` result-code
branch (`transport.ts ~line 86`), `exec` catch (`~line 73`), `subscribe` catch (`~line 366`). Add a
**silent path** that does NOT raise the overlay: e.g. an `exec(msgType, payload, signal, opts?: {
silent?: boolean })` flag, plus an `authGuestSilent(locale)` on the `GatewayClient` interface
(`lib/client.ts`), the real transport, **and the mock** (`lib/mock/client.ts` — the e2e reconciliation
leg goes through the mock). Foreground `auth.*` keep the overlay.
- **Reconciliation:** when native + online + **no** server session → background `authGuestSilent`,
`saveSession` + adopt (`setToken` + `app.session`/`app.profile`, or reuse `adoptSession`), then
**clear the auto-offline** (`setOfflineMode(false)`) so online features + Profile light up. Guard
duplicates (only when no cached session — never mint a second server guest). On `update_required` it
stays offline silently (no overlay). Local games stay device-only. Fire it on boot (if reachable) and
on network-recovery (a connection-online handler).
5. **Both offline modes render for the local guest — ✅ mostly (verify at D close).** `NewGame` gates the
3. **Cold offline-first boot — ✅ DONE & verified (2026-07-12)** (`ui/src/lib/app.svelte.ts`). **High
blast-radius: app startup for every platform — every change is native-gated; web / PWA / TG / VK stay
byte-for-byte.** As built:
- The blocking Login was **`bootstrap()`'s `else` of `if (saved)`** (no cached session, formerly
`navigate('/login')`). On native it is now the offline-first entry: `localGuestId()` +
`setOfflineMode(true, false)` (**non-sticky** auto-offline, so reconciliation may clear it) + land in
the lobby (`navigate('/')` only if on login/confirm) + `scheduleRecovery(0)` to kick reconciliation.
Web keeps the `navigate('/login')` else. `app.ready = true`.
- **CORRECTION — `shouldBootOffline` was NOT changed.** The native-no-session case is fully caught in
that `else` branch (there `hasSession` is provably false and `saved`'s `if`-block — the web
canOffline logic — is skipped), so a `native` param would be unused in the load-bearing path. The
plan's "add a native-no-session path to `shouldBootOffline`" is dropped, and the matching unit test
with it (the boot decision is exercised by `e2e/native.spec.ts`).
- **`offlinePreloadEligible` was NOT changed either** — it already returns false off a standalone web
PWA (native is not `isStandalone()`), so the server preload is already skipped on native; no edit needed.
- The lobby renders without a session/profile: `Lobby.svelte`/`NewGame.svelte` use optional chaining
and the offline branch loads only device-local games (no session-gated `gamesList`). `NewGame` gates
its offline flows on `guest || offlineMode.active`, both true for the local guest.
4. **Lazy server-guest reconciliation + the silent seam — ✅ DONE & verified (2026-07-12).** As built:
- **Silent seam:** `exec` gained `opts?: { silent?: boolean; allowOffline?: boolean }`.
**CORRECTION — `silent` alone was not enough:** reconciliation runs while the app is still in
auto-offline, so `exec`'s `assertOnline()` kill switch would refuse it — hence **`allowOffline`**
(bypass the kill switch, like the reachability probe). `silent` gates the two `exec`
`reportUpdateRequired()` sites (result-code + catch); the `subscribe` catch keeps the overlay
(reconciliation never subscribes). New `authGuestSilent(locale)` on the `GatewayClient` interface
(`lib/client.ts`), the real transport (`{ silent: true, allowOffline: true }`) and the mock.
- **Reconciliation — `reconcileServerGuest()` (`app.svelte.ts`):** native + no session → `authGuestSilent`,
`setOfflineMode(false)` **before** `adoptSession` (so adopt's `profileGet`/stream ride the online
transport, not the kill switch), then adopt. Guarded by `reconcileInFlight` + `app.session` (never a
second guest). `update_required`/unreachable are swallowed (stay a local guest). Local games stay
device-only. Fired at boot and by the recovery poll + the online event.
- **CORRECTION — `checkReachable` cannot gate a session-less guest:** its authenticated probe rejects
with no token, so it never reports "reachable" for a local guest. The recovery poll (`scheduleRecovery`)
therefore routes the **no-session** case straight through `reconcileServerGuest` (the mint attempt IS
the reachability check); the session case keeps `checkReachable`.
- **`scheduleRecovery` is now mock-guarded** (like `initNetworkReactivity`) so the e2e's boot does not
race a mock reconcile to online before the spec observes the offline lobby; the e2e drives
reconciliation via a new **`window.__native.reconcile()`** hook (mirrors `__conn`/`__maint`/`__update`).
5. **Both offline modes render for the local guest — ✅ DONE & verified (2026-07-12).** `NewGame` gates the
offline flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
"quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id
(D.2).
6. **Soft registration = reuse the `Profile` screen — ▢ TODO.**
- Profile is already reachable for an **online** guest (guests keep the Profile tab —
`SettingsHub.svelte:27` only hides friends/wallet for guests; `:33` hides profile/friends/wallet in
**offline** mode). So once the native guest is online and reconciled (D.4 clears the auto-offline),
Profile shows and email sign-in works. Offline you cannot register anyway (every method needs the
server), so the offline-hidden behaviour is correct — **no change is needed to reach Profile**, provided
D.4 clears the auto-offline when online.
- **Hide tg/vk on native:** `Profile.svelte` — the Telegram link button (`~line 485`, gated on
`telegramLinkable = loginWidgetAvailable()`, `:62`) and the VK link button (`~line 499`). Add a
`clientChannel()` android/ios check to hide both on the native build; keep email + account management.
(See the decision block above for why.)
(D.2). Verified: `e2e/native.spec.ts` plays a vs_ai move + starts a hotseat game, and the on-device smoke
(Pixel_10 / API 37) played a full offline vs_ai turn (Hint "FEZ", robot "NEEDFIRE") and reached New Game
offering both modes.
6. **Soft registration = reuse the `Profile` screen — ✅ DONE & verified (2026-07-12).** As built:
- **Reaching Profile needed no change:** guests keep the Profile tab (`SettingsHub.svelte:27` only hides
friends/wallet for guests; `:33` hides profile/friends/wallet in **offline** mode), so once the native
guest is online and reconciled (D.4 clears the auto-offline) Profile shows and email sign-in works.
Offline you cannot register anyway (every method needs the server), so the offline-hidden behaviour is
correct.
- **Hid tg/vk on native:** `Profile.svelte` gates `telegramLinkable = loginWidgetAvailable() && !nativeShell`
and `vkLinkable = vkWebLinkAvailable() && !nativeShell` (`nativeShell = clientChannel()` android/ios), so
both LINK buttons are hidden on native while email + account management (and an existing link's UNLINK — a
redirect-free `link.unlink` call) stay. Verified in `e2e/native.spec.ts` (the reconcile test opens Profile
and asserts no Link Telegram / Link VK, email present; `loginWidgetAvailable`/`vkWebLinkAvailable` are true
under the mock, so the buttons show on web — the native count-0 proves the gate).
**Tests — ▢ TODO** (docs/TESTING.md layers; the mock e2e bypasses the codec, so the gate's server path
stays in the Go tests):
- **Unit (vitest, node):** the bundled-dict tier — a `fetch` mock returning a dawg blob **with the native
channel forced** (`vi.stubGlobal('window', { Capacitor: { getPlatform: () => 'android' } })`, since the
tier is native-gated; `idbPutDawg`/`requestPersist` are best-effort `void`); `shouldBootOffline`
native-no-session; `localguest` (`localGuestId` persistence + `isLocalGuestId`). (`update.svelte.ts`-style
reminder: a `$state` rune module can't be imported under this project's plugin-less vitest.)
- **Playwright offline-first spec:** **simulate native** by injecting `window.Capacitor = { getPlatform:
() => 'android' }` via `addInitScript` (makes `clientChannel()` return `android`, activating the bundled
tier + the native boot). Place bundled dicts at **`dist-e2e/dict/<variant>@dev.dawg`** — extend the
`playwright.config.ts` webServer command to also run `DICT_DIR=$E2E_DICT_DIR OUT_DIR=dist-e2e node
scripts/bundle-dicts.mjs` (`VITE_DICT_VERSION` unset → `dev`, matching `__DICT_VERSION__`). Boot with the
network hook off (`window.__conn.offline()` / the offline pref) → assert the **lobby** (not login), play a
local vs_ai move, start a hotseat game; then network on → reconciliation lights up.
**GOTCHA:** with `window.Capacitor` injected, `initNativeShell` (`App.svelte:40`) will `await
import('@capacitor/app')` and call `App.addListener('backButton', …)` — there is no real Capacitor
bridge in the browser, so guard/stub it (inject a fake `Capacitor.Plugins.App`, or make `initNativeShell`
tolerate a missing bridge) or the boot may throw.
**Tests — ✅ DONE for D.3/D.4 (2026-07-12):**
- **Unit (vitest, node):** no NEW unit test was added for D.3/D.4 — the boot decision, reconciliation and
silent seam live in `$state`/transport modules the plugin-less vitest cannot import (cf. `update.svelte.ts`),
and the `shouldBootOffline` native path was dropped (§D.3). `localguest` + the bundled-dict tier are D.1/D.2
coverage (a bundled-tier `fetch`-mock unit test remains a D-close nicety if wanted). `vitest` stays 591.
- **Playwright — new `e2e/native.spec.ts`** (NOT an extension of `offline.spec`): **GOTCHA — inject
`window.androidBridge`, NOT `window.Capacitor.getPlatform`.** `@capacitor/core` (loaded during boot by
`initNativeShell`) derives the platform from `window.androidBridge` / `window.webkit` and **replaces** any
pre-set `window.Capacitor` with its own shim whose `getPlatform()` is `web` — so a bare injected
`window.Capacitor = { getPlatform: () => 'android' }` is clobbered and the boot falls to `/login`.
`simulateNative` injects `window.androidBridge = { postMessage(){} }` (+ a Capacitor stub for the pre-core
window), which makes `clientChannel()` resolve to `android`. Two tests: (1) boot → **assert the offline guest
lobby (not login)**, play a local vs_ai move (bundled dawg), Back, `window.__native.reconcile()` → assert
**online lights up** (offline tint gone, the seeded online game appears, Stats re-enabled); (2) boot → start a
2-player hotseat game. The `playwright.config.ts` webServer now also runs `DICT_DIR="${E2E_DICT_DIR:-…}"
OUT_DIR=dist-e2e node scripts/bundle-dicts.mjs` → `dist-e2e/dict/<variant>@<version>.dawg` (version flows from
`VITE_DICT_VERSION`, default `dev`, matching `__DICT_VERSION__`). **Ran locally green (chromium + webkit, 4/4)
against the installed browsers + the sibling `scrabble-solver/dawg`; also runs in CI.**
- **GOTCHA resolved:** `initNativeShell` was made **bridge-tolerant** (`try/catch` around the `@capacitor/app`
import + `addListener`) rather than stubbing a fake Capacitor bridge — cleaner and defensive for a real WebView
that lacks the App plugin.
**Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and
plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on
@@ -529,7 +629,7 @@ silently establishes a server guest (Profile + online play light up). Emulator s
→ "Native Android build", with the extra `DICT_DIR=<release> VITE_DICT_VERSION=<ver> node
scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`.
### E. CI — signed APK artifact
### E. CI — signed APK artifact — ✅ CODE-COMPLETE (workflow written + `build.gradle` wired + locally proven; the signed dispatch is §G)
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
@@ -547,6 +647,9 @@ New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-depl
5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`,
`build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
*As built:* the runner is a **host-executor** on a remote Debian host, so the **SDK is host-provisioned**
(`ANDROID_SDK_DIR` var, default `/opt/android-sdk`) and a **Verify the host Android SDK** step fail-fasts on a
missing package / no runner-user access; JDK 21 still comes from `actions/setup-java`.
7. `npx cap sync android`.
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
tag (scheme below).
@@ -561,11 +664,14 @@ for the MVP.
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
Wire in `ui/android/app/build.gradle` `defaultConfig`:
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`,
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release`
reading env/props (guarded so a local `assembleDebug` still builds unsigned).
`versionCode = (project.findProperty('versionCode') ?: '1').toInteger()`,
`versionName = (project.findProperty('versionName') ?: '0.0.0').toString()`, plus a guarded
`signingConfigs.release` reading env/props. **Use `=` assignment, not the command form**
`versionCode (…).toInteger()` — the latter binds `.toInteger()` to the DSL setter's null return
(`> Value is null`). The signing block attaches only when `ANDROID_KEYSTORE_FILE` exists, so a keyless
`assembleRelease` (and every `assembleDebug`) builds UNSIGNED instead of failing.
### F. Docs (bake in the same PR)
### F. Docs (bake in the same PR) — ✅ DONE
- `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** +
the gate×offline rule. New sections — the **identity model** (local guest / server guest /
@@ -578,14 +684,23 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned).
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`,
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value.
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_RUSTORE_URL`,
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. Gitea CI vars/secrets
for the APK build: `ANDROID_RUSTORE_URL` (var, empty until publish), `PROD_PUBLIC_BASE_URL` (reused as the
gateway origin), and the release-signing secrets `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` /
`ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`.
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
ships an incompatible wire change.
### G. Release + owner handoff
0. **Land the offline-model redesign — ✅ DONE (O1O7, 2026-07-13; staged detail below).** Resolved the deferred
native local-game-visibility decision — a native guest now sees / resumes their device-local games once online
(the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline
is an implicit **net-state machine** (O1/O2), the lobby is unified (O5), and the **two-tier** version gate is in
(O4). Cross-cutting (web + native + a small additive backend/wire bit); **contour-safe** (both version vars empty
⇒ dormant; the wire add is additive). The remaining G steps below are the release chain.
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
2. Promote `development` → `master`; tag `vX.Y.0`.
3. Dispatch `android-build`; watch green; retrieve the signed APK.
@@ -594,6 +709,324 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned).
---
## Offline-model redesign (design — resolves G-step-0)
> Owner-approved design (brainstormed 2026-07-12). Cross-cutting web + native + a small **additive**
> backend/wire bit. This is the Android release's G-step-0 and its own staged work; `writing-plans` turns it
> into the implementation plan. English throughout; the FBS change is additive-only (§frozen contract).
### Goal & locked decisions
Remove the explicit online/offline toggle. Offline becomes an **implicit, detected** state driven by a single
**net-state machine**. vs_ai stays as-is (offline → local, online → server; the app decides by the detected
state). The lobby becomes **unified**. The version gate gains a **soft** tier and its hard tier degrades to
offline instead of a terminal lockout. TG/VK stay **always-online**. Client-only except the additive
soft-tier threshold + signal.
| Decision | Choice |
|---|---|
| Offline trigger | Implicit — detected connectivity + version, **no user toggle** |
| State model | **Single net-state machine** (replaces the two-layer `connection` / `offlineMode` split) |
| Switch UX | **Auto + a toast**, no dialog; hysteresis against flap; self-heal to online |
| Server vs_ai | **Kept** — offline → local, online → server; identical create flow, app decides by state |
| With-friends create | Explicit online/offline choice; offline **disabled (greyed)** when no network; create always works (→ local hotseat) |
| Lobby offline | Unified; server games **greyed from the last cache**, local games active |
| Version — critical (hard) | Terminal lockout → now a **notice "Update / Play offline"**, then forced offline |
| Version — recommended (soft) | **Built now** — notify but still play online (`GATEWAY_RECOMMENDED_CLIENT_VERSION` + an additive signal) |
| TG/VK | **Always online** (exempt); no local lobby / offline states |
| Native net signal | Add **`@capacitor/network`** as a hint; web keeps `navigator` + gateway probes |
| Migration | Clear the persisted `offlinePref` (nobody stuck without a toggle) |
### The net-state machine
One reactive `netState` absorbs `connection.svelte.ts` + `offline.svelte.ts`; the ~14 readers of
`offlineMode.active` migrate to a derived `offline` boolean and the "Connecting…" readers to `connecting`.
Written as a pure reducer (reusable for a future iOS shell).
**States**
- `online` — gateway reachable, version accepted. Full features. May carry an orthogonal dismissible
**`updateRecommended`** nudge (soft tier); play continues.
- `connecting` — a call/probe is currently failing but the hysteresis window has not elapsed. "Connecting…";
chrome stays online. Transient — the anti-flap buffer. **Not** offline (no kill-switch).
- `offlineNoNetwork` — sustained unreachable (hysteresis exceeded). Offline mode (blue chrome, local-only
active lobby, transport kill-switch). Self-heals.
- `offlineVersionLocked` — gateway reachable but version < min (critical). Offline mode + the
"Update / Play offline" notice. **Sticky** — exits only via an app update (fresh version next boot).
`offline` (kill-switch / blue chrome / local play) = `offlineNoNetwork || offlineVersionLocked`.
**Events:** `callFailed` · `callOk`/`probeOk` · `probeFailed` · `osOffline`/`osOnline` (navigator /
`@capacitor/network` hints) · `versionRejected` (update_required) · `versionRecommended` (soft) · `boot`.
**Transitions**
| From | Event | To | Side-effect |
|---|---|---|---|
| online | callFailed / osOffline | connecting | start hysteresis (probe + timer) |
| connecting | probeOk / callOk | online | — |
| connecting | hysteresis exceeded (K fails / `OFFLINE_DEBOUNCE_MS`) | offlineNoNetwork | toast "offline" |
| offlineNoNetwork | osOnline | (probe) | trigger a probe; stay until it wins |
| offlineNoNetwork | probeOk / callOk | online | toast "back online" |
| **any** | versionRejected | offlineVersionLocked | show the Update/Offline notice (supersedes the soft nudge) |
| offlineVersionLocked | probe (still rejected) | offlineVersionLocked | stays; only an app update exits |
| online | versionRecommended | online (+ nudge) | dismissible "update available" banner |
| boot (no net) | — | connecting → offlineNoNetwork | offline-first |
| boot (version too old) | versionRejected | offlineVersionLocked | notice |
**Hysteresis / anti-flap (explicit):** `online → offline` only after the probe fails **and** (K consecutive
failures **or** `connecting` held ≥ `OFFLINE_DEBOUNCE_MS`); a single blip lives entirely in `connecting`
(spinner only). Recovery to `online` is immediate on the first `probeOk`. `osOnline` never flips to online by
itself — it only **triggers** a probe (navigator / `@capacitor/network` can lie; the probe decides). Reuses
the existing probe watcher + backoff.
**Session-less guest (from D):** the probe is authenticated, so a not-yet-reconciled local guest cannot probe;
there the **reconcile attempt (`auth.guest`) IS the connectivity signal** — success → online + adopt;
failure / `update_required` → stays offline (swallowed, as D already does).
### Edge cases (each gets a test)
1. Rapid flap (fail→ok inside the window) → never leaves `connecting`; no chrome thrash.
2. Cold boot, no network → `offlineNoNetwork`, offline-first lobby.
3. Cold boot, gateway up but version < min → `offlineVersionLocked` + notice.
4. Cold boot, session-less guest → reconcile = probe; ok→online, fail→offline.
5. Mid-session min-version bump → next call `versionRejected` → `offlineVersionLocked` mid-play.
6. Captive portal: `osOnline` fires but the gateway is down → probe fails → stays offline.
7. Recovery race: `probeOk` + a queued `callOk` together → one idempotent transition to online.
8. Soft nudge, then a hard reject → escalate to `offlineVersionLocked` (notice supersedes the banner).
9. `offlineVersionLocked` on plain web with no cached dicts → offline but create disabled → the notice is the only action.
10. Offline → create local vs_ai → back online → the local game persists and shows in the unified lobby.
11. Persisted stale `offlinePref` on upgrade → ignored/cleared; never stuck offline.
12. TG/VK → the machine is inert (always `online`); no offline states, no local lobby.
### Version gate — two tiers (backend + wire, additive)
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`** (exists): `version < min` → `update_required` (Execute
result_code / Subscribe `FailedPrecondition`). Client reaction **changes**: no terminal overlay →
`offlineVersionLocked` + a **notice** with "Update" (native → `VITE_RUSTORE_URL`, web → reload) and
"Play offline" (dismiss → stay offline). Background reconcile stays silent (D).
- **Soft (recommended) — NEW `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` → a
**non-blocking** signal. Wire: **additive** — a **response header** `X-Update-Recommended: 1` on gated
responses (headers are the version-tolerant layer, like `X-Client-Version`); the call still succeeds and
never carries a breaking payload change. Client shows a **dismissible** "update available" banner and plays
online. Empty ⇒ soft tier off.
- Config: `recommended` must be ≥ `min` (validated at load); both empty ⇒ the gate is fully dormant (web
unchanged). Frozen-contract compliance: the soft signal is additive/trailing; the `update_required`
sentinel is unchanged.
### Unified lobby
Merge `localSource.list()` + `gateway.gamesList()`. Offline: local games active + **server games greyed from
the last `lobbycache` snapshot** (un-openable, an "offline" hint). Identity: recognise **both** the
`server user id` and the `localGuestId` as "you" (they differ after reconciliation) for turn / medal logic.
One `lobbysort`; ids never collide. Removes the `loadSeq` mode-exclusive branch — both sources feed one list.
TG/VK: server-only (unchanged).
### Create flows
- **vs_ai (quick):** unchanged logic — offline → `localSource.create`, online → `lobbyEnqueue` — driven by
`netState`. **Dict guard:** if the variant's dawg is unavailable offline, disable create with a reason.
- **With-friends:** a new **online/offline segmented control** (online = friend invite, offline = hotseat;
both forms exist). No network → the online segment is disabled ("needs network"), offline preselected.
### Settings / chrome / migration / platform
Remove the offline-toggle section from `Settings.svelte` (+ `requestOffline`, the `offlineEligible` gate);
`SettingsHub` keeps disabling Profile/Friends/Wallet while offline (now from `netState`). On upgrade,
ignore/clear the persisted `offlinePref` (never strand a deliberate-offline user). Implicit offline applies to
**native (always) + web (plain tab + PWA)**; **TG/VK exempt**. Plain-web offline is best-effort (local play
only for variants whose dawg is cached). No server / data migration (server vs_ai + all server games
untouched).
### Test matrix (exhaustive — owner requirement)
- **vitest (pure):** the net-state machine as a pure reducer — **every** transition, the hysteresis/debounce
(blip vs sustained), the session-less-guest reconcile-as-probe branch, the two-tier version decision
(min / recommended ordering + all three bands), the dict guard, the identity "self" test (both ids). Every
edge case #1#12 gets a case.
- **Go:** `clientver`/config extended for `GATEWAY_RECOMMENDED_CLIENT_VERSION` (parse + ordering validation);
`connectsrv` for the soft signal (`v < min` ⇒ hard, `min ≤ v < recommended` ⇒ soft, `v ≥ recommended` ⇒
none, absent/garbled ⇒ fail-open).
- **Playwright (mock):** auto online→offline (toast) + self-heal; `offlineVersionLocked` notice → "Play
offline" → local play; unified lobby (greyed server games offline; both active online); with-friends toggle
(online disabled offline); the soft nudge banner; update `native.spec.ts`.
- **Docs (revise the F bake):** ARCHITECTURE §2 gate → two tiers + graceful degrade; §3 offline model;
FUNCTIONAL (+`_ru`) offline + "update required"; TESTING; deploy/README the two version vars.
### Scope / sequencing
Client refactor + a small **additive** backend/wire bit; **contour-safe** (both version vars empty ⇒ dormant;
the wire add is additive). It is **G-step-0** and gates the Android release. After it lands: the normal G
chain (PR → development → contour verify → master → tag → dispatch `android-build`).
### Out of scope
Dropping server vs_ai (kept). TG/VK offline. In-app store-update SDK. The iOS shell (the machine is written to
be reusable for it; iOS is not built here).
### Implementation stages
Executed via `stage-implementation` (interview on every fork, tests at each layer, bake docs). Ordered so each
stage leaves the app working and independently testable. **TDD:** the pure machine (O1) is written test-first.
Standing constraints (from the design): one `netState` machine; hysteresis explicit; **server vs_ai
untouched**; wire changes **additive-only**; **TG/VK inert** (always online); **contour-safe** (both version
vars empty ⇒ dormant). Minimal-diff: keep `connection`/`offline` module paths as thin **derived shims** over
`netState` so the ~14 `offlineMode.active` consumers are not mass-renamed (single source of truth underneath).
- **O1 — Pure net-state reducer + exhaustive unit tests (test-first). — ✅ DONE (2026-07-12).**
- Files — Create: `ui/src/lib/netstate.ts`, `ui/src/lib/netstate.test.ts`.
- Interfaces (as built) — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`;
`type NetEvent` (`boot`/`callOk`/`callFailed`/`probeOk`/`probeFailed`/`osOnline`/`osOffline`/`versionRejected`/
`versionRecommended`); `reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig) => NetResult` where
`NetInput = { type: NetEvent; at: number }` (**time-on-the-event** — owner-chosen option A: the reducer stays
a pure function of `(state, event)` and decides *both* hysteresis branches; O2 stamps `Date.now()`),
`NetSnapshot = { state; fails; connectingSince }` (the hysteresis bookkeeping is carried in the reduced value
so `reduce` is pure yet the whole anti-flap is unit-tested), `NetResult = { next: NetSnapshot; effects: Effect[] }`,
`Effect = {kind:'toast',toast:'offline'|'online'} | {kind:'startProbe'} | {kind:'showVersionNotice'} | {kind:'setNudge'}`,
`NetConfig = { k; debounceMs }` (K + `OFFLINE_DEBOUNCE_MS`), plus `INITIAL` (optimistic pre-boot `online`).
Consumes: nothing (pure). **As-built semantics:** entry via `callFailed`/`probeFailed` counts as `fails 1`;
`osOffline` is a hint (enters `connecting` with `fails 0`, only `startProbe`) — so a single blip stays `connecting`
for K≥2; `boot` resets from any state (the sole exit from the sticky lock) → `connecting` + `startProbe`;
`offlineVersionLocked` is sticky (no connectivity event exits it, notice shown once on entry); the soft nudge is
**effect-only** (`setNudge` from `online`), latched later in O4, not stored in the snapshot.
- Acceptance: every transition-table row + all 12 edge cases pass as pure `reduce` assertions; the blip vs
sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected`
from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`.
- Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). **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. — ✅ DONE (2026-07-12).**
- Files (as built) — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`; the derived
`state`/`online`/`connecting`/`offline`/`versionLocked` getters; one self-rescheduling probe/recovery watcher —
backoff while `connecting`, a 4 s poll while `offlineNoNetwork`, idle otherwise; the OS-signal wiring). Modify:
`ui/src/lib/connection.svelte.ts` + `ui/src/lib/offline.svelte.ts` (thin shims over `netState` — `connection.online`
= the online state, `offlineMode.active` = the machine `offline`; `reportOffline`→`callFailed` **once** from online,
`reportOnline`→`callOk`), `ui/src/lib/update.svelte.ts` (`reportUpdateRequired` also emits `versionRejected`),
`ui/src/lib/native.ts` (+`initNativeNetwork` — `@capacitor/network` → `osOnline`/`osOffline`),
`ui/src/lib/app.svelte.ts` (the boot/recovery-seam rewrite), `ui/src/lib/gateway.ts` (the mock `__net` hook),
`ui/src/App.svelte` (dialog removal), `ui/src/lib/i18n/{en,ru}.ts` (`net.offline`/`net.online` toast), `ui/package.json`
(+`@capacitor/network@8.0.1`). **Owner-confirmed scope A (full migration):** the store subsumes the old
`scheduleRecovery` poll + `initNetworkReactivity` listeners + the cold-boot `promptOfflineChoice` **dialog** (removed —
"auto + toast, no dialog"); the native offline-first boot + the web unreachable-cached boot now enter via `bootOffline()`.
- Interfaces (as built) — Produces: `netState` (`.state`/`.online`/`.connecting`/`.offline`/`.versionLocked`),
`emit(type)` (stamps `Date.now()`), `bootOffline(kickNow?)`, `checkReachable`, `resetNetState`, `initNetSignals`, and a
**register-hook inversion** so the store stays a leaf module (no cycle with `app.svelte.ts`): `registerProbe`
(transport's reachability read), `registerRecovery` (the app's smart reconcile-or-reachability), `registerNetToast`
(i18n toast). `NetConfig = { k: 3, debounceMs: 15000 }` (owner-confirmed). **Wiring:** `reportOffline` enters
`connecting` once (only from online) and the **probe decides** offline via K/debounce; the session-less native
reconcile IS the probe (`recover()` → `reconcileServerGuest` → `emit('callOk')` before adopt). **`boot` is not
emitted** — a real relaunch reloads the bundle to `INITIAL` online, which also clears the sticky version lock; O1's
`boot` handling stays valid but unused here. **TG/VK inertness falls out for free:** the registrations run only past
the Telegram/VK early-returns in `bootstrap`, so those channels are never fed an offline signal (they can show
`connecting` but never reach `offline`).
- Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses `@capacitor/network`,
web `navigator`; the derived `offline` matches the old `offlineMode.active` for every consumer (no regression). **Verified.**
- Targeted tests: `e2e/offline.spec.ts` **rewritten** (implicit offline via the mock `__net` hook — no toggle — + the
toast + local play + persist + self-heal), `e2e/hotseat.spec.ts` migrated off the toggle, `e2e/native.spec.ts` green
(the native boot/offline/reconcile IS the store's e2e). **Full suite 119/119 on chromium + webkit; svelte-check 0/0;
vitest 622; `cap sync` registers `@capacitor/network`.**
- **O3 — Remove the explicit offline toggle + migrate the pref. — ✅ DONE (2026-07-12).**
- Files (as built) — Modify: `ui/src/screens/Settings.svelte` (deleted the whole "Play mode" Online/Offline segment +
`offlineEligible` + `goOffline` + the `checking`/`needsData` state + the now-unused `isStandalone`/`insideVK`/offline
imports + the `.onote` CSS), `ui/src/lib/offline.svelte.ts` (dropped the inert `setOfflineMode`/`requestOffline`/
`TOGGLE_READY_BUDGET_MS` stubs + the dead `offlineMode.auto` getter — `offlineMode.active` = `netState.offline` stays),
`ui/src/lib/offline.ts` (dropped `loadOfflinePref`/`saveOfflinePref`/`shouldBootOffline`/`offlineReady`/`missingDicts`/
`raceOfflineReady`; **new** `clearOfflinePref`; only `offlinePreloadEligible` + the cleanup remain), `ui/src/lib/app.svelte.ts`
(calls `clearOfflinePref()` once at boot, before the Mini-App branches), `ui/src/lib/i18n/{en,ru}.ts` (pruned 7 orphaned
keys each: `settings.{offlineMode,online,offlineChecking,offlineNeedsData}` + `offline.prompt{Title,Yes,No}`). **Deleted**
`ui/src/lib/dict/offlineready.ts` (orphaned once `requestOffline` went). Tests: `ui/src/lib/offline.test.ts` slimmed to
`offlinePreloadEligible` + `clearOfflinePref`; `ui/e2e/offline.spec.ts` +1 case.
- **Kept, not changed:** `settings.offline` (the Header offline chip still uses it) + `offline.preloadWarning` (the lobby
dict-preload notice). **`SettingsHub.svelte` needed no change** — its tab-disable already reads `offlineMode.active`,
which is `netState.offline` (the O2 shim); the plan's "tab-disable from `netState`" is satisfied without a mass-rename.
- Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck) and is cleared; offline
chrome/tab-gating still driven from `netState`. **Verified.**
- Targeted tests: `e2e/offline.spec.ts` new case (a stale `scrabble.offlineMode` → boots online, key cleared, Settings has
no `Offline` toggle even in the eligible context); `offline.test.ts` (`clearOfflinePref`). **Full suite 120/120 on
chromium + webkit; svelte-check 0/0; vitest 616.**
- **O4 — Two-tier version gate. — ✅ DONE (2026-07-13).**
- Files (as built) — Backend: `gateway/internal/config/config.go` (+`RecommendedClientVersion` from
`GATEWAY_RECOMMENDED_CLIENT_VERSION`; validate parseable + `≥ MinClientVersion`, an empty min imposing no lower bound),
`gateway/internal/connectsrv/server.go` (soft-tier fields `recClient`/`recOn`; `clientUpdateRecommended` = `min ≤ v <
recommended`, fail-open; Execute sets `X-Update-Recommended: 1` on any served response in the band via a named-return
`defer`; hard band + Subscribe unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (a
Connect-ES **interceptor** reads `x-update-recommended` on unary responses ⇒ `reportUpdateRecommended`),
`ui/src/lib/update.svelte.ts` (dropped the terminal `updateRequired` latch — `reportUpdateRequired` now only emits
`versionRejected`; new `updateRecommended` store + `latchUpdateNudge`/`dismissUpdateNudge`/`reportUpdateRecommended` +
shared `openUpdate`), `ui/src/lib/netstate.svelte.ts` (`registerNetNudge`; the `setNudge` effect fires it),
`ui/src/lib/app.svelte.ts` (`registerNetNudge(latchUpdateNudge)`), `ui/src/lib/gateway.ts` (mock `__update.recommend`),
`ui/src/lib/i18n/{en,ru}.ts` (`update.playOffline` + `update.available`). Rework: `ui/src/components/UpdateOverlay.svelte`
(terminal → dismissable notice reading `netState.versionLocked`, "Update" + "Play offline"). New:
`ui/src/components/UpdateNudge.svelte` (the soft banner, rendered **bottom of the lobby, above the tab bar** — shown only
while `online && updateRecommended.active && !dismissed`).
- Owner-chosen (interview): the hard notice keeps the **full-screen** form (now dismissable); the soft nudge is a
**bottom-of-lobby** banner (not in-game); the header rides **Execute only** (Subscribe untouched); read via a transport
interceptor; nudge dismiss is **per-session**; the nudge latches via `setNudge → registerNetNudge` (machine is the source,
"only from online"). `update.body` copy kept as-is (owner).
- Acceptance: `v<min` → notice → "Play offline" → offline lobby; `min≤v<recommended` → dismissible banner, online continues;
`v≥recommended`/both empty → nothing; fail-open on an absent/garbled header. **Verified.**
- Targeted tests: Go `config_test` (`TestLoadRecommendedClientVersion`) + `server_test` (`TestExecuteUpdateRecommended` — 3
bands + at-min/at-recommended boundaries + fail-open + recommended-alone + dormant); `e2e/update.spec.ts` rewritten (notice
+ both actions; Update reloads; Play offline → offline lobby; the soft nudge shows in the lobby + dismisses). **Full gateway
go-test/build/vet/gofmt clean; e2e 122/122 chromium + webkit; svelte-check 0/0; vitest 616.**
- **O5 — Unified lobby. — ✅ DONE (2026-07-13).**
- Files (as built) — `ui/src/screens/Lobby.svelte` (`load()` merges `localSource.list()` + `gateway.gamesList()`;
online ⇒ `[...local, ...server]`; offline ⇒ `[...localFresh, ...cachedServer]` greyed; the mode-exclusive branch is
gone; `grey(g) = offline && !isLocalGameId` drives a `.rowwrap.greyed` dim + an un-openable `openGame` that toasts
`net.offline`; **invitations hidden offline** — owner addition — via `{#if invitations.length && !offline}`), `ui/src/lib/lobbysort.ts`
+ `ui/src/lib/result.ts` (`myId: string` → `myIds: readonly string[]` self-set across `isMyTurn`/`scoreStanding`/
`gamePhase`/`groupGames`/`resultBadge`; game seat-matching uses `myIds.includes(...)`), `ui/src/lib/lobbycache.ts`
(dropped the `offline` snapshot key — **one unified snapshot**; `getLobby()` no arg; offline reuses its server games),
`ui/src/screens/NewGame.svelte` (`getLobby()` + filter to server games — device-local games do not count toward the
server per-kind limit), `ui/src/lib/localguest`/`telegram`/`vk` imports in the lobby.
- **Identity split:** game functions take the self-set `myIds = [session.userId, localGuestId()]` (a reconciled
guest's local games seat you under `localGuestId`, server games under `userId`, ids never collide); **invitations
keep `session.userId`** (a server concept). **TG/VK server-only:** the local merge is gated on
`insideTelegram()`/`insideVK()` (the shared-origin local store must not leak into a mini-app).
- Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache (un-openable);
a reconciled guest's local games stay visible online (**closes G-step-0**); invitations hidden offline; TG/VK
server-only. **Verified.**
- Targeted tests: `lobbysort.test.ts` + `result.test.ts` extended (two-id "self" recognition), `lobbycache.test.ts`
reworked (unified snapshot, mode-aware tests removed); `e2e/native.spec.ts` proves the local vs_ai game stays listed
online (G-step-0); `e2e/offline.spec.ts` + `hotseat.spec.ts` + `update.spec.ts` updated for greyed-from-cache server
games offline. **Full suite 122/122 on chromium + webkit; svelte-check 0/0; vitest 617.**
- **O6 — Create flows. — ✅ DONE (2026-07-13).**
- Files (as built) — `ui/src/screens/NewGame.svelte`: **with-friends** now carries a `friendsMode` segmented control
(`new.playRemote` = online invite / `new.playLocal` = offline hotseat); the online segment is `disabled` when
`offlineMode.active` and an `$effect` forces `friendsMode='offline'` there (a `new.needsNetwork` note explains it);
the friends markup is restructured from the mode-exclusive `{:else if offlineMode.active}` chain into
`{:else} <segment> {#if friendsMode==='offline'} hotseat {:else if !friends} noFriends {:else} invite`. **vs_ai
unchanged** (verified: `find()` still offline→`localSource.create`, online→`lobbyEnqueue`). **Dict guard** (owner-chosen
**async getDawg**): a `dawgReady`/`dictBlocked` pair — an `$effect` awaits `getDawg(guardVariant, version)` while
offline (`guardVariant` = the quick pick or the hotseat pick) and disables create + shows a `new.dictUnavailable` note
only on a **confirmed** miss (`dawgReady === false`, never while `null`/checking, so a still-warming dawg does not
false-block). `ui/src/lib/i18n/{en,ru}.ts` (`new.{playRemote,playLocal,needsNetwork,dictUnavailable}`).
- Owner-chosen (interview): the dict guard uses the **async getDawg** precheck (no reliable sync signal; native bundles
every variant so it mostly guards web offline); **guest gating unchanged** — an online guest still sees vs_ai only (no
new online-guest hotseat); a guest offline keeps its hotseat.
- Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline preselected,
create works; vs_ai offline→local / online→server unchanged; create disabled with a reason on an unavailable offline
dawg. **Verified.**
- Targeted tests: `e2e/hotseat.spec.ts` asserts the offline segment (online disabled, hotseat forced); `offline.spec.ts`
+ `native.spec.ts` exercise vs_ai-local + hotseat create through the new segment. **Full suite 122/122 on chromium +
webkit; svelte-check 0/0; vitest 617.**
- **O7 — Docs + native e2e. — ✅ DONE (2026-07-13).**
- Files (as built) — `docs/ARCHITECTURE.md`: **§2** rewrote the connectivity signal into the **net-state machine**
(four states, implicit offline, hysteresis, self-heal, TG/VK-exempt), the version gate into the **two tiers** (hard →
`offlineVersionLocked` + dismissable notice, *not* terminal; soft → `X-Update-Recommended` nudge), and the gate×offline
rule; **§13** (PWA/offline) rewrote the offline model from the deliberate toggle/dialog to implicit detection + the
**unified lobby** (greyed cached server games) + the with-friends **segment** + the dict guard. `docs/FUNCTIONAL.md`
(+`_ru` mirror): rewrote *Offline mode* (implicit, unified lobby, segment) and added a *Staying up to date* /
*«Актуальная версия»* story (Update/Play-offline notice + the soft nudge). `docs/TESTING.md`: the `netstate.test.ts`
reducer layer, the `__net`-driven offline spec, the two-tier `update.spec`, the soft-tier Go server/config tests, and
the native G-step-0 assertion. `deploy/README.md`: a **`GATEWAY_RECOMMENDED_CLIENT_VERSION`** row (soft tier;
validated ≥ `MIN`). `ui/e2e/native.spec.ts` already reflects the new boot/offline (done in O5).
- Acceptance: the docs describe both tiers + the implicit model + the unified lobby; `deploy/README` lists
`GATEWAY_RECOMMENDED_CLIENT_VERSION`. **Verified** (a full-doc sweep leaves no stale deliberate-offline / terminal /
toggle / dialog reference). Code unchanged from O6 (docs only): gateway Go green, svelte-check 0/0, vitest 617,
e2e 122/122 chromium + webkit.
**Offline-model redesign COMPLETE (O1O7).** G-step-0 is resolved — the redesign is contour-safe (both version vars empty
⇒ dormant; the wire add is additive). Next is the normal G chain: PR → `development` → contour verify → `master` → tag →
dispatch `android-build`.
---
## Google Play variant (later — design now so we don't repaint)
One codebase, two build variants selected by flags:
@@ -616,8 +1049,10 @@ target, built later.
## Build & env matrix
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var).
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target.
- `VITE_GATEWAY_URL` — absolute gateway origin; the workflow reuses `vars.PROD_PUBLIC_BASE_URL`
(`https://erudit-game.ru`, trailing slash stripped). The native-critical var.
- `VITE_RUSTORE_URL` — the update-overlay store target. **Empty in the MVP** (set the `ANDROID_RUSTORE_URL`
Gitea variable once published); the gate is dormant so the button never fires yet.
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
+28
View File
@@ -118,6 +118,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | **Hard** tier of the client-version gate (ARCHITECTURE.md §2). Minimum client build the gateway will serve. Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind — the client then degrades to an offline "Update / Play offline" notice, not a hard lockout. Validated at load; a non-empty unparseable value fails startup. |
| `GATEWAY_RECOMMENDED_CLIENT_VERSION` | variable | _(empty)_ | **Soft** tier of the client-version gate (ARCHITECTURE.md §2). A build at or above `GATEWAY_MIN_CLIENT_VERSION` but **below** this is served normally, but every gated `Execute` response carries an additive `X-Update-Recommended: 1` header ⇒ the client shows a dismissable *update available* nudge (play continues). Empty ⇒ off. Validated at load: unparseable, or **below** `GATEWAY_MIN_CLIENT_VERSION`, fails startup. Bump it (ahead of `MIN`) to nudge upgrades before a hard cut-over. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
@@ -236,6 +238,32 @@ redeploy the matching old tag. This dump is a belt-and-braces net for a bad migr
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
that survives losing the host.
## Android app build & release (RuStore)
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md).
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`,
`versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`.
- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an
**unsigned** release APK otherwise (a dry run still proves the pipeline).
- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned**
install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the
`runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the
`ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if
the SDK is missing or unreadable.
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
variable to the store listing once published (empty until then — the in-app update button no-ops).
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
away cleanly instead of failing blind.
- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP).
## Point-in-time recovery (PITR)
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
+104 -37
View File
@@ -100,15 +100,24 @@ dropped). Horizontal scaling is explicit future work.
auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes. The client treats a connectivity edge failure as
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
response was lost — its button is disabled while offline and the player re-issues it on
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online, never fed an offline
signal, so those channels never enter an offline state or show a local lobby.
**Edge hardening:** every request body on the public listener is capped at
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
@@ -140,6 +149,40 @@ dropped). Horizontal scaling is explicit future work.
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
client's in-app stream while the app is open. Out-of-app delivery uses
platform-native push via the platform side-service.
- **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
vars empty ⇒ off, web behaviour unchanged).
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
`offlineVersionLocked` (offline mode) and shows a **dismissable notice****"Update"** (native → the
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **`min`**
(validated at config load); empty ⇒ the soft tier is off.
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never
delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside*
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
rollout (see [`../deploy/README.md`](../deploy/README.md)).
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
rather than interrupting local play.
## 3. Authentication & sessions
@@ -224,6 +267,19 @@ arrive from a platform rather than completing a mandatory registration).
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
Platform and email users are auto-provisioned **durable** accounts with an
identity.
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
`update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
**Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
prior online-session rule.
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
> two-bot model (one bot per service language, with `accounts.service_language`, a
@@ -1332,13 +1388,16 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
creates one through the in-browser engine — the same game screen then drives it, the robot replying
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
invitations are hidden while offline. New Game's *with friends* carries an **online/offline segmented
control** — online = a friend invite, offline = **local pass-and-play (hotseat)** — with the online
segment disabled and offline forced when there is no network; a `vs_ai` or hotseat create is guarded when
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
the in-browser engine and driven by the same game screen, the robot replying locally.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
@@ -1366,27 +1425,17 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
mode** boots from the persisted session and
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
while offline. The gateway registers the `.webmanifest` MIME type
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
@@ -1468,6 +1517,24 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
contour activates; the prod main host omits it.
**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8,
appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is
a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there
is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to
speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an
**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is
the cache). It is **offline-first**:
`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into
`dist/dict/<variant>@<version>.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network
enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are
hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow
(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
with a host-provisioned Android SDK; RuStore upload is manual. Full plan + runbook:
[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md).
## 14. CI & branches
- **Two long-lived branches**: **`development`** is the integration
+43 -23
View File
@@ -277,22 +277,25 @@ add-friend are off. AI games are **practice** — they never count toward a play
statistics.
### Offline mode
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
A New Game against the robot creates a device-local **vs_ai** game — it
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
offline mode, and never sync to the account. So a game can be created and played with no connection,
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
(once installed) launches from a precached shell even with no network. Switching **to** offline first
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
(the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches.
The app plays **offline automatically** — there is **no on/off switch**. When it cannot reach the
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
app never touches the network.
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
The **lobby stays unified**: your device-local games are active, while your server games are still
listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
backend; local games are kept on the device and never sync to the account, so a game can be created and
played with no connection. The app quietly preloads the dictionaries for the player's enabled variants
while still online, and (once installed, or on the native app) launches from bundled/precached data even
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
a short note.
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
@@ -305,14 +308,31 @@ pass-and-play game. A game that finishes normally is saved to the offline lobby
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
so no one can wipe a game the moment they have moved.
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
with no connection it switches to offline for that session; when the device is online but the gateway
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
the network — airplane mode — switches to offline automatically, and restoring it returns online by
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
dialog — is the player's choice: it is kept, and never undone automatically.
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
offline, so you are never interrupted for a hiccup.
### Staying up to date
When a new client version is required, the app does not lock you out. On the **native** app a
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
required — version exists; you can update or dismiss it, and play continues either way.
### Native app (Android)
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
client. Everything it needs is bundled, so it opens with **no network**: a first launch offline drops you
straight into the lobby as a **guest** (no sign-in wall) and you can play right away — against the robot, or
a **pass-and-play** game with friends on one device — using the dictionaries shipped inside the app. When the
network is available the app quietly establishes a guest in the background and online features (random
opponents, friends, statistics) light up without interrupting play; local games you started offline stay on
the device. To keep a durable account you sign in with **email** from Profile (the guest becomes your
account); Telegram and VK sign-in are not offered in the native app. In-app purchases are hidden in this
first release. Because an installed app can be far older than the server, a build that is **too old** to talk
to the current server shows a single, non-dismissable *update* screen whose button opens the store listing —
this appears only on an online action, never while you are playing offline.
### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six
+43 -23
View File
@@ -283,22 +283,24 @@ e-mail) либо ввод фразы. Активные игры форфейтя
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
Приложение играет **офлайн автоматически** — никакого переключателя нет. Когда оно не может достучаться
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
сети.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди. В
игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
@@ -312,14 +314,32 @@ e-mail) либо ввод фразы. Активные игры форфейтя
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
вас не прерывают из-за короткой икоты.
### Актуальная версия
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
### Нативное приложение (Android)
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
офлайн-игры.
### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
+37 -5
View File
@@ -17,21 +17,43 @@ tests or touching CI.
- **UI** — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/copy/download choice, plus Playwright specs against the
derivation, the GCG share/copy/download choice and the **net-state reducer**
(`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted). The
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
end-to-end: it forces the installed-PWA display mode, enters offline through the
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
Stats tab), self-heals back online, then creates and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
rack is deterministic and the human can tap out a precomputed opening), asserting the
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
production build.
production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects
`window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare
`Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
`window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
**unified lobby** (closing G-step-0) — and Profile hides the Telegram/VK link buttons.
The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
update_required` mapping.
- **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
assert the **hard** tier — a too-old `Execute` returns `result_code = "update_required"` (and the op
handler never ran), a too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min /
unparseable header / equal version all pass (fail-open) — and the **soft** tier (`TestExecuteUpdateRecommended`):
a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
minimum.
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
real self-played 35-move game) must rasterize to a plausible PNG
@@ -175,6 +197,16 @@ tests or touching CI.
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device /
emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and
store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in
**airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a
**2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the
root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on**
lights up online play (a server guest is established); Profile offers **email** sign-in but no
Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action
raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by
eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`).
## Principles
+18 -17
View File
@@ -221,23 +221,24 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
registry := transcode.NewRegistry(backend, validator, regOpts...)
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
Backend: backend,
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
Blocklist: blocklist,
Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger,
AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
MaxBodyBytes: cfg.MaxBodyBytes,
MinClientVersion: cfg.MinClientVersion,
Registry: registry,
Sessions: sessions,
Backend: backend,
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
Blocklist: blocklist,
Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger,
AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
MaxBodyBytes: cfg.MaxBodyBytes,
MinClientVersion: cfg.MinClientVersion,
RecommendedClientVersion: cfg.RecommendedClientVersion,
})
// Bridge the backend push stream into the fan-out hub (and the out-of-app
+27 -9
View File
@@ -60,6 +60,11 @@ type Config struct {
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
// (every client is served) — the default for web-only deployments.
MinClientVersion string
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
RecommendedClientVersion string
// RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
@@ -232,15 +237,16 @@ func (c VKIDConfig) Enabled() bool {
func Load() (Config, error) {
var err error
c := Config{
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
@@ -344,6 +350,18 @@ func (c Config) validate() error {
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
}
}
if c.RecommendedClientVersion != "" {
rec, ok := clientver.Parse(c.RecommendedClientVersion)
if !ok {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
}
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
// misconfiguration (every client below min is already turned away). An empty/unparseable
// minimum imposes no lower bound, so the recommended may stand alone.
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
}
}
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
}
+36
View File
@@ -70,6 +70,42 @@ func TestLoadMinClientVersion(t *testing.T) {
}
}
// TestLoadRecommendedClientVersion verifies the soft-tier config: dormant (empty) by default, a
// parseable version accepted (standing alone with no minimum, or at/above one), an unparseable one
// rejected, and a recommended below the minimum rejected.
func TestLoadRecommendedClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.RecommendedClientVersion != "" {
t.Errorf("RecommendedClientVersion = %q, want empty (soft tier dormant)", c.RecommendedClientVersion)
}
// Stands alone with no minimum configured.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.20.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid recommended version (no min): %v", err)
}
if c.RecommendedClientVersion != "v1.20.0" {
t.Errorf("RecommendedClientVersion = %q, want %q", c.RecommendedClientVersion, "v1.20.0")
}
// At or above the minimum is accepted.
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if _, err = Load(); err != nil {
t.Fatalf("Load with recommended >= min: %v", err)
}
// Below the minimum is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.10.0")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a recommended version below the minimum, got nil")
}
// Unparseable is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_RECOMMENDED_CLIENT_VERSION, got nil")
}
}
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) {
+61 -4
View File
@@ -51,10 +51,15 @@ const honeypotHeader = "X-Scrabble-Honeypot"
// the edge can turn away a build too old to speak the current wire contract, before it decodes
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
// "you must update". Both are part of the frozen wire contract (docs/ARCHITECTURE.md §2).
// "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
// on a served Execute response when the client is at or above the hard minimum but below the
// recommended version, it nudges an update without failing the call. Headers are the
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
// contract (docs/ARCHITECTURE.md §2).
const (
clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required"
clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required"
updateRecommendedHeader = "X-Update-Recommended"
)
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
@@ -104,6 +109,12 @@ type Server struct {
minClient clientver.Version
gateOn bool
// recClient is the version below which a served client is nudged to update (the soft tier);
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
// unparseable). It is independent of the hard gate.
recClient clientver.Version
recOn bool
publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy
@@ -148,6 +159,9 @@ type Deps struct {
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
// the gate dormant.
MinClientVersion string
// RecommendedClientVersion is the version below which a served client is nudged to update (the
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
RecommendedClientVersion string
}
// NewServer constructs the edge service.
@@ -192,6 +206,18 @@ func NewServer(d Deps) *Server {
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
}
}
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
// construction; an empty value leaves the soft tier dormant.
var recClient clientver.Version
recOn := false
if d.RecommendedClientVersion != "" {
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
recClient, recOn = v, true
} else {
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
}
}
return &Server{
registry: d.Registry,
sessions: d.Sessions,
@@ -210,6 +236,8 @@ func NewServer(d Deps) *Server {
maxBodyBytes: maxBody,
minClient: minClient,
gateOn: gateOn,
recClient: recClient,
recOn: recOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
@@ -335,15 +363,44 @@ func (s *Server) clientTooOld(header string) bool {
return clientver.Less(v, s.minClient)
}
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
// band — at or above the hard minimum but below the recommended version — so a served response can
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
// leaves minClient at the zero version, which no real version is below, so the band is simply
// "below recommended").
func (s *Server) clientUpdateRecommended(header string) bool {
if !s.recOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
}
// Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors.
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) {
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
start := time.Now()
msgType := req.Msg.GetMessageType()
result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
// client at or above the hard minimum but below the recommended version is told an update is
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
// version yields no nudge.
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
defer func() {
if recommend && resp != nil {
resp.Header().Set(updateRecommendedHeader, "1")
}
}()
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
// the payload is decoded, so a too-old client makes zero successful calls but sees the
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
@@ -53,6 +53,33 @@ func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc
}
}
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
RecommendedClientVersion: recommendedVersion,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
return client, func() {
edgeSrv.Close()
_ = backend.Close()
backendSrv.Close()
}
}
func TestExecuteGuestAuthOK(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
@@ -156,6 +183,54 @@ func TestExecuteVersionGate(t *testing.T) {
}
}
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
// and a dormant soft tier get no header.
func TestExecuteUpdateRecommended(t *testing.T) {
backend := func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
}
tests := []struct {
name string
min string
rec string
header string
wantResult string
wantHeader bool
}{
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
resp, err := client.Execute(context.Background(), req)
if err != nil {
t.Fatalf("execute: %v", err)
}
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
}
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
}
})
}
}
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
// fails Unauthenticated with no session, proving it passed the version gate).
+5
View File
@@ -31,6 +31,11 @@ enables the "Link Telegram" web sign-in (the Login Widget) — inert until the s
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and
`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by
`.gitea/workflows/android-build.yaml`; see `ANDROID_PLAN.md`.
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
+29 -2
View File
@@ -3,12 +3,22 @@ apply plugin: 'com.android.application'
android {
namespace = "ru.eruditgame.app"
compileSdk = rootProject.ext.compileSdkVersion
// Release signing is supplied by the android-build CI workflow via env: it decodes the keystore
// secret to a file and points ANDROID_KEYSTORE_FILE at it. A local build or a keyless CI run leaves
// it unset, so the release APK is produced UNSIGNED rather than failing — assembleDebug and
// assembleRelease both build without the keystore. The keystore is never committed (see .gitignore).
def keystoreFile = System.getenv('ANDROID_KEYSTORE_FILE')
def hasKeystore = keystoreFile != null && !keystoreFile.isEmpty() && file(keystoreFile).exists()
defaultConfig {
applicationId "ru.eruditgame.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
// Deterministic from the release tag by the android-build workflow (vMA.MI.PA ->
// MA*1_000_000 + MI*1_000 + PA); the defaults keep a local assembleDebug building.
versionCode = (project.findProperty('versionCode') ?: '1').toInteger()
versionName = (project.findProperty('versionName') ?: '0.0.0').toString()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -16,10 +26,27 @@ android {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
// Populated only when the workflow provided a keystore (see hasKeystore above); left empty
// otherwise so it is never referenced with a null storeFile.
if (hasKeystore) {
storeFile file(keystoreFile)
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
keyAlias System.getenv('ANDROID_KEY_ALIAS')
keyPassword System.getenv('ANDROID_KEY_PASSWORD')
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Sign only when a keystore was supplied; a keyless release build stays unsigned instead
// of failing.
if (hasKeystore) {
signingConfig signingConfigs.release
}
}
}
}
+1
View File
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-network')
}
+3
View File
@@ -4,3 +4,6 @@ project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capa
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.4.1/node_modules/@capacitor/app/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/.pnpm/@capacitor+network@8.0.1_@capacitor+core@8.4.1/node_modules/@capacitor/network/android')
+12 -10
View File
@@ -1,8 +1,9 @@
import { test, expect, type Page } from './fixtures';
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
// Settings offline toggle shows.
// Offline pass-and-play (hotseat) is offered only in offline mode, which is now implicit (the net-state
// machine detects it — no toggle). The e2e drives the machine through the mock's __net hook. Standalone
// display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
// installed PWA.
async function forceStandalone(page: Page): Promise<void> {
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
@@ -19,11 +20,9 @@ async function enterLobby(page: Page): Promise<void> {
}
async function goOffline(page: Page): Promise<void> {
await page.locator('button.tab').nth(2).click(); // Settings
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
// The machine auto-detects offline (no toggle, no dialog); the lobby stays mounted and turns blue.
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
@@ -46,9 +45,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
// New game -> the offline mode selector now offers "with friends" (hotseat).
// New game -> "with friends" offers an online (invite) / offline (hotseat) segment. Offline, the
// online segment is disabled and the offline (pass-and-play) one is forced, so the hotseat form shows.
await page.locator('button.tab').nth(0).click();
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
await expect(page.getByRole('button', { name: /Invite a friend|Пригласить друга/i })).toBeDisabled();
await expect(page.locator('.hostpin')).toBeVisible();
// The player rows are disabled until the mandatory host (master) PIN is set.
await expect(page.locator('.pname').first()).toBeDisabled();
@@ -115,12 +117,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('.rowwrap')).toHaveCount(1);
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(1);
await page.locator('.kebab').first().click();
const del = page.locator('.rowwrap.revealed .del');
await expect(del).toBeVisible();
await del.click();
await typePin(page, '9999');
await expect(page.locator('.rowwrap')).toHaveCount(0);
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(0);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { test, expect, type Page } from './fixtures';
// Simulate the native (Capacitor) channel. @capacitor/core — loaded during boot by initNativeShell —
// derives the platform from window.androidBridge / window.webkit and REPLACES any pre-set
// window.Capacitor with its own shim (whose getPlatform() would otherwise report 'web'), so the
// reliable native signal is the bridge marker, injected before any app script runs. clientChannel()
// then resolves to 'android', which flips the offline-first cold boot (app.svelte.ts), the loader's
// bundled-dict tier (lib/dict/loader.ts) and every native gate. The bridge is a stub with no real
// native runtime behind it; initNativeShell tolerates that (its @capacitor/app addListener is wrapped).
async function simulateNative(page: Page): Promise<void> {
await page.addInitScript(() => {
(window as unknown as { androidBridge: { postMessage: () => void } }).androidBridge = { postMessage: () => {} };
(window as unknown as { Capacitor: { getPlatform: () => string } }).Capacitor = { getPlatform: () => 'android' };
});
}
// The native cold boot skips the blocking login and lands straight in the lobby as a device-local
// guest in (auto) offline mode: the header carries the offline tint and the tab bar is present with
// no guest sign-in click. Offline the seeded online game (vs Ann) is hidden and Stats is disabled.
async function expectOfflineGuestLobby(page: Page): Promise<void> {
await expect(page.locator('header.nav.offline')).toBeVisible();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
}
// Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct).
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
await page.locator('.rack .tile', { hasText: glyph }).first().click();
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
}
// typePin clicks the on-screen keypad digits (mirrors hotseat.spec — the pad has no OK button, so the
// 4th digit is the action; wait for the dots to clear so a call right after a previous entry keeps them).
async function typePin(page: Page, digits: string): Promise<void> {
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
}
test.describe('native offline-first', () => {
test('cold-boots to the offline guest lobby, plays local vs_ai, reconciles a server guest online', async ({ page }) => {
await simulateNative(page);
await page.goto('/');
await expectOfflineGuestLobby(page);
// Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary
// comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no
// network is needed — this is the device-local guest playing with zero connectivity.
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('.variant', { hasText: 'Scrabble' }).click();
await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a
// real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three.
await placeTile(page, 'W', 7, 5);
await placeTile(page, 'A', 7, 6);
await placeTile(page, 'Y', 7, 7);
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click();
await expect(async () => {
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
}).toPass({ timeout: 15000 });
// Back to the lobby (still offline): the device-local vs_ai game (🤖) is listed. Then the network
// returns: reconciliation silently mints + adopts a server guest and clears the auto-offline, so
// online lights up — the offline tint drops, the seeded online game (vs Ann) appears and Stats
// re-enables. The local game STAYS visible in the unified lobby alongside the server games — a
// reconciled guest keeps its device-local games (this closes G-step-0).
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
await expect(page.locator('.rowwrap').filter({ hasText: '🤖' })).toBeVisible(); // the local vs_ai game, listed while offline
await page.evaluate(() => (window as unknown as { __native: { reconcile(): void } }).__native.reconcile());
await expect(page.locator('header.nav.offline')).toHaveCount(0);
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible({ timeout: 15000 });
await expect(page.locator('.rowwrap').filter({ hasText: '🤖' })).toBeVisible(); // still listed online — the reconciled guest keeps it (G-step-0)
await expect(page.locator('button.tab').nth(1)).toBeEnabled();
// The native sign-in surface is guest + email only: on Profile the Telegram + VK LINK buttons are
// hidden (their login widget / full-page redirect cannot return into the WebView), while email account
// management stays. loginWidgetAvailable/vkWebLinkAvailable are true under the mock, so on web these
// buttons DO show (social.spec links Telegram there) — a count of 0 here proves the native gate, not a
// merely-absent button.
await page.getByRole('button', { name: /Settings/ }).click();
await page.getByRole('button', { name: 'Profile', exact: true }).click();
await expect(page.getByRole('button', { name: /Link Telegram/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /Link VK/i })).toHaveCount(0);
await expect(page.getByText('you@example.com')).toBeVisible();
});
test('starts a 2-player hotseat game as an offline guest', async ({ page }) => {
await simulateNative(page);
await page.goto('/');
await expectOfflineGuestLobby(page);
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
// A minimal create: set the mandatory host (master) PIN, decline a seat, English, two open seats.
await page.locator('button.tab').nth(0).click();
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
await page.locator('.hostpin .plink').click();
await typePin(page, '9999');
await typePin(page, '9999');
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
await page.locator('.variant', { hasText: 'Scrabble' }).click();
await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob');
await page.locator('button.invite').click();
// The hotseat game starts: the board renders — a 2-player local pass-and-play game running with no
// network, reached straight from the native offline-first boot as the device-local guest.
await expect(page.locator('[data-cell]').first()).toBeVisible();
});
});
+71 -27
View File
@@ -1,8 +1,9 @@
import { test, expect, type Page } from './fixtures';
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through.
// Offline is now implicit — the net-state machine detects it; there is no toggle. The e2e drives the
// machine through the mock's __net hook (the real probe watcher is inert under the mock). Standalone
// display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
// installed PWA; only the `(display-mode: standalone)` query is overridden.
async function forceStandalone(page: Page): Promise<void> {
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
@@ -15,25 +16,30 @@ async function forceStandalone(page: Page): Promise<void> {
// After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so
// click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes
// this deterministic: a count() sampled during the pre-login splash frame returned 0, skipped the
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
// this deterministic.
async function enterLobby(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest|гост/i }).first().click();
// The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run
// onboarding strips after it completes).
// robust to locale, emoji variation selectors and the coachmark anchors.
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
// Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has
// all-distinct letters, so the glyph is unambiguous).
// Drive the net-state machine offline / online through the mock hook (no toggle, no dialog).
async function goOffline(page: Page): Promise<void> {
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
}
async function goOnline(page: Page): Promise<void> {
await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online());
}
// Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct).
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
await page.locator('.rack .tile', { hasText: glyph }).first().click();
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
}
test.describe('offline mode', () => {
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => {
test.describe('offline mode (implicit)', () => {
test('auto-detects offline, plays a local vs_ai game, persists across a reload', async ({ page }) => {
await forceStandalone(page);
await page.goto('/');
await enterLobby(page);
@@ -41,23 +47,22 @@ test.describe('offline mode', () => {
// Online: a seeded online game (vs Ann) is listed.
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
// Enter offline through the real Settings toggle (its readiness check fetches every enabled
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip.
await page.locator('button.tab').nth(2).click();
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
// The network drops: the machine auto-detects offline (no toggle, no dialog) — a toast announces it
// and the header turns blue with the chip.
await goOffline(page);
await expect(page.locator('.toast')).toContainText(/offline|соединени/i);
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
// Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled.
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
// The (now offline) lobby stays mounted: the server game (vs Ann) rides along GREYED from the cache
// (un-openable), and the Stats tab disables.
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
// Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO).
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
await page.locator('button.tab').nth(0).click();
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian
// variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian variants
// are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
await page.locator('.variant', { hasText: 'Scrabble' }).click();
await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
@@ -72,8 +77,8 @@ test.describe('offline mode', () => {
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click();
// The play commits and the local robot replies with a real move, so the board carries more than
// WAY's three tiles.
// The play commits and the local robot replies with a real move (which needs the bundled
// dictionary), so the board carries more than WAY's three tiles.
await expect(async () => {
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
}).toPass({ timeout: 15000 });
@@ -83,13 +88,52 @@ test.describe('offline mode', () => {
// button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here).
await expect(page.locator('.lock')).toBeVisible();
// Reload: the hash router restores the /game/<id> route and the local game replays from
// IndexedDB with every committed tile intact.
// Reload: the hash router restores the /game/<id> route and the local game replays from IndexedDB
// with every committed tile intact.
await page.reload();
await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload
// the wait was not reset by relaunching.
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload.
await expect(page.locator('.lock')).toBeVisible();
});
test('self-heals to online when the network returns', async ({ page }) => {
await forceStandalone(page);
await page.goto('/');
await enterLobby(page);
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
// Offline, then the network returns: the machine heals to online on its own (a "back online" toast),
// the offline tint drops and the online games return — no user action.
await goOffline(page);
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
await goOnline(page);
await expect(page.locator('header.nav.offline')).toHaveCount(0, { timeout: 15000 });
// Back online, the server game is active again (no longer greyed).
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
await expect(page.locator('button.tab').nth(1)).toBeEnabled();
});
test('a stale deliberate-offline pref is ignored and cleared; Settings has no toggle', async ({ page }) => {
await forceStandalone(page);
// Seed a pre-redesign deliberate-offline flag: offline is implicit now, so it must be ignored
// (nobody stuck offline) and cleared on boot.
await page.addInitScript(() => localStorage.setItem('scrabble.offlineMode', '1'));
await page.goto('/');
await enterLobby(page);
// Booted online despite the stale pref: the online game (vs Ann) shows and the header is not blue.
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('header.nav.offline')).toHaveCount(0);
// The orphaned key was cleared at boot.
expect(await page.evaluate(() => localStorage.getItem('scrabble.offlineMode'))).toBeNull();
// Settings no longer offers the offline toggle (the "Play mode" Online/Offline segment is gone),
// even in the eligible (standalone + email) context where it used to appear.
await page.locator('button.tab').nth(2).click();
await expect(page.getByRole('button', { name: /^(Offline|Оффлайн)$/ })).toHaveCount(0);
});
});
+61 -18
View File
@@ -1,34 +1,77 @@
import { expect, test } from './fixtures';
import { expect, test, type Page } from './fixtures';
// The "update required" overlay is raised in prod when the edge's client-version gate turns away a
// too-old build (the update_required result_code on Execute, a Subscribe FailedPrecondition). The
// mock transport never produces one, so — like the maintenance overlay — the e2e drives it through
// the window.__update hook (gateway.ts, mock-only). It is app-global (mounted outside the route
// blocks in App.svelte), so it shows without a session, and it is terminal (no self-clearing poll).
test('update overlay covers the app when the client is too old', async ({ page }) => {
// The two-tier client-version gate (docs/ARCHITECTURE.md §2). The mock transport never produces a real
// version signal, so — like the maintenance overlay — the e2e drives both tiers through the
// window.__update hook (gateway.ts, mock-only): __update.on() raises the HARD "update required" notice
// (the net-state machine's offlineVersionLocked), __update.recommend() raises the SOFT "update
// available" nudge.
async function enterLobby(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest|гост/i }).first().click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
test('the update-required notice covers the app and offers Update or Play offline', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const overlay = page.getByRole('alertdialog');
await expect(overlay).toBeVisible();
// One action button (EN "Update" / RU "Обновить" depending on locale).
await expect(overlay.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
const notice = page.getByRole('alertdialog');
await expect(notice).toBeVisible();
await expect(notice.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
await expect(notice.getByRole('button', { name: /Play offline|Играть офлайн/i })).toBeVisible();
});
test('the update action reloads the web client', async ({ page }) => {
test('the Update action reloads the web client', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const overlay = page.getByRole('alertdialog');
await expect(overlay).toBeVisible();
const notice = page.getByRole('alertdialog');
await expect(notice).toBeVisible();
// On the web the action reloads the page to fetch the current client (on a native build it opens
// the store instead). The reload resets the terminal store, so the app re-bootstraps: the overlay
// is gone and the login is back.
// On the web the action reloads the page to fetch the current client (a native build opens the store
// instead). The reload re-bootstraps to INITIAL online, so the notice is gone and the login is back.
await Promise.all([
page.waitForEvent('load'),
overlay.getByRole('button', { name: /Update|Обновить/i }).click(),
notice.getByRole('button', { name: /Update|Обновить/i }).click(),
]);
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
});
test('Play offline dismisses the notice into the offline lobby', async ({ page }) => {
await page.goto('/');
await enterLobby(page);
// Online first: the seeded online game (vs Ann) is listed.
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
// A too-old foreground call locks the version: the notice covers the (now offline) app.
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const notice = page.getByRole('alertdialog');
await expect(notice).toBeVisible();
// "Play offline" dismisses the notice and leaves the app in the offline lobby (the version lock is an
// offline state): the header is blue and the online game is gone.
await notice.getByRole('button', { name: /Play offline|Играть офлайн/i }).click();
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await expect(page.locator('header.nav.offline')).toBeVisible();
// The server game (vs Ann) rides along greyed from the cache (offline, un-openable).
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
});
test('the soft update-available nudge shows in the lobby and dismisses', async ({ page }) => {
await page.goto('/');
await enterLobby(page);
await expect(page.locator('.nudge')).toHaveCount(0);
// A served response below the recommended version carries X-Update-Recommended: the non-blocking
// nudge appears in the lobby (above the tab bar); play is unaffected — no overlay.
await page.evaluate(() => (window as unknown as { __update: { recommend(): void } }).__update.recommend());
const nudge = page.locator('.nudge');
await expect(nudge).toBeVisible();
await expect(nudge).toContainText(/Update available|Доступно обновление/i);
await expect(page.getByRole('alertdialog')).toHaveCount(0);
// Dismissing it (the ×) hides it for the session.
await nudge.getByRole('button', { name: /Close|Закрыть/i }).click();
await expect(page.locator('.nudge')).toHaveCount(0);
});
+1
View File
@@ -22,6 +22,7 @@
"@capacitor/android": "^8.4.1",
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.4.1",
"@capacitor/network": "8.0.1",
"@connectrpc/connect": "^2.1.0",
"@connectrpc/connect-web": "^2.1.0",
"@vkontakte/vk-bridge": "^3.0.2",
+5 -3
View File
@@ -22,10 +22,12 @@ export default defineConfig({
// /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset
// URLs, so the minified JS under test is identical to the contour's.
// After the mock build, copy the real per-variant dawgs into the preview output (dist-e2e) so the
// offline spec can play a real local vs_ai game; the files are e2e-only (never committed, never in
// the production build). Source: E2E_DICT_DIR (CI: the fetched release; local: the sibling).
// offline specs can play a real local vs_ai game; the files are e2e-only (never committed, never in
// the production build). Source: E2E_DICT_DIR (CI: the fetched release; local: the sibling). Two
// copies for the two loader tiers: e2edict/<variant>.dawg for the mock's session-gated fetchDict
// (network tier) and dict/<variant>@<version>.dawg for the native offline-first spec's bundled tier.
command:
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && node scripts/e2e-dict.mjs && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && node scripts/e2e-dict.mjs && DICT_DIR="${E2E_DICT_DIR:-../../scrabble-solver/dawg}" OUT_DIR=dist-e2e node scripts/bundle-dicts.mjs && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
+12
View File
@@ -20,6 +20,9 @@ importers:
'@capacitor/core':
specifier: ^8.4.1
version: 8.4.1
'@capacitor/network':
specifier: 8.0.1
version: 8.0.1(@capacitor/core@8.4.1)
'@connectrpc/connect':
specifier: ^2.1.0
version: 2.1.1(@bufbuild/protobuf@2.12.0)
@@ -638,6 +641,11 @@ packages:
'@capacitor/core@8.4.1':
resolution: {integrity: sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg==}
'@capacitor/network@8.0.1':
resolution: {integrity: sha512-9xK/FHFmzKGanB6BdoSZOzXk8vF0OFVQSQ4PAsIrzAzLuXHryO317qy8dcHVpgxYeuZq2noI0My9z1DvVDi/9w==}
peerDependencies:
'@capacitor/core': '>=8.0.0'
'@connectrpc/connect-web@2.1.1':
resolution: {integrity: sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==}
peerDependencies:
@@ -4333,6 +4341,10 @@ snapshots:
dependencies:
tslib: 2.8.1
'@capacitor/network@8.0.1(@capacitor/core@8.4.1)':
dependencies:
'@capacitor/core': 8.4.1
'@connectrpc/connect-web@2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))':
dependencies:
'@bufbuild/protobuf': 2.12.0
+1 -59
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
import { app, bootstrap } from './lib/app.svelte';
import { router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import { initNativeShell } from './lib/native';
@@ -145,21 +145,6 @@
<MaintenanceOverlay />
<UpdateOverlay />
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
keep trying to connect. bootstrap awaits this choice (app.offlinePrompt). -->
{#if app.offlinePrompt}
<div class="offprompt" role="dialog" aria-modal="true">
<div class="card">
<p class="msg">{t('offline.promptTitle')}</p>
<div class="acts">
<button class="opt primary" onclick={() => resolveOfflinePrompt(true)}>{t('offline.promptYes')}</button>
<button class="opt" onclick={() => resolveOfflinePrompt(false)}>{t('offline.promptNo')}</button>
</div>
</div>
</div>
{/if}
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
<Splash />
{/if}
@@ -169,49 +154,6 @@
{/if}
<style>
/* The cold-start "no connection" dialog — a centred card over a scrim, above the splash. */
.offprompt {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: var(--pad);
}
.offprompt .card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 20px;
max-width: 320px;
width: 100%;
text-align: center;
}
.offprompt .msg {
margin: 0 0 16px;
font-size: 1rem;
color: var(--text);
}
.offprompt .acts {
display: flex;
flex-direction: column;
gap: 8px;
}
.offprompt .opt {
padding: 11px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: 0.95rem;
}
.offprompt .opt.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.splash {
height: 100%;
display: grid;
+13 -9
View File
@@ -54,16 +54,20 @@
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
content-safe-area inset, 0 elsewhere. */
--tg-content-top: 0px;
/* Device safe-area top (the notch); inside Telegram TG's own nav controls sit between it and
--tg-content-top, so the in-app header aligns to that band. Inside Telegram these are set from
the SDK (lib/app.svelte.ts); elsewhere they fall back to the CSS env() safe area, so a VK Mini
App / Capacitor / PWA webview clears the device cut-outs too (a plain browser tab reports 0). */
--tg-safe-top: env(safe-area-inset-top, 0px);
/* Device safe-area top (the notch / status bar); inside Telegram TG's own nav controls sit between
it and --tg-content-top, so the in-app header aligns to that band. Inside Telegram these are set
from the SDK (lib/app.svelte.ts); on the native Capacitor build the value is the SystemBars core
plugin's injected --safe-area-inset-* correct on EVERY Android WebView, including the < 140
versions where env(safe-area-inset-*) wrongly reports 0 under Android's mandatory edge-to-edge and
the app chrome would otherwise draw under the system bars. It falls back to the CSS env() safe area
on iOS / PWA / a plain browser tab (0 there). */
--tg-safe-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
/* Device safe-area bottom / sides (home indicator; landscape notch) the screen pads its bottom
and left/right edges by these so content clears the cut-outs (e.g. the VK mobile home bar). */
--tg-safe-bottom: env(safe-area-inset-bottom, 0px);
--tg-safe-left: env(safe-area-inset-left, 0px);
--tg-safe-right: env(safe-area-inset-right, 0px);
and left/right edges by these so content clears the cut-outs (e.g. the gesture-nav home indicator
or the VK mobile home bar). */
--tg-safe-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
--tg-safe-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
--tg-safe-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
+2 -1
View File
@@ -53,7 +53,8 @@
place-items: center;
/* Darker than the onboarding scrim — this blocks the board until the dictionary is ready. */
background: rgba(0, 0, 0, 0.72);
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
/* Via the shared --tg-safe-* tokens so the Android WebView < 140 env() bug is covered (app.css). */
padding: var(--tg-safe-top) var(--tg-safe-right) var(--tg-safe-bottom) var(--tg-safe-left);
}
.box {
display: grid;
+5
View File
@@ -101,6 +101,11 @@
align-items: center;
gap: var(--gap);
padding: 5px var(--pad);
/* Clear the top safe area on the native build: under Android 15+ edge-to-edge the WebView draws
behind the status bar, so the header row must drop below it. --safe-area-inset-top is injected by
the Capacitor SystemBars plugin (native only — unset, so 0, on web / PWA / Telegram / VK, which do
not draw behind our header). Telegram fullscreen overrides this with its own nav-band rule below. */
padding-top: calc(var(--safe-area-inset-top, 0px) + 5px);
}
h1 {
font-size: 1.05rem;
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
// The soft "update available" nudge (the soft version tier). A non-blocking banner: play continues.
// Shown only while online — the hard "update required" notice is an offline state, so it naturally
// supersedes this — and only until the user dismisses it for the session. Rendered at the bottom of
// the lobby (above the tab bar), so it never covers a game in progress.
import { netState } from '../lib/netstate.svelte';
import { updateRecommended, dismissUpdateNudge, openUpdate } from '../lib/update.svelte';
import { t } from '../lib/i18n/index.svelte';
const show = $derived(netState.online && updateRecommended.active && !updateRecommended.dismissed);
</script>
{#if show}
<div class="nudge" role="status">
<span class="msg">{t('update.available')}</span>
<button class="upd" type="button" onclick={openUpdate}>{t('update.action')}</button>
<button class="x" type="button" onclick={dismissUpdateNudge} aria-label={t('common.close')}>×</button>
</div>
{/if}
<style>
.nudge {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.9rem;
border-top: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 0.9rem;
}
.msg {
flex: 1 1 auto;
min-width: 0;
}
.upd {
flex: 0 0 auto;
font: inherit;
padding: 0.3rem 0.8rem;
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-text);
cursor: pointer;
}
.x {
flex: 0 0 auto;
font-size: 1.2rem;
line-height: 1;
padding: 0.2rem 0.4rem;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
</style>
+31 -22
View File
@@ -1,33 +1,29 @@
<script lang="ts">
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call
// as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is
// terminal: an installed build cannot become compatible without an actual update, so its one
// action takes the user to the fix — the store listing on a native build (VITE_STORE_URL, opened
// in the system browser / store app) or a plain reload on the web (which fetches the current
// client). Mirrors MaintenanceOverlay.svelte's look.
import { updateRequired } from '../lib/update.svelte';
import { clientChannel } from '../lib/channel';
// The "update required" notice (the hard version tier). Shown when the gateway has refused a
// foreground call as too old, driving the net-state machine into offlineVersionLocked. Unlike the
// old terminal cover it is dismissable: "Update" takes the user to the fix (the store on native,
// a reload on the web), while "Play offline" dismisses the notice and leaves the app in the offline
// lobby (the version lock is an offline state, sticky until an actual update on the next launch).
// Mirrors MaintenanceOverlay.svelte's look.
import { netState } from '../lib/netstate.svelte';
import { openUpdate } from '../lib/update.svelte';
import { t } from '../lib/i18n/index.svelte';
function onAction(): void {
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') {
// '_system' hands the URL to the OS (the store app / external browser) rather than the WebView.
const url = import.meta.env.VITE_STORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
}
}
// Dismissed for the session once the user chooses "Play offline". The lock is sticky until a fresh
// launch, so the notice does not reappear this session; the app stays in the offline lobby.
let dismissed = $state(false);
</script>
{#if updateRequired.active}
{#if netState.versionLocked && !dismissed}
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="update-title" aria-describedby="update-body">
<div class="card">
<div class="tile" aria-hidden="true">Э</div>
<h1 id="update-title">{t('update.title')}</h1>
<p id="update-body">{t('update.body')}</p>
<button type="button" onclick={onAction}>{t('update.action')}</button>
<div class="acts">
<button type="button" class="primary" onclick={openUpdate}>{t('update.action')}</button>
<button type="button" onclick={() => (dismissed = true)}>{t('update.playOffline')}</button>
</div>
</div>
</div>
{/if}
@@ -36,8 +32,7 @@
.scrim {
position: fixed;
inset: 0;
/* Above the game (z 60) and toasts (z 50) — a terminal update block covers everything the user
could otherwise interact with; below the dev DebugPanel (z 10000). */
/* Above the game (z 60) and toasts (z 50); below the dev DebugPanel (z 10000). */
z-index: 100;
background: rgba(0, 0, 0, 0.55);
display: grid;
@@ -79,6 +74,11 @@
color: var(--text-muted);
line-height: 1.5;
}
.acts {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
button {
font: inherit;
padding: 0.55rem 1.4rem;
@@ -92,4 +92,13 @@
border-color: var(--accent);
color: var(--accent);
}
.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.primary:hover {
color: var(--accent-text);
opacity: 0.9;
}
</style>
+90 -106
View File
@@ -56,8 +56,12 @@ import {
} from './session';
import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
import { offlineMode, setOfflineMode } from './offline.svelte';
import { shouldBootOffline } from './offline';
import { bootOffline, emit, initNetSignals, registerNetNudge, registerNetToast, registerRecovery } from './netstate.svelte';
import { latchUpdateNudge } from './update.svelte';
import { clearOfflinePref } from './offline';
import { initNativeNetwork } from './native';
import { clientChannel } from './channel';
import { localGuestId } from './localguest';
import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
@@ -78,10 +82,6 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login a Mini App has no manual sign-in to fall back to. */
bootError: boolean;
/** True while the cold-start "no connection go offline?" dialog is up (the reachability check
* timed out with the network interface reportedly online). App.svelte renders it over the splash;
* bootstrap awaits the choice via resolveOfflinePrompt. */
offlinePrompt: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) a shareable probe for why Telegram
@@ -158,7 +158,6 @@ export const app = $state<{
}>({
ready: false,
bootError: false,
offlinePrompt: false,
launchError: null,
debugOpen: false,
lobbyReady: false,
@@ -572,72 +571,58 @@ async function adoptSession(s: Session): Promise<void> {
void refreshNotifications();
}
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice
// here; App.svelte's dialog buttons call resolveOfflinePrompt.
// The cold-start reachability probe budget: how long the boot waits for the gateway to answer before
// launching from the cache into the implicit offline lobby (offline is auto-detected now — no dialog).
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
let offlinePromptResolve: ((goOffline: boolean) => void) | null = null;
/** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */
export function resolveOfflinePrompt(goOffline: boolean): void {
app.offlinePrompt = false;
const resolve = offlinePromptResolve;
offlinePromptResolve = null;
resolve?.(goOffline);
}
/** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */
function promptOfflineChoice(): Promise<boolean> {
app.offlinePrompt = true;
return new Promise<boolean>((resolve) => {
offlinePromptResolve = resolve;
});
}
/**
* initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode):
* losing the network interface enters offline mode automatically (session-only); regaining it
* verifies the gateway is really reachable (an interface being up does not guarantee it) and returns
* to online but only if the offline was auto. A deliberate offline (the Settings toggle or the
* cold-start dialog) is left as the player's choice. These are passive OS events no polling, no
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
* recover is the smart recovery the net-state store's watcher runs while connecting/offline: a
* session-less native local guest reconciles a server guest (the reconcile attempt IS the reachability
* probe it needs no prior session), while any cached-session launch does a cheap authenticated read.
* It resolves when the gateway is reachable (the store then heals to online) and rejects to stay offline.
* bootstrap wires it to the store, which owns the poll/backoff timer and the OS-signal listeners.
*/
// While in auto-offline, poll for the network to return so the app comes back online — robust to the
// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it
// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the
// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in
// auto-offline (a deliberate offline is the player's choice) and stops on returning online.
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
export function scheduleRecovery(delayMs: number): void {
if (recoveryTimer) {
clearTimeout(recoveryTimer);
recoveryTimer = null;
async function recover(): Promise<void> {
const channel = clientChannel();
if ((channel === 'android' || channel === 'ios') && !app.session) {
await reconcileServerGuest();
if (!app.session) throw new Error('still a local guest'); // reconcile did not adopt — stay offline
return;
}
if (typeof window === 'undefined' || !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 && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) {
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
return;
}
scheduleRecovery(4000);
}, delayMs);
if (!(await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) throw new Error('gateway unreachable');
}
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
// at once — only the first proceeds; the rest see the in-flight flag (or the adopted session) and return.
let reconcileInFlight = false;
/**
* reconcileServerGuest lazily mints a server guest for a native local-guest launch once the gateway is
* reachable: with no cached session it silently calls auth.guest, adopts the returned session and clears
* the auto-offline so online features (matchmaking, friends, the Profile sign-in surface) light up. It is
* idempotent and best-effort it never mints a second server guest (guarded on the absence of a session
* and the in-flight flag), and a too-old client (a swallowed update_required no overlay) or an
* unreachable gateway just leaves the device a local guest until the next attempt. Local (device-only)
* games are never migrated. Native channel only; a no-op elsewhere.
*/
async function reconcileServerGuest(): Promise<void> {
if (reconcileInFlight || app.session) return;
const channel = clientChannel();
if (channel !== 'android' && channel !== 'ios') return;
reconcileInFlight = true;
try {
const s = await gateway.authGuestSilent(app.locale);
// The gateway answered, so heal the machine to online BEFORE adopting: adoptSession's profile fetch
// and live stream ride the normal (online) transport, which the offline kill switch would otherwise
// refuse. emit('callOk') is idempotent when already online (a re-entrant recover after adoption).
emit('callOk');
await adoptSession(s);
} catch {
// A too-old client (update_required, swallowed — stay a local guest, no overlay) or an unreachable
// gateway: remain offline; the recovery poll / online event retries when the network returns.
} finally {
reconcileInFlight = false;
}
}
/**
@@ -813,6 +798,9 @@ export async function bootstrap(): Promise<void> {
// has no real backend). Deliberately before any await, so even an early-return launch path
// (e.g. the Telegram launch-error screen) still counts as an app open.
if (import.meta.env.MODE !== 'mock') startLocalEvalMetrics();
// One-time cleanup of the retired deliberate-offline preference (offline is implicit now — the key is
// never read again). Runs for every channel, before the Mini-App branches return.
clearOfflinePref();
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
@@ -922,37 +910,27 @@ export async function bootstrap(): Promise<void> {
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
// and skipped in the mock build; see lib/pwa.svelte.
registerServiceWorker();
// React to mid-session network changes (e.g. flight mode) for the rest of the session.
initNetworkReactivity();
// Deliberate offline mode carried over from a prior session: with no network the session adoption
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
// with no cached profile is unreachable: enabling offline requires a prior online session.)
if (offlineMode.active) {
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
gateway.setToken(offlineSession!.token);
app.session = offlineSession!;
app.profile = offlineProfile!;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
// Wire the net-state machine's signal sources for the rest of the session: the OS connectivity hints
// (web navigator + native @capacitor/network), the smart recovery the store's watcher runs, and the
// offline/online toast. Reached only on web / native — the Telegram/VK branches returned above, so
// those channels are never fed an offline signal and the machine stays inert (always online) there.
registerNetToast((toast) => showToast(t(toast === 'offline' ? 'net.offline' : 'net.online')));
registerNetNudge(latchUpdateNudge);
registerRecovery(recover);
initNetSignals();
void initNativeNetwork();
const saved = await loadSession();
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
// from VK; capture and clear it here (it needs the restored session to link against).
const vkcb = pendingVKLink();
if (saved) {
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
// the network is down: a device with no network interface goes offline for the session (no
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
// Cold-start network auto-detect. With a cached profile to fall back on and no pending VK-link, do
// not hang on adoptSession's retrying profile fetch when the network is down: if the interface is
// down, or the gateway does not answer within a short window, launch straight from the cache into
// the implicit offline lobby (no dialog — offline is detected now). The store's recovery poll heals
// to online when the network returns. Web-only reaches here (the Mini-App branches returned above),
// so isStandalone gates it.
const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
if (canOffline) {
@@ -960,21 +938,12 @@ export async function bootstrap(): Promise<void> {
gateway.setToken(saved.token);
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
if (!reachable) {
// No network interface → go offline for the session (no dialog); interface up but the
// gateway is unreachable → ambiguous, so ask.
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
if (goOffline) {
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
setOfflineMode(true, !interfaceOffline);
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
// watcher shows "Connecting…").
bootOffline();
app.session = saved;
app.profile = cachedProfile;
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
app.ready = true;
return;
}
}
await adoptSession(saved);
@@ -986,6 +955,15 @@ export async function bootstrap(): Promise<void> {
} else if (router.route.name === 'login') {
navigate('/');
}
} else if (clientChannel() === 'android' || clientChannel() === 'ios') {
// Native offline-first cold boot: no cached server session, so enter as a device-local guest in the
// implicit offline lobby and land straight there — never /login. bootOffline(true) drops into offline
// and kicks the recovery poll now; recover() mints + adopts a server guest and heals to online once
// the gateway is reachable, and until then the guest plays local vs_ai / hotseat with the APK's
// bundled dictionaries. Web / PWA / Telegram / VK keep the online-session rule below.
localGuestId(); // establish + persist the device-local identity from the very first launch
bootOffline(true); // offline-first + kick reconciliation now, then poll until the network returns
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login');
}
@@ -1370,4 +1348,10 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
navigate,
route: () => router.route.name,
};
// Drive the native offline-first reconciliation from the e2e: the net-state store's recovery poll that
// fires it in a real build is inert under the mock, so the spec calls this to simulate "the network
// returned" and prove a server guest is minted + adopted and the machine heals to online.
(window as unknown as { __native?: { reconcile(): void } }).__native = {
reconcile: () => void reconcileServerGuest(),
};
}
+4
View File
@@ -68,6 +68,10 @@ export interface GatewayClient {
* cosmetic seed for a brand-new account). */
authVK(params: string, displayName: string): Promise<Session>;
authGuest(locale?: string): Promise<Session>;
/** Mint a server guest for a native local guest gaining the network (background reconciliation).
* Unlike foreground auth it bypasses the offline kill switch and never raises the terminal update
* overlay a too-old client stays a local guest instead (the gate×offline rule, docs/ARCHITECTURE.md §2). */
authGuestSilent(locale?: string): Promise<Session>;
authEmailRequest(email: string, language: string, pwa: boolean): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>;
/** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
+28 -59
View File
@@ -1,80 +1,49 @@
// Global connectivity signal. `online` is false while the app is actively failing to
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live
// stream dropped. The transport and the live-stream owner report transitions; the UI reads
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive
// actions. In mock mode nothing ever reports trouble, so it simply stays online.
//
// Recovery is guaranteed by a reachability watcher: while offline it periodically fires a
// registered probe (a lightweight read) until one succeeds, so the indicator clears even when no
// other traffic is in flight.
// connection.svelte.ts is now a thin shim over the net-state store (netstate.svelte.ts, O2). It keeps
// the historical surface — `connection.online` plus the report/probe helpers the transport and the live
// stream call — so those consumers are unchanged while the single net-state machine drives everything
// underneath. `online` is true only in the fully-online machine state; a failing call or dropped stream
// reports via reportOffline, which enters the connecting hysteresis buffer once, and the store's probe
// then decides whether it becomes offline — so a transient blip never flips the chrome.
import { backoffMs } from './retry';
import { offlineMode } from './offline.svelte';
let online = $state(true);
let watchTimer: ReturnType<typeof setTimeout> | null = null;
let probe: (() => Promise<void>) | null = null;
import {
netState,
emit,
registerProbe as registerNetProbe,
checkReachable as checkNetReachable,
resetNetState,
} from './netstate.svelte';
export const connection = {
/** online is true when the app believes it can reach the gateway. */
/** online is true when the app can reach the gateway (the fully-online machine state). */
get online(): boolean {
return online;
return netState.online;
},
};
/** registerProbe installs the reachability probe the watcher fires while offline. The transport
* wires a cheap authenticated read; it should reject when there is no session. */
/** registerProbe installs the reachability probe the store's watcher fires while connecting/offline.
* The transport wires a cheap authenticated read; it should reject when there is no session. */
export function registerProbe(fn: () => Promise<void>): void {
probe = fn;
registerNetProbe(fn);
}
/**
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
* gateway answered a single attempt (no retry loop), for the cold-start network decision while
* updating the online signal. It reports false when no probe is registered or the timeout wins.
*/
export async function checkReachable(timeoutMs: number): Promise<boolean> {
if (!probe) return false;
try {
await Promise.race([
probe(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
]);
return true;
} catch {
return false;
}
/** checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
* gateway answered a single attempt for the cold-start / recovery decision. */
export function checkReachable(timeoutMs: number): Promise<boolean> {
return checkNetReachable(timeoutMs);
}
/** reportOnline marks the gateway reachable and stops the watcher. */
/** reportOnline marks the gateway reachable — a successful round-trip heals the machine to online. */
export function reportOnline(): void {
online = true;
if (watchTimer) {
clearTimeout(watchTimer);
watchTimer = null;
}
emit('callOk');
}
/** reportOffline marks the gateway unreachable and starts the reachability watcher (once). */
/** reportOffline reports a failing call or dropped stream. It opens the connecting hysteresis buffer
* once (only from online); the store's probe then decides whether it becomes offline. */
export function reportOffline(): void {
online = false;
if (!watchTimer && probe) scheduleProbe(1);
if (netState.online) emit('callFailed');
}
/** resetConnection restores the online state and stops the watcher (e.g. on logout). */
export function resetConnection(): void {
reportOnline();
}
function scheduleProbe(attempt: number): void {
watchTimer = setTimeout(
() => {
watchTimer = null;
// Never probe the network in offline mode (the kill switch); the online/offline events drive
// recovery there instead.
if (online || !probe || offlineMode.active) return;
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
},
backoffMs(attempt),
);
resetNetState();
}
-27
View File
@@ -1,27 +0,0 @@
// Browser orchestration for the offline-toggle readiness wait. Lazily imported by
// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main
// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by
// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the
// fetch running on a timeout so a later flip is instant.
import type { Profile } from '../model';
import { preloadDicts } from './preload';
import { getDawg, dictLoadingDisabled } from '../dict';
import { raceOfflineReady } from '../offline';
/**
* ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when
* already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is
* available the offline toggle's readiness gate. With no enabled variant there is nothing to play
* offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the
* on-device cache in the background so a later flip to offline succeeds immediately.
*/
export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise<boolean> {
if (prof.variantPreferences.length === 0) return false;
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
getDawg,
disabled: dictLoadingDisabled,
retries: 1,
});
return raceOfflineReady(run, budgetMs);
}
+21 -4
View File
@@ -7,8 +7,9 @@ import { MockGateway } from './mock/client';
import { createTransport } from './transport';
import { setForcedSeed } from './localgame/id';
import { reportOffline, reportOnline } from './connection.svelte';
import { emit } from './netstate.svelte';
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
import { reportUpdateRequired } from './update.svelte';
import { reportUpdateRecommended, reportUpdateRequired } from './update.svelte';
const isMock = import.meta.env.MODE === 'mock';
@@ -32,9 +33,25 @@ if (isMock && typeof window !== 'undefined') {
off: clearMaintenance,
recover: maintenanceRecovered,
};
// The mock never receives a real update_required from the edge, so the e2e drives the terminal
// "update required" overlay directly (it is terminal — there is no clear hook).
(window as unknown as { __update?: { on(): void } }).__update = { on: reportUpdateRequired };
// The mock never receives a real version signal from the edge, so the e2e drives them directly:
// __update.on() raises the hard-tier notice (netState.versionLocked), __update.recommend() raises the
// soft-tier nudge (versionRecommended → the setNudge latch, from online).
(window as unknown as { __update?: { on(): void; recommend(): void } }).__update = {
on: reportUpdateRequired,
recommend: reportUpdateRecommended,
};
// The mock has no real transport, so the net-state store's probe watcher is inert; the e2e drives the
// machine directly. __net.offline() rides past the connecting hysteresis (k failures) into the offline
// lobby; __net.online() heals back to online (the "back online" toast fires from the offline state).
(window as unknown as { __net?: { offline(): void; online(): void } }).__net = {
offline: () => {
emit('callFailed');
emit('probeFailed');
emit('probeFailed');
emit('probeFailed');
},
online: () => emit('callOk'),
};
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
// attaches a robot on a timer).
(
+8 -7
View File
@@ -7,12 +7,16 @@ export const en = {
'app.brand': 'Erudit',
'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…',
'net.offline': 'You are offline',
'net.online': 'Back online',
'maintenance.title': 'Under maintenance',
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
'maintenance.retry': 'Try again',
'update.title': 'Update required',
'update.body': 'This app is out of date and can no longer run. Download the update to keep playing — and winning.',
'update.action': 'Update',
'update.playOffline': 'Play offline',
'update.available': 'Update available',
'blocked.title': 'Account blocked',
'blocked.permanent': 'Your account is blocked.',
@@ -219,15 +223,8 @@ export const en = {
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'settings.zoomBoard': 'Zoom the board',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
'settings.offlineChecking': 'Loading dictionaries…',
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
'offline.promptTitle': 'No connection. Enable offline mode?',
'offline.promptYes': 'Enable',
'offline.promptNo': 'Keep trying',
// --- wallet ---
'wallet.title': 'Wallet',
@@ -378,6 +375,10 @@ export const en = {
'new.auto': 'Quick match',
'new.withFriends': 'Play with friends',
'new.playRemote': 'Invite a friend',
'new.playLocal': 'Pass and play',
'new.needsNetwork': 'Online play needs a connection.',
'new.dictUnavailable': "This dictionary isn't available offline yet.",
'new.pickFriends': 'Choose who to invite',
'new.searchFriends': 'Search friends',
'new.gameType': 'Variant',
+8 -7
View File
@@ -8,12 +8,16 @@ export const ru: Record<MessageKey, string> = {
'app.brand': 'Эрудит',
'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…',
'net.offline': 'Нет соединения',
'net.online': 'Соединение восстановлено',
'maintenance.title': 'Технические работы',
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
'maintenance.retry': 'Повторить',
'update.title': 'Требуется обновление',
'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.',
'update.action': 'Обновить',
'update.playOffline': 'Играть офлайн',
'update.available': 'Доступно обновление',
'blocked.title': 'Учётная запись заблокирована',
'blocked.permanent': 'Ваша учётная запись заблокирована.',
@@ -219,15 +223,8 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'settings.zoomBoard': 'Приближать доску',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
'settings.offlineChecking': 'Загрузка словарей…',
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
'offline.promptYes': 'Включить',
'offline.promptNo': 'Ждать сеть',
// --- wallet ---
'wallet.title': 'Кошелёк',
@@ -378,6 +375,10 @@ export const ru: Record<MessageKey, string> = {
'new.auto': 'Быстрая игра',
'new.withFriends': 'Игра с друзьями',
'new.playRemote': 'Пригласить друга',
'new.playLocal': 'На одном устройстве',
'new.needsNetwork': 'Для игры онлайн нужна сеть.',
'new.dictUnavailable': 'Этот словарь пока недоступен офлайн.',
'new.pickFriends': 'Кого пригласить',
'new.searchFriends': 'Поиск друзей',
'new.gameType': 'Вариант',
+28 -35
View File
@@ -44,10 +44,10 @@ beforeEach(() => clearLobby());
describe('patchLobbyGame', () => {
it('replaces the matching game by id and leaves the others untouched', () => {
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], offline: false });
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
// The player's own move flipped game "a" to the opponent's turn.
patchLobbyGame(gameView('a', 'active', 1));
const snap = getLobby(false);
const snap = getLobby();
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
@@ -55,27 +55,27 @@ describe('patchLobbyGame', () => {
it('preserves invitations and incoming when patching a game', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('a')], invitations: [], incoming, offline: false });
setLobby({ games: [gameView('a')], invitations: [], incoming });
patchLobbyGame(gameView('a', 'finished'));
expect(getLobby(false)?.incoming).toEqual(incoming);
expect(getLobby(false)?.games[0].status).toBe('finished');
expect(getLobby()?.incoming).toEqual(incoming);
expect(getLobby()?.games[0].status).toBe('finished');
});
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
setLobby({ games: [gameView('a')], invitations: [], incoming: [], offline: false });
setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
patchLobbyGame(gameView('z', 'active'));
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyGame(gameView('a'));
expect(getLobby(false)).toBeNull();
expect(getLobby()).toBeNull();
});
it('does not mutate the previous snapshot array', () => {
const games = [gameView('a', 'active', 0)];
setLobby({ games, invitations: [], incoming: [], offline: false });
setLobby({ games, invitations: [], incoming: [] });
patchLobbyGame(gameView('a', 'active', 1));
expect(games[0].toMove).toBe(0); // the original array/object is left intact
});
@@ -83,59 +83,52 @@ describe('patchLobbyGame', () => {
describe('patchLobbyInvitation', () => {
it('adds a new pending invitation to the cached lobby', () => {
setLobby({ games: [], invitations: [], incoming: [], offline: false });
setLobby({ games: [], invitations: [], incoming: [] });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
});
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
patchLobbyInvitation(updated);
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
expect(getLobby(false)?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
});
it('removes an invitation that reached a terminal status', () => {
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', terminal));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
}
});
it('is a no-op for a terminal invitation that is not in the list', () => {
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], offline: false });
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
patchLobbyInvitation(invitation('i1', 'declined'));
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
});
it('is a no-op when there is no cached lobby yet', () => {
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)).toBeNull();
expect(getLobby()).toBeNull();
});
it('preserves games and incoming when patching an invitation', () => {
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
setLobby({ games: [gameView('g1')], invitations: [], incoming, offline: false });
setLobby({ games: [gameView('g1')], invitations: [], incoming });
patchLobbyInvitation(invitation('i1', 'pending'));
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby(false)?.incoming).toEqual(incoming);
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
expect(getLobby()?.incoming).toEqual(incoming);
});
});
describe('getLobby is mode-aware (a mode flip must not render the other modes games)', () => {
it('returns the snapshot only for the mode it was stored under', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
expect(getLobby(true)).toBeNull();
});
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], offline: true });
expect(getLobby(false)).toBeNull();
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
describe('the unified snapshot (one lobby cache — offline reuses its server games, greyed)', () => {
it('replaces the whole snapshot on each store, so the latest merged lists win', () => {
setLobby({ games: [gameView('online1')], invitations: [], incoming: [] });
expect(getLobby()?.games.map((g) => g.id)).toEqual(['online1']);
setLobby({ games: [gameView('local1'), gameView('online2')], invitations: [], incoming: [] });
expect(getLobby()?.games.map((g) => g.id)).toEqual(['local1', 'online2']);
});
});
+5 -8
View File
@@ -10,18 +10,15 @@ interface LobbySnapshot {
games: GameView[];
invitations: Invitation[];
incoming: AccountRef[];
// Which mode the snapshot belongs to (offline = device-local games, online = server games). The
// lobby renders it instantly only when it matches the current mode, so a mode flip never flashes —
// or lets the player open — the other mode's games before the background refresh replaces them.
offline: boolean;
}
let snapshot: LobbySnapshot | null = null;
/** getLobby returns the last lobby lists for the given mode, or null before the first load or when
* the cached snapshot belongs to the other mode (so the lobby cold-renders after a mode flip). */
export function getLobby(offline: boolean): LobbySnapshot | null {
return snapshot && snapshot.offline === offline ? snapshot : null;
/** getLobby returns the last lobby lists, or null before the first load. The unified lobby caches one
* snapshot (device-local + server games merged); offline reuses its server games, greyed, and drops
* the local ones for a fresh device read. */
export function getLobby(): LobbySnapshot | null {
return snapshot;
}
/** setLobby stores the latest lobby lists. */
+46 -30
View File
@@ -3,6 +3,9 @@ import { gamePhase, groupGames, isMyTurn, orderedSeats, scoreStanding, shouldBli
import type { GameView, Seat } from './model';
const ME = 'me';
// The self-id set the lobby passes (server user id + device-local guest id); ME is used as a bare seat
// id where a single seat is built, MINE where a function takes the whole "you" set.
const MINE = [ME];
const seat = (s: number, accountId: string): Seat => ({
seat: s,
accountId,
@@ -41,7 +44,7 @@ describe('groupGames', () => {
game('b', 'active', 1, 100), // their turn
game('c', 'finished', 0, 100),
],
ME,
MINE,
{},
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
@@ -59,7 +62,7 @@ describe('groupGames', () => {
game('f_new', 'finished', 0, 200),
game('f_old', 'finished', 0, 100),
],
ME,
MINE,
{},
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
@@ -77,7 +80,7 @@ describe('groupGames', () => {
game('f_unread', 'finished', 0, 100),
game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first
],
ME,
MINE,
{ y_unread: true, t_unread: true, f_unread: true },
);
// Unread is the primary key, so it overrides the within-section activity order.
@@ -88,9 +91,9 @@ describe('groupGames', () => {
});
it('isMyTurn is false for a finished game even at my seat', () => {
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false);
expect(isMyTurn(game('x', 'finished', 0, 0), MINE)).toBe(false);
expect(isMyTurn(game('x', 'active', 0, 0), MINE)).toBe(true);
expect(isMyTurn(game('x', 'active', 1, 0), MINE)).toBe(false);
});
it('treats an open game (awaiting an opponent) as in progress, not finished', () => {
@@ -99,23 +102,36 @@ describe('groupGames', () => {
game('open_mine', 'open', 0, 100), // my turn while waiting
game('open_wait', 'open', 1, 100), // the empty seat's turn
],
ME,
MINE,
{},
);
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
expect(g.finished).toEqual([]);
expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true);
expect(isMyTurn(game('x', 'open', 0, 0), MINE)).toBe(true);
});
it('recognises "you" under any of the self ids (local game under localGuestId, server under userId)', () => {
// After reconciliation a device-local game seats you under the local guest id and a server game
// under the user id; the two differ (ids never collide) and both must count as your turn.
const localGame: GameView = { ...game('local', 'active', 0, 100), seats: [seat(0, 'guest'), seat(1, 'robot')] };
const serverGame: GameView = { ...game('server', 'active', 0, 100), seats: [seat(0, ME), seat(1, 'opp')] };
const both = ['guest', ME];
expect(isMyTurn(localGame, both)).toBe(true);
expect(isMyTurn(serverGame, both)).toBe(true);
expect(isMyTurn(localGame, [ME])).toBe(false); // a single id misses the other source's game
const g = groupGames([localGame, serverGame], both, {});
expect(g.yourTurn.map((x) => x.id).sort()).toEqual(['local', 'server']);
});
});
describe('gamePhase', () => {
it('maps each game to its lobby bucket (open folds into mine/theirs like active)', () => {
expect(gamePhase(game('x', 'active', 0, 0), ME)).toBe('mine');
expect(gamePhase(game('x', 'active', 1, 0), ME)).toBe('theirs');
expect(gamePhase(game('x', 'open', 0, 0), ME)).toBe('mine');
expect(gamePhase(game('x', 'open', 1, 0), ME)).toBe('theirs');
expect(gamePhase(game('x', 'finished', 0, 0), ME)).toBe('finished');
expect(gamePhase(game('x', 'active', 0, 0), MINE)).toBe('mine');
expect(gamePhase(game('x', 'active', 1, 0), MINE)).toBe('theirs');
expect(gamePhase(game('x', 'open', 0, 0), MINE)).toBe('mine');
expect(gamePhase(game('x', 'open', 1, 0), MINE)).toBe('theirs');
expect(gamePhase(game('x', 'finished', 0, 0), MINE)).toBe('finished');
});
});
@@ -131,38 +147,38 @@ describe('scoreStanding', () => {
it('greens the leading viewer and reds a trailing one; the opponent stays muted', () => {
const lead = withScores('active', 30, 10);
expect(scoreStanding(lead, ME, at(lead, ME))).toBe('win');
expect(scoreStanding(lead, ME, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured
expect(scoreStanding(lead, MINE, at(lead, ME))).toBe('win');
expect(scoreStanding(lead, MINE, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured
const behind = withScores('active', 5, 10);
expect(scoreStanding(behind, ME, at(behind, ME))).toBe('lose');
expect(scoreStanding(behind, ME, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured
expect(scoreStanding(behind, MINE, at(behind, ME))).toBe('lose');
expect(scoreStanding(behind, MINE, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured
});
it('greens both seats on an equal non-zero score', () => {
const tie = withScores('active', 10, 10);
expect(scoreStanding(tie, ME, at(tie, ME))).toBe('win');
expect(scoreStanding(tie, ME, at(tie, 'opp'))).toBe('win');
expect(scoreStanding(tie, MINE, at(tie, ME))).toBe('win');
expect(scoreStanding(tie, MINE, at(tie, 'opp'))).toBe('win');
});
it('leaves a fresh 0:0 board muted (nobody has scored yet)', () => {
const fresh = withScores('active', 0, 0);
expect(scoreStanding(fresh, ME, at(fresh, ME))).toBeNull();
expect(scoreStanding(fresh, ME, at(fresh, 'opp'))).toBeNull();
expect(scoreStanding(fresh, MINE, at(fresh, ME))).toBeNull();
expect(scoreStanding(fresh, MINE, at(fresh, 'opp'))).toBeNull();
});
it('is null for any non-active game (open or finished)', () => {
const fin = withScores('finished', 30, 10);
expect(scoreStanding(fin, ME, at(fin, ME))).toBeNull();
expect(scoreStanding(fin, MINE, at(fin, ME))).toBeNull();
const op = withScores('open', 0, 0);
expect(scoreStanding(op, ME, at(op, ME))).toBeNull();
expect(scoreStanding(op, MINE, at(op, ME))).toBeNull();
});
it('is null when the viewer is not seated or has no opponent', () => {
const g = withScores('active', 30, 10);
expect(scoreStanding(g, 'stranger', at(g, ME))).toBeNull();
expect(scoreStanding(g, ['stranger'], at(g, ME))).toBeNull();
const solo: GameView = { ...game('g', 'active', 0, 0), seats: [{ ...seat(0, ME), score: 9 }] };
expect(scoreStanding(solo, ME, solo.seats[0])).toBeNull();
expect(scoreStanding(solo, MINE, solo.seats[0])).toBeNull();
});
it('compares against the strongest opponent in a multiplayer game', () => {
@@ -175,8 +191,8 @@ describe('scoreStanding', () => {
{ ...seat(2, 'b'), score: 50 }, // b is ahead -> the viewer is losing
],
};
expect(scoreStanding(g3, ME, at(g3, ME))).toBe('lose');
expect(scoreStanding(g3, ME, at(g3, 'b'))).toBeNull(); // a leading opponent is not coloured
expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('lose');
expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull(); // a leading opponent is not coloured
});
it('greens every seat tied with the viewer for the lead in a multiplayer game', () => {
@@ -189,9 +205,9 @@ describe('scoreStanding', () => {
{ ...seat(2, 'b'), score: 20 }, // trailing -> muted
],
};
expect(scoreStanding(g3, ME, at(g3, ME))).toBe('win');
expect(scoreStanding(g3, ME, at(g3, 'a'))).toBe('win');
expect(scoreStanding(g3, ME, at(g3, 'b'))).toBeNull();
expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('win');
expect(scoreStanding(g3, MINE, at(g3, 'a'))).toBe('win');
expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull();
});
});
+15 -12
View File
@@ -5,9 +5,12 @@
import type { GameView, Seat } from './model';
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */
export function isMyTurn(game: GameView, myId: string): boolean {
const me = game.seats.find((s) => s.accountId === myId);
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. myIds carries every id
* that is "you" the server user id and the device-local guest id since after reconciliation a
* device-local game seats you under localGuestId while a server game seats you under the user id (the
* ids never collide). */
export function isMyTurn(game: GameView, myIds: readonly string[]): boolean {
const me = game.seats.find((s) => myIds.includes(s.accountId));
// 'open' (an auto-match game still awaiting an opponent) is in progress like 'active'.
return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat;
}
@@ -21,16 +24,16 @@ export function isMyTurn(game: GameView, myId: string): boolean {
* the result emoji instead), for a fresh 0:0 board where nobody has scored yet, and when myId is
* not seated against an opponent.
*/
export function scoreStanding(game: GameView, myId: string, seat: Seat): 'win' | 'lose' | null {
export function scoreStanding(game: GameView, myIds: readonly string[], seat: Seat): 'win' | 'lose' | null {
if (game.status !== 'active') return null;
const me = game.seats.find((s) => s.accountId === myId);
const me = game.seats.find((s) => myIds.includes(s.accountId));
if (!me) return null;
const others = game.seats.filter((s) => s.accountId && s.accountId !== myId).map((s) => s.score);
const others = game.seats.filter((s) => s.accountId && !myIds.includes(s.accountId)).map((s) => s.score);
if (!others.length) return null;
const top = Math.max(me.score, ...others);
if (top === 0) return null; // nobody has scored yet: leave the 0:0 board muted, like a finished game
const meLeads = me.score === top;
if (seat.accountId === myId) return meLeads ? 'win' : 'lose';
if (myIds.includes(seat.accountId)) return meLeads ? 'win' : 'lose';
// Another seat greens only when it ties the leading viewer at the top — the equal-score case.
return meLeads && seat.score === top ? 'win' : null;
}
@@ -47,13 +50,13 @@ export function orderedSeats(game: GameView): GameView['seats'] {
export type LobbyPhase = 'mine' | 'theirs' | 'finished';
/**
* gamePhase reports a game's lobby bucket for myId: 'finished' for any non-active/open status,
* gamePhase reports a game's lobby bucket for myIds: 'finished' for any non-active/open status,
* else 'mine' when it is the caller's turn, else 'theirs'. It mirrors groupGames' partition and
* drives the per-card blink, which fires when a card transitions into 'mine' or 'finished'.
*/
export function gamePhase(game: GameView, myId: string): LobbyPhase {
export function gamePhase(game: GameView, myIds: readonly string[]): LobbyPhase {
if (game.status !== 'active' && game.status !== 'open') return 'finished';
return isMyTurn(game, myId) ? 'mine' : 'theirs';
return isMyTurn(game, myIds) ? 'mine' : 'theirs';
}
/**
@@ -81,7 +84,7 @@ export interface LobbyGroups {
*/
export function groupGames(
games: GameView[],
myId: string,
myIds: readonly string[],
unread: Record<string, boolean>,
): LobbyGroups {
const yourTurn: GameView[] = [];
@@ -89,7 +92,7 @@ export function groupGames(
const finished: GameView[] = [];
for (const g of games) {
if (g.status !== 'active' && g.status !== 'open') finished.push(g);
else if (isMyTurn(g, myId)) yourTurn.push(g);
else if (isMyTurn(g, myIds)) yourTurn.push(g);
else theirTurn.push(g);
}
// Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break.
+6
View File
@@ -180,6 +180,12 @@ export class MockGateway implements GatewayClient {
async authGuest(): Promise<Session> {
return { ...SESSION };
}
async authGuestSilent(): Promise<Session> {
// The reconciliation leg the native offline-first e2e drives (via __mock.reconcile): mint the
// server guest the local guest adopts when the network returns. The mock has no kill switch, so
// this is the same guest session as authGuest.
return { ...SESSION };
}
async authEmailRequest(_email: string, _language: string, _pwa: boolean): Promise<void> {}
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
return { purpose: 'link', status: 'confirmed', session: null };
+35 -5
View File
@@ -4,6 +4,7 @@
// statement is only ever reached inside the packaged native app.
import { clientChannel } from './channel';
import { emit } from './netstate.svelte';
/**
* initNativeShell wires native-shell behaviour that only applies inside the packaged
@@ -17,9 +18,38 @@ import { clientChannel } from './channel';
export async function initNativeShell(atNavigationRoot: () => boolean): Promise<void> {
const ch = clientChannel();
if (ch !== 'android' && ch !== 'ios') return;
const { App } = await import('@capacitor/app');
App.addListener('backButton', () => {
if (atNavigationRoot()) void App.exitApp();
else history.back();
});
try {
const { App } = await import('@capacitor/app');
await App.addListener('backButton', () => {
if (atNavigationRoot()) void App.exitApp();
else history.back();
});
} catch {
// No Capacitor bridge behind window.Capacitor — an e2e simulating the native channel in a plain
// browser, or a WebView without the App plugin: the hardware-Back binding is simply unavailable,
// which is harmless off a real device. Never reached on the web (the early return above).
}
}
/**
* initNativeNetwork feeds the OS connectivity hint from @capacitor/network into the net-state machine
* (osOnline / osOffline) on the packaged native app. It is a hint only the machine's probe still
* decides whether to actually go offline or heal to online so on the web (which keeps navigator's
* online/offline events, wired by initNetSignals) this is a no-op and never loads the plugin. Tolerates
* a missing bridge (a WebView without the Network plugin) exactly like initNativeShell.
*/
export async function initNativeNetwork(): Promise<void> {
const ch = clientChannel();
if (ch !== 'android' && ch !== 'ios') return;
try {
const { Network } = await import('@capacitor/network');
await Network.addListener('networkStatusChange', (status) => {
emit(status.connected ? 'osOnline' : 'osOffline');
});
} catch {
// No @capacitor/network bridge behind window.Capacitor (an e2e simulating native in a plain
// browser, or a WebView without the plugin): the native hint is unavailable, which is harmless —
// the gateway probe still drives the machine.
}
}
+196
View File
@@ -0,0 +1,196 @@
// The reactive net-state store (O2): the single source of truth for the detected
// connectivity/version state, running the pure reducer (netstate.ts, O1) over a `$state` snapshot and
// owning the side-effects the reducer asks for — the probe/recovery watcher, the OS-signal listeners
// and the offline/online toast. connection.svelte.ts and offline.svelte.ts are now thin derived shims
// over this module, so their ~40 consumers keep reading `connection.online` / `offlineMode.active`
// unchanged while a single machine drives them underneath.
//
// The store is a leaf module: it never imports app.svelte.ts. The pieces that need app context — the
// smart recovery (reconcile-or-reachability) and the toast text (i18n) — are injected via
// register*() hooks the bootstrap wires, which also breaks what would otherwise be an import cycle.
import { INITIAL, reduce, type Effect, type NetConfig, type NetEvent, type NetSnapshot } from './netstate';
import { backoffMs } from './retry';
const isMock = import.meta.env.MODE === 'mock';
// Production hysteresis tuning (owner-confirmed). k = 3 consecutive probe failures ⇒ offline (a single
// blip is one failure, so it rides out in `connecting`); debounceMs is the wall-clock backstop for a
// slow, spaced-out failure sequence. Both sit on top of the transport's own retry/backoff.
const cfg: NetConfig = { k: 3, debounceMs: 15000 };
// While offline, re-check reachability on this cadence (mirrors the old app.svelte.ts recovery poll).
const OFFLINE_POLL_MS = 4000;
let snap = $state<NetSnapshot>({ ...INITIAL });
/** netState exposes the reactive detected state; read the getters in markup / `$derived`. */
export const netState = {
/** state is the raw machine state. */
get state() {
return snap.state;
},
/** online is true only when the gateway is reachable and the version accepted (full features). */
get online() {
return snap.state === 'online';
},
/** connecting is true while a call/probe is failing but the anti-flap window has not elapsed. */
get connecting() {
return snap.state === 'connecting';
},
/** offline is true in either offline state (the kill switch / blue chrome / local-only lobby). */
get offline() {
return snap.state === 'offlineNoNetwork' || snap.state === 'offlineVersionLocked';
},
/** versionLocked is true when the client is below the hard minimum version (the Update notice). */
get versionLocked() {
return snap.state === 'offlineVersionLocked';
},
};
// Injected hooks (dependency inversion — see the module comment).
let probeFn: (() => Promise<void>) | null = null;
let recoverFn: (() => Promise<void>) | null = null;
let toastFn: ((toast: 'offline' | 'online') => void) | null = null;
let nudgeFn: (() => void) | null = null;
/** registerProbe installs the low-level reachability probe (a cheap authenticated read); the transport
* wires it, and checkReachable / the smart recovery use it. It should reject when there is no session. */
export function registerProbe(fn: () => Promise<void>): void {
probeFn = fn;
}
/** registerRecovery installs the smart recovery the watcher runs while connecting/offline: reconcile a
* server guest when there is no session (native), otherwise a reachability check. It resolves when the
* gateway is reachable and rejects to stay offline. The bootstrap wires it. */
export function registerRecovery(fn: () => Promise<void>): void {
recoverFn = fn;
}
/** registerNetToast installs the offline/online toast renderer (i18n text lives in app scope). */
export function registerNetToast(fn: (toast: 'offline' | 'online') => void): void {
toastFn = fn;
}
/** registerNetNudge installs the soft "update available" latch the setNudge effect fires. It runs only
* when the machine accepts a versionRecommended from online (O1), so the nudge never shows offline. */
export function registerNetNudge(fn: () => void): void {
nudgeFn = fn;
}
// The probe/recovery watcher: one self-rescheduling timer whose cadence follows the current state —
// exponential backoff while `connecting`, a fixed poll while `offlineNoNetwork`, and idle otherwise. It
// is inert under the mock (no real transport); the e2e drives transitions through the window hooks.
let timer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0;
let probing = false;
function stopTimer(): void {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
function arm(delayMs: number): void {
if (isMock) return;
stopTimer();
timer = setTimeout(runProbe, delayMs);
}
async function runProbe(): Promise<void> {
timer = null;
const st = snap.state;
// Nothing to check when online, and the version lock only exits on an app update (a fresh boot).
if (st === 'online' || st === 'offlineVersionLocked') return;
if (probing || !recoverFn) return;
probing = true;
try {
await recoverFn();
emit('probeOk');
} catch {
emit('probeFailed');
// Reschedule by the (possibly just-changed) state: keep backing off while connecting, poll while
// offline; stop once online / version-locked (the guard at the top of the next run).
if (snap.state === 'connecting') arm(backoffMs(Math.min(++attempt, 6)));
else if (snap.state === 'offlineNoNetwork') arm(OFFLINE_POLL_MS);
} finally {
probing = false;
}
}
/**
* checkReachable runs the registered reachability probe once, bounded by timeoutMs, and reports whether
* the gateway answered a single attempt for the cold-start / recovery decision. It reports false when
* no probe is registered or the timeout wins.
*/
export async function checkReachable(timeoutMs: number): Promise<boolean> {
if (!probeFn) return false;
try {
await Promise.race([
probeFn(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
]);
return true;
} catch {
return false;
}
}
/** emit applies one event to the machine (stamping it with the current time the reducer chose
* time-on-the-event) and runs the resulting effects. It is the single entry point every signal source
* funnels through. */
export function emit(type: NetEvent): void {
const { next, effects } = reduce(snap, { type, at: Date.now() }, cfg);
snap = next;
for (const effect of effects) applyEffect(effect);
}
function applyEffect(effect: Effect): void {
switch (effect.kind) {
case 'toast':
toastFn?.(effect.toast);
break;
case 'startProbe':
// Kick the watcher from the top of the backoff (entering connecting, or an osOnline hint).
attempt = 0;
arm(backoffMs(1));
break;
case 'showVersionNotice':
// The Update/Play-offline notice (UpdateOverlay.svelte) reads netState.versionLocked directly, so
// this effect needs no side-effect here — the state transition is the signal.
break;
case 'setNudge':
// Latch the soft "update available" nudge (update.svelte.ts, wired via registerNetNudge). Fires
// only from online (O1), so the nudge is never raised while offline / version-locked.
nudgeFn?.();
break;
}
}
/**
* bootOffline drives the machine straight into offlineNoNetwork for a known-offline launch a native
* device-local guest with no server session, or a web cached-session launch the reachability check
* could not confirm and arms the recovery poll. It bypasses the connecting hysteresis on purpose:
* boot is not a mid-session flap, so there is nothing to ride out. Pass kickNow to start the recovery
* poll immediately (the native reconcile) rather than after the first interval.
*/
export function bootOffline(kickNow = false): void {
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
arm(kickNow ? 0 : OFFLINE_POLL_MS);
}
/** initNetSignals wires the web connectivity hints (navigator online/offline) to the machine. The
* native @capacitor/network hint is wired separately in native.ts. A no-op under SSR and the mock (the
* e2e drives connectivity through the window hooks). */
export function initNetSignals(): void {
if (typeof window === 'undefined' || isMock) return;
window.addEventListener('offline', () => emit('osOffline'));
window.addEventListener('online', () => emit('osOnline'));
}
/** resetNetState returns the machine to online and stops the watcher (e.g. on logout). */
export function resetNetState(): void {
stopTimer();
attempt = 0;
snap = { state: 'online', fails: 0, connectingSince: 0 };
}
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect } from 'vitest';
import {
reduce,
INITIAL,
type NetConfig,
type NetEvent,
type NetInput,
type NetSnapshot,
} from './netstate';
// A deterministic config for the assertions: k = 3 (a single blip rides out; three consecutive
// failures trip offline) and a 1 s debounce window (the wall-clock anti-flap branch).
const cfg: NetConfig = { k: 3, debounceMs: 1000 };
// Event and snapshot builders keep the cases terse; `at` defaults to 0 where the debounce branch is
// irrelevant.
const e = (type: NetEvent, at = 0): NetInput => ({ type, at });
const online: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
const offline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
const locked: NetSnapshot = { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 };
const connecting = (over: Partial<NetSnapshot> = {}): NetSnapshot => ({
state: 'connecting',
fails: 0,
connectingSince: 0,
...over,
});
// fold applies a sequence of inputs, threading the snapshot and collecting every emitted effect —
// the shape most edge cases assert against.
function fold(start: NetSnapshot, inputs: NetInput[], config: NetConfig) {
let snap = start;
const effects = [];
for (const ev of inputs) {
const r = reduce(snap, ev, config);
snap = r.next;
effects.push(...r.effects);
}
return { final: snap, effects };
}
describe('reduce — transition table', () => {
it('online + callFailed → connecting (starts a probe, counts the failure)', () => {
const r = reduce(online, e('callFailed', 100), cfg);
expect(r.next).toEqual({ state: 'connecting', fails: 1, connectingSince: 100 });
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
});
it('online + osOffline → connecting (starts a probe, no failure counted — a hint)', () => {
const r = reduce(online, e('osOffline', 100), cfg);
expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
});
it('connecting + probeOk → online (silent; a blip must not thrash the chrome)', () => {
const r = reduce(connecting({ fails: 2, connectingSince: 100 }), e('probeOk', 200), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([]);
});
it('connecting + callOk → online (silent)', () => {
const r = reduce(connecting({ fails: 1, connectingSince: 100 }), e('callOk', 200), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([]);
});
it('connecting + a failure reaching K → offlineNoNetwork (toast "offline")', () => {
const r = reduce(connecting({ fails: cfg.k - 1, connectingSince: 0 }), e('probeFailed', 10), cfg);
expect(r.next).toEqual({ state: 'offlineNoNetwork', fails: 0, connectingSince: 0 });
expect(r.effects).toEqual([{ kind: 'toast', toast: 'offline' }]);
});
it('offlineNoNetwork + osOnline → stays offline, triggers a probe (a hint never flips by itself)', () => {
const r = reduce(offline, e('osOnline', 100), cfg);
expect(r.next).toEqual(offline);
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
});
it('offlineNoNetwork + probeOk → online (toast "back online")', () => {
const r = reduce(offline, e('probeOk', 100), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
it('offlineNoNetwork + callOk → online (toast "back online")', () => {
const r = reduce(offline, e('callOk', 100), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
it('any state + versionRejected → offlineVersionLocked (shows the notice once)', () => {
for (const start of [online, connecting({ fails: 1, connectingSince: 5 }), offline]) {
const r = reduce(start, e('versionRejected', 100), cfg);
expect(r.next).toEqual({ state: 'offlineVersionLocked', fails: 0, connectingSince: 0 });
expect(r.effects).toEqual([{ kind: 'showVersionNotice' }]);
}
});
it('offlineVersionLocked + versionRejected (probe still rejected) → stays locked, no repeat notice', () => {
const r = reduce(locked, e('versionRejected', 100), cfg);
expect(r.next).toEqual(locked);
expect(r.effects).toEqual([]);
});
it('online + versionRecommended → online + a nudge', () => {
const r = reduce(online, e('versionRecommended', 100), cfg);
expect(r.next).toEqual(online);
expect(r.effects).toEqual([{ kind: 'setNudge' }]);
});
it('boot → connecting + startProbe from any state (resets the sticky lock)', () => {
for (const start of [online, offline, locked, connecting({ fails: 2, connectingSince: 5 })]) {
const r = reduce(start, e('boot', 100), cfg);
expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
}
});
});
describe('reduce — hysteresis (blip vs sustained)', () => {
it('a single fail then a success rides out inside connecting (no offline toast)', () => {
const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 200)], cfg);
expect(final).toEqual(online);
expect(effects).toEqual([{ kind: 'startProbe' }]);
});
it('K consecutive failures trip offlineNoNetwork', () => {
const { final, effects } = fold(online, [e('callFailed', 0), e('probeFailed', 10), e('probeFailed', 20)], cfg);
expect(final.state).toBe('offlineNoNetwork');
expect(effects).toContainEqual({ kind: 'toast', toast: 'offline' });
});
it('the debounce window trips offlineNoNetwork even below K', () => {
const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs + 1)], cfg);
expect(final.state).toBe('offlineNoNetwork');
});
it('a failure inside the debounce window and below K stays connecting', () => {
const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs - 1)], cfg);
expect(final.state).toBe('connecting');
expect(final.fails).toBe(1);
});
});
describe('reduce — version gate (two tiers)', () => {
it('versionRecommended sets the nudge only from online', () => {
expect(reduce(online, e('versionRecommended', 0), cfg).effects).toEqual([{ kind: 'setNudge' }]);
for (const start of [connecting({ connectingSince: 0 }), offline, locked]) {
expect(reduce(start, e('versionRecommended', 0), cfg).effects).toEqual([]);
}
});
it('versionRejected from any state locks (the hard tier), superseding a pending soft nudge', () => {
const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
expect(final.state).toBe('offlineVersionLocked');
expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
});
});
describe('reduce — edge cases (#1#12)', () => {
it('#1 rapid flap (fail→ok inside the window) never leaves connecting for offline', () => {
const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 50)], cfg);
expect(final).toEqual(online);
expect(effects).not.toContainEqual({ kind: 'toast', toast: 'offline' });
});
it('#2 cold boot, no network → offlineNoNetwork', () => {
const { final } = fold(
INITIAL,
[e('boot', 0), e('osOffline', 1), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
cfg,
);
expect(final.state).toBe('offlineNoNetwork');
});
it('#3 cold boot, gateway up but version < min → offlineVersionLocked + notice', () => {
const { final, effects } = fold(INITIAL, [e('boot', 0), e('versionRejected', 20)], cfg);
expect(final.state).toBe('offlineVersionLocked');
expect(effects).toContainEqual({ kind: 'showVersionNotice' });
});
it('#4 cold boot, session-less guest: the reconcile IS the probe — ok→online, fail→offline', () => {
expect(fold(INITIAL, [e('boot', 0), e('callOk', 20)], cfg).final).toEqual(online);
const failed = fold(
INITIAL,
[e('boot', 0), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
cfg,
);
expect(failed.final.state).toBe('offlineNoNetwork');
});
it('#5 mid-session min-version bump → next call versionRejected → locked mid-play', () => {
const r = reduce(online, e('versionRejected', 100), cfg);
expect(r.next.state).toBe('offlineVersionLocked');
expect(r.effects).toContainEqual({ kind: 'showVersionNotice' });
});
it('#6 captive portal: osOnline fires but the gateway is down → probe fails → stays offline', () => {
const { final, effects } = fold(offline, [e('osOnline', 0), e('probeFailed', 10)], cfg);
expect(final.state).toBe('offlineNoNetwork');
expect(effects).toEqual([{ kind: 'startProbe' }]);
});
it('#7 recovery race: probeOk then a queued callOk → one idempotent transition to online', () => {
const { final, effects } = fold(offline, [e('probeOk', 0), e('callOk', 1)], cfg);
expect(final).toEqual(online);
expect(effects.filter((x) => x.kind === 'toast')).toEqual([{ kind: 'toast', toast: 'online' }]);
});
it('#8 soft nudge then a hard reject → escalates to locked (notice after the nudge)', () => {
const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
expect(final.state).toBe('offlineVersionLocked');
expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
});
it('#9 offlineVersionLocked is sticky — no connectivity event exits it; only boot does', () => {
const stuck: NetEvent[] = ['callOk', 'probeOk', 'osOnline', 'osOffline', 'callFailed', 'probeFailed', 'versionRecommended'];
for (const ev of stuck) {
expect(reduce(locked, e(ev, 100), cfg).next).toEqual(locked);
}
expect(reduce(locked, e('boot', 100), cfg).next.state).toBe('connecting');
});
it('#10 offline → back online transitions cleanly (local-game persistence is O5, not the machine)', () => {
expect(fold(offline, [e('probeOk', 0)], cfg).final).toEqual(online);
});
it('#11 nothing forces the machine offline without a real failure/version signal (a stale pref cannot stick)', () => {
expect(reduce(INITIAL, e('boot', 0), cfg).next.state).toBe('connecting');
const { final } = fold(
INITIAL,
[e('boot', 0), e('callOk', 10), e('osOnline', 20), e('versionRecommended', 30)],
cfg,
);
expect(final.state).toBe('online');
});
it('#12 TG/VK inertness: with only success/hint events the machine never leaves online', () => {
const { final, effects } = fold(online, [e('callOk', 0), e('probeOk', 10), e('osOnline', 20), e('callOk', 30)], cfg);
expect(final).toEqual(online);
expect(effects).toEqual([]);
});
});
describe('reduce — purity', () => {
it('does not mutate the previous snapshot', () => {
const prev = Object.freeze<NetSnapshot>({ state: 'connecting', fails: 1, connectingSince: 5 });
expect(() => reduce(prev, e('probeFailed', 10), cfg)).not.toThrow();
expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
});
});
+214
View File
@@ -0,0 +1,214 @@
// The pure net-state machine (O1 of the offline-model redesign). A single reducer replaces the old
// two-layer connection.svelte.ts / offline.svelte.ts split: connectivity and the client-version gate
// collapse into one detected `state`, with an explicit hysteresis buffer so a brief network blip does
// not thrash the chrome. It is a pure function of (previous snapshot, event, config) — no timers, no
// storage, no reactivity — so every transition and the whole hysteresis are unit-tested in the node
// env and the machine is reusable verbatim by a future iOS shell. The reactive store, the probe timer
// and the event wiring live in O2 (netstate.svelte.ts); the effects returned here tell that store what
// to do (raise a toast, kick a probe, show the version notice, set the soft nudge).
/**
* NetState is the single detected connectivity/version state.
* - `online` the gateway is reachable and the client version is accepted; full features. It may
* additionally carry an orthogonal dismissible "update available" nudge (the soft tier) without
* leaving this state.
* - `connecting` a call or probe is currently failing but the anti-flap window has not elapsed;
* the chrome stays online ("Connecting…"). Transient never a kill switch.
* - `offlineNoNetwork` sustained unreachable (hysteresis exceeded): offline mode (blue chrome,
* local-only active lobby, transport kill switch). Self-heals when a probe or call gets through.
* - `offlineVersionLocked` the gateway is reachable but the version is below the hard minimum:
* offline mode plus the "Update / Play offline" notice. Sticky it exits only via an app update
* (a fresh version on the next boot).
*/
export type NetState = 'online' | 'connecting' | 'offlineNoNetwork' | 'offlineVersionLocked';
/**
* NetEvent is every signal that can drive the machine.
* - `boot` a fresh app start (the only exit from the sticky version lock).
* - `callOk` / `callFailed` a foreground call succeeded / failed at the transport.
* - `probeOk` / `probeFailed` the reachability probe succeeded / failed.
* - `osOnline` / `osOffline` an OS connectivity hint (navigator / @capacitor/network); a hint only
* triggers a probe, it never decides the state by itself.
* - `versionRejected` the hard gate refused the client as too old (update_required).
* - `versionRecommended` the soft gate signalled an available (non-blocking) update.
*/
export type NetEvent =
| 'boot'
| 'callOk'
| 'callFailed'
| 'probeOk'
| 'probeFailed'
| 'osOnline'
| 'osOffline'
| 'versionRejected'
| 'versionRecommended';
/** NetConfig tunes the anti-flap hysteresis. */
export interface NetConfig {
/**
* k is the consecutive-failure threshold that trips `connecting` `offlineNoNetwork`. It must be
* at least 2 so a single blip (one failure then a success) rides out inside `connecting`.
*/
k: number;
/**
* debounceMs is the wall-clock anti-flap window (the design's `OFFLINE_DEBOUNCE_MS`): a failure
* whose timestamp is at least this far past the moment `connecting` was entered also trips
* `offlineNoNetwork`, even below k. This bounds how long a slow, spaced-out failure sequence can
* hold the chrome in "Connecting…".
*/
debounceMs: number;
}
/**
* NetSnapshot is the machine's full memory. Beyond the visible `state` it carries the hysteresis
* bookkeeping (`fails`, `connectingSince`) so the reducer stays pure while still deciding both the
* count-based and the time-based anti-flap branches; both are zero outside `connecting`.
*/
export interface NetSnapshot {
/** state is the current detected state. */
state: NetState;
/** fails counts consecutive failure signals accrued while `connecting` (0 in every other state). */
fails: number;
/**
* connectingSince is the timestamp `connecting` was entered; the debounce window is measured from
* it (0 while not `connecting`).
*/
connectingSince: number;
}
/** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch
* reads `at`, and callers stamp `Date.now()`. */
export interface NetInput {
/** type is the event. */
type: NetEvent;
/** at is the event's timestamp in milliseconds. */
at: number;
}
/**
* Effect is a side-effect the reducer asks the O2 store to perform. It is data, not an action the
* reducer never runs it.
* - `toast` show the offline / back-online toast.
* - `startProbe` kick the reachability probe (entering `connecting`, or on an `osOnline` hint).
* - `showVersionNotice` raise the "Update / Play offline" notice (entering the version lock).
* - `setNudge` latch the dismissible "update available" soft nudge.
*/
export type Effect =
| { kind: 'toast'; toast: 'offline' | 'online' }
| { kind: 'startProbe' }
| { kind: 'showVersionNotice' }
| { kind: 'setNudge' };
/** NetResult is a reduction: the next snapshot plus the effects the store should perform. */
export interface NetResult {
/** next is the snapshot after applying the event. */
next: NetSnapshot;
/** effects are the side-effects the O2 store should run, in order. */
effects: Effect[];
}
/**
* INITIAL is the optimistic pre-boot snapshot: `online` until proven otherwise. The O2 store emits a
* `boot` event on start, which immediately moves the machine to `connecting` and kicks a probe.
*/
export const INITIAL: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
function onlineSnapshot(): NetSnapshot {
return { state: 'online', fails: 0, connectingSince: 0 };
}
function enterConnecting(at: number, fails: number): NetResult {
return { next: { state: 'connecting', fails, connectingSince: at }, effects: [{ kind: 'startProbe' }] };
}
/**
* reduce applies one event to the machine and returns the next snapshot plus the effects the store
* should perform. It is a pure function: it never mutates `prev`, reads the clock, or runs an effect.
*
* `boot` and `versionRejected` are handled first because they fire from any state: `boot` resets the
* machine (the sole exit from the sticky version lock), and `versionRejected` (the hard tier) always
* supersedes into `offlineVersionLocked`.
*/
export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResult {
// A fresh app start: reachability is unknown, so enter `connecting` and kick a probe; the probe /
// version result then resolves online, offlineNoNetwork or offlineVersionLocked. This is the only
// transition that leaves the sticky version lock (a possibly-newer build).
if (ev.type === 'boot') {
return enterConnecting(ev.at, 0);
}
// The hard version gate supersedes everything and is sticky. The notice is raised once, on entry;
// a repeated rejection while already locked changes nothing.
if (ev.type === 'versionRejected') {
const effects: Effect[] = prev.state === 'offlineVersionLocked' ? [] : [{ kind: 'showVersionNotice' }];
return { next: { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 }, effects };
}
switch (prev.state) {
case 'offlineVersionLocked':
// Sticky: no connectivity event exits it. A probe may reach the gateway, but real calls stay
// version-gated, so only `boot` / `versionRejected` (both handled above) touch this state.
return { next: prev, effects: [] };
case 'online':
switch (ev.type) {
case 'callFailed':
case 'probeFailed':
// A real failure opens the hysteresis window (probe + timer); this first failure counts.
return enterConnecting(ev.at, 1);
case 'osOffline':
// An OS hint only opens the window and a probe — it never decides offline by itself, so it
// enters `connecting` with no failure counted.
return enterConnecting(ev.at, 0);
case 'versionRecommended':
// The soft tier nudges only from online; play continues.
return { next: prev, effects: [{ kind: 'setNudge' }] };
default:
// callOk / probeOk / osOnline: already online — idempotent, no effect.
return { next: prev, effects: [] };
}
case 'connecting':
switch (ev.type) {
case 'callOk':
case 'probeOk':
// Recovery is immediate on the first success — a blip resolves silently (no toast).
return { next: onlineSnapshot(), effects: [] };
case 'callFailed':
case 'probeFailed': {
const fails = prev.fails + 1;
const sustained = fails >= cfg.k || ev.at - prev.connectingSince >= cfg.debounceMs;
if (sustained) {
return {
next: { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 },
effects: [{ kind: 'toast', toast: 'offline' }],
};
}
return { next: { ...prev, fails }, effects: [] };
}
case 'osOnline':
// A positive hint triggers an immediate probe but never flips to online by itself.
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// osOffline (already probing) / versionRecommended (no nudge outside online): no-op.
return { next: prev, effects: [] };
}
case 'offlineNoNetwork':
switch (ev.type) {
case 'callOk':
case 'probeOk':
// The probe (or a reconcile call) won — self-heal to online with a "back online" toast.
return { next: onlineSnapshot(), effects: [{ kind: 'toast', toast: 'online' }] };
case 'osOnline':
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// callFailed / probeFailed / osOffline (still offline), versionRecommended (no nudge): no-op.
return { next: prev, effects: [] };
}
}
// Unreachable: the switch above is exhaustive over NetState. Kept so the function is total.
return { next: prev, effects: [] };
}
+19 -61
View File
@@ -1,68 +1,26 @@
// The deliberate offline MODE: a sticky, device-scoped reactive flag the app reads to gate the
// network, tint the chrome blue and show only local games. It is the player's own choice (the
// Settings toggle is the source of truth), distinct from connection.svelte.ts's transient
// gateway-reachability signal. The pure persistence + readiness logic lives in offline.ts.
// offline.svelte.ts: the offline-mode read surface (a thin shim over the net-state store,
// netstate.svelte.ts) plus the orthogonal dictionary-preload warning + background preload. Offline is
// implicit — detected by the net-state machine — so offlineMode.active just derives from netState; there
// is no deliberate toggle. The dict-preload pieces are unrelated to connectivity and stay live here.
import { loadOfflinePref, saveOfflinePref, offlinePreloadEligible } from './offline';
import { netState } from './netstate.svelte';
import { offlinePreloadEligible } from './offline';
import { isStandalone } from './pwa';
import { insideTelegram } from './telegram';
import { insideVK } from './vk';
import type { Profile } from './model';
// Not named `state` (a svelte-check hazard: `$state` would then read as a store subscription).
let active = $state(loadOfflinePref());
// True while the current offline state was entered automatically (no network detected), not chosen
// by the player. An auto offline self-heals to online when the network returns; a deliberate one is
// left as the player's choice.
let auto = $state(false);
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
export const offlineMode = {
/** active is true while the app is in offline mode (deliberate or auto-detected). */
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked). */
get active(): boolean {
return active;
},
/** auto is true while offline was entered automatically (no network), not by the player. */
get auto(): boolean {
return auto;
return netState.offline;
},
};
/**
* setOfflineMode enters or leaves offline mode. By default it persists the choice (device-scoped)
* a deliberate choice (the Settings toggle, or the cold-start "no connection" dialog). Pass
* persist=false for a transient, auto-detected offline (a cold start with no network interface): the
* flag holds for the session but is not saved, so the next launch re-evaluates the network.
*/
export function setOfflineMode(on: boolean, persist = true): void {
active = on;
auto = on && !persist; // a non-persisted offline is auto-detected; a persisted one is deliberate
if (persist) saveOfflinePref(on);
}
/** The toggle-flip readiness wait: entering offline waits at most this long for the enabled
* variants' dictionaries before reverting to online (the fetch then continues in the background). */
export const TOGGLE_READY_BUDGET_MS = 5000;
/**
* requestOffline attempts to enter offline mode from the Settings toggle. It fetches the enabled
* variants' dictionaries cache-first and, if every one is available within the readiness budget,
* switches to a deliberate (persisted) offline mode and returns true. Otherwise it stays online and
* returns false the caller shows the "needs internet" note while the fetch keeps warming the
* cache so a later flip is instant. The readiness glue and the dict loader are imported dynamically,
* so neither is pulled into the main bundle. Returns false with no profile.
*/
export async function requestOffline(prof: Profile | null, budgetMs = TOGGLE_READY_BUDGET_MS): Promise<boolean> {
if (!prof) return false;
const m = await import('./dict/offlineready');
const ready = await m.ensureOfflineDicts(prof, budgetMs);
if (ready) setOfflineMode(true);
return ready;
}
// The dict-preload warning: true when a first-lobby background preload could not fetch every
// enabled variant's dictionary (typically a poor connection), so offline mode may be incomplete.
// The lobby shows a notice in place of the ad banner while it holds.
// The dict-preload warning: true when a first-lobby background preload could not fetch every enabled
// variant's dictionary (typically a poor connection), so offline play may be incomplete. The lobby
// shows a notice in place of the ad banner while it holds.
let preloadWarn = $state(false);
/** dictPreloadWarning exposes the reactive preload-failure flag; read it in markup / $derived. */
@@ -78,18 +36,18 @@ export function setDictPreloadWarning(on: boolean): void {
preloadWarn = on;
}
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby mount or
// a variant-preference change) can start another. Module-scoped so mounts do not stack.
let preloadInFlight = false;
/**
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
* browser tab, without a confirmed email, while offline, or when a preload is already running;
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
* and generator are imported dynamically, so neither is pulled into the main bundle.
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later switch
* to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain browser tab,
* without a confirmed email, while offline, or when a preload is already running; getDawg's caching
* makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a fetch failure raises the
* in-lobby notice, and a later successful run clears it. The dict loader and generator are imported
* dynamically, so neither is pulled into the main bundle.
*/
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
if (preloadInFlight || !prof) return;
+9 -51
View File
@@ -1,8 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline';
import type { Variant } from './model';
import { clearOfflinePref, offlinePreloadEligible } from './offline';
// A minimal in-memory localStorage for the persistence tests (node has none).
// A minimal in-memory localStorage for the cleanup test (node has none).
beforeEach(() => {
const store = new Map<string, string>();
(globalThis as unknown as { localStorage: Storage }).localStorage = {
@@ -15,27 +14,7 @@ beforeEach(() => {
} as Storage;
});
describe('offline mode helpers', () => {
it('persists and reads the device-scoped offline flag', () => {
expect(loadOfflinePref()).toBe(false);
saveOfflinePref(true);
expect(loadOfflinePref()).toBe(true);
saveOfflinePref(false);
expect(loadOfflinePref()).toBe(false);
});
it('offlineReady requires every enabled variant to have a dictionary', () => {
const has = (v: Variant): boolean => v !== 'erudit_ru';
expect(offlineReady(['scrabble_en', 'scrabble_ru'], has)).toBe(true);
expect(offlineReady(['scrabble_en', 'erudit_ru'], has)).toBe(false);
expect(offlineReady([], has)).toBe(false);
});
it('missingDicts lists the enabled variants without a dictionary', () => {
const has = (v: Variant): boolean => v === 'scrabble_en';
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
});
describe('offline helpers', () => {
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
expect(offlinePreloadEligible(base)).toBe(true);
@@ -46,32 +25,11 @@ describe('offline mode helpers', () => {
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
});
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => {
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true);
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false);
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
});
});
describe('raceOfflineReady (toggle-flip readiness wait)', () => {
// A sleep that never elapses: the fetch always wins the race, exercising its resolution.
const never = (): Promise<void> => new Promise<void>(() => {});
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
const now = (): Promise<void> => Promise.resolve();
it('is ready when the fetch resolves with nothing failed before the budget', async () => {
const run = Promise.resolve({ failed: [] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(true);
});
it('is not ready when a variant is still missing after the fetch', async () => {
const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] });
expect(await raceOfflineReady(run, 5000, never)).toBe(false);
});
it('is not ready when the budget elapses before the fetch resolves', async () => {
const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves
expect(await raceOfflineReady(run, 5000, now)).toBe(false);
it('clearOfflinePref removes the retired deliberate-offline key (best-effort, idempotent)', () => {
localStorage.setItem('scrabble.offlineMode', '1');
clearOfflinePref();
expect(localStorage.getItem('scrabble.offlineMode')).toBeNull();
// Idempotent: a second call on an already-clear key is a no-op, not an error.
expect(() => clearOfflinePref()).not.toThrow();
});
});
+17 -68
View File
@@ -1,70 +1,29 @@
// Pure helpers for the deliberate offline MODE — its device-scoped persistence and the readiness
// decision — kept out of the reactive module (offline.svelte.ts) so they unit-test in the node env.
// The deliberate offline mode is distinct from connection.svelte.ts's transient "can we reach the
// gateway" signal: it is the player's own sticky choice, and it gates the network, tints the chrome
// and shows only local games.
import type { Variant } from './model';
// Pure helpers for offline-mode support, kept out of the reactive module (offline.svelte.ts) so they
// unit-test in the node env. Offline became implicit — the net-state machine detects connectivity, and
// there is no deliberate toggle — so only the background dict-preload eligibility check and a one-time
// cleanup of the retired deliberate-offline preference remain here.
const STORAGE_KEY = 'scrabble.offlineMode';
/** loadOfflinePref reads the persisted offline-mode flag (device-scoped); false when unset or when
* storage is unavailable, so a device that cannot persist simply starts online. */
export function loadOfflinePref(): boolean {
/**
* clearOfflinePref removes the retired deliberate-offline preference key. The offline model is implicit
* now the app boots online and the machine detects offline so a persisted flag from a pre-redesign
* install is orphaned data that is never read again; boot clears it once (best-effort) so nobody is left
* with a dead key.
*/
export function clearOfflinePref(): void {
try {
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
if (typeof localStorage !== 'undefined') localStorage.removeItem(STORAGE_KEY);
} catch {
return false;
}
}
/** saveOfflinePref persists the offline-mode flag (best-effort). */
export function saveOfflinePref(on: boolean): void {
try {
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
} catch {
/* best-effort — a failed persist just reverts to online on the next launch */
/* best-effort — a storage failure just leaves the orphaned key, which is never read anyway */
}
}
/**
* offlineReady reports whether the device can play offline right now: at least one variant is
* enabled and every enabled variant's dictionary is already available on the device (hasDict). The
* offline toggle uses it to decide whether flipping to offline can succeed immediately.
*/
export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): boolean {
return enabled.length > 0 && enabled.every((v) => hasDict(v));
}
/** missingDicts lists the enabled variants whose dictionary is not yet available the ones the
* toggle must fetch (or wait on) before offline mode can be entered. */
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
return enabled.filter((v) => !hasDict(v));
}
/**
* raceOfflineReady runs the dictionary fetch `run` against a `budgetMs` wait and reports whether
* offline mode can be entered now: ready only when the fetch resolves with nothing still failed
* before the budget elapses. On a timeout the caller stops waiting but does NOT abort `run` it
* keeps warming the on-device cache so a later flip to offline is instant. The sleep is injected so
* the logic stays pure and unit-tests in the node env.
*/
export async function raceOfflineReady(
run: Promise<{ failed: readonly unknown[] }>,
budgetMs: number,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
): Promise<boolean> {
const elapsed = sleep(budgetMs).then(() => null);
const res = await Promise.race([run, elapsed]);
return res !== null && res.failed.length === 0;
}
/**
* offlinePreloadEligible reports whether a background dictionary preload should run in this
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
* with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth the context
* has no offline mode to prepare for so kickDictPreload skips it. Mirrors the Settings offline
* toggle's eligibility so the two never disagree.
* offlinePreloadEligible reports whether a background dictionary preload should run in this context: an
* installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab) with a confirmed
* email, currently online. Elsewhere the preload is wasted bandwidth the context has no offline play
* to prepare for so kickDictPreload skips it.
*/
export function offlinePreloadEligible(opts: {
hasEmail: boolean;
@@ -75,13 +34,3 @@ export function offlinePreloadEligible(opts: {
}): boolean {
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
}
/**
* shouldBootOffline decides whether a cold start skips the network and launches straight into
* offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached
* session and profile to start from. Without both (e.g. cleared storage, or a never-online install),
* the app must boot online once first the caller then drops the sticky flag and continues online.
*/
export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean {
return opts.offlineActive && opts.hasSession && opts.hasProfile;
}
+21 -13
View File
@@ -34,28 +34,36 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
}
describe('resultBadge', () => {
it('recognises "you" under any of the self ids (a device-local game seats you under the guest id)', () => {
// myIds carries both the server user id and the device-local guest id, so the badge resolves the
// right seat regardless of which one seated you.
const local = game([seat(0, 'guest', 5), seat(1, 'robot', 3)], 'active', 0);
expect(resultBadge(local, ['guest', 'me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...local, toMove: 1 }, ['guest', 'me']).key).toBe('result.oppMove');
});
it('active: your move vs opponent', () => {
const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
});
it('open (awaiting an opponent) reads as in-progress, not a finished result', () => {
const g = game([seat(0, 'me', 0), seat(1, '', 0)], 'open', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
});
it('finished two-player: victory / defeat / draw', () => {
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), ['me'])).toEqual({
key: 'result.victory',
emoji: '🏆',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), 'me')).toEqual({
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), ['me'])).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), 'me')).toEqual({
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), ['me'])).toEqual({
key: 'result.draw',
emoji: '🏅',
});
@@ -64,7 +72,7 @@ describe('resultBadge', () => {
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
// The opponent won by resignation (isWinner) although neither side scored — the lobby
// must read this as a loss, matching the game-detail screen (regression).
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), ['me'])).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
@@ -72,21 +80,21 @@ describe('resultBadge', () => {
it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
expect(resultBadge(last, ['me'])).toEqual({ key: 'result.place4', emoji: '🏅' });
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
expect(resultBadge(second, ['me'])).toEqual({ key: 'result.place2', emoji: '🥈' });
});
it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => {
// The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single
// winner(), so this must NOT read as a full draw for everyone.
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' });
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' });
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), ['me'])).toEqual({ key: 'result.victory', emoji: '🏆' });
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), ['me'])).toEqual({ key: 'result.place3', emoji: '🥉' });
});
it('an aborted game reads as a draw for everyone, whatever the scores', () => {
const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' };
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' });
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.draw', emoji: '🏅' });
});
});
+3 -3
View File
@@ -16,8 +16,8 @@ function placeBadge(rank: number, players: number): ResultBadge {
return { key: 'result.place4', emoji: '🏅' };
}
export function resultBadge(game: GameView, myId: string): ResultBadge {
const me = game.seats.find((s) => s.accountId === myId);
export function resultBadge(game: GameView, myIds: readonly string[]): ResultBadge {
const me = game.seats.find((s) => myIds.includes(s.accountId));
if (game.status === 'active' || game.status === 'open') {
return game.toMove === me?.seat
@@ -42,7 +42,7 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
// A winner exists but it is not me — even when scores are level (a win by resignation or timeout
// can leave the winner at or below my score). The winner takes rank 1; place me among the remaining
// (non-resigned) seats by score, starting at rank 2.
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && !myIds.includes(s.accountId) && s.score > (me?.score ?? 0)).length;
return placeBadge(2 + ahead, game.players);
}
+43 -7
View File
@@ -17,7 +17,7 @@ import { offlineMode } from './offline.svelte';
import { maintenanceRecovered, registerMaintenanceProbe, reportMaintenance } from './maintenance.svelte';
import { maintenanceRetryMs } from './maintenance';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
import { UPDATE_REQUIRED, reportUpdateRequired } from './update.svelte';
import { UPDATE_REQUIRED, reportUpdateRecommended, reportUpdateRequired } from './update.svelte';
const MAX_RETRIES = 6;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
@@ -31,7 +31,21 @@ function assertOnline(): void {
export function createTransport(baseUrl: string): GatewayClient {
const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : '');
const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true });
const transport = createConnectTransport({
baseUrl: origin,
useBinaryFormat: true,
// Observe the soft-tier version nudge: a served unary response may carry X-Update-Recommended (the
// additive header the edge sets for a client at or above the hard minimum but below the recommended
// version). Reading it in an interceptor keeps it off every call site; the stream path is not
// soft-gated. It never fails the call — the nudge is non-blocking.
interceptors: [
(next) => async (req) => {
const res = await next(req);
if (!res.stream && res.header.get('x-update-recommended') === '1') reportUpdateRecommended();
return res;
},
],
});
const client = createClient(Gateway, transport);
let token: string | null = null;
@@ -61,8 +75,17 @@ export function createTransport(baseUrl: string): GatewayClient {
// exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a
// dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting
// indicator. A successful round-trip marks the gateway reachable; a domain result_code is final.
async function exec(messageType: string, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array> {
assertOnline();
async function exec(
messageType: string,
payload: Uint8Array,
signal?: AbortSignal,
opts?: { silent?: boolean; allowOffline?: boolean },
): Promise<Uint8Array> {
// allowOffline lets the background guest-reconciliation call reach the gateway while the app is
// still in auto-offline mode (that call IS the reachability check); every other call honours the
// kill switch. silent suppresses the terminal update overlay on update_required (the caller — the
// reconciliation — swallows it and stays a local guest); foreground calls still raise it.
if (!opts?.allowOffline) assertOnline();
for (let attempt = 0; ; attempt++) {
let res;
try {
@@ -78,8 +101,9 @@ export function createTransport(baseUrl: string): GatewayClient {
throw toGatewayError(e);
}
const err = toGatewayError(e);
// A too-old client turned away on a foreground call raises the terminal update overlay.
if (err.code === UPDATE_REQUIRED) reportUpdateRequired();
// A too-old client turned away on a foreground call raises the terminal update overlay; a
// silent (background reconciliation) call swallows it and stays a local guest.
if (err.code === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired();
if (retryable(err.code, messageType) && attempt < MAX_RETRIES) {
reportOffline();
await sleep(backoffMs(attempt + 1));
@@ -93,7 +117,7 @@ export function createTransport(baseUrl: string): GatewayClient {
// reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts).
maintenanceRecovered();
if (res.resultCode && res.resultCode !== 'ok') {
if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();
if (res.resultCode === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired();
throw new GatewayError(res.resultCode);
}
return res.payload;
@@ -145,6 +169,18 @@ export function createTransport(baseUrl: string): GatewayClient {
async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset(), platformSubtype())));
},
async authGuestSilent(locale) {
// Background reconciliation of a native local guest gaining the network: it runs while the app is
// still in auto-offline mode (allowOffline bypasses the kill switch) and must never raise the
// terminal update overlay (silent) — a too-old client stays a local guest instead of being
// interrupted mid-play (docs/ARCHITECTURE.md §2, the gate×offline rule).
return codec.decodeSession(
await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset(), platformSubtype()), undefined, {
silent: true,
allowOffline: true,
}),
);
},
async authEmailRequest(email, language, pwa) {
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa));
},
+65 -15
View File
@@ -1,26 +1,76 @@
// Global "client too old" signal. `active` latches true the first time the gateway answers a
// user-initiated online call with the update_required sentinel — the HTTP-header version gate the
// edge checks before decoding the payload (docs/ARCHITECTURE.md §2). It is terminal: unlike the
// maintenance signal there is no self-clearing poll, because an installed build cannot become
// compatible without an actual update. A non-dismissable overlay (UpdateOverlay.svelte) then covers
// the app; its one action sends the user to the store (native) or reloads (web). Offline play never
// trips it — the network kill switch refuses the call before it leaves the device.
// The client-version gate's two client signals (docs/ARCHITECTURE.md §2).
//
// HARD tier ("client too old"): the gateway refuses a foreground call with the update_required
// sentinel (the Execute result_code, the Subscribe FailedPrecondition). The transport reports it here,
// which drives the net-state machine into offlineVersionLocked; the notice (UpdateOverlay.svelte) reads
// netState.versionLocked and offers "Update" (store / reload) or "Play offline" (dismiss → stay
// offline). Offline play never trips it — the kill switch refuses the call before it leaves the device,
// and the background guest reconcile swallows it (stays a local guest, no notice).
//
// SOFT tier ("update available"): a served response carries the X-Update-Recommended header, which the
// transport reports here. It drives the machine (versionRecommended), whose setNudge effect — fired
// only from online — latches the dismissible nudge (UpdateNudge.svelte). The hard notice supersedes the
// nudge naturally: the nudge shows only while online, and version-locked is an offline state.
import { emit } from './netstate.svelte';
import { clientChannel } from './channel';
/** UPDATE_REQUIRED is the stable sentinel the gateway returns (the Execute result_code, and the
* GatewayError code the Subscribe FailedPrecondition maps to) for a client too old to be served. */
export const UPDATE_REQUIRED = 'update_required';
let required = $state(false);
/** openUpdate sends the user to the fix, shared by the hard-tier notice and the soft-tier nudge: the
* store listing on a native build (VITE_RUSTORE_URL, handed to the OS via '_system'), or a plain
* reload on the web (which fetches the current client). */
export function openUpdate(): void {
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') {
const url = import.meta.env.VITE_RUSTORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
}
}
export const updateRequired = {
/** active is true once a foreground online call has been refused as too old. Terminal. */
/** reportUpdateRequired drives the net-state machine into offlineVersionLocked (the hard tier). The
* transport calls it only for a foreground update_required the background reconcile swallows it and
* stays offline. The notice reads netState.versionLocked; there is no separate latch. */
export function reportUpdateRequired(): void {
emit('versionRejected');
}
// The soft "update available" nudge. `active` latches when the machine accepts a versionRecommended
// from online (via the setNudge effect → latchUpdateNudge); `dismissed` hides the banner for the
// session once the user closes it. A fresh launch re-evaluates the version, so both reset on reload.
let recommended = $state(false);
let nudgeDismissed = $state(false);
export const updateRecommended = {
/** active is true once the gateway has signalled an available (non-blocking) update. */
get active(): boolean {
return required;
return recommended;
},
/** dismissed is true once the user has closed the nudge for this session. */
get dismissed(): boolean {
return nudgeDismissed;
},
};
/** reportUpdateRequired latches the terminal "update required" overlay. The transport calls it when
* a foreground call returns the update_required sentinel; idempotent. */
export function reportUpdateRequired(): void {
required = true;
/** latchUpdateNudge raises the soft-nudge latch. It is wired to the machine's setNudge effect
* (registerNetNudge), which fires only from online, so the nudge is never raised while offline or
* version-locked. */
export function latchUpdateNudge(): void {
recommended = true;
}
/** dismissUpdateNudge hides the nudge for the session (a fresh launch re-evaluates the version). */
export function dismissUpdateNudge(): void {
nudgeDismissed = true;
}
/** reportUpdateRecommended signals an available update (the soft tier). The transport calls it when a
* served response carries the X-Update-Recommended header; it drives the machine (versionRecommended),
* which latches the nudge from online. */
export function reportUpdateRecommended(): void {
emit('versionRecommended');
}
+59 -27
View File
@@ -3,9 +3,10 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import UpdateNudge from '../components/UpdateNudge.svelte';
import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
@@ -16,6 +17,9 @@
import { preloadGames } from '../lib/preload';
import { kickDictPreload, offlineMode } from '../lib/offline.svelte';
import { localSource, isLocalGameId } from '../lib/gamesource';
import { localGuestId } from '../lib/localguest';
import { insideTelegram } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
@@ -36,27 +40,37 @@
let loadSeq = 0;
async function load() {
const seq = ++loadSeq;
// Deliberate offline mode: never touch the network. The lobby shows only the device-local
// vs_ai games (reconstructed from the store) plus the New-vs-AI entry; there are no online
// games, invitations or incoming requests to fetch.
if (offlineMode.active) {
const local = await localSource.list();
if (seq !== loadSeq) return;
games = local;
const offline = offlineMode.active;
// Device-local games (vs_ai / hotseat) merge into the unified lobby everywhere except the
// always-online Telegram/VK mini-apps, which are server-only (the shared-origin local store must
// not leak device-local games into a mini-app).
const serverOnly = insideTelegram() || insideVK();
const local = serverOnly ? [] : await localSource.list();
if (seq !== loadSeq) return;
if (offline) {
// Offline: device-local games are active; the last-known server games ride along greyed from the
// cache (un-openable). No network — and no invitations/incoming (a server concept, hidden until
// back online).
const cachedServer = (getLobby()?.games ?? []).filter((g) => !isLocalGameId(g.id));
games = [...local, ...cachedServer];
invitations = [];
incoming = [];
app.notifications = 0;
setLobby({ games, invitations, incoming, offline: offlineMode.active });
setLobby({ games, invitations, incoming });
app.lobbyReady = true;
return;
}
try {
const list = await gateway.gamesList();
if (seq !== loadSeq) return;
games = list.games;
// Seed the per-game unread badges from the authoritative list. The live stream only raises
const server = list.games;
// Seed the per-game unread badges from the authoritative server list. The live stream only raises
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
for (const g of server) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
// The unified list: device-local games alongside the server ones (so a reconciled guest's local
// games stay visible online). Ids never collide.
games = [...local, ...server];
if (!guest) {
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
if (seq !== loadSeq) return;
@@ -67,12 +81,12 @@
app.notifications = incoming.length;
void refreshFeedbackBadge();
}
setLobby({ games, invitations, incoming, offline: offlineMode.active });
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
setLobby({ games, invitations, incoming });
// Warm the cache for the ongoing SERVER games so opening one from the lobby is instant (local
// games open from the device). The list just loaded, so the connection is up; non-blocking and
// cheap (already-cached ones are skipped).
if (connection.online) {
void preloadGames(games);
void preloadGames(server);
// Warm the offline dictionaries for an eligible install (PWA + email) so a later switch to
// offline mode already has the data; a no-op elsewhere, and cheap once cached. The first
// lobby entry surfaces a notice in place of the ad banner if a fetch fails.
@@ -90,7 +104,7 @@
onMount(() => {
// Render instantly from the cached lists (so the screen does not "draw in" during
// the back-slide), then refresh in the background.
const cached = getLobby(offlineMode.active);
const cached = getLobby();
if (cached) {
games = cached.games;
invitations = cached.invitations;
@@ -118,8 +132,16 @@
}
});
// myId is the server user id, used for the (server-only) invitation "from me" checks. myIds is the
// full "you" set — the server user id plus the device-local guest id — used for game seat matching,
// since a reconciled guest's device-local games are seated under localGuestId while server games are
// seated under the user id (the ids never collide). guestId is read once (it mints on first launch).
const guestId = localGuestId();
const myId = $derived(app.session?.userId ?? '');
const groups = $derived(groupGames(games, myId, app.chatUnread));
const myIds = $derived([app.session?.userId, guestId].filter((x): x is string => !!x));
// grey marks a server game shown only from the offline cache: dimmed and un-openable until online.
const grey = (g: GameView): boolean => offlineMode.active && !isLocalGameId(g.id);
const groups = $derived(groupGames(games, myIds, app.chatUnread));
// Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished"
// while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is
@@ -152,7 +174,7 @@
const seen = new Set<string>();
for (const g of games) {
seen.add(g.id);
const phase = gamePhase(g, myId);
const phase = gamePhase(g, myIds);
if (shouldBlink(prevPhase.get(g.id), phase)) blink(g.id);
prevPhase.set(g.id, phase);
}
@@ -169,7 +191,7 @@
// An honest-AI game shows the robot opponent as 🤖, never its (pooled) name.
if (g.vsAi) return '🤖';
return g.seats
.filter((s) => s.accountId !== myId)
.filter((s) => !myIds.includes(s.accountId))
.map((s) => s.displayName)
.join(', ');
}
@@ -213,6 +235,11 @@
revealedId = null;
return;
}
// A server game shown only from the offline cache is un-openable — a toast explains why.
if (grey(g)) {
showToast(t('net.offline'));
return;
}
navigate(`/game/${g.id}`);
}
function toggleReveal(id: string): void {
@@ -222,7 +249,7 @@
revealedId = null;
const prev = games;
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
setLobby({ games, invitations, incoming, offline: offlineMode.active });
setLobby({ games, invitations, incoming });
try {
// A local (offline) game is deleted from the device store; an online game is hidden on the
// backend. Routing by id keeps the delete off the network for a local game (the transport
@@ -231,7 +258,7 @@
else await gateway.hideGame(id);
} catch (e) {
games = prev;
setLobby({ games, invitations, incoming, offline: offlineMode.active });
setLobby({ games, invitations, incoming });
handleError(e);
}
}
@@ -276,7 +303,7 @@
<Screen title={app.profile?.displayName ?? t('app.title')}>
<div class="lobby">
{#if invitations.length}
{#if invitations.length && !offlineMode.active}
<section>
<h2>{t('lobby.invitations')}</h2>
{#each invitations as inv (inv.id)}
@@ -319,7 +346,7 @@
<div class="list">
{#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id} class:greyed={grey(g)}>
{#if group.finished || g.hotseat}
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if}
@@ -336,7 +363,7 @@
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
</span>
<span class="sub scoreline"
>{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myId, s)}{#if i > 0}<span class="sep">{' : '}</span
>{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myIds, s)}{#if i > 0}<span class="sep">{' : '}</span
>{/if}<span
class="num"
class:win={standing === 'win'}
@@ -349,7 +376,7 @@
plaques, and resultBadge is viewer-centric (no seat matches the account). A
vs_ai game keeps its medal (one human, a straightforward win/loss). -->
{#key blinkNonce.get(g.id) ?? 0}
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myId).emoji}</span>
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myIds).emoji}</span>
{/key}
</button>
{#if group.finished || g.hotseat}
@@ -397,6 +424,7 @@
{/if}
{#snippet tabbar()}
<UpdateNudge />
<TabBar>
<button class="tab" onclick={() => navigate('/new')}>
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
@@ -495,6 +523,10 @@
.rowwrap + .rowwrap {
border-top: 1px solid var(--border);
}
/* A server game shown only from the offline cache: dimmed and un-openable (openGame toasts instead). */
.rowwrap.greyed {
opacity: 0.5;
}
.del {
position: absolute;
inset: 0 0 0 auto;
+66 -5
View File
@@ -11,7 +11,7 @@
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { localSource } from '../lib/gamesource';
import { localSource, isLocalGameId } from '../lib/gamesource';
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
import { localGuestId } from '../lib/localguest';
import { navigate } from '../lib/router.svelte';
@@ -52,6 +52,13 @@
const guest = $derived(app.profile?.isGuest ?? true);
let mode = $state<'auto' | 'friends'>('auto');
// Within "with friends": online = a remote invite, offline = a pass-and-play (hotseat) game on this
// device. Both exist regardless of connectivity, but with no network the online segment is disabled
// and offline is forced — so the create flow always works.
let friendsMode = $state<'online' | 'offline'>('online');
$effect(() => {
if (offlineMode.active) friendsMode = 'offline';
});
// The quick-game opponent: an honest AI (a robot that joins and moves at once, shown as 🤖)
// or a random human via auto-match. AI is the default.
let opponent = $state<'ai' | 'random'>('ai');
@@ -63,7 +70,10 @@
const autoKind = $derived(opponent === 'ai' ? GameKind.vsAi : GameKind.random);
// The player's current games for the per-kind lock: seeded from the lobby snapshot for an instant
// render, then refreshed on mount (below) so a game created since the last lobby visit is counted.
let lobbyGames = $state<GameView[]>(getLobby(false)?.games ?? []);
// Only server games count toward the per-kind server limit (device-local games are unlimited), so
// the merged snapshot is filtered to the server ones here (the on-mount refresh below already reads
// the server list directly).
let lobbyGames = $state<GameView[]>(getLobby()?.games.filter((g) => !isLocalGameId(g.id)) ?? []);
const autoLocked = $derived(!offlineMode.active && isKindLocked(lobbyGames, app.profile?.gameLimits, autoKind));
let limitOpen = $state(false);
@@ -125,6 +135,37 @@
$effect(() => {
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
});
// The offline dictionary guard: creating a device-local game (vs_ai or hotseat) needs the variant's
// dawg to be loadable without the network — bundled (native, always) or IndexedDB-cached (web). A
// native build bundles every variant, so this only ever bites a web install whose dawg was not
// cached. dawgReady is null while the async check runs, true/false once resolved; online it stays
// true (the guard is inert).
let dawgReady = $state<boolean | null>(true);
const guardVariant = $derived(mode === 'auto' ? selectedAuto : friendsMode === 'offline' ? inviteVariant : '');
$effect(() => {
const v = guardVariant;
if (!offlineMode.active || !v) {
dawgReady = true;
return;
}
dawgReady = null; // checking
const version = app.profile?.dictVersions?.[v] ?? __DICT_VERSION__;
let cancelled = false;
// getDawg resolves from IndexedDB / the native bundle offline (the network tier is refused by the
// kill switch), so a null result means the dictionary is genuinely unavailable on this device.
void import('../lib/dict')
.then((m) => m.getDawg(v, version))
.then((d) => {
if (!cancelled) dawgReady = !!d;
});
return () => {
cancelled = true;
};
});
// dictBlocked gates the offline create on a genuinely-missing dictionary (never while still checking).
const dictBlocked = $derived(offlineMode.active && dawgReady === false);
let timeoutSecs = $state(86400);
let hints = $state(1);
@@ -355,10 +396,11 @@
{/if}
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
{#if dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
<button
class="invite"
class:locked={autoLocked}
disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || starting}
disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || dictBlocked || starting}
onclick={() => (autoLocked ? (limitOpen = true) : selectedAuto && find(selectedAuto))}
>{#if autoLocked}🔒 {/if}{t('new.start')}</button>
<GameLimitModal
@@ -367,7 +409,16 @@
onclose={() => (limitOpen = false)}
onlogin={() => { limitOpen = false; navigate('/profile'); }}
/>
{:else if offlineMode.active}
{:else}
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this
device. Both exist regardless of connectivity, but with no network the online segment is
disabled and offline is forced (friendsMode). -->
<div class="seg modes">
<button class="opt" class:active={friendsMode === 'online'} disabled={offlineMode.active} onclick={() => (friendsMode = 'online')}>{t('new.playRemote')}</button>
<button class="opt" class:active={friendsMode === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button>
</div>
{#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if}
{#if friendsMode === 'offline'}
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
PIN first (it gates the roster); each seat may add its own PIN. -->
<div class="hostpin">
@@ -434,9 +485,10 @@
+ {t('hotseat.addPlayer')}
</button>
{/if}
{#if dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
<button
class="invite"
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || dictBlocked || starting}
onclick={startHotseat}
>{t('new.start')}</button>
{:else if friends.length === 0}
@@ -485,6 +537,7 @@
{/if}
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{/if}
{#if pad}
@@ -708,6 +761,14 @@
font-size: 0.85rem;
line-height: 1.4;
}
/* The offline-blocker reason (a missing dictionary, or the online segment needing a connection). */
.dictnote {
margin: 0;
text-align: center;
color: var(--warn);
font-size: 0.85rem;
line-height: 1.4;
}
/* --- offline hotseat roster --- */
.hostpin {
display: flex;
+8 -2
View File
@@ -16,6 +16,7 @@
import { gateway } from '../lib/gateway';
import { insideTelegram, loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { clientChannel } from '../lib/channel';
import { kickDictPreload } from '../lib/offline.svelte';
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
import { t } from '../lib/i18n/index.svelte';
@@ -59,8 +60,13 @@
let deleting = $state<null | { method: 'email' | 'phrase' }>(null);
let deleteCode = $state('');
let deletePhrase = $state('');
const telegramLinkable = loginWidgetAvailable();
const vkLinkable = vkWebLinkAvailable();
// The native build hides the Telegram + VK LINK buttons: VK ID web-login is a full-page redirect to
// id.vk.com that cannot return into the Capacitor WebView, and the Telegram Login Widget is unreliable
// there — so guest + email is the native sign-in surface (native tg/vk login is a later stage). An
// existing link's UNLINK button stays (a pure gateway call, no redirect), as does all account management.
const nativeShell = clientChannel() === 'android' || clientChannel() === 'ios';
const telegramLinkable = loginWidgetAvailable() && !nativeShell;
const vkLinkable = vkWebLinkAvailable() && !nativeShell;
// Inside a host Mini App the current platform's own identity must not be managed from the
// profile — unlinking the very platform you are signed in through is meaningless — so that
// provider's linked-account row is hidden here (the web / native builds still show both).
-63
View File
@@ -11,22 +11,12 @@
import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels';
import { insideTelegram } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { offlineMode, setOfflineMode, requestOffline } from '../lib/offline.svelte';
import { isStandalone } from '../lib/pwa';
import InstallApp from '../components/InstallApp.svelte';
// The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit
// the whole board and never auto-zoom), so it is shown only for a coarse pointer.
const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
const offlineEligible = $derived(
isStandalone() && !insideTelegram() && !insideVK() && !!app.profile?.email,
);
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
auto: 'settings.themeAuto',
@@ -41,24 +31,6 @@
none: 'settings.labelsNone',
};
// The offline toggle gates entry on dictionary readiness: flipping to offline fetches the enabled
// variants' dictionaries (cache-first) and waits at most a few seconds; if they cannot be made
// available it stays online and shows the "needs internet" note, while the fetch keeps warming the
// cache in the background so a later flip is instant. Leaving offline (-> online) is never gated.
let checking = $state(false);
let needsData = $state(false);
async function goOffline(): Promise<void> {
if (offlineMode.active || checking) return;
needsData = false;
checking = true;
try {
const ready = await requestOffline(app.profile);
if (!ready) needsData = true;
} finally {
checking = false;
}
}
</script>
<div class="page">
@@ -119,33 +91,6 @@
{/if}
</section>
{#if offlineEligible}
<section>
<h3>{t('settings.offlineMode')}</h3>
<div class="seg">
<button
class="opt"
class:active={!offlineMode.active}
disabled={checking}
onclick={() => {
needsData = false;
setOfflineMode(false);
}}
>
{t('settings.online')}
</button>
<button class="opt" class:active={offlineMode.active} disabled={checking} onclick={goOffline}>
{t('settings.offline')}
</button>
</div>
{#if checking}
<p class="onote">{t('settings.offlineChecking')}</p>
{:else if needsData}
<p class="onote onote--warn" role="alert">{t('settings.offlineNeedsData')}</p>
{/if}
</section>
{/if}
<!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app
is installable here — see components/InstallApp.svelte / lib/pwa). -->
<InstallApp />
@@ -190,14 +135,6 @@
opacity: 0.55;
cursor: default;
}
.onote {
font-size: 0.85rem;
color: var(--text-muted);
margin: 8px 0 0;
}
.onote--warn {
color: var(--warn);
}
.row {
display: flex;
align-items: center;
+3 -2
View File
@@ -7,8 +7,9 @@ interface ImportMetaEnv {
readonly VITE_GATEWAY_URL?: string;
/** "1" hides the in-app-currency purchase actions in the thin native MVP that defers store billing. */
readonly VITE_PAYMENTS_DISABLED?: string;
/** Store listing URL the native update overlay opens for its "update" action (e.g. the RuStore page). */
readonly VITE_STORE_URL?: string;
/** RuStore listing URL the native update overlay opens for its "update" action. Empty until the app
* is published (the button then no-ops); the version gate is dormant in the MVP so it never fires yet. */
readonly VITE_RUSTORE_URL?: string;
/** Bundled-dictionary version the native offline path requests; matches the packaged DAWG files. */
readonly VITE_DICT_VERSION?: string;
}