Compare commits
37 Commits
a035edfb54
...
v1.17.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a41281c495 | |||
| 11f89f6477 | |||
| 2d1fadb50c | |||
| 09e05eef18 | |||
| 73baf58002 | |||
| eec225c4ee | |||
| ca2c6487cf | |||
| e7cb60c996 | |||
| 49c53794f4 | |||
| 4a0689a4ac | |||
| 0eb72ba955 | |||
| e077258567 | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
+34
-3
@@ -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
|
||||
@@ -353,6 +353,11 @@ jobs:
|
||||
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
|
||||
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
|
||||
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||
|
||||
@@ -112,6 +112,12 @@ jobs:
|
||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
|
||||
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
|
||||
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
|
||||
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||
|
||||
+525
-90
@@ -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.
|
||||
- **E–G — pending.**
|
||||
- **D. Offline-first — ✅ CODE-COMPLETE, e2e-verified & on-device-smoke-verified (D.1–D.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 (O1–O7)** in the "Offline-model redesign" section below.
|
||||
**➡ Next actionable work: implement O1** (the pure `netState` reducer, test-first) via `stage-implementation`,
|
||||
then O2–O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
|
||||
O1–O7 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.1–D.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 (O1–O7, 2026-07-13; staged detail below).** Resolved the deferred
|
||||
native local-game-visibility decision — a native guest now sees / resumes their device-local games once online
|
||||
(the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline
|
||||
is an implicit **net-state machine** (O1/O2), the lobby is unified (O5), and the **two-tier** version gate is in
|
||||
(O4). Cross-cutting (web + native + a small additive backend/wire bit); **contour-safe** (both version vars empty
|
||||
⇒ dormant; the wire add is additive). The remaining G steps below are the release chain.
|
||||
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
|
||||
2. Promote `development` → `master`; tag `vX.Y.0`.
|
||||
3. Dispatch `android-build`; watch green; retrieve the signed APK.
|
||||
@@ -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 (O1–O7).** G-step-0 is resolved — the redesign is contour-safe (both version vars empty
|
||||
⇒ dormant; the wire add is additive). Next is the normal G chain: PR → `development` → contour verify → `master` → tag →
|
||||
dispatch `android-build`.
|
||||
|
||||
---
|
||||
|
||||
## 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.)
|
||||
|
||||
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
|
||||
TELEGRAM_TEST_ENV=false
|
||||
TELEGRAM_API_BASE_URL=
|
||||
|
||||
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
|
||||
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
|
||||
# variables, shared across contours; in the test contour the stamped client version is a commit hash
|
||||
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
|
||||
GATEWAY_MIN_CLIENT_VERSION=
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION=
|
||||
|
||||
# --- VK Mini App ------------------------------------------------------------
|
||||
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -222,6 +222,12 @@ services:
|
||||
GATEWAY_HTTP_ADDR: ":8081"
|
||||
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
|
||||
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
|
||||
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
|
||||
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
|
||||
# Telegram auth validates against the home validator (plaintext, internal).
|
||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||
|
||||
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||
export DICT_VERSION='$DICT_VERSION'
|
||||
export APP_VERSION='$APP_VERSION'
|
||||
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
|
||||
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
|
||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
|
||||
+108
-37
@@ -100,15 +100,26 @@ 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: they are never fed an
|
||||
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
|
||||
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
|
||||
local lobby, no transport kill switch and no device-local create paths.
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`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 +151,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 +269,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 +1390,18 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
|
||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||
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. On offline-capable channels (native / plain web) 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;
|
||||
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
|
||||
hotseat create is guarded when
|
||||
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
|
||||
the in-browser engine and driven by the same game screen, the robot replying locally.
|
||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||
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 +1429,17 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
|
||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||
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 +1521,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
|
||||
|
||||
+46
-23
@@ -277,22 +277,28 @@ 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 **web and native apps** play **offline automatically** — there is **no on/off switch**; the
|
||||
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
|
||||
native apps only). 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 the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
|
||||
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||||
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 +311,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
|
||||
|
||||
+46
-23
@@ -283,22 +283,27 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||
сохраняется между запусками.
|
||||
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
|
||||
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
|
||||
приложениям). Когда оно не может достучаться
|
||||
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
||||
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
||||
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
||||
сети.
|
||||
|
||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
|
||||
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
|
||||
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
|
||||
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
|
||||
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
|
||||
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
|
||||
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
|
||||
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
||||
|
||||
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
|
||||
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
|
||||
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||
@@ -312,14 +317,32 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||
своего хода.
|
||||
|
||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
|
||||
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
|
||||
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
|
||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||
вас не прерывают из-за короткой икоты.
|
||||
|
||||
### Актуальная версия
|
||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
|
||||
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
|
||||
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
|
||||
|
||||
### Нативное приложение (Android)
|
||||
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
|
||||
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
|
||||
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
|
||||
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
|
||||
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
|
||||
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
|
||||
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
|
||||
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
|
||||
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
|
||||
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
|
||||
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
|
||||
офлайн-игры.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
|
||||
+37
-5
@@ -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
|
||||
|
||||
|
||||
+8
-3
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
||||
VITE_APP_VERSION=$VITE_APP_VERSION \
|
||||
VITE_ADS_STUB=$VITE_ADS_STUB
|
||||
|
||||
# Install with the lockfile first (the workspace file carries pnpm's build-script
|
||||
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
||||
# Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
|
||||
# --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
|
||||
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
|
||||
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
|
||||
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
|
||||
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
|
||||
# installs (where android:assets does need sharp built).
|
||||
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY ui ./
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
+18
-17
@@ -221,23 +221,24 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
}
|
||||
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
Backend: backend,
|
||||
Limiter: limiter,
|
||||
Tracker: tracker,
|
||||
Banlist: banlist,
|
||||
Blocklist: blocklist,
|
||||
Honeytoken: cfg.Abuse.Honeytoken,
|
||||
VKAppSecret: cfg.VKAppSecret,
|
||||
Hub: hub,
|
||||
RateLimit: cfg.RateLimit,
|
||||
Heartbeat: cfg.PushHeartbeatInterval,
|
||||
Logger: logger,
|
||||
AdminProxy: adminProxy,
|
||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||
MinClientVersion: cfg.MinClientVersion,
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
Backend: backend,
|
||||
Limiter: limiter,
|
||||
Tracker: tracker,
|
||||
Banlist: banlist,
|
||||
Blocklist: blocklist,
|
||||
Honeytoken: cfg.Abuse.Honeytoken,
|
||||
VKAppSecret: cfg.VKAppSecret,
|
||||
Hub: hub,
|
||||
RateLimit: cfg.RateLimit,
|
||||
Heartbeat: cfg.PushHeartbeatInterval,
|
||||
Logger: logger,
|
||||
AdminProxy: adminProxy,
|
||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||
MinClientVersion: cfg.MinClientVersion,
|
||||
RecommendedClientVersion: cfg.RecommendedClientVersion,
|
||||
})
|
||||
|
||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||
|
||||
@@ -60,6 +60,11 @@ type Config struct {
|
||||
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
|
||||
// (every client is served) — the default for web-only deployments.
|
||||
MinClientVersion string
|
||||
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
|
||||
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
|
||||
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
|
||||
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
|
||||
RecommendedClientVersion string
|
||||
// RateLimit configures the in-memory anti-abuse limiter.
|
||||
RateLimit RateLimitConfig
|
||||
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||
@@ -232,15 +237,16 @@ func (c VKIDConfig) Enabled() bool {
|
||||
func Load() (Config, error) {
|
||||
var err error
|
||||
c := Config{
|
||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
|
||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
|
||||
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
|
||||
VKID: VKIDConfig{
|
||||
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
||||
@@ -344,6 +350,18 @@ func (c Config) validate() error {
|
||||
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
|
||||
}
|
||||
}
|
||||
if c.RecommendedClientVersion != "" {
|
||||
rec, ok := clientver.Parse(c.RecommendedClientVersion)
|
||||
if !ok {
|
||||
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
|
||||
}
|
||||
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
|
||||
// misconfiguration (every client below min is already turned away). An empty/unparseable
|
||||
// minimum imposes no lower bound, so the recommended may stand alone.
|
||||
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
|
||||
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
|
||||
}
|
||||
}
|
||||
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
||||
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
||||
}
|
||||
|
||||
@@ -70,6 +70,42 @@ func TestLoadMinClientVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRecommendedClientVersion verifies the soft-tier config: dormant (empty) by default, a
|
||||
// parseable version accepted (standing alone with no minimum, or at/above one), an unparseable one
|
||||
// rejected, and a recommended below the minimum rejected.
|
||||
func TestLoadRecommendedClientVersion(t *testing.T) {
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.RecommendedClientVersion != "" {
|
||||
t.Errorf("RecommendedClientVersion = %q, want empty (soft tier dormant)", c.RecommendedClientVersion)
|
||||
}
|
||||
// Stands alone with no minimum configured.
|
||||
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.20.0")
|
||||
if c, err = Load(); err != nil {
|
||||
t.Fatalf("Load with a valid recommended version (no min): %v", err)
|
||||
}
|
||||
if c.RecommendedClientVersion != "v1.20.0" {
|
||||
t.Errorf("RecommendedClientVersion = %q, want %q", c.RecommendedClientVersion, "v1.20.0")
|
||||
}
|
||||
// At or above the minimum is accepted.
|
||||
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
|
||||
if _, err = Load(); err != nil {
|
||||
t.Fatalf("Load with recommended >= min: %v", err)
|
||||
}
|
||||
// Below the minimum is rejected.
|
||||
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.10.0")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for a recommended version below the minimum, got nil")
|
||||
}
|
||||
// Unparseable is rejected.
|
||||
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "dev")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for an unparseable GATEWAY_RECOMMENDED_CLIENT_VERSION, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
|
||||
// the agreed thresholds, and no honeytoken.
|
||||
func TestLoadAbuseDefaults(t *testing.T) {
|
||||
|
||||
@@ -51,10 +51,15 @@ const honeypotHeader = "X-Scrabble-Honeypot"
|
||||
// the edge can turn away a build too old to speak the current wire contract, before it decodes
|
||||
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
|
||||
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
|
||||
// "you must update". Both are part of the frozen wire contract (docs/ARCHITECTURE.md §2).
|
||||
// "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
|
||||
// on a served Execute response when the client is at or above the hard minimum but below the
|
||||
// recommended version, it nudges an update without failing the call. Headers are the
|
||||
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
|
||||
// contract (docs/ARCHITECTURE.md §2).
|
||||
const (
|
||||
clientVersionHeader = "X-Client-Version"
|
||||
resultUpdateRequired = "update_required"
|
||||
clientVersionHeader = "X-Client-Version"
|
||||
resultUpdateRequired = "update_required"
|
||||
updateRecommendedHeader = "X-Update-Recommended"
|
||||
)
|
||||
|
||||
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||
@@ -104,6 +109,12 @@ type Server struct {
|
||||
minClient clientver.Version
|
||||
gateOn bool
|
||||
|
||||
// recClient is the version below which a served client is nudged to update (the soft tier);
|
||||
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
|
||||
// unparseable). It is independent of the hard gate.
|
||||
recClient clientver.Version
|
||||
recOn bool
|
||||
|
||||
publicPolicy ratelimit.Policy
|
||||
userPolicy ratelimit.Policy
|
||||
emailPolicy ratelimit.Policy
|
||||
@@ -148,6 +159,9 @@ type Deps struct {
|
||||
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
|
||||
// the gate dormant.
|
||||
MinClientVersion string
|
||||
// RecommendedClientVersion is the version below which a served client is nudged to update (the
|
||||
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
|
||||
RecommendedClientVersion string
|
||||
}
|
||||
|
||||
// NewServer constructs the edge service.
|
||||
@@ -192,6 +206,18 @@ func NewServer(d Deps) *Server {
|
||||
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
|
||||
}
|
||||
}
|
||||
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
|
||||
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
|
||||
// construction; an empty value leaves the soft tier dormant.
|
||||
var recClient clientver.Version
|
||||
recOn := false
|
||||
if d.RecommendedClientVersion != "" {
|
||||
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
|
||||
recClient, recOn = v, true
|
||||
} else {
|
||||
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
|
||||
}
|
||||
}
|
||||
return &Server{
|
||||
registry: d.Registry,
|
||||
sessions: d.Sessions,
|
||||
@@ -210,6 +236,8 @@ func NewServer(d Deps) *Server {
|
||||
maxBodyBytes: maxBody,
|
||||
minClient: minClient,
|
||||
gateOn: gateOn,
|
||||
recClient: recClient,
|
||||
recOn: recOn,
|
||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
||||
@@ -335,15 +363,44 @@ func (s *Server) clientTooOld(header string) bool {
|
||||
return clientver.Less(v, s.minClient)
|
||||
}
|
||||
|
||||
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
|
||||
// band — at or above the hard minimum but below the recommended version — so a served response can
|
||||
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
|
||||
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
|
||||
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
|
||||
// leaves minClient at the zero version, which no real version is below, so the band is simply
|
||||
// "below recommended").
|
||||
func (s *Server) clientUpdateRecommended(header string) bool {
|
||||
if !s.recOn {
|
||||
return false
|
||||
}
|
||||
v, ok := clientver.Parse(header)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
|
||||
}
|
||||
|
||||
// Execute runs one unary operation. Domain failures are returned in the envelope
|
||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||
// session, unknown type, internal) become Connect errors.
|
||||
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) {
|
||||
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
|
||||
start := time.Now()
|
||||
msgType := req.Msg.GetMessageType()
|
||||
result := "internal"
|
||||
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
|
||||
|
||||
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
|
||||
// client at or above the hard minimum but below the recommended version is told an update is
|
||||
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
|
||||
// version yields no nudge.
|
||||
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
|
||||
defer func() {
|
||||
if recommend && resp != nil {
|
||||
resp.Header().Set(updateRecommendedHeader, "1")
|
||||
}
|
||||
}()
|
||||
|
||||
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
|
||||
// the payload is decoded, so a too-old client makes zero successful calls but sees the
|
||||
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
|
||||
|
||||
@@ -53,6 +53,33 @@ func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc
|
||||
}
|
||||
}
|
||||
|
||||
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
|
||||
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||
t.Helper()
|
||||
backendSrv := httptest.NewServer(backendHandler)
|
||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: transcode.NewRegistry(backend, nil),
|
||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||
Limiter: ratelimit.New(),
|
||||
Hub: push.NewHub(0),
|
||||
RateLimit: config.DefaultRateLimit(),
|
||||
Heartbeat: 15 * time.Second,
|
||||
MinClientVersion: minVersion,
|
||||
RecommendedClientVersion: recommendedVersion,
|
||||
})
|
||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
||||
return client, func() {
|
||||
edgeSrv.Close()
|
||||
_ = backend.Close()
|
||||
backendSrv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteGuestAuthOK(t *testing.T) {
|
||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
@@ -156,6 +183,54 @@ func TestExecuteVersionGate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
|
||||
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
|
||||
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
|
||||
// and a dormant soft tier get no header.
|
||||
func TestExecuteUpdateRecommended(t *testing.T) {
|
||||
backend := func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
min string
|
||||
rec string
|
||||
header string
|
||||
wantResult string
|
||||
wantHeader bool
|
||||
}{
|
||||
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
|
||||
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
|
||||
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
|
||||
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
|
||||
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
|
||||
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
|
||||
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
|
||||
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
|
||||
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
|
||||
defer cleanup()
|
||||
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
|
||||
if tc.header != "" {
|
||||
req.Header().Set("X-Client-Version", tc.header)
|
||||
}
|
||||
resp, err := client.Execute(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
|
||||
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
|
||||
}
|
||||
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
|
||||
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
|
||||
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
|
||||
// fails Unauthenticated with no session, proving it passed the version gate).
|
||||
|
||||
@@ -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 `/`).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-network')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -4,3 +4,6 @@ project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capa
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.4.1/node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-network'
|
||||
project(':capacitor-network').projectDir = new File('../node_modules/.pnpm/@capacitor+network@8.0.1_@capacitor+core@8.4.1/node_modules/@capacitor/network/android')
|
||||
|
||||
+12
-10
@@ -1,8 +1,9 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
|
||||
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
|
||||
// Settings offline toggle shows.
|
||||
// Offline pass-and-play (hotseat) is offered only in offline mode, which is now implicit (the net-state
|
||||
// machine detects it — no toggle). The e2e drives the machine through the mock's __net hook. Standalone
|
||||
// display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
|
||||
// installed PWA.
|
||||
async function forceStandalone(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
@@ -19,11 +20,9 @@ async function enterLobby(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goOffline(page: Page): Promise<void> {
|
||||
await page.locator('button.tab').nth(2).click(); // Settings
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
// The machine auto-detects offline (no toggle, no dialog); the lobby stays mounted and turns blue.
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
||||
@@ -46,9 +45,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
||||
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
||||
|
||||
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||
// New game -> "with friends" offers an online (invite) / offline (hotseat) segment. Offline, the
|
||||
// online segment is disabled and the offline (pass-and-play) one is forced, so the hotseat form shows.
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||
await expect(page.getByRole('button', { name: /Invite a friend|Пригласить друга/i })).toBeDisabled();
|
||||
await expect(page.locator('.hostpin')).toBeVisible();
|
||||
|
||||
// The player rows are disabled until the mandatory host (master) PIN is set.
|
||||
await expect(page.locator('.pname').first()).toBeDisabled();
|
||||
@@ -115,12 +117,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
||||
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
await expect(page.locator('.rowwrap')).toHaveCount(1);
|
||||
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(1);
|
||||
await page.locator('.kebab').first().click();
|
||||
const del = page.locator('.rowwrap.revealed .del');
|
||||
await expect(del).toBeVisible();
|
||||
await del.click();
|
||||
await typePin(page, '9999');
|
||||
await expect(page.locator('.rowwrap')).toHaveCount(0);
|
||||
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,54 @@ test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a
|
||||
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('inside Telegram (online-only), an offline signal never switches the app to offline mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The Telegram mini-app is online-only: the offline model (implicit offline, device-local games,
|
||||
// the transport kill switch) must stay inert there. Even when the net-state machine is driven to an
|
||||
// offline state, offlineMode must remain false — otherwise the chrome turns blue, the lobby greys the
|
||||
// server games and, critically, a vs_ai start would create a device-local game instead of enqueuing.
|
||||
await page.addInitScript((stub) => {
|
||||
Object.assign(window, stub);
|
||||
}, webAppStub());
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible(); // the durable mini-app session, in the lobby
|
||||
|
||||
// Drive the net-state machine to an offline state through the mock hook.
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
|
||||
// Because the channel is online-only, offlineMode stays inert: on the lobby the chrome never turns blue
|
||||
// and no server game is greyed…
|
||||
await expect(page.locator('header.nav.offline')).toHaveCount(0);
|
||||
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
|
||||
// …and New Game's quick match still offers the opponent choice (AI / random), which is hidden only in
|
||||
// real offline mode. Its presence is the positive proof that offlineMode is false — so the vs_ai start,
|
||||
// whose device-local branch is gated on offlineMode, enqueues on the server instead of creating a
|
||||
// device-local game.
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
await expect(page.locator('button.opt', { hasText: '🤖' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('inside Telegram (online-only), New Game with friends offers remote invites only', async ({
|
||||
page,
|
||||
}) => {
|
||||
// A Telegram launch authenticates a durable account, so New Game shows the auto/with-friends selector.
|
||||
// "Play with friends" must offer only the remote invite — never the online/offline segment, whose
|
||||
// "Pass and play" is the device-local hotseat flow that has no place in an online-only mini-app.
|
||||
await page.addInitScript((stub) => {
|
||||
Object.assign(window, stub);
|
||||
}, webAppStub());
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
|
||||
await page.locator('button.tab').nth(0).click(); // the New tab opens the create screen
|
||||
await page.getByRole('button', { name: 'Play with friends' }).click();
|
||||
|
||||
// The online/offline segment (Invite a friend / Pass and play) is absent — only the invite form shows.
|
||||
await expect(page.getByRole('button', { name: 'Pass and play' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Invite a friend' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
|
||||
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
|
||||
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
|
||||
|
||||
+61
-18
@@ -1,34 +1,77 @@
|
||||
import { expect, test } from './fixtures';
|
||||
import { expect, test, type Page } from './fixtures';
|
||||
|
||||
// The "update required" overlay is raised in prod when the edge's client-version gate turns away a
|
||||
// too-old build (the update_required result_code on Execute, a Subscribe FailedPrecondition). The
|
||||
// mock transport never produces one, so — like the maintenance overlay — the e2e drives it through
|
||||
// the window.__update hook (gateway.ts, mock-only). It is app-global (mounted outside the route
|
||||
// blocks in App.svelte), so it shows without a session, and it is terminal (no self-clearing poll).
|
||||
test('update overlay covers the app when the client is too old', async ({ page }) => {
|
||||
// The two-tier client-version gate (docs/ARCHITECTURE.md §2). The mock transport never produces a real
|
||||
// version signal, so — like the maintenance overlay — the e2e drives both tiers through the
|
||||
// window.__update hook (gateway.ts, mock-only): __update.on() raises the HARD "update required" notice
|
||||
// (the net-state machine's offlineVersionLocked), __update.recommend() raises the SOFT "update
|
||||
// available" nudge.
|
||||
|
||||
async function enterLobby(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
test('the update-required notice covers the app and offers Update or Play offline', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||
|
||||
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
|
||||
const overlay = page.getByRole('alertdialog');
|
||||
await expect(overlay).toBeVisible();
|
||||
// One action button (EN "Update" / RU "Обновить" depending on locale).
|
||||
await expect(overlay.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
|
||||
const notice = page.getByRole('alertdialog');
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
|
||||
await expect(notice.getByRole('button', { name: /Play offline|Играть офлайн/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('the update action reloads the web client', async ({ page }) => {
|
||||
test('the Update action reloads the web client', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
|
||||
const overlay = page.getByRole('alertdialog');
|
||||
await expect(overlay).toBeVisible();
|
||||
const notice = page.getByRole('alertdialog');
|
||||
await expect(notice).toBeVisible();
|
||||
|
||||
// On the web the action reloads the page to fetch the current client (on a native build it opens
|
||||
// the store instead). The reload resets the terminal store, so the app re-bootstraps: the overlay
|
||||
// is gone and the login is back.
|
||||
// On the web the action reloads the page to fetch the current client (a native build opens the store
|
||||
// instead). The reload re-bootstraps to INITIAL online, so the notice is gone and the login is back.
|
||||
await Promise.all([
|
||||
page.waitForEvent('load'),
|
||||
overlay.getByRole('button', { name: /Update|Обновить/i }).click(),
|
||||
notice.getByRole('button', { name: /Update|Обновить/i }).click(),
|
||||
]);
|
||||
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Play offline dismisses the notice into the offline lobby', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await enterLobby(page);
|
||||
// Online first: the seeded online game (vs Ann) is listed.
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
|
||||
// A too-old foreground call locks the version: the notice covers the (now offline) app.
|
||||
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
|
||||
const notice = page.getByRole('alertdialog');
|
||||
await expect(notice).toBeVisible();
|
||||
|
||||
// "Play offline" dismisses the notice and leaves the app in the offline lobby (the version lock is an
|
||||
// offline state): the header is blue and the online game is gone.
|
||||
await notice.getByRole('button', { name: /Play offline|Играть офлайн/i }).click();
|
||||
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||
await expect(page.locator('header.nav.offline')).toBeVisible();
|
||||
// The server game (vs Ann) rides along greyed from the cache (offline, un-openable).
|
||||
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('the soft update-available nudge shows in the lobby and dismisses', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await enterLobby(page);
|
||||
await expect(page.locator('.nudge')).toHaveCount(0);
|
||||
|
||||
// A served response below the recommended version carries X-Update-Recommended: the non-blocking
|
||||
// nudge appears in the lobby (above the tab bar); play is unaffected — no overlay.
|
||||
await page.evaluate(() => (window as unknown as { __update: { recommend(): void } }).__update.recommend());
|
||||
const nudge = page.locator('.nudge');
|
||||
await expect(nudge).toBeVisible();
|
||||
await expect(nudge).toContainText(/Update available|Доступно обновление/i);
|
||||
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||
|
||||
// Dismissing it (the ×) hides it for the session.
|
||||
await nudge.getByRole('button', { name: /Close|Закрыть/i }).click();
|
||||
await expect(page.locator('.nudge')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@capacitor/android": "^8.4.1",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/core": "^8.4.1",
|
||||
"@capacitor/network": "8.0.1",
|
||||
"@connectrpc/connect": "^2.1.0",
|
||||
"@connectrpc/connect-web": "^2.1.0",
|
||||
"@vkontakte/vk-bridge": "^3.0.2",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+12
@@ -20,6 +20,9 @@ importers:
|
||||
'@capacitor/core':
|
||||
specifier: ^8.4.1
|
||||
version: 8.4.1
|
||||
'@capacitor/network':
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1(@capacitor/core@8.4.1)
|
||||
'@connectrpc/connect':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.1(@bufbuild/protobuf@2.12.0)
|
||||
@@ -638,6 +641,11 @@ packages:
|
||||
'@capacitor/core@8.4.1':
|
||||
resolution: {integrity: sha512-xqhOGLbTAYeOWK+IDUNSjQJAPapQjRHrIcgk9PYp52or9zFTaaMko31uNi16N6W+CRJ8VrRram6fOYILkBG2Hg==}
|
||||
|
||||
'@capacitor/network@8.0.1':
|
||||
resolution: {integrity: sha512-9xK/FHFmzKGanB6BdoSZOzXk8vF0OFVQSQ4PAsIrzAzLuXHryO317qy8dcHVpgxYeuZq2noI0My9z1DvVDi/9w==}
|
||||
peerDependencies:
|
||||
'@capacitor/core': '>=8.0.0'
|
||||
|
||||
'@connectrpc/connect-web@2.1.1':
|
||||
resolution: {integrity: sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==}
|
||||
peerDependencies:
|
||||
@@ -4333,6 +4341,10 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@capacitor/network@8.0.1(@capacitor/core@8.4.1)':
|
||||
dependencies:
|
||||
'@capacitor/core': 8.4.1
|
||||
|
||||
'@connectrpc/connect-web@2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))':
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 2.12.0
|
||||
|
||||
@@ -36,9 +36,13 @@ const DIST = 'dist';
|
||||
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
|
||||
// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront
|
||||
// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same
|
||||
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
|
||||
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 127, shared: 31, landing: 5 };
|
||||
// always-loaded Wallet screen — then to 130 for the offline-model redesign: the net-state machine
|
||||
// (`netstate`), the two-tier version gate (the transport interceptor + the Update/Play-offline notice
|
||||
// + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded
|
||||
// transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The
|
||||
// heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy
|
||||
// chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 130, shared: 31, landing: 5 };
|
||||
|
||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||
|
||||
+1
-59
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
|
||||
import { app, bootstrap } from './lib/app.svelte';
|
||||
import { router, type RouteName } from './lib/router.svelte';
|
||||
import { t } from './lib/i18n/index.svelte';
|
||||
import { initNativeShell } from './lib/native';
|
||||
@@ -145,21 +145,6 @@
|
||||
<MaintenanceOverlay />
|
||||
<UpdateOverlay />
|
||||
|
||||
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
|
||||
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
|
||||
keep trying to connect. bootstrap awaits this choice (app.offlinePrompt). -->
|
||||
{#if app.offlinePrompt}
|
||||
<div class="offprompt" role="dialog" aria-modal="true">
|
||||
<div class="card">
|
||||
<p class="msg">{t('offline.promptTitle')}</p>
|
||||
<div class="acts">
|
||||
<button class="opt primary" onclick={() => resolveOfflinePrompt(true)}>{t('offline.promptYes')}</button>
|
||||
<button class="opt" onclick={() => resolveOfflinePrompt(false)}>{t('offline.promptNo')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||
<Splash />
|
||||
{/if}
|
||||
@@ -169,49 +154,6 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* The cold-start "no connection" dialog — a centred card over a scrim, above the splash. */
|
||||
.offprompt {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: var(--pad);
|
||||
}
|
||||
.offprompt .card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.offprompt .msg {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.offprompt .acts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.offprompt .opt {
|
||||
padding: 11px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.offprompt .opt.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.splash {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
|
||||
+13
-9
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
// The soft "update available" nudge (the soft version tier). A non-blocking banner: play continues.
|
||||
// Shown only while online — the hard "update required" notice is an offline state, so it naturally
|
||||
// supersedes this — and only until the user dismisses it for the session. Rendered at the bottom of
|
||||
// the lobby (above the tab bar), so it never covers a game in progress.
|
||||
import { netState } from '../lib/netstate.svelte';
|
||||
import { updateRecommended, dismissUpdateNudge, openUpdate } from '../lib/update.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
|
||||
const show = $derived(netState.online && updateRecommended.active && !updateRecommended.dismissed);
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="nudge" role="status">
|
||||
<span class="msg">{t('update.available')}</span>
|
||||
<button class="upd" type="button" onclick={openUpdate}>{t('update.action')}</button>
|
||||
<button class="x" type="button" onclick={dismissUpdateNudge} aria-label={t('common.close')}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.nudge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.msg {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.upd {
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
padding: 0.3rem 0.8rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.x {
|
||||
flex: 0 0 auto;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +1,29 @@
|
||||
<script lang="ts">
|
||||
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call
|
||||
// as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is
|
||||
// terminal: an installed build cannot become compatible without an actual update, so its one
|
||||
// action takes the user to the fix — the store listing on a native build (VITE_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
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,80 +1,49 @@
|
||||
// Global connectivity signal. `online` is false while the app is actively failing to
|
||||
// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live
|
||||
// stream dropped. The transport and the live-stream owner report transitions; the UI reads
|
||||
// `connection.online` to show the "Connecting…" indicator and to softly disable proactive
|
||||
// actions. In mock mode nothing ever reports trouble, so it simply stays online.
|
||||
//
|
||||
// Recovery is guaranteed by a reachability watcher: while offline it periodically fires a
|
||||
// registered probe (a lightweight read) until one succeeds, so the indicator clears even when no
|
||||
// other traffic is in flight.
|
||||
// connection.svelte.ts is now a thin shim over the net-state store (netstate.svelte.ts, O2). It keeps
|
||||
// the historical surface — `connection.online` plus the report/probe helpers the transport and the live
|
||||
// stream call — so those consumers are unchanged while the single net-state machine drives everything
|
||||
// underneath. `online` is true only in the fully-online machine state; a failing call or dropped stream
|
||||
// reports via reportOffline, which enters the connecting hysteresis buffer once, and the store's probe
|
||||
// then decides whether it becomes offline — so a transient blip never flips the chrome.
|
||||
|
||||
import { backoffMs } from './retry';
|
||||
import { offlineMode } from './offline.svelte';
|
||||
|
||||
let online = $state(true);
|
||||
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let probe: (() => Promise<void>) | null = null;
|
||||
import {
|
||||
netState,
|
||||
emit,
|
||||
registerProbe as registerNetProbe,
|
||||
checkReachable as checkNetReachable,
|
||||
resetNetState,
|
||||
} from './netstate.svelte';
|
||||
|
||||
export const connection = {
|
||||
/** online is true when the app believes it can reach the gateway. */
|
||||
/** online is true when the app can reach the gateway (the fully-online machine state). */
|
||||
get online(): boolean {
|
||||
return online;
|
||||
return netState.online;
|
||||
},
|
||||
};
|
||||
|
||||
/** registerProbe installs the reachability probe the watcher fires while offline. The transport
|
||||
* wires a cheap authenticated read; it should reject when there is no session. */
|
||||
/** registerProbe installs the reachability probe the store's watcher fires while connecting/offline.
|
||||
* The transport wires a cheap authenticated read; it should reject when there is no session. */
|
||||
export function registerProbe(fn: () => Promise<void>): void {
|
||||
probe = fn;
|
||||
registerNetProbe(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
|
||||
* gateway answered — a single attempt (no retry loop), for the cold-start network decision — while
|
||||
* updating the online signal. It reports false when no probe is registered or the timeout wins.
|
||||
*/
|
||||
export async function checkReachable(timeoutMs: number): Promise<boolean> {
|
||||
if (!probe) return false;
|
||||
try {
|
||||
await Promise.race([
|
||||
probe(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
/** checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
|
||||
* gateway answered — a single attempt for the cold-start / recovery decision. */
|
||||
export function checkReachable(timeoutMs: number): Promise<boolean> {
|
||||
return checkNetReachable(timeoutMs);
|
||||
}
|
||||
|
||||
/** reportOnline marks the gateway reachable and stops the watcher. */
|
||||
/** reportOnline marks the gateway reachable — a successful round-trip heals the machine to online. */
|
||||
export function reportOnline(): void {
|
||||
online = true;
|
||||
if (watchTimer) {
|
||||
clearTimeout(watchTimer);
|
||||
watchTimer = null;
|
||||
}
|
||||
emit('callOk');
|
||||
}
|
||||
|
||||
/** reportOffline marks the gateway unreachable and starts the reachability watcher (once). */
|
||||
/** reportOffline reports a failing call or dropped stream. It opens the connecting hysteresis buffer
|
||||
* once (only from online); the store's probe then decides whether it becomes offline. */
|
||||
export function reportOffline(): void {
|
||||
online = false;
|
||||
if (!watchTimer && probe) scheduleProbe(1);
|
||||
if (netState.online) emit('callFailed');
|
||||
}
|
||||
|
||||
/** resetConnection restores the online state and stops the watcher (e.g. on logout). */
|
||||
export function resetConnection(): void {
|
||||
reportOnline();
|
||||
}
|
||||
|
||||
function scheduleProbe(attempt: number): void {
|
||||
watchTimer = setTimeout(
|
||||
() => {
|
||||
watchTimer = null;
|
||||
// Never probe the network in offline mode (the kill switch); the online/offline events drive
|
||||
// recovery there instead.
|
||||
if (online || !probe || offlineMode.active) return;
|
||||
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
|
||||
},
|
||||
backoffMs(attempt),
|
||||
);
|
||||
resetNetState();
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
// Browser orchestration for the offline-toggle readiness wait. Lazily imported by
|
||||
// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main
|
||||
// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by
|
||||
// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the
|
||||
// fetch running on a timeout so a later flip is instant.
|
||||
|
||||
import type { Profile } from '../model';
|
||||
import { preloadDicts } from './preload';
|
||||
import { getDawg, dictLoadingDisabled } from '../dict';
|
||||
import { raceOfflineReady } from '../offline';
|
||||
|
||||
/**
|
||||
* ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when
|
||||
* already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is
|
||||
* available — the offline toggle's readiness gate. With no enabled variant there is nothing to play
|
||||
* offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the
|
||||
* on-device cache in the background so a later flip to offline succeeds immediately.
|
||||
*/
|
||||
export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise<boolean> {
|
||||
if (prof.variantPreferences.length === 0) return false;
|
||||
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
|
||||
getDawg,
|
||||
disabled: dictLoadingDisabled,
|
||||
retries: 1,
|
||||
});
|
||||
return raceOfflineReady(run, budgetMs);
|
||||
}
|
||||
+21
-4
@@ -7,8 +7,9 @@ import { MockGateway } from './mock/client';
|
||||
import { createTransport } from './transport';
|
||||
import { setForcedSeed } from './localgame/id';
|
||||
import { reportOffline, reportOnline } from './connection.svelte';
|
||||
import { emit } from './netstate.svelte';
|
||||
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
||||
import { reportUpdateRequired } from './update.svelte';
|
||||
import { reportUpdateRecommended, reportUpdateRequired } from './update.svelte';
|
||||
|
||||
const isMock = import.meta.env.MODE === 'mock';
|
||||
|
||||
@@ -32,9 +33,25 @@ if (isMock && typeof window !== 'undefined') {
|
||||
off: clearMaintenance,
|
||||
recover: maintenanceRecovered,
|
||||
};
|
||||
// The mock never receives a real update_required from the edge, so the e2e drives the terminal
|
||||
// "update required" overlay directly (it is terminal — there is no clear hook).
|
||||
(window as unknown as { __update?: { on(): void } }).__update = { on: reportUpdateRequired };
|
||||
// The mock never receives a real version signal from the edge, so the e2e drives them directly:
|
||||
// __update.on() raises the hard-tier notice (netState.versionLocked), __update.recommend() raises the
|
||||
// soft-tier nudge (versionRecommended → the setNudge latch, from online).
|
||||
(window as unknown as { __update?: { on(): void; recommend(): void } }).__update = {
|
||||
on: reportUpdateRequired,
|
||||
recommend: reportUpdateRecommended,
|
||||
};
|
||||
// The mock has no real transport, so the net-state store's probe watcher is inert; the e2e drives the
|
||||
// machine directly. __net.offline() rides past the connecting hysteresis (k failures) into the offline
|
||||
// lobby; __net.online() heals back to online (the "back online" toast fires from the offline state).
|
||||
(window as unknown as { __net?: { offline(): void; online(): void } }).__net = {
|
||||
offline: () => {
|
||||
emit('callFailed');
|
||||
emit('probeFailed');
|
||||
emit('probeFailed');
|
||||
emit('probeFailed');
|
||||
},
|
||||
online: () => emit('callOk'),
|
||||
};
|
||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||
// attaches a robot on a timer).
|
||||
(
|
||||
|
||||
@@ -7,12 +7,16 @@ export const en = {
|
||||
'app.brand': 'Erudit',
|
||||
'dict.loading': 'Loading…',
|
||||
'connection.connecting': 'Connecting…',
|
||||
'net.offline': 'You are offline',
|
||||
'net.online': 'Back online',
|
||||
'maintenance.title': 'Under maintenance',
|
||||
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
|
||||
'maintenance.retry': 'Try again',
|
||||
'update.title': 'Update required',
|
||||
'update.body': 'This app is out of date and can no longer run. Download the update to keep playing — and winning.',
|
||||
'update.action': 'Update',
|
||||
'update.playOffline': 'Play offline',
|
||||
'update.available': 'Update available',
|
||||
|
||||
'blocked.title': 'Account blocked',
|
||||
'blocked.permanent': 'Your account is blocked.',
|
||||
@@ -219,15 +223,8 @@ export const en = {
|
||||
'settings.labelsNone': 'None',
|
||||
'settings.reduceMotion': 'Reduce motion',
|
||||
'settings.zoomBoard': 'Zoom the board',
|
||||
'settings.offlineMode': 'Play mode',
|
||||
'settings.online': 'Online',
|
||||
'settings.offline': 'Offline',
|
||||
'settings.offlineChecking': 'Loading dictionaries…',
|
||||
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
|
||||
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
|
||||
'offline.promptTitle': 'No connection. Enable offline mode?',
|
||||
'offline.promptYes': 'Enable',
|
||||
'offline.promptNo': 'Keep trying',
|
||||
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Wallet',
|
||||
@@ -378,6 +375,10 @@ export const en = {
|
||||
|
||||
'new.auto': 'Quick match',
|
||||
'new.withFriends': 'Play with friends',
|
||||
'new.playRemote': 'Invite a friend',
|
||||
'new.playLocal': 'Pass and play',
|
||||
'new.needsNetwork': 'Online play needs a connection.',
|
||||
'new.dictUnavailable': "This dictionary isn't available offline yet.",
|
||||
'new.pickFriends': 'Choose who to invite',
|
||||
'new.searchFriends': 'Search friends',
|
||||
'new.gameType': 'Variant',
|
||||
|
||||
@@ -8,12 +8,16 @@ export const ru: Record<MessageKey, string> = {
|
||||
'app.brand': 'Эрудит',
|
||||
'dict.loading': 'Загрузка…',
|
||||
'connection.connecting': 'Подключение…',
|
||||
'net.offline': 'Нет соединения',
|
||||
'net.online': 'Соединение восстановлено',
|
||||
'maintenance.title': 'Технические работы',
|
||||
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
|
||||
'maintenance.retry': 'Повторить',
|
||||
'update.title': 'Требуется обновление',
|
||||
'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.',
|
||||
'update.action': 'Обновить',
|
||||
'update.playOffline': 'Играть офлайн',
|
||||
'update.available': 'Доступно обновление',
|
||||
|
||||
'blocked.title': 'Учётная запись заблокирована',
|
||||
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||
@@ -219,15 +223,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'settings.labelsNone': 'Без текста',
|
||||
'settings.reduceMotion': 'Меньше анимаций',
|
||||
'settings.zoomBoard': 'Приближать доску',
|
||||
'settings.offlineMode': 'Режим игры',
|
||||
'settings.online': 'Онлайн',
|
||||
'settings.offline': 'Оффлайн',
|
||||
'settings.offlineChecking': 'Загрузка словарей…',
|
||||
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
|
||||
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
|
||||
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
|
||||
'offline.promptYes': 'Включить',
|
||||
'offline.promptNo': 'Ждать сеть',
|
||||
|
||||
// --- wallet ---
|
||||
'wallet.title': 'Кошелёк',
|
||||
@@ -378,6 +375,10 @@ export const ru: Record<MessageKey, string> = {
|
||||
|
||||
'new.auto': 'Быстрая игра',
|
||||
'new.withFriends': 'Игра с друзьями',
|
||||
'new.playRemote': 'Пригласить друга',
|
||||
'new.playLocal': 'На одном устройстве',
|
||||
'new.needsNetwork': 'Для игры онлайн нужна сеть.',
|
||||
'new.dictUnavailable': 'Этот словарь пока недоступен офлайн.',
|
||||
'new.pickFriends': 'Кого пригласить',
|
||||
'new.searchFriends': 'Поиск друзей',
|
||||
'new.gameType': 'Вариант',
|
||||
|
||||
@@ -44,10 +44,10 @@ beforeEach(() => clearLobby());
|
||||
|
||||
describe('patchLobbyGame', () => {
|
||||
it('replaces the matching game by id and leaves the others untouched', () => {
|
||||
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games: [gameView('a', 'active', 0), gameView('b', 'active', 0)], invitations: [], incoming: [] });
|
||||
// The player's own move flipped game "a" to the opponent's turn.
|
||||
patchLobbyGame(gameView('a', 'active', 1));
|
||||
const snap = getLobby(false);
|
||||
const snap = getLobby();
|
||||
expect(snap?.games.map((g) => g.id)).toEqual(['a', 'b']);
|
||||
expect(snap?.games.find((g) => g.id === 'a')?.toMove).toBe(1);
|
||||
expect(snap?.games.find((g) => g.id === 'b')?.toMove).toBe(0);
|
||||
@@ -55,27 +55,27 @@ describe('patchLobbyGame', () => {
|
||||
|
||||
it('preserves invitations and incoming when patching a game', () => {
|
||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming, offline: false });
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming });
|
||||
patchLobbyGame(gameView('a', 'finished'));
|
||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||
expect(getLobby(false)?.games[0].status).toBe('finished');
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
expect(getLobby()?.games[0].status).toBe('finished');
|
||||
});
|
||||
|
||||
it('adds the game when it is not yet in the cached lobby (a game started elsewhere)', () => {
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games: [gameView('a')], invitations: [], incoming: [] });
|
||||
patchLobbyGame(gameView('z', 'active'));
|
||||
// Order is irrelevant — the lobby re-groups and re-sorts on render — so assert membership.
|
||||
expect(getLobby(false)?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
||||
expect(getLobby()?.games.map((g) => g.id).sort()).toEqual(['a', 'z']);
|
||||
});
|
||||
|
||||
it('is a no-op when there is no cached lobby yet', () => {
|
||||
patchLobbyGame(gameView('a'));
|
||||
expect(getLobby(false)).toBeNull();
|
||||
expect(getLobby()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mutate the previous snapshot array', () => {
|
||||
const games = [gameView('a', 'active', 0)];
|
||||
setLobby({ games, invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games, invitations: [], incoming: [] });
|
||||
patchLobbyGame(gameView('a', 'active', 1));
|
||||
expect(games[0].toMove).toBe(0); // the original array/object is left intact
|
||||
});
|
||||
@@ -83,59 +83,52 @@ describe('patchLobbyGame', () => {
|
||||
|
||||
describe('patchLobbyInvitation', () => {
|
||||
it('adds a new pending invitation to the cached lobby', () => {
|
||||
setLobby({ games: [], invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [], incoming: [] });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1']);
|
||||
});
|
||||
|
||||
it('replaces a pending invitation already in the list (e.g. an updated response)', () => {
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
||||
const updated = { ...invitation('i1', 'pending'), turnTimeoutSecs: 999 };
|
||||
patchLobbyInvitation(updated);
|
||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||
expect(getLobby(false)?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i1', 'i2']);
|
||||
expect(getLobby()?.invitations.find((x) => x.id === 'i1')?.turnTimeoutSecs).toBe(999);
|
||||
});
|
||||
|
||||
it('removes an invitation that reached a terminal status', () => {
|
||||
for (const terminal of ['declined', 'cancelled', 'started', 'expired']) {
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [invitation('i1', 'pending'), invitation('i2', 'pending')], incoming: [] });
|
||||
patchLobbyInvitation(invitation('i1', terminal));
|
||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
}
|
||||
});
|
||||
|
||||
it('is a no-op for a terminal invitation that is not in the list', () => {
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [], offline: false });
|
||||
setLobby({ games: [], invitations: [invitation('i2', 'pending')], incoming: [] });
|
||||
patchLobbyInvitation(invitation('i1', 'declined'));
|
||||
expect(getLobby(false)?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
expect(getLobby()?.invitations.map((x) => x.id)).toEqual(['i2']);
|
||||
});
|
||||
|
||||
it('is a no-op when there is no cached lobby yet', () => {
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby(false)).toBeNull();
|
||||
expect(getLobby()).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves games and incoming when patching an invitation', () => {
|
||||
const incoming: AccountRef[] = [{ accountId: 'u9', displayName: 'Nine' }];
|
||||
setLobby({ games: [gameView('g1')], invitations: [], incoming, offline: false });
|
||||
setLobby({ games: [gameView('g1')], invitations: [], incoming });
|
||||
patchLobbyInvitation(invitation('i1', 'pending'));
|
||||
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['g1']);
|
||||
expect(getLobby(false)?.incoming).toEqual(incoming);
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['g1']);
|
||||
expect(getLobby()?.incoming).toEqual(incoming);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLobby is mode-aware (a mode flip must not render the other mode’s games)', () => {
|
||||
it('returns the snapshot only for the mode it was stored under', () => {
|
||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
|
||||
expect(getLobby(false)?.games.map((g) => g.id)).toEqual(['online1']);
|
||||
// Reading it as the OTHER (offline) mode must not surface the online snapshot.
|
||||
expect(getLobby(true)).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces the snapshot when the mode changes, so the stale mode is dropped', () => {
|
||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [], offline: false });
|
||||
setLobby({ games: [gameView('local1')], invitations: [], incoming: [], offline: true });
|
||||
expect(getLobby(false)).toBeNull();
|
||||
expect(getLobby(true)?.games.map((g) => g.id)).toEqual(['local1']);
|
||||
describe('the unified snapshot (one lobby cache — offline reuses its server games, greyed)', () => {
|
||||
it('replaces the whole snapshot on each store, so the latest merged lists win', () => {
|
||||
setLobby({ games: [gameView('online1')], invitations: [], incoming: [] });
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['online1']);
|
||||
setLobby({ games: [gameView('local1'), gameView('online2')], invitations: [], incoming: [] });
|
||||
expect(getLobby()?.games.map((g) => g.id)).toEqual(['local1', 'online2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,18 +10,15 @@ interface LobbySnapshot {
|
||||
games: GameView[];
|
||||
invitations: Invitation[];
|
||||
incoming: AccountRef[];
|
||||
// Which mode the snapshot belongs to (offline = device-local games, online = server games). The
|
||||
// lobby renders it instantly only when it matches the current mode, so a mode flip never flashes —
|
||||
// or lets the player open — the other mode's games before the background refresh replaces them.
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
let snapshot: LobbySnapshot | null = null;
|
||||
|
||||
/** getLobby returns the last lobby lists for the given mode, or null before the first load or when
|
||||
* the cached snapshot belongs to the other mode (so the lobby cold-renders after a mode flip). */
|
||||
export function getLobby(offline: boolean): LobbySnapshot | null {
|
||||
return snapshot && snapshot.offline === offline ? snapshot : null;
|
||||
/** getLobby returns the last lobby lists, or null before the first load. The unified lobby caches one
|
||||
* snapshot (device-local + server games merged); offline reuses its server games, greyed, and drops
|
||||
* the local ones for a fresh device read. */
|
||||
export function getLobby(): LobbySnapshot | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** setLobby stores the latest lobby lists. */
|
||||
|
||||
@@ -3,6 +3,9 @@ import { gamePhase, groupGames, isMyTurn, orderedSeats, scoreStanding, shouldBli
|
||||
import type { GameView, Seat } from './model';
|
||||
|
||||
const ME = 'me';
|
||||
// The self-id set the lobby passes (server user id + device-local guest id); ME is used as a bare seat
|
||||
// id where a single seat is built, MINE where a function takes the whole "you" set.
|
||||
const MINE = [ME];
|
||||
const seat = (s: number, accountId: string): Seat => ({
|
||||
seat: s,
|
||||
accountId,
|
||||
@@ -41,7 +44,7 @@ describe('groupGames', () => {
|
||||
game('b', 'active', 1, 100), // their turn
|
||||
game('c', 'finished', 0, 100),
|
||||
],
|
||||
ME,
|
||||
MINE,
|
||||
{},
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['a']);
|
||||
@@ -59,7 +62,7 @@ describe('groupGames', () => {
|
||||
game('f_new', 'finished', 0, 200),
|
||||
game('f_old', 'finished', 0, 100),
|
||||
],
|
||||
ME,
|
||||
MINE,
|
||||
{},
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']);
|
||||
@@ -77,7 +80,7 @@ describe('groupGames', () => {
|
||||
game('f_unread', 'finished', 0, 100),
|
||||
game('f_new', 'finished', 0, 300), // finished ignores unread, stays newest-first
|
||||
],
|
||||
ME,
|
||||
MINE,
|
||||
{ y_unread: true, t_unread: true, f_unread: true },
|
||||
);
|
||||
// Unread is the primary key, so it overrides the within-section activity order.
|
||||
@@ -88,9 +91,9 @@ describe('groupGames', () => {
|
||||
});
|
||||
|
||||
it('isMyTurn is false for a finished game even at my seat', () => {
|
||||
expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false);
|
||||
expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true);
|
||||
expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false);
|
||||
expect(isMyTurn(game('x', 'finished', 0, 0), MINE)).toBe(false);
|
||||
expect(isMyTurn(game('x', 'active', 0, 0), MINE)).toBe(true);
|
||||
expect(isMyTurn(game('x', 'active', 1, 0), MINE)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an open game (awaiting an opponent) as in progress, not finished', () => {
|
||||
@@ -99,23 +102,36 @@ describe('groupGames', () => {
|
||||
game('open_mine', 'open', 0, 100), // my turn while waiting
|
||||
game('open_wait', 'open', 1, 100), // the empty seat's turn
|
||||
],
|
||||
ME,
|
||||
MINE,
|
||||
{},
|
||||
);
|
||||
expect(g.yourTurn.map((x) => x.id)).toEqual(['open_mine']);
|
||||
expect(g.theirTurn.map((x) => x.id)).toEqual(['open_wait']);
|
||||
expect(g.finished).toEqual([]);
|
||||
expect(isMyTurn(game('x', 'open', 0, 0), ME)).toBe(true);
|
||||
expect(isMyTurn(game('x', 'open', 0, 0), MINE)).toBe(true);
|
||||
});
|
||||
|
||||
it('recognises "you" under any of the self ids (local game under localGuestId, server under userId)', () => {
|
||||
// After reconciliation a device-local game seats you under the local guest id and a server game
|
||||
// under the user id; the two differ (ids never collide) and both must count as your turn.
|
||||
const localGame: GameView = { ...game('local', 'active', 0, 100), seats: [seat(0, 'guest'), seat(1, 'robot')] };
|
||||
const serverGame: GameView = { ...game('server', 'active', 0, 100), seats: [seat(0, ME), seat(1, 'opp')] };
|
||||
const both = ['guest', ME];
|
||||
expect(isMyTurn(localGame, both)).toBe(true);
|
||||
expect(isMyTurn(serverGame, both)).toBe(true);
|
||||
expect(isMyTurn(localGame, [ME])).toBe(false); // a single id misses the other source's game
|
||||
const g = groupGames([localGame, serverGame], both, {});
|
||||
expect(g.yourTurn.map((x) => x.id).sort()).toEqual(['local', 'server']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gamePhase', () => {
|
||||
it('maps each game to its lobby bucket (open folds into mine/theirs like active)', () => {
|
||||
expect(gamePhase(game('x', 'active', 0, 0), ME)).toBe('mine');
|
||||
expect(gamePhase(game('x', 'active', 1, 0), ME)).toBe('theirs');
|
||||
expect(gamePhase(game('x', 'open', 0, 0), ME)).toBe('mine');
|
||||
expect(gamePhase(game('x', 'open', 1, 0), ME)).toBe('theirs');
|
||||
expect(gamePhase(game('x', 'finished', 0, 0), ME)).toBe('finished');
|
||||
expect(gamePhase(game('x', 'active', 0, 0), MINE)).toBe('mine');
|
||||
expect(gamePhase(game('x', 'active', 1, 0), MINE)).toBe('theirs');
|
||||
expect(gamePhase(game('x', 'open', 0, 0), MINE)).toBe('mine');
|
||||
expect(gamePhase(game('x', 'open', 1, 0), MINE)).toBe('theirs');
|
||||
expect(gamePhase(game('x', 'finished', 0, 0), MINE)).toBe('finished');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,38 +147,38 @@ describe('scoreStanding', () => {
|
||||
|
||||
it('greens the leading viewer and reds a trailing one; the opponent stays muted', () => {
|
||||
const lead = withScores('active', 30, 10);
|
||||
expect(scoreStanding(lead, ME, at(lead, ME))).toBe('win');
|
||||
expect(scoreStanding(lead, ME, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured
|
||||
expect(scoreStanding(lead, MINE, at(lead, ME))).toBe('win');
|
||||
expect(scoreStanding(lead, MINE, at(lead, 'opp'))).toBeNull(); // a trailing opponent is not coloured
|
||||
|
||||
const behind = withScores('active', 5, 10);
|
||||
expect(scoreStanding(behind, ME, at(behind, ME))).toBe('lose');
|
||||
expect(scoreStanding(behind, ME, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured
|
||||
expect(scoreStanding(behind, MINE, at(behind, ME))).toBe('lose');
|
||||
expect(scoreStanding(behind, MINE, at(behind, 'opp'))).toBeNull(); // a leading opponent is not coloured
|
||||
});
|
||||
|
||||
it('greens both seats on an equal non-zero score', () => {
|
||||
const tie = withScores('active', 10, 10);
|
||||
expect(scoreStanding(tie, ME, at(tie, ME))).toBe('win');
|
||||
expect(scoreStanding(tie, ME, at(tie, 'opp'))).toBe('win');
|
||||
expect(scoreStanding(tie, MINE, at(tie, ME))).toBe('win');
|
||||
expect(scoreStanding(tie, MINE, at(tie, 'opp'))).toBe('win');
|
||||
});
|
||||
|
||||
it('leaves a fresh 0:0 board muted (nobody has scored yet)', () => {
|
||||
const fresh = withScores('active', 0, 0);
|
||||
expect(scoreStanding(fresh, ME, at(fresh, ME))).toBeNull();
|
||||
expect(scoreStanding(fresh, ME, at(fresh, 'opp'))).toBeNull();
|
||||
expect(scoreStanding(fresh, MINE, at(fresh, ME))).toBeNull();
|
||||
expect(scoreStanding(fresh, MINE, at(fresh, 'opp'))).toBeNull();
|
||||
});
|
||||
|
||||
it('is null for any non-active game (open or finished)', () => {
|
||||
const fin = withScores('finished', 30, 10);
|
||||
expect(scoreStanding(fin, ME, at(fin, ME))).toBeNull();
|
||||
expect(scoreStanding(fin, MINE, at(fin, ME))).toBeNull();
|
||||
const op = withScores('open', 0, 0);
|
||||
expect(scoreStanding(op, ME, at(op, ME))).toBeNull();
|
||||
expect(scoreStanding(op, MINE, at(op, ME))).toBeNull();
|
||||
});
|
||||
|
||||
it('is null when the viewer is not seated or has no opponent', () => {
|
||||
const g = withScores('active', 30, 10);
|
||||
expect(scoreStanding(g, 'stranger', at(g, ME))).toBeNull();
|
||||
expect(scoreStanding(g, ['stranger'], at(g, ME))).toBeNull();
|
||||
const solo: GameView = { ...game('g', 'active', 0, 0), seats: [{ ...seat(0, ME), score: 9 }] };
|
||||
expect(scoreStanding(solo, ME, solo.seats[0])).toBeNull();
|
||||
expect(scoreStanding(solo, MINE, solo.seats[0])).toBeNull();
|
||||
});
|
||||
|
||||
it('compares against the strongest opponent in a multiplayer game', () => {
|
||||
@@ -175,8 +191,8 @@ describe('scoreStanding', () => {
|
||||
{ ...seat(2, 'b'), score: 50 }, // b is ahead -> the viewer is losing
|
||||
],
|
||||
};
|
||||
expect(scoreStanding(g3, ME, at(g3, ME))).toBe('lose');
|
||||
expect(scoreStanding(g3, ME, at(g3, 'b'))).toBeNull(); // a leading opponent is not coloured
|
||||
expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('lose');
|
||||
expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull(); // a leading opponent is not coloured
|
||||
});
|
||||
|
||||
it('greens every seat tied with the viewer for the lead in a multiplayer game', () => {
|
||||
@@ -189,9 +205,9 @@ describe('scoreStanding', () => {
|
||||
{ ...seat(2, 'b'), score: 20 }, // trailing -> muted
|
||||
],
|
||||
};
|
||||
expect(scoreStanding(g3, ME, at(g3, ME))).toBe('win');
|
||||
expect(scoreStanding(g3, ME, at(g3, 'a'))).toBe('win');
|
||||
expect(scoreStanding(g3, ME, at(g3, 'b'))).toBeNull();
|
||||
expect(scoreStanding(g3, MINE, at(g3, ME))).toBe('win');
|
||||
expect(scoreStanding(g3, MINE, at(g3, 'a'))).toBe('win');
|
||||
expect(scoreStanding(g3, MINE, at(g3, 'b'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-12
@@ -5,9 +5,12 @@
|
||||
|
||||
import type { GameView, Seat } from './model';
|
||||
|
||||
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */
|
||||
export function isMyTurn(game: GameView, myId: string): boolean {
|
||||
const me = game.seats.find((s) => s.accountId === myId);
|
||||
/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. myIds carries every id
|
||||
* that is "you" — the server user id and the device-local guest id — since after reconciliation a
|
||||
* device-local game seats you under localGuestId while a server game seats you under the user id (the
|
||||
* ids never collide). */
|
||||
export function isMyTurn(game: GameView, myIds: readonly string[]): boolean {
|
||||
const me = game.seats.find((s) => myIds.includes(s.accountId));
|
||||
// 'open' (an auto-match game still awaiting an opponent) is in progress like 'active'.
|
||||
return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat;
|
||||
}
|
||||
@@ -21,16 +24,16 @@ export function isMyTurn(game: GameView, myId: string): boolean {
|
||||
* the result emoji instead), for a fresh 0:0 board where nobody has scored yet, and when myId is
|
||||
* not seated against an opponent.
|
||||
*/
|
||||
export function scoreStanding(game: GameView, myId: string, seat: Seat): 'win' | 'lose' | null {
|
||||
export function scoreStanding(game: GameView, myIds: readonly string[], seat: Seat): 'win' | 'lose' | null {
|
||||
if (game.status !== 'active') return null;
|
||||
const me = game.seats.find((s) => s.accountId === myId);
|
||||
const me = game.seats.find((s) => myIds.includes(s.accountId));
|
||||
if (!me) return null;
|
||||
const others = game.seats.filter((s) => s.accountId && s.accountId !== myId).map((s) => s.score);
|
||||
const others = game.seats.filter((s) => s.accountId && !myIds.includes(s.accountId)).map((s) => s.score);
|
||||
if (!others.length) return null;
|
||||
const top = Math.max(me.score, ...others);
|
||||
if (top === 0) return null; // nobody has scored yet: leave the 0:0 board muted, like a finished game
|
||||
const meLeads = me.score === top;
|
||||
if (seat.accountId === myId) return meLeads ? 'win' : 'lose';
|
||||
if (myIds.includes(seat.accountId)) return meLeads ? 'win' : 'lose';
|
||||
// Another seat greens only when it ties the leading viewer at the top — the equal-score case.
|
||||
return meLeads && seat.score === top ? 'win' : null;
|
||||
}
|
||||
@@ -47,13 +50,13 @@ export function orderedSeats(game: GameView): GameView['seats'] {
|
||||
export type LobbyPhase = 'mine' | 'theirs' | 'finished';
|
||||
|
||||
/**
|
||||
* gamePhase reports a game's lobby bucket for myId: 'finished' for any non-active/open status,
|
||||
* gamePhase reports a game's lobby bucket for myIds: 'finished' for any non-active/open status,
|
||||
* else 'mine' when it is the caller's turn, else 'theirs'. It mirrors groupGames' partition and
|
||||
* drives the per-card blink, which fires when a card transitions into 'mine' or 'finished'.
|
||||
*/
|
||||
export function gamePhase(game: GameView, myId: string): LobbyPhase {
|
||||
export function gamePhase(game: GameView, myIds: readonly string[]): LobbyPhase {
|
||||
if (game.status !== 'active' && game.status !== 'open') return 'finished';
|
||||
return isMyTurn(game, myId) ? 'mine' : 'theirs';
|
||||
return isMyTurn(game, myIds) ? 'mine' : 'theirs';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +84,7 @@ export interface LobbyGroups {
|
||||
*/
|
||||
export function groupGames(
|
||||
games: GameView[],
|
||||
myId: string,
|
||||
myIds: readonly string[],
|
||||
unread: Record<string, boolean>,
|
||||
): LobbyGroups {
|
||||
const yourTurn: GameView[] = [];
|
||||
@@ -89,7 +92,7 @@ export function groupGames(
|
||||
const finished: GameView[] = [];
|
||||
for (const g of games) {
|
||||
if (g.status !== 'active' && g.status !== 'open') finished.push(g);
|
||||
else if (isMyTurn(g, myId)) yourTurn.push(g);
|
||||
else if (isMyTurn(g, myIds)) yourTurn.push(g);
|
||||
else theirTurn.push(g);
|
||||
}
|
||||
// Unread is the primary key in the active sections (rank 1 before rank 0), last activity the tie-break.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// The reactive net-state store (O2): the single source of truth for the detected
|
||||
// connectivity/version state, running the pure reducer (netstate.ts, O1) over a `$state` snapshot and
|
||||
// owning the side-effects the reducer asks for — the probe/recovery watcher, the OS-signal listeners
|
||||
// and the offline/online toast. connection.svelte.ts and offline.svelte.ts are now thin derived shims
|
||||
// over this module, so their ~40 consumers keep reading `connection.online` / `offlineMode.active`
|
||||
// unchanged while a single machine drives them underneath.
|
||||
//
|
||||
// The store is a leaf module: it never imports app.svelte.ts. The pieces that need app context — the
|
||||
// smart recovery (reconcile-or-reachability) and the toast text (i18n) — are injected via
|
||||
// register*() hooks the bootstrap wires, which also breaks what would otherwise be an import cycle.
|
||||
|
||||
import { INITIAL, reduce, type Effect, type NetConfig, type NetEvent, type NetSnapshot } from './netstate';
|
||||
import { backoffMs } from './retry';
|
||||
|
||||
const isMock = import.meta.env.MODE === 'mock';
|
||||
|
||||
// Production hysteresis tuning (owner-confirmed). k = 3 consecutive probe failures ⇒ offline (a single
|
||||
// blip is one failure, so it rides out in `connecting`); debounceMs is the wall-clock backstop for a
|
||||
// slow, spaced-out failure sequence. Both sit on top of the transport's own retry/backoff.
|
||||
const cfg: NetConfig = { k: 3, debounceMs: 15000 };
|
||||
// While offline, re-check reachability on this cadence (mirrors the old app.svelte.ts recovery poll).
|
||||
const OFFLINE_POLL_MS = 4000;
|
||||
|
||||
let snap = $state<NetSnapshot>({ ...INITIAL });
|
||||
|
||||
/** netState exposes the reactive detected state; read the getters in markup / `$derived`. */
|
||||
export const netState = {
|
||||
/** state is the raw machine state. */
|
||||
get state() {
|
||||
return snap.state;
|
||||
},
|
||||
/** online is true only when the gateway is reachable and the version accepted (full features). */
|
||||
get online() {
|
||||
return snap.state === 'online';
|
||||
},
|
||||
/** connecting is true while a call/probe is failing but the anti-flap window has not elapsed. */
|
||||
get connecting() {
|
||||
return snap.state === 'connecting';
|
||||
},
|
||||
/** offline is true in either offline state (the kill switch / blue chrome / local-only lobby). */
|
||||
get offline() {
|
||||
return snap.state === 'offlineNoNetwork' || snap.state === 'offlineVersionLocked';
|
||||
},
|
||||
/** versionLocked is true when the client is below the hard minimum version (the Update notice). */
|
||||
get versionLocked() {
|
||||
return snap.state === 'offlineVersionLocked';
|
||||
},
|
||||
};
|
||||
|
||||
// Injected hooks (dependency inversion — see the module comment).
|
||||
let probeFn: (() => Promise<void>) | null = null;
|
||||
let recoverFn: (() => Promise<void>) | null = null;
|
||||
let toastFn: ((toast: 'offline' | 'online') => void) | null = null;
|
||||
let nudgeFn: (() => void) | null = null;
|
||||
|
||||
/** registerProbe installs the low-level reachability probe (a cheap authenticated read); the transport
|
||||
* wires it, and checkReachable / the smart recovery use it. It should reject when there is no session. */
|
||||
export function registerProbe(fn: () => Promise<void>): void {
|
||||
probeFn = fn;
|
||||
}
|
||||
|
||||
/** registerRecovery installs the smart recovery the watcher runs while connecting/offline: reconcile a
|
||||
* server guest when there is no session (native), otherwise a reachability check. It resolves when the
|
||||
* gateway is reachable and rejects to stay offline. The bootstrap wires it. */
|
||||
export function registerRecovery(fn: () => Promise<void>): void {
|
||||
recoverFn = fn;
|
||||
}
|
||||
|
||||
/** registerNetToast installs the offline/online toast renderer (i18n text lives in app scope). */
|
||||
export function registerNetToast(fn: (toast: 'offline' | 'online') => void): void {
|
||||
toastFn = fn;
|
||||
}
|
||||
|
||||
/** registerNetNudge installs the soft "update available" latch the setNudge effect fires. It runs only
|
||||
* when the machine accepts a versionRecommended from online (O1), so the nudge never shows offline. */
|
||||
export function registerNetNudge(fn: () => void): void {
|
||||
nudgeFn = fn;
|
||||
}
|
||||
|
||||
// The probe/recovery watcher: one self-rescheduling timer whose cadence follows the current state —
|
||||
// exponential backoff while `connecting`, a fixed poll while `offlineNoNetwork`, and idle otherwise. It
|
||||
// is inert under the mock (no real transport); the e2e drives transitions through the window hooks.
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let attempt = 0;
|
||||
let probing = false;
|
||||
|
||||
function stopTimer(): void {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function arm(delayMs: number): void {
|
||||
if (isMock) return;
|
||||
stopTimer();
|
||||
timer = setTimeout(runProbe, delayMs);
|
||||
}
|
||||
|
||||
async function runProbe(): Promise<void> {
|
||||
timer = null;
|
||||
const st = snap.state;
|
||||
// Nothing to check when online, and the version lock only exits on an app update (a fresh boot).
|
||||
if (st === 'online' || st === 'offlineVersionLocked') return;
|
||||
if (probing || !recoverFn) return;
|
||||
probing = true;
|
||||
try {
|
||||
await recoverFn();
|
||||
emit('probeOk');
|
||||
} catch {
|
||||
emit('probeFailed');
|
||||
// Reschedule by the (possibly just-changed) state: keep backing off while connecting, poll while
|
||||
// offline; stop once online / version-locked (the guard at the top of the next run).
|
||||
if (snap.state === 'connecting') arm(backoffMs(Math.min(++attempt, 6)));
|
||||
else if (snap.state === 'offlineNoNetwork') arm(OFFLINE_POLL_MS);
|
||||
} finally {
|
||||
probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* checkReachable runs the registered reachability probe once, bounded by timeoutMs, and reports whether
|
||||
* the gateway answered — a single attempt for the cold-start / recovery decision. It reports false when
|
||||
* no probe is registered or the timeout wins.
|
||||
*/
|
||||
export async function checkReachable(timeoutMs: number): Promise<boolean> {
|
||||
if (!probeFn) return false;
|
||||
try {
|
||||
await Promise.race([
|
||||
probeFn(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** emit applies one event to the machine (stamping it with the current time — the reducer chose
|
||||
* time-on-the-event) and runs the resulting effects. It is the single entry point every signal source
|
||||
* funnels through. */
|
||||
export function emit(type: NetEvent): void {
|
||||
const { next, effects } = reduce(snap, { type, at: Date.now() }, cfg);
|
||||
snap = next;
|
||||
for (const effect of effects) applyEffect(effect);
|
||||
}
|
||||
|
||||
function applyEffect(effect: Effect): void {
|
||||
switch (effect.kind) {
|
||||
case 'toast':
|
||||
toastFn?.(effect.toast);
|
||||
break;
|
||||
case 'startProbe':
|
||||
// Kick the watcher from the top of the backoff (entering connecting, or an osOnline hint).
|
||||
attempt = 0;
|
||||
arm(backoffMs(1));
|
||||
break;
|
||||
case 'showVersionNotice':
|
||||
// The Update/Play-offline notice (UpdateOverlay.svelte) reads netState.versionLocked directly, so
|
||||
// this effect needs no side-effect here — the state transition is the signal.
|
||||
break;
|
||||
case 'setNudge':
|
||||
// Latch the soft "update available" nudge (update.svelte.ts, wired via registerNetNudge). Fires
|
||||
// only from online (O1), so the nudge is never raised while offline / version-locked.
|
||||
nudgeFn?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* bootOffline drives the machine straight into offlineNoNetwork for a known-offline launch — a native
|
||||
* device-local guest with no server session, or a web cached-session launch the reachability check
|
||||
* could not confirm — and arms the recovery poll. It bypasses the connecting hysteresis on purpose:
|
||||
* boot is not a mid-session flap, so there is nothing to ride out. Pass kickNow to start the recovery
|
||||
* poll immediately (the native reconcile) rather than after the first interval.
|
||||
*/
|
||||
export function bootOffline(kickNow = false): void {
|
||||
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
|
||||
arm(kickNow ? 0 : OFFLINE_POLL_MS);
|
||||
}
|
||||
|
||||
/** initNetSignals wires the web connectivity hints (navigator online/offline) to the machine. The
|
||||
* native @capacitor/network hint is wired separately in native.ts. A no-op under SSR and the mock (the
|
||||
* e2e drives connectivity through the window hooks). */
|
||||
export function initNetSignals(): void {
|
||||
if (typeof window === 'undefined' || isMock) return;
|
||||
window.addEventListener('offline', () => emit('osOffline'));
|
||||
window.addEventListener('online', () => emit('osOnline'));
|
||||
}
|
||||
|
||||
/** resetNetState returns the machine to online and stops the watcher (e.g. on logout). */
|
||||
export function resetNetState(): void {
|
||||
stopTimer();
|
||||
attempt = 0;
|
||||
snap = { state: 'online', fails: 0, connectingSince: 0 };
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
reduce,
|
||||
INITIAL,
|
||||
type NetConfig,
|
||||
type NetEvent,
|
||||
type NetInput,
|
||||
type NetSnapshot,
|
||||
} from './netstate';
|
||||
|
||||
// A deterministic config for the assertions: k = 3 (a single blip rides out; three consecutive
|
||||
// failures trip offline) and a 1 s debounce window (the wall-clock anti-flap branch).
|
||||
const cfg: NetConfig = { k: 3, debounceMs: 1000 };
|
||||
|
||||
// Event and snapshot builders keep the cases terse; `at` defaults to 0 where the debounce branch is
|
||||
// irrelevant.
|
||||
const e = (type: NetEvent, at = 0): NetInput => ({ type, at });
|
||||
const online: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
|
||||
const offline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
|
||||
const locked: NetSnapshot = { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 };
|
||||
const connecting = (over: Partial<NetSnapshot> = {}): NetSnapshot => ({
|
||||
state: 'connecting',
|
||||
fails: 0,
|
||||
connectingSince: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
// fold applies a sequence of inputs, threading the snapshot and collecting every emitted effect —
|
||||
// the shape most edge cases assert against.
|
||||
function fold(start: NetSnapshot, inputs: NetInput[], config: NetConfig) {
|
||||
let snap = start;
|
||||
const effects = [];
|
||||
for (const ev of inputs) {
|
||||
const r = reduce(snap, ev, config);
|
||||
snap = r.next;
|
||||
effects.push(...r.effects);
|
||||
}
|
||||
return { final: snap, effects };
|
||||
}
|
||||
|
||||
describe('reduce — transition table', () => {
|
||||
it('online + callFailed → connecting (starts a probe, counts the failure)', () => {
|
||||
const r = reduce(online, e('callFailed', 100), cfg);
|
||||
expect(r.next).toEqual({ state: 'connecting', fails: 1, connectingSince: 100 });
|
||||
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
|
||||
});
|
||||
|
||||
it('online + osOffline → connecting (starts a probe, no failure counted — a hint)', () => {
|
||||
const r = reduce(online, e('osOffline', 100), cfg);
|
||||
expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
|
||||
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
|
||||
});
|
||||
|
||||
it('connecting + probeOk → online (silent; a blip must not thrash the chrome)', () => {
|
||||
const r = reduce(connecting({ fails: 2, connectingSince: 100 }), e('probeOk', 200), cfg);
|
||||
expect(r.next).toEqual(online);
|
||||
expect(r.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it('connecting + callOk → online (silent)', () => {
|
||||
const r = reduce(connecting({ fails: 1, connectingSince: 100 }), e('callOk', 200), cfg);
|
||||
expect(r.next).toEqual(online);
|
||||
expect(r.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it('connecting + a failure reaching K → offlineNoNetwork (toast "offline")', () => {
|
||||
const r = reduce(connecting({ fails: cfg.k - 1, connectingSince: 0 }), e('probeFailed', 10), cfg);
|
||||
expect(r.next).toEqual({ state: 'offlineNoNetwork', fails: 0, connectingSince: 0 });
|
||||
expect(r.effects).toEqual([{ kind: 'toast', toast: 'offline' }]);
|
||||
});
|
||||
|
||||
it('offlineNoNetwork + osOnline → stays offline, triggers a probe (a hint never flips by itself)', () => {
|
||||
const r = reduce(offline, e('osOnline', 100), cfg);
|
||||
expect(r.next).toEqual(offline);
|
||||
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
|
||||
});
|
||||
|
||||
it('offlineNoNetwork + probeOk → online (toast "back online")', () => {
|
||||
const r = reduce(offline, e('probeOk', 100), cfg);
|
||||
expect(r.next).toEqual(online);
|
||||
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
|
||||
});
|
||||
|
||||
it('offlineNoNetwork + callOk → online (toast "back online")', () => {
|
||||
const r = reduce(offline, e('callOk', 100), cfg);
|
||||
expect(r.next).toEqual(online);
|
||||
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
|
||||
});
|
||||
|
||||
it('any state + versionRejected → offlineVersionLocked (shows the notice once)', () => {
|
||||
for (const start of [online, connecting({ fails: 1, connectingSince: 5 }), offline]) {
|
||||
const r = reduce(start, e('versionRejected', 100), cfg);
|
||||
expect(r.next).toEqual({ state: 'offlineVersionLocked', fails: 0, connectingSince: 0 });
|
||||
expect(r.effects).toEqual([{ kind: 'showVersionNotice' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('offlineVersionLocked + versionRejected (probe still rejected) → stays locked, no repeat notice', () => {
|
||||
const r = reduce(locked, e('versionRejected', 100), cfg);
|
||||
expect(r.next).toEqual(locked);
|
||||
expect(r.effects).toEqual([]);
|
||||
});
|
||||
|
||||
it('online + versionRecommended → online + a nudge', () => {
|
||||
const r = reduce(online, e('versionRecommended', 100), cfg);
|
||||
expect(r.next).toEqual(online);
|
||||
expect(r.effects).toEqual([{ kind: 'setNudge' }]);
|
||||
});
|
||||
|
||||
it('boot → connecting + startProbe from any state (resets the sticky lock)', () => {
|
||||
for (const start of [online, offline, locked, connecting({ fails: 2, connectingSince: 5 })]) {
|
||||
const r = reduce(start, e('boot', 100), cfg);
|
||||
expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
|
||||
expect(r.effects).toEqual([{ kind: 'startProbe' }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce — hysteresis (blip vs sustained)', () => {
|
||||
it('a single fail then a success rides out inside connecting (no offline toast)', () => {
|
||||
const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 200)], cfg);
|
||||
expect(final).toEqual(online);
|
||||
expect(effects).toEqual([{ kind: 'startProbe' }]);
|
||||
});
|
||||
|
||||
it('K consecutive failures trip offlineNoNetwork', () => {
|
||||
const { final, effects } = fold(online, [e('callFailed', 0), e('probeFailed', 10), e('probeFailed', 20)], cfg);
|
||||
expect(final.state).toBe('offlineNoNetwork');
|
||||
expect(effects).toContainEqual({ kind: 'toast', toast: 'offline' });
|
||||
});
|
||||
|
||||
it('the debounce window trips offlineNoNetwork even below K', () => {
|
||||
const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs + 1)], cfg);
|
||||
expect(final.state).toBe('offlineNoNetwork');
|
||||
});
|
||||
|
||||
it('a failure inside the debounce window and below K stays connecting', () => {
|
||||
const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs - 1)], cfg);
|
||||
expect(final.state).toBe('connecting');
|
||||
expect(final.fails).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce — version gate (two tiers)', () => {
|
||||
it('versionRecommended sets the nudge only from online', () => {
|
||||
expect(reduce(online, e('versionRecommended', 0), cfg).effects).toEqual([{ kind: 'setNudge' }]);
|
||||
for (const start of [connecting({ connectingSince: 0 }), offline, locked]) {
|
||||
expect(reduce(start, e('versionRecommended', 0), cfg).effects).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('versionRejected from any state locks (the hard tier), superseding a pending soft nudge', () => {
|
||||
const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
|
||||
expect(final.state).toBe('offlineVersionLocked');
|
||||
expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce — edge cases (#1–#12)', () => {
|
||||
it('#1 rapid flap (fail→ok inside the window) never leaves connecting for offline', () => {
|
||||
const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 50)], cfg);
|
||||
expect(final).toEqual(online);
|
||||
expect(effects).not.toContainEqual({ kind: 'toast', toast: 'offline' });
|
||||
});
|
||||
|
||||
it('#2 cold boot, no network → offlineNoNetwork', () => {
|
||||
const { final } = fold(
|
||||
INITIAL,
|
||||
[e('boot', 0), e('osOffline', 1), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
|
||||
cfg,
|
||||
);
|
||||
expect(final.state).toBe('offlineNoNetwork');
|
||||
});
|
||||
|
||||
it('#3 cold boot, gateway up but version < min → offlineVersionLocked + notice', () => {
|
||||
const { final, effects } = fold(INITIAL, [e('boot', 0), e('versionRejected', 20)], cfg);
|
||||
expect(final.state).toBe('offlineVersionLocked');
|
||||
expect(effects).toContainEqual({ kind: 'showVersionNotice' });
|
||||
});
|
||||
|
||||
it('#4 cold boot, session-less guest: the reconcile IS the probe — ok→online, fail→offline', () => {
|
||||
expect(fold(INITIAL, [e('boot', 0), e('callOk', 20)], cfg).final).toEqual(online);
|
||||
const failed = fold(
|
||||
INITIAL,
|
||||
[e('boot', 0), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
|
||||
cfg,
|
||||
);
|
||||
expect(failed.final.state).toBe('offlineNoNetwork');
|
||||
});
|
||||
|
||||
it('#5 mid-session min-version bump → next call versionRejected → locked mid-play', () => {
|
||||
const r = reduce(online, e('versionRejected', 100), cfg);
|
||||
expect(r.next.state).toBe('offlineVersionLocked');
|
||||
expect(r.effects).toContainEqual({ kind: 'showVersionNotice' });
|
||||
});
|
||||
|
||||
it('#6 captive portal: osOnline fires but the gateway is down → probe fails → stays offline', () => {
|
||||
const { final, effects } = fold(offline, [e('osOnline', 0), e('probeFailed', 10)], cfg);
|
||||
expect(final.state).toBe('offlineNoNetwork');
|
||||
expect(effects).toEqual([{ kind: 'startProbe' }]);
|
||||
});
|
||||
|
||||
it('#7 recovery race: probeOk then a queued callOk → one idempotent transition to online', () => {
|
||||
const { final, effects } = fold(offline, [e('probeOk', 0), e('callOk', 1)], cfg);
|
||||
expect(final).toEqual(online);
|
||||
expect(effects.filter((x) => x.kind === 'toast')).toEqual([{ kind: 'toast', toast: 'online' }]);
|
||||
});
|
||||
|
||||
it('#8 soft nudge then a hard reject → escalates to locked (notice after the nudge)', () => {
|
||||
const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
|
||||
expect(final.state).toBe('offlineVersionLocked');
|
||||
expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
|
||||
});
|
||||
|
||||
it('#9 offlineVersionLocked is sticky — no connectivity event exits it; only boot does', () => {
|
||||
const stuck: NetEvent[] = ['callOk', 'probeOk', 'osOnline', 'osOffline', 'callFailed', 'probeFailed', 'versionRecommended'];
|
||||
for (const ev of stuck) {
|
||||
expect(reduce(locked, e(ev, 100), cfg).next).toEqual(locked);
|
||||
}
|
||||
expect(reduce(locked, e('boot', 100), cfg).next.state).toBe('connecting');
|
||||
});
|
||||
|
||||
it('#10 offline → back online transitions cleanly (local-game persistence is O5, not the machine)', () => {
|
||||
expect(fold(offline, [e('probeOk', 0)], cfg).final).toEqual(online);
|
||||
});
|
||||
|
||||
it('#11 nothing forces the machine offline without a real failure/version signal (a stale pref cannot stick)', () => {
|
||||
expect(reduce(INITIAL, e('boot', 0), cfg).next.state).toBe('connecting');
|
||||
const { final } = fold(
|
||||
INITIAL,
|
||||
[e('boot', 0), e('callOk', 10), e('osOnline', 20), e('versionRecommended', 30)],
|
||||
cfg,
|
||||
);
|
||||
expect(final.state).toBe('online');
|
||||
});
|
||||
|
||||
it('#12 TG/VK inertness: with only success/hint events the machine never leaves online', () => {
|
||||
const { final, effects } = fold(online, [e('callOk', 0), e('probeOk', 10), e('osOnline', 20), e('callOk', 30)], cfg);
|
||||
expect(final).toEqual(online);
|
||||
expect(effects).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce — purity', () => {
|
||||
it('does not mutate the previous snapshot', () => {
|
||||
const prev = Object.freeze<NetSnapshot>({ state: 'connecting', fails: 1, connectingSince: 5 });
|
||||
expect(() => reduce(prev, e('probeFailed', 10), cfg)).not.toThrow();
|
||||
expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
// The pure net-state machine (O1 of the offline-model redesign). A single reducer replaces the old
|
||||
// two-layer connection.svelte.ts / offline.svelte.ts split: connectivity and the client-version gate
|
||||
// collapse into one detected `state`, with an explicit hysteresis buffer so a brief network blip does
|
||||
// not thrash the chrome. It is a pure function of (previous snapshot, event, config) — no timers, no
|
||||
// storage, no reactivity — so every transition and the whole hysteresis are unit-tested in the node
|
||||
// env and the machine is reusable verbatim by a future iOS shell. The reactive store, the probe timer
|
||||
// and the event wiring live in O2 (netstate.svelte.ts); the effects returned here tell that store what
|
||||
// to do (raise a toast, kick a probe, show the version notice, set the soft nudge).
|
||||
|
||||
/**
|
||||
* NetState is the single detected connectivity/version state.
|
||||
* - `online` — the gateway is reachable and the client version is accepted; full features. It may
|
||||
* additionally carry an orthogonal dismissible "update available" nudge (the soft tier) without
|
||||
* leaving this state.
|
||||
* - `connecting` — a call or probe is currently failing but the anti-flap window has not elapsed;
|
||||
* the chrome stays online ("Connecting…"). Transient — never a kill switch.
|
||||
* - `offlineNoNetwork` — sustained unreachable (hysteresis exceeded): offline mode (blue chrome,
|
||||
* local-only active lobby, transport kill switch). Self-heals when a probe or call gets through.
|
||||
* - `offlineVersionLocked` — the gateway is reachable but the version is below the hard minimum:
|
||||
* offline mode plus the "Update / Play offline" notice. Sticky — it exits only via an app update
|
||||
* (a fresh version on the next boot).
|
||||
*/
|
||||
export type NetState = 'online' | 'connecting' | 'offlineNoNetwork' | 'offlineVersionLocked';
|
||||
|
||||
/**
|
||||
* NetEvent is every signal that can drive the machine.
|
||||
* - `boot` — a fresh app start (the only exit from the sticky version lock).
|
||||
* - `callOk` / `callFailed` — a foreground call succeeded / failed at the transport.
|
||||
* - `probeOk` / `probeFailed` — the reachability probe succeeded / failed.
|
||||
* - `osOnline` / `osOffline` — an OS connectivity hint (navigator / @capacitor/network); a hint only
|
||||
* triggers a probe, it never decides the state by itself.
|
||||
* - `versionRejected` — the hard gate refused the client as too old (update_required).
|
||||
* - `versionRecommended` — the soft gate signalled an available (non-blocking) update.
|
||||
*/
|
||||
export type NetEvent =
|
||||
| 'boot'
|
||||
| 'callOk'
|
||||
| 'callFailed'
|
||||
| 'probeOk'
|
||||
| 'probeFailed'
|
||||
| 'osOnline'
|
||||
| 'osOffline'
|
||||
| 'versionRejected'
|
||||
| 'versionRecommended';
|
||||
|
||||
/** NetConfig tunes the anti-flap hysteresis. */
|
||||
export interface NetConfig {
|
||||
/**
|
||||
* k is the consecutive-failure threshold that trips `connecting` → `offlineNoNetwork`. It must be
|
||||
* at least 2 so a single blip (one failure then a success) rides out inside `connecting`.
|
||||
*/
|
||||
k: number;
|
||||
/**
|
||||
* debounceMs is the wall-clock anti-flap window (the design's `OFFLINE_DEBOUNCE_MS`): a failure
|
||||
* whose timestamp is at least this far past the moment `connecting` was entered also trips
|
||||
* `offlineNoNetwork`, even below k. This bounds how long a slow, spaced-out failure sequence can
|
||||
* hold the chrome in "Connecting…".
|
||||
*/
|
||||
debounceMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* NetSnapshot is the machine's full memory. Beyond the visible `state` it carries the hysteresis
|
||||
* bookkeeping (`fails`, `connectingSince`) so the reducer stays pure while still deciding both the
|
||||
* count-based and the time-based anti-flap branches; both are zero outside `connecting`.
|
||||
*/
|
||||
export interface NetSnapshot {
|
||||
/** state is the current detected state. */
|
||||
state: NetState;
|
||||
/** fails counts consecutive failure signals accrued while `connecting` (0 in every other state). */
|
||||
fails: number;
|
||||
/**
|
||||
* connectingSince is the timestamp `connecting` was entered; the debounce window is measured from
|
||||
* it (0 while not `connecting`).
|
||||
*/
|
||||
connectingSince: number;
|
||||
}
|
||||
|
||||
/** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch
|
||||
* reads `at`, and callers stamp `Date.now()`. */
|
||||
export interface NetInput {
|
||||
/** type is the event. */
|
||||
type: NetEvent;
|
||||
/** at is the event's timestamp in milliseconds. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effect is a side-effect the reducer asks the O2 store to perform. It is data, not an action — the
|
||||
* reducer never runs it.
|
||||
* - `toast` — show the offline / back-online toast.
|
||||
* - `startProbe` — kick the reachability probe (entering `connecting`, or on an `osOnline` hint).
|
||||
* - `showVersionNotice` — raise the "Update / Play offline" notice (entering the version lock).
|
||||
* - `setNudge` — latch the dismissible "update available" soft nudge.
|
||||
*/
|
||||
export type Effect =
|
||||
| { kind: 'toast'; toast: 'offline' | 'online' }
|
||||
| { kind: 'startProbe' }
|
||||
| { kind: 'showVersionNotice' }
|
||||
| { kind: 'setNudge' };
|
||||
|
||||
/** NetResult is a reduction: the next snapshot plus the effects the store should perform. */
|
||||
export interface NetResult {
|
||||
/** next is the snapshot after applying the event. */
|
||||
next: NetSnapshot;
|
||||
/** effects are the side-effects the O2 store should run, in order. */
|
||||
effects: Effect[];
|
||||
}
|
||||
|
||||
/**
|
||||
* INITIAL is the optimistic pre-boot snapshot: `online` until proven otherwise. The O2 store emits a
|
||||
* `boot` event on start, which immediately moves the machine to `connecting` and kicks a probe.
|
||||
*/
|
||||
export const INITIAL: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
|
||||
|
||||
function onlineSnapshot(): NetSnapshot {
|
||||
return { state: 'online', fails: 0, connectingSince: 0 };
|
||||
}
|
||||
|
||||
function enterConnecting(at: number, fails: number): NetResult {
|
||||
return { next: { state: 'connecting', fails, connectingSince: at }, effects: [{ kind: 'startProbe' }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* reduce applies one event to the machine and returns the next snapshot plus the effects the store
|
||||
* should perform. It is a pure function: it never mutates `prev`, reads the clock, or runs an effect.
|
||||
*
|
||||
* `boot` and `versionRejected` are handled first because they fire from any state: `boot` resets the
|
||||
* machine (the sole exit from the sticky version lock), and `versionRejected` (the hard tier) always
|
||||
* supersedes into `offlineVersionLocked`.
|
||||
*/
|
||||
export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResult {
|
||||
// A fresh app start: reachability is unknown, so enter `connecting` and kick a probe; the probe /
|
||||
// version result then resolves online, offlineNoNetwork or offlineVersionLocked. This is the only
|
||||
// transition that leaves the sticky version lock (a possibly-newer build).
|
||||
if (ev.type === 'boot') {
|
||||
return enterConnecting(ev.at, 0);
|
||||
}
|
||||
|
||||
// The hard version gate supersedes everything and is sticky. The notice is raised once, on entry;
|
||||
// a repeated rejection while already locked changes nothing.
|
||||
if (ev.type === 'versionRejected') {
|
||||
const effects: Effect[] = prev.state === 'offlineVersionLocked' ? [] : [{ kind: 'showVersionNotice' }];
|
||||
return { next: { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 }, effects };
|
||||
}
|
||||
|
||||
switch (prev.state) {
|
||||
case 'offlineVersionLocked':
|
||||
// Sticky: no connectivity event exits it. A probe may reach the gateway, but real calls stay
|
||||
// version-gated, so only `boot` / `versionRejected` (both handled above) touch this state.
|
||||
return { next: prev, effects: [] };
|
||||
|
||||
case 'online':
|
||||
switch (ev.type) {
|
||||
case 'callFailed':
|
||||
case 'probeFailed':
|
||||
// A real failure opens the hysteresis window (probe + timer); this first failure counts.
|
||||
return enterConnecting(ev.at, 1);
|
||||
case 'osOffline':
|
||||
// An OS hint only opens the window and a probe — it never decides offline by itself, so it
|
||||
// enters `connecting` with no failure counted.
|
||||
return enterConnecting(ev.at, 0);
|
||||
case 'versionRecommended':
|
||||
// The soft tier nudges only from online; play continues.
|
||||
return { next: prev, effects: [{ kind: 'setNudge' }] };
|
||||
default:
|
||||
// callOk / probeOk / osOnline: already online — idempotent, no effect.
|
||||
return { next: prev, effects: [] };
|
||||
}
|
||||
|
||||
case 'connecting':
|
||||
switch (ev.type) {
|
||||
case 'callOk':
|
||||
case 'probeOk':
|
||||
// Recovery is immediate on the first success — a blip resolves silently (no toast).
|
||||
return { next: onlineSnapshot(), effects: [] };
|
||||
case 'callFailed':
|
||||
case 'probeFailed': {
|
||||
const fails = prev.fails + 1;
|
||||
const sustained = fails >= cfg.k || ev.at - prev.connectingSince >= cfg.debounceMs;
|
||||
if (sustained) {
|
||||
return {
|
||||
next: { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 },
|
||||
effects: [{ kind: 'toast', toast: 'offline' }],
|
||||
};
|
||||
}
|
||||
return { next: { ...prev, fails }, effects: [] };
|
||||
}
|
||||
case 'osOnline':
|
||||
// A positive hint triggers an immediate probe but never flips to online by itself.
|
||||
return { next: prev, effects: [{ kind: 'startProbe' }] };
|
||||
default:
|
||||
// osOffline (already probing) / versionRecommended (no nudge outside online): no-op.
|
||||
return { next: prev, effects: [] };
|
||||
}
|
||||
|
||||
case 'offlineNoNetwork':
|
||||
switch (ev.type) {
|
||||
case 'callOk':
|
||||
case 'probeOk':
|
||||
// The probe (or a reconcile call) won — self-heal to online with a "back online" toast.
|
||||
return { next: onlineSnapshot(), effects: [{ kind: 'toast', toast: 'online' }] };
|
||||
case 'osOnline':
|
||||
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
|
||||
return { next: prev, effects: [{ kind: 'startProbe' }] };
|
||||
default:
|
||||
// callFailed / probeFailed / osOffline (still offline), versionRecommended (no nudge): no-op.
|
||||
return { next: prev, effects: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable: the switch above is exhaustive over NetState. Kept so the function is total.
|
||||
return { next: prev, effects: [] };
|
||||
}
|
||||
@@ -1,68 +1,39 @@
|
||||
// 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);
|
||||
/**
|
||||
* offlineCapable reports whether this channel supports the offline model — implicit offline mode,
|
||||
* device-local games (vs_ai and hotseat) and the transport kill switch. Native builds and the plain web
|
||||
* app do; the Telegram and VK mini-apps are online-only wrappers, so the whole offline model stays inert
|
||||
* there (offlineMode.active is forced false regardless of the detected net state, and the create flow
|
||||
* hides its device-local segment).
|
||||
*/
|
||||
export function offlineCapable(): boolean {
|
||||
return !insideTelegram() && !insideVK();
|
||||
}
|
||||
|
||||
/** 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) on a
|
||||
* channel that supports offline play; it is always false in the online-only Telegram/VK mini-apps, so
|
||||
* every offline-model consumer (chrome, lobby, kill switch, device-local create) stays inert there. */
|
||||
get active(): boolean {
|
||||
return active;
|
||||
},
|
||||
/** auto is true while offline was entered automatically (no network), not by the player. */
|
||||
get auto(): boolean {
|
||||
return auto;
|
||||
return netState.offline && offlineCapable();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 +49,18 @@ export function setDictPreloadWarning(on: boolean): void {
|
||||
preloadWarn = on;
|
||||
}
|
||||
|
||||
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby
|
||||
// mount or a variant-preference change) can start another. Module-scoped so mounts do not stack.
|
||||
// A preload runs at most once at a time; it finishes before a fresh trigger (a repeated lobby mount or
|
||||
// a variant-preference change) can start another. Module-scoped so mounts do not stack.
|
||||
let preloadInFlight = false;
|
||||
|
||||
/**
|
||||
* kickDictPreload starts a background preload of the enabled variants' dictionaries for an
|
||||
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later
|
||||
* switch to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain
|
||||
* browser tab, without a confirmed email, while offline, or when a preload is already running;
|
||||
* getDawg's caching makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a
|
||||
* fetch failure raises the in-lobby notice, and a later successful run clears it. The dict loader
|
||||
* and generator are imported dynamically, so neither is pulled into the main bundle.
|
||||
* offline-capable install (a standalone web PWA with a confirmed email) while online, so a later switch
|
||||
* to offline mode already has the data. It is a no-op in a Telegram/VK mini-app, in a plain browser tab,
|
||||
* without a confirmed email, while offline, or when a preload is already running; getDawg's caching
|
||||
* makes a repeat run cheap. When warnOnFail is set (the first lobby entry), a fetch failure raises the
|
||||
* in-lobby notice, and a later successful run clears it. The dict loader and generator are imported
|
||||
* dynamically, so neither is pulled into the main bundle.
|
||||
*/
|
||||
export function kickDictPreload(prof: Profile | null, warnOnFail = false): void {
|
||||
if (preloadInFlight || !prof) return;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { loadOfflinePref, saveOfflinePref, offlineReady, missingDicts, offlinePreloadEligible, shouldBootOffline, raceOfflineReady } from './offline';
|
||||
import type { Variant } from './model';
|
||||
import { clearOfflinePref, offlinePreloadEligible } from './offline';
|
||||
|
||||
// A minimal in-memory localStorage for the persistence tests (node has none).
|
||||
// A minimal in-memory localStorage for the cleanup test (node has none).
|
||||
beforeEach(() => {
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = {
|
||||
@@ -15,27 +14,7 @@ beforeEach(() => {
|
||||
} as Storage;
|
||||
});
|
||||
|
||||
describe('offline mode helpers', () => {
|
||||
it('persists and reads the device-scoped offline flag', () => {
|
||||
expect(loadOfflinePref()).toBe(false);
|
||||
saveOfflinePref(true);
|
||||
expect(loadOfflinePref()).toBe(true);
|
||||
saveOfflinePref(false);
|
||||
expect(loadOfflinePref()).toBe(false);
|
||||
});
|
||||
|
||||
it('offlineReady requires every enabled variant to have a dictionary', () => {
|
||||
const has = (v: Variant): boolean => v !== 'erudit_ru';
|
||||
expect(offlineReady(['scrabble_en', 'scrabble_ru'], has)).toBe(true);
|
||||
expect(offlineReady(['scrabble_en', 'erudit_ru'], has)).toBe(false);
|
||||
expect(offlineReady([], has)).toBe(false);
|
||||
});
|
||||
|
||||
it('missingDicts lists the enabled variants without a dictionary', () => {
|
||||
const has = (v: Variant): boolean => v === 'scrabble_en';
|
||||
expect(missingDicts(['scrabble_en', 'scrabble_ru', 'erudit_ru'], has)).toEqual(['scrabble_ru', 'erudit_ru']);
|
||||
});
|
||||
|
||||
describe('offline helpers', () => {
|
||||
it('offlinePreloadEligible requires a standalone PWA with email, online, outside mini-apps', () => {
|
||||
const base = { hasEmail: true, standalone: true, inTelegram: false, inVK: false, online: true };
|
||||
expect(offlinePreloadEligible(base)).toBe(true);
|
||||
@@ -46,32 +25,11 @@ describe('offline mode helpers', () => {
|
||||
expect(offlinePreloadEligible({ ...base, online: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('shouldBootOffline requires the offline flag plus a cached session and profile', () => {
|
||||
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: true })).toBe(true);
|
||||
expect(shouldBootOffline({ offlineActive: false, hasSession: true, hasProfile: true })).toBe(false);
|
||||
expect(shouldBootOffline({ offlineActive: true, hasSession: false, hasProfile: true })).toBe(false);
|
||||
expect(shouldBootOffline({ offlineActive: true, hasSession: true, hasProfile: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('raceOfflineReady (toggle-flip readiness wait)', () => {
|
||||
// A sleep that never elapses: the fetch always wins the race, exercising its resolution.
|
||||
const never = (): Promise<void> => new Promise<void>(() => {});
|
||||
// A sleep that elapses immediately: the budget always wins the race, exercising the timeout.
|
||||
const now = (): Promise<void> => Promise.resolve();
|
||||
|
||||
it('is ready when the fetch resolves with nothing failed before the budget', async () => {
|
||||
const run = Promise.resolve({ failed: [] as Variant[] });
|
||||
expect(await raceOfflineReady(run, 5000, never)).toBe(true);
|
||||
});
|
||||
|
||||
it('is not ready when a variant is still missing after the fetch', async () => {
|
||||
const run = Promise.resolve({ failed: ['erudit_ru'] as Variant[] });
|
||||
expect(await raceOfflineReady(run, 5000, never)).toBe(false);
|
||||
});
|
||||
|
||||
it('is not ready when the budget elapses before the fetch resolves', async () => {
|
||||
const run = new Promise<{ failed: Variant[] }>(() => {}); // never resolves
|
||||
expect(await raceOfflineReady(run, 5000, now)).toBe(false);
|
||||
it('clearOfflinePref removes the retired deliberate-offline key (best-effort, idempotent)', () => {
|
||||
localStorage.setItem('scrabble.offlineMode', '1');
|
||||
clearOfflinePref();
|
||||
expect(localStorage.getItem('scrabble.offlineMode')).toBeNull();
|
||||
// Idempotent: a second call on an already-clear key is a no-op, not an error.
|
||||
expect(() => clearOfflinePref()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+17
-68
@@ -1,70 +1,29 @@
|
||||
// Pure helpers for the deliberate offline MODE — its device-scoped persistence and the readiness
|
||||
// decision — kept out of the reactive module (offline.svelte.ts) so they unit-test in the node env.
|
||||
// The deliberate offline mode is distinct from connection.svelte.ts's transient "can we reach the
|
||||
// gateway" signal: it is the player's own sticky choice, and it gates the network, tints the chrome
|
||||
// and shows only local games.
|
||||
|
||||
import type { Variant } from './model';
|
||||
// Pure helpers for offline-mode support, kept out of the reactive module (offline.svelte.ts) so they
|
||||
// unit-test in the node env. Offline became implicit — the net-state machine detects connectivity, and
|
||||
// there is no deliberate toggle — so only the background dict-preload eligibility check and a one-time
|
||||
// cleanup of the retired deliberate-offline preference remain here.
|
||||
|
||||
const STORAGE_KEY = 'scrabble.offlineMode';
|
||||
|
||||
/** loadOfflinePref reads the persisted offline-mode flag (device-scoped); false when unset or when
|
||||
* storage is unavailable, so a device that cannot persist simply starts online. */
|
||||
export function loadOfflinePref(): boolean {
|
||||
/**
|
||||
* clearOfflinePref removes the retired deliberate-offline preference key. The offline model is implicit
|
||||
* now — the app boots online and the machine detects offline — so a persisted flag from a pre-redesign
|
||||
* install is orphaned data that is never read again; boot clears it once (best-effort) so nobody is left
|
||||
* with a dead key.
|
||||
*/
|
||||
export function clearOfflinePref(): void {
|
||||
try {
|
||||
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||
if (typeof localStorage !== 'undefined') localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** saveOfflinePref persists the offline-mode flag (best-effort). */
|
||||
export function saveOfflinePref(on: boolean): void {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
|
||||
} catch {
|
||||
/* best-effort — a failed persist just reverts to online on the next launch */
|
||||
/* best-effort — a storage failure just leaves the orphaned key, which is never read anyway */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* offlineReady reports whether the device can play offline right now: at least one variant is
|
||||
* enabled and every enabled variant's dictionary is already available on the device (hasDict). The
|
||||
* offline toggle uses it to decide whether flipping to offline can succeed immediately.
|
||||
*/
|
||||
export function offlineReady(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): boolean {
|
||||
return enabled.length > 0 && enabled.every((v) => hasDict(v));
|
||||
}
|
||||
|
||||
/** missingDicts lists the enabled variants whose dictionary is not yet available — the ones the
|
||||
* toggle must fetch (or wait on) before offline mode can be entered. */
|
||||
export function missingDicts(enabled: readonly Variant[], hasDict: (v: Variant) => boolean): Variant[] {
|
||||
return enabled.filter((v) => !hasDict(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* raceOfflineReady runs the dictionary fetch `run` against a `budgetMs` wait and reports whether
|
||||
* offline mode can be entered now: ready only when the fetch resolves with nothing still failed
|
||||
* before the budget elapses. On a timeout the caller stops waiting but does NOT abort `run` — it
|
||||
* keeps warming the on-device cache so a later flip to offline is instant. The sleep is injected so
|
||||
* the logic stays pure and unit-tests in the node env.
|
||||
*/
|
||||
export async function raceOfflineReady(
|
||||
run: Promise<{ failed: readonly unknown[] }>,
|
||||
budgetMs: number,
|
||||
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
): Promise<boolean> {
|
||||
const elapsed = sleep(budgetMs).then(() => null);
|
||||
const res = await Promise.race([run, elapsed]);
|
||||
return res !== null && res.failed.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* offlinePreloadEligible reports whether a background dictionary preload should run in this
|
||||
* context: an installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab)
|
||||
* with a confirmed email, currently online. Elsewhere the preload is wasted bandwidth — the context
|
||||
* has no offline mode to prepare for — so kickDictPreload skips it. Mirrors the Settings offline
|
||||
* toggle's eligibility so the two never disagree.
|
||||
* offlinePreloadEligible reports whether a background dictionary preload should run in this context: an
|
||||
* installed standalone web PWA (not a Telegram/VK mini-app, not a plain browser tab) with a confirmed
|
||||
* email, currently online. Elsewhere the preload is wasted bandwidth — the context has no offline play
|
||||
* to prepare for — so kickDictPreload skips it.
|
||||
*/
|
||||
export function offlinePreloadEligible(opts: {
|
||||
hasEmail: boolean;
|
||||
@@ -75,13 +34,3 @@ export function offlinePreloadEligible(opts: {
|
||||
}): boolean {
|
||||
return opts.hasEmail && opts.standalone && !opts.inTelegram && !opts.inVK && opts.online;
|
||||
}
|
||||
|
||||
/**
|
||||
* shouldBootOffline decides whether a cold start skips the network and launches straight into
|
||||
* offline mode: the deliberate offline flag is persisted-on AND a prior online session left a cached
|
||||
* session and profile to start from. Without both (e.g. cleared storage, or a never-online install),
|
||||
* the app must boot online once first — the caller then drops the sticky flag and continues online.
|
||||
*/
|
||||
export function shouldBootOffline(opts: { offlineActive: boolean; hasSession: boolean; hasProfile: boolean }): boolean {
|
||||
return opts.offlineActive && opts.hasSession && opts.hasProfile;
|
||||
}
|
||||
|
||||
+21
-13
@@ -34,28 +34,36 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
|
||||
}
|
||||
|
||||
describe('resultBadge', () => {
|
||||
it('recognises "you" under any of the self ids (a device-local game seats you under the guest id)', () => {
|
||||
// myIds carries both the server user id and the device-local guest id, so the badge resolves the
|
||||
// right seat regardless of which one seated you.
|
||||
const local = game([seat(0, 'guest', 5), seat(1, 'robot', 3)], 'active', 0);
|
||||
expect(resultBadge(local, ['guest', 'me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
|
||||
expect(resultBadge({ ...local, toMove: 1 }, ['guest', 'me']).key).toBe('result.oppMove');
|
||||
});
|
||||
|
||||
it('active: your move vs opponent', () => {
|
||||
const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0);
|
||||
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
|
||||
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
|
||||
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
|
||||
expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
|
||||
});
|
||||
|
||||
it('open (awaiting an opponent) reads as in-progress, not a finished result', () => {
|
||||
const g = game([seat(0, 'me', 0), seat(1, '', 0)], 'open', 0);
|
||||
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
|
||||
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
|
||||
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.yourMove', emoji: '🟢' });
|
||||
expect(resultBadge({ ...g, toMove: 1 }, ['me']).key).toBe('result.oppMove');
|
||||
});
|
||||
|
||||
it('finished two-player: victory / defeat / draw', () => {
|
||||
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({
|
||||
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), ['me'])).toEqual({
|
||||
key: 'result.victory',
|
||||
emoji: '🏆',
|
||||
});
|
||||
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), 'me')).toEqual({
|
||||
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), ['me'])).toEqual({
|
||||
key: 'result.defeat',
|
||||
emoji: '🥈',
|
||||
});
|
||||
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), 'me')).toEqual({
|
||||
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), ['me'])).toEqual({
|
||||
key: 'result.draw',
|
||||
emoji: '🏅',
|
||||
});
|
||||
@@ -64,7 +72,7 @@ describe('resultBadge', () => {
|
||||
it('finished two-player: a 0-0 resignation is a defeat, not a score-tied win', () => {
|
||||
// The opponent won by resignation (isWinner) although neither side scored — the lobby
|
||||
// must read this as a loss, matching the game-detail screen (regression).
|
||||
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), 'me')).toEqual({
|
||||
expect(resultBadge(game([seat(0, 'me', 0), seat(1, 'a', 0, true)]), ['me'])).toEqual({
|
||||
key: 'result.defeat',
|
||||
emoji: '🥈',
|
||||
});
|
||||
@@ -72,21 +80,21 @@ describe('resultBadge', () => {
|
||||
|
||||
it('finished four-player: places by score', () => {
|
||||
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
|
||||
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
|
||||
expect(resultBadge(last, ['me'])).toEqual({ key: 'result.place4', emoji: '🏅' });
|
||||
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
|
||||
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
|
||||
expect(resultBadge(second, ['me'])).toEqual({ key: 'result.place2', emoji: '🥈' });
|
||||
});
|
||||
|
||||
it('tie for the lead (3-4p): a shared victory for the leaders, a placed finish for those below', () => {
|
||||
// The reported case, viewer-side: two seats tie for the lead (-8), one is last (-14) — no single
|
||||
// winner(), so this must NOT read as a full draw for everyone.
|
||||
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.victory', emoji: '🏆' });
|
||||
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), 'me')).toEqual({ key: 'result.place3', emoji: '🥉' });
|
||||
expect(resultBadge(game([seat(0, 'me', -8), seat(1, 'a', -14), seat(2, 'b', -8)]), ['me'])).toEqual({ key: 'result.victory', emoji: '🏆' });
|
||||
expect(resultBadge(game([seat(0, 'x', -8), seat(1, 'me', -14), seat(2, 'b', -8)]), ['me'])).toEqual({ key: 'result.place3', emoji: '🥉' });
|
||||
});
|
||||
|
||||
it('an aborted game reads as a draw for everyone, whatever the scores', () => {
|
||||
const g = { ...game([seat(0, 'me', 50), seat(1, 'a', 30)]), endReason: 'aborted' };
|
||||
expect(resultBadge(g, 'me')).toEqual({ key: 'result.draw', emoji: '🏅' });
|
||||
expect(resultBadge(g, ['me'])).toEqual({ key: 'result.draw', emoji: '🏅' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ function placeBadge(rank: number, players: number): ResultBadge {
|
||||
return { key: 'result.place4', emoji: '🏅' };
|
||||
}
|
||||
|
||||
export function resultBadge(game: GameView, myId: string): ResultBadge {
|
||||
const me = game.seats.find((s) => s.accountId === myId);
|
||||
export function resultBadge(game: GameView, myIds: readonly string[]): ResultBadge {
|
||||
const me = game.seats.find((s) => myIds.includes(s.accountId));
|
||||
|
||||
if (game.status === 'active' || game.status === 'open') {
|
||||
return game.toMove === me?.seat
|
||||
@@ -42,7 +42,7 @@ export function resultBadge(game: GameView, myId: string): ResultBadge {
|
||||
// A winner exists but it is not me — even when scores are level (a win by resignation or timeout
|
||||
// can leave the winner at or below my score). The winner takes rank 1; place me among the remaining
|
||||
// (non-resigned) seats by score, starting at rank 2.
|
||||
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && s.accountId !== myId && s.score > (me?.score ?? 0)).length;
|
||||
const ahead = game.seats.filter((s) => !s.isWinner && !s.resigned && !myIds.includes(s.accountId) && s.score > (me?.score ?? 0)).length;
|
||||
return placeBadge(2 + ahead, game.players);
|
||||
}
|
||||
|
||||
|
||||
+43
-7
@@ -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
@@ -1,26 +1,76 @@
|
||||
// Global "client too old" signal. `active` latches true the first time the gateway answers a
|
||||
// user-initiated online call with the update_required sentinel — the HTTP-header version gate the
|
||||
// edge checks before decoding the payload (docs/ARCHITECTURE.md §2). It is terminal: unlike the
|
||||
// maintenance signal there is no self-clearing poll, because an installed build cannot become
|
||||
// compatible without an actual update. A non-dismissable overlay (UpdateOverlay.svelte) then covers
|
||||
// the app; its one action sends the user to the store (native) or reloads (web). Offline play never
|
||||
// trips it — the network kill switch refuses the call before it leaves the device.
|
||||
// The client-version gate's two client signals (docs/ARCHITECTURE.md §2).
|
||||
//
|
||||
// HARD tier ("client too old"): the gateway refuses a foreground call with the update_required
|
||||
// sentinel (the Execute result_code, the Subscribe FailedPrecondition). The transport reports it here,
|
||||
// which drives the net-state machine into offlineVersionLocked; the notice (UpdateOverlay.svelte) reads
|
||||
// netState.versionLocked and offers "Update" (store / reload) or "Play offline" (dismiss → stay
|
||||
// offline). Offline play never trips it — the kill switch refuses the call before it leaves the device,
|
||||
// and the background guest reconcile swallows it (stays a local guest, no notice).
|
||||
//
|
||||
// SOFT tier ("update available"): a served response carries the X-Update-Recommended header, which the
|
||||
// transport reports here. It drives the machine (versionRecommended), whose setNudge effect — fired
|
||||
// only from online — latches the dismissible nudge (UpdateNudge.svelte). The hard notice supersedes the
|
||||
// nudge naturally: the nudge shows only while online, and version-locked is an offline state.
|
||||
|
||||
import { emit } from './netstate.svelte';
|
||||
import { clientChannel } from './channel';
|
||||
|
||||
/** UPDATE_REQUIRED is the stable sentinel the gateway returns (the Execute result_code, and the
|
||||
* GatewayError code the Subscribe FailedPrecondition maps to) for a client too old to be served. */
|
||||
export const UPDATE_REQUIRED = 'update_required';
|
||||
|
||||
let required = $state(false);
|
||||
/** openUpdate sends the user to the fix, shared by the hard-tier notice and the soft-tier nudge: the
|
||||
* store listing on a native build (VITE_RUSTORE_URL, handed to the OS via '_system'), or a plain
|
||||
* reload on the web (which fetches the current client). */
|
||||
export function openUpdate(): void {
|
||||
const ch = clientChannel();
|
||||
if (ch === 'android' || ch === 'ios') {
|
||||
const url = import.meta.env.VITE_RUSTORE_URL;
|
||||
if (url) window.open(url, '_system');
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
export const updateRequired = {
|
||||
/** active is true once a foreground online call has been refused as too old. Terminal. */
|
||||
/** reportUpdateRequired drives the net-state machine into offlineVersionLocked (the hard tier). The
|
||||
* transport calls it only for a foreground update_required — the background reconcile swallows it and
|
||||
* stays offline. The notice reads netState.versionLocked; there is no separate latch. */
|
||||
export function reportUpdateRequired(): void {
|
||||
emit('versionRejected');
|
||||
}
|
||||
|
||||
// The soft "update available" nudge. `active` latches when the machine accepts a versionRecommended
|
||||
// from online (via the setNudge effect → latchUpdateNudge); `dismissed` hides the banner for the
|
||||
// session once the user closes it. A fresh launch re-evaluates the version, so both reset on reload.
|
||||
let recommended = $state(false);
|
||||
let nudgeDismissed = $state(false);
|
||||
|
||||
export const updateRecommended = {
|
||||
/** active is true once the gateway has signalled an available (non-blocking) update. */
|
||||
get active(): boolean {
|
||||
return required;
|
||||
return recommended;
|
||||
},
|
||||
/** dismissed is true once the user has closed the nudge for this session. */
|
||||
get dismissed(): boolean {
|
||||
return nudgeDismissed;
|
||||
},
|
||||
};
|
||||
|
||||
/** reportUpdateRequired latches the terminal "update required" overlay. The transport calls it when
|
||||
* a foreground call returns the update_required sentinel; idempotent. */
|
||||
export function reportUpdateRequired(): void {
|
||||
required = true;
|
||||
/** latchUpdateNudge raises the soft-nudge latch. It is wired to the machine's setNudge effect
|
||||
* (registerNetNudge), which fires only from online, so the nudge is never raised while offline or
|
||||
* version-locked. */
|
||||
export function latchUpdateNudge(): void {
|
||||
recommended = true;
|
||||
}
|
||||
|
||||
/** dismissUpdateNudge hides the nudge for the session (a fresh launch re-evaluates the version). */
|
||||
export function dismissUpdateNudge(): void {
|
||||
nudgeDismissed = true;
|
||||
}
|
||||
|
||||
/** reportUpdateRecommended signals an available update (the soft tier). The transport calls it when a
|
||||
* served response carries the X-Update-Recommended header; it drives the machine (versionRecommended),
|
||||
* which latches the nudge from online. */
|
||||
export function reportUpdateRecommended(): void {
|
||||
emit('versionRecommended');
|
||||
}
|
||||
|
||||
+59
-27
@@ -3,9 +3,10 @@
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import UpdateNudge from '../components/UpdateNudge.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import PinPad from '../components/PinPad.svelte';
|
||||
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
|
||||
import { app, handleError, refreshFeedbackBadge, seedChatUnread, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
@@ -16,6 +17,9 @@
|
||||
import { preloadGames } from '../lib/preload';
|
||||
import { kickDictPreload, offlineMode } from '../lib/offline.svelte';
|
||||
import { localSource, isLocalGameId } from '../lib/gamesource';
|
||||
import { localGuestId } from '../lib/localguest';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import { insideVK } from '../lib/vk';
|
||||
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
|
||||
import type { AccountRef, GameView, Invitation } from '../lib/model';
|
||||
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
|
||||
@@ -36,27 +40,37 @@
|
||||
let loadSeq = 0;
|
||||
async function load() {
|
||||
const seq = ++loadSeq;
|
||||
// Deliberate offline mode: never touch the network. The lobby shows only the device-local
|
||||
// vs_ai games (reconstructed from the store) plus the New-vs-AI entry; there are no online
|
||||
// games, invitations or incoming requests to fetch.
|
||||
if (offlineMode.active) {
|
||||
const local = await localSource.list();
|
||||
if (seq !== loadSeq) return;
|
||||
games = local;
|
||||
const offline = offlineMode.active;
|
||||
// Device-local games (vs_ai / hotseat) merge into the unified lobby everywhere except the
|
||||
// always-online Telegram/VK mini-apps, which are server-only (the shared-origin local store must
|
||||
// not leak device-local games into a mini-app).
|
||||
const serverOnly = insideTelegram() || insideVK();
|
||||
const local = serverOnly ? [] : await localSource.list();
|
||||
if (seq !== loadSeq) return;
|
||||
|
||||
if (offline) {
|
||||
// Offline: device-local games are active; the last-known server games ride along greyed from the
|
||||
// cache (un-openable). No network — and no invitations/incoming (a server concept, hidden until
|
||||
// back online).
|
||||
const cachedServer = (getLobby()?.games ?? []).filter((g) => !isLocalGameId(g.id));
|
||||
games = [...local, ...cachedServer];
|
||||
invitations = [];
|
||||
incoming = [];
|
||||
app.notifications = 0;
|
||||
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||
setLobby({ games, invitations, incoming });
|
||||
app.lobbyReady = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = await gateway.gamesList();
|
||||
if (seq !== loadSeq) return;
|
||||
games = list.games;
|
||||
// Seed the per-game unread badges from the authoritative list. The live stream only raises
|
||||
const server = list.games;
|
||||
// Seed the per-game unread badges from the authoritative server list. The live stream only raises
|
||||
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
|
||||
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
|
||||
for (const g of server) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
|
||||
// The unified list: device-local games alongside the server ones (so a reconciled guest's local
|
||||
// games stay visible online). Ids never collide.
|
||||
games = [...local, ...server];
|
||||
if (!guest) {
|
||||
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
|
||||
if (seq !== loadSeq) return;
|
||||
@@ -67,12 +81,12 @@
|
||||
app.notifications = incoming.length;
|
||||
void refreshFeedbackBadge();
|
||||
}
|
||||
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
|
||||
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
|
||||
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
|
||||
setLobby({ games, invitations, incoming });
|
||||
// Warm the cache for the ongoing SERVER games so opening one from the lobby is instant (local
|
||||
// games open from the device). The list just loaded, so the connection is up; non-blocking and
|
||||
// cheap (already-cached ones are skipped).
|
||||
if (connection.online) {
|
||||
void preloadGames(games);
|
||||
void preloadGames(server);
|
||||
// Warm the offline dictionaries for an eligible install (PWA + email) so a later switch to
|
||||
// offline mode already has the data; a no-op elsewhere, and cheap once cached. The first
|
||||
// lobby entry surfaces a notice in place of the ad banner if a fetch fails.
|
||||
@@ -90,7 +104,7 @@
|
||||
onMount(() => {
|
||||
// Render instantly from the cached lists (so the screen does not "draw in" during
|
||||
// the back-slide), then refresh in the background.
|
||||
const cached = getLobby(offlineMode.active);
|
||||
const cached = getLobby();
|
||||
if (cached) {
|
||||
games = cached.games;
|
||||
invitations = cached.invitations;
|
||||
@@ -118,8 +132,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
// myId is the server user id, used for the (server-only) invitation "from me" checks. myIds is the
|
||||
// full "you" set — the server user id plus the device-local guest id — used for game seat matching,
|
||||
// since a reconciled guest's device-local games are seated under localGuestId while server games are
|
||||
// seated under the user id (the ids never collide). guestId is read once (it mints on first launch).
|
||||
const guestId = localGuestId();
|
||||
const myId = $derived(app.session?.userId ?? '');
|
||||
const groups = $derived(groupGames(games, myId, app.chatUnread));
|
||||
const myIds = $derived([app.session?.userId, guestId].filter((x): x is string => !!x));
|
||||
// grey marks a server game shown only from the offline cache: dimmed and un-openable until online.
|
||||
const grey = (g: GameView): boolean => offlineMode.active && !isLocalGameId(g.id);
|
||||
const groups = $derived(groupGames(games, myIds, app.chatUnread));
|
||||
|
||||
// Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished"
|
||||
// while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is
|
||||
@@ -152,7 +174,7 @@
|
||||
const seen = new Set<string>();
|
||||
for (const g of games) {
|
||||
seen.add(g.id);
|
||||
const phase = gamePhase(g, myId);
|
||||
const phase = gamePhase(g, myIds);
|
||||
if (shouldBlink(prevPhase.get(g.id), phase)) blink(g.id);
|
||||
prevPhase.set(g.id, phase);
|
||||
}
|
||||
@@ -169,7 +191,7 @@
|
||||
// An honest-AI game shows the robot opponent as 🤖, never its (pooled) name.
|
||||
if (g.vsAi) return '🤖';
|
||||
return g.seats
|
||||
.filter((s) => s.accountId !== myId)
|
||||
.filter((s) => !myIds.includes(s.accountId))
|
||||
.map((s) => s.displayName)
|
||||
.join(', ');
|
||||
}
|
||||
@@ -213,6 +235,11 @@
|
||||
revealedId = null;
|
||||
return;
|
||||
}
|
||||
// A server game shown only from the offline cache is un-openable — a toast explains why.
|
||||
if (grey(g)) {
|
||||
showToast(t('net.offline'));
|
||||
return;
|
||||
}
|
||||
navigate(`/game/${g.id}`);
|
||||
}
|
||||
function toggleReveal(id: string): void {
|
||||
@@ -222,7 +249,7 @@
|
||||
revealedId = null;
|
||||
const prev = games;
|
||||
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
|
||||
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||
setLobby({ games, invitations, incoming });
|
||||
try {
|
||||
// A local (offline) game is deleted from the device store; an online game is hidden on the
|
||||
// backend. Routing by id keeps the delete off the network for a local game (the transport
|
||||
@@ -231,7 +258,7 @@
|
||||
else await gateway.hideGame(id);
|
||||
} catch (e) {
|
||||
games = prev;
|
||||
setLobby({ games, invitations, incoming, offline: offlineMode.active });
|
||||
setLobby({ games, invitations, incoming });
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -276,7 +303,7 @@
|
||||
|
||||
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||||
<div class="lobby">
|
||||
{#if invitations.length}
|
||||
{#if invitations.length && !offlineMode.active}
|
||||
<section>
|
||||
<h2>{t('lobby.invitations')}</h2>
|
||||
{#each invitations as inv (inv.id)}
|
||||
@@ -319,7 +346,7 @@
|
||||
<div class="list">
|
||||
{#each group.list as g (g.id)}
|
||||
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
|
||||
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
|
||||
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id} class:greyed={grey(g)}>
|
||||
{#if group.finished || g.hotseat}
|
||||
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
|
||||
{/if}
|
||||
@@ -336,7 +363,7 @@
|
||||
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||
</span>
|
||||
<span class="sub scoreline"
|
||||
>{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myId, s)}{#if i > 0}<span class="sep">{' : '}</span
|
||||
>{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myIds, s)}{#if i > 0}<span class="sep">{' : '}</span
|
||||
>{/if}<span
|
||||
class="num"
|
||||
class:win={standing === 'win'}
|
||||
@@ -349,7 +376,7 @@
|
||||
plaques, and resultBadge is viewer-centric (no seat matches the account). A
|
||||
vs_ai game keeps its medal (one human, a straightforward win/loss). -->
|
||||
{#key blinkNonce.get(g.id) ?? 0}
|
||||
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myId).emoji}</span>
|
||||
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myIds).emoji}</span>
|
||||
{/key}
|
||||
</button>
|
||||
{#if group.finished || g.hotseat}
|
||||
@@ -397,6 +424,7 @@
|
||||
{/if}
|
||||
|
||||
{#snippet tabbar()}
|
||||
<UpdateNudge />
|
||||
<TabBar>
|
||||
<button class="tab" onclick={() => navigate('/new')}>
|
||||
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||
@@ -495,6 +523,10 @@
|
||||
.rowwrap + .rowwrap {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
/* A server game shown only from the offline cache: dimmed and un-openable (openGame toasts instead). */
|
||||
.rowwrap.greyed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.del {
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
import { gateway } from '../lib/gateway';
|
||||
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 { offlineMode, offlineCapable } from '../lib/offline.svelte';
|
||||
import { localSource, isLocalGameId } from '../lib/gamesource';
|
||||
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
|
||||
import { localGuestId } from '../lib/localguest';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
@@ -51,7 +51,18 @@
|
||||
];
|
||||
|
||||
const guest = $derived(app.profile?.isGuest ?? true);
|
||||
// Whether this channel supports device-local play. Native and plain web do; the Telegram/VK mini-apps
|
||||
// are online-only, so they offer no offline/hotseat segment and never create a device-local game. The
|
||||
// channel is fixed for the session, so a plain const (not reactive) is enough.
|
||||
const canPlayOffline = offlineCapable();
|
||||
let mode = $state<'auto' | 'friends'>('auto');
|
||||
// Within "with friends" (offline-capable channels only): online = a remote invite, offline = a
|
||||
// pass-and-play (hotseat) game on this device. With no network the online segment is disabled and
|
||||
// offline is forced — so the create flow always works.
|
||||
let friendsMode = $state<'online' | 'offline'>('online');
|
||||
$effect(() => {
|
||||
if (offlineMode.active) friendsMode = 'offline';
|
||||
});
|
||||
// The quick-game opponent: an honest AI (a robot that joins and moves at once, shown as 🤖)
|
||||
// or a random human via auto-match. AI is the default.
|
||||
let opponent = $state<'ai' | 'random'>('ai');
|
||||
@@ -63,7 +74,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 +139,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 +400,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 +413,19 @@
|
||||
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.
|
||||
The online/offline segment is offered only on offline-capable channels (native / plain web);
|
||||
the online-only Telegram/VK mini-apps skip it and show the remote invite directly. With no
|
||||
network the online segment is disabled and offline is forced (friendsMode). -->
|
||||
{#if canPlayOffline}
|
||||
<div class="seg modes">
|
||||
<button class="opt" class:active={friendsMode === 'online'} disabled={offlineMode.active} onclick={() => (friendsMode = 'online')}>{t('new.playRemote')}</button>
|
||||
<button class="opt" class:active={friendsMode === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button>
|
||||
</div>
|
||||
{#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if}
|
||||
{/if}
|
||||
{#if friendsMode === 'offline'}
|
||||
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
|
||||
PIN first (it gates the roster); each seat may add its own PIN. -->
|
||||
<div class="hostpin">
|
||||
@@ -434,9 +492,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 +544,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 +768,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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+3
-2
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user