diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..88f7760 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,242 @@ +# Agent field notes — scrabble-game + +Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`, +`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working +memory, not a spec — **verify any named file / flag / function against the current code before acting +on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up. + +## Codegen & build + +- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may + reorder output. After running it, revert the churn on tables you did not touch and commit only your + table's change. +- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different + flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with + another flatc version. +- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust + `go build` / `go test`, not the editor squiggles. +- **go-jet type mapping:** SQL `numeric` → `float64`, `interval` → `string`. Never store money as + `numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as + **int seconds**, not `interval`. +- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a + bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a + prod bump goes through a solver **PR → master + a published tag**, not a local replace. +- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`, + Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would. +- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to + `${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise). +- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack + pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/`. + +## Native Android build (Capacitor) + +The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under +`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code: + +- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle + run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula** + `brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable + non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so + `/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`. +- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0** + (`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer + `android-36.1` does NOT satisfy compileSdk 36. +- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install + --cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`). + Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific). +- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android && + ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug` + → `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap` + (dodges the corepack flake). +- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`; + `emulator -avd `, `adb install -r `, `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). 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-.tar.gz`, + keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT + `scrabble-solver/dawg` (those are the solver's pinned test fixtures). + +## Wire / schema evolution (client ↔ gateway ↔ backend) + +- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never + delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs + and breaks older readers. +- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion, + and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value. +- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing + both the FBS schema and the backend DTO. +- **Seat display name is built in two places** — `game.Service` live events **and** the server REST + DTOs. Change both or they drift. +- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST, + then bumps it from the live event. Don't expect it on the shared broadcast. + +## UI / Svelte 5 + +- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store + subscription. Rename (e.g. `view`). +- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression: + `{' : '}`. +- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design + tokens (mirror `NewGame`'s `.invite`). +- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site + with `$state.snapshot(...)` before storing. + +## Platform / WebView quirks (Telegram, VK, iOS, native, old Android) + +- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a + `showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs. +- **iOS Telegram `` navigates away** and strands the SPA. Deliver files by **Web + Share on mobile**, and only use a `` Blob path on **desktop**. +- **Android TG/VK WebViews** lack `navigator.share` and ignore ``. Deliver a + client-generated file by **copying it to the clipboard**. +- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts` + (routes to `vk.com/away.php`). Verify on-device. +- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery + matrix; do not re-litigate it without new on-device facts. +- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers + syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot + gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin` + glyph fallback for old rendering. +- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify + those live on the deployed contour, not in the e2e. +- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived + Subscribe stream, cosmetic, deliberately left as-is. + +## Testing + +- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock + e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage. +- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it + intercepts Playwright taps. +- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to + 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 + 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. **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) + or the service crash-loops on start. + +## Deploy / test contour (operational) + +- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side + `curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way. +- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in + prod, not a bug — which is why the IP ban / blocklist are **prod-only**. +- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one + PR, one deploy) so deploys don't clobber each other. +- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a + prerelease step in `PRERELEASE.md`. +- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then + syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing + locale. +- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the + bot-link silently dies. Diagnose from inside the netns. +- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns + socket-inspect trick (single-service recreate). +- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for + caddy; don't trust "deploy green" for an edge-config change. +- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through + to the landing catch-all. Add a CI probe for the route. +- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request" + telemetry log or Tempo, not caddy. +- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside + `/assets/main-*.js`. +- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open; + clients cache it ~30 days. Fix with `Alt-Svc: clear`. +- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source + produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir. +- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans + the whole roll, the SPA overlay reloads on recovery. Don't break these invariants. +- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep + their pinned version. The **owner** does the upload. +- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes — + re-run, it's not your code. + +## Repo workflow + +- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it* + / add a plan line — not file a tracker issue. +- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The + agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the + stale-mergeable trap (re-check mergeability right before merging). +- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched + **bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the + gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten + case — watch the post-merge runs too. +- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the + local feature branch. +- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a + wedged contour can be recovered by recreating the host container set. + +## Domain semantics (not obvious from the code) + +- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the + owner on every deletion point before wiring it. +- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email + account row is created at the **code-request** step, not at confirmation. +- **The robot has two distinct time windows:** a **sleep window** (~00:00–07:00, gates its moves and + nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the + **sleep** window. + +## Production topology + +- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina). + SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling + + health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there — + the source of truth for IPs/roles). +- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every + owner-side step **before** a deploy, not after. + +## Feature-area pointers (state lives in the linked docs/plans, not here) + +- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in + `docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies. +- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in + `deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with + `--pg1-user=scrabble`. +- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports** + of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native + offline-first bundles the dictionaries (see `ANDROID_PLAN.md`). +- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini + App, server-side confidential code exchange. +- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts. +- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate, + offline-first, RuStore). diff --git a/.gitea/workflows/android-build.yaml.disabled b/.gitea/workflows/android-build.yaml.disabled new file mode 100644 index 0000000..8eb9463 --- /dev/null +++ b/.gitea/workflows/android-build.yaml.disabled @@ -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/@.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 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 32eda42..425f51f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 }} diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index af01215..b45b4bb 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -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 }} diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md new file mode 100644 index 0000000..a4ec135 --- /dev/null +++ b/ANDROID_PLAN.md @@ -0,0 +1,1115 @@ +# Native Android (Capacitor) — RuStore MVP: bundle + client-version gate + offline-first + +> Self-contained implementation plan for the standalone Android app, to be executed in a fresh +> session (another device). Work through the breakdown A–G in order; each part states its own +> "Done when". `PLAN.md` at the repo root is the precedent for a top-level plan doc here. + +--- + +## Context (why) + +`erudit-game.ru` ships today as a web SPA (Svelte + Vite) served by the gateway, plus Telegram and +VK Mini Apps. The owner wants a **standalone Android app**, first on **RuStore**. The stack already +declares Capacitor as the target and is pre-seamed for it (feature-detected `window.Capacitor`, +`file://`-safe hash router, relative asset base, a reserved native share branch, +`viewport-fit=cover`/safe-area layout, a build-time gateway origin var), but **nothing is +scaffolded** — no `@capacitor/*` dep, no `capacitor.config.*`, no `android/`. + +Two problems native introduces that the web never had: + +1. **An installed build can be arbitrarily old.** On web every load fetches the current client, so + client and server are always paired. In a store a user may run a months-old bundle; if the + FlatBuffers wire schema changes incompatibly, an old bundle cannot speak to the server at all. + Today there is **no** client↔server version contract — the only "update" path is the web-only + `location.reload()`, useless to a bundled APK. → build a **minimum-supported-client gate**. + +2. **First launch must work with no network.** The app must open straight into a guest experience + and let the user play locally (vs_ai *and* 2-4-player pass-and-play / hotseat) even if the + internet is off on first launch. The current offline mode is a *returning-user* feature: it needs + a cached session/profile and server-fetched dictionaries (`offline.ts shouldBootOffline` requires + `hasSession && hasProfile`; dicts come from `gateway.fetchDict`). → build **offline-first**: + bundle dictionaries in the APK and boot as a device-local guest with no server session. + +**Intended outcome:** a signed APK on RuStore that (a) loads the packaged SPA against the production +gateway, (b) is protected by the version gate so future incompatible server changes turn old +installs away cleanly, and (c) opens offline-first as a soft guest with local play. + +## Locked decisions (owner interview) + +| Decision | Choice | +|---|---| +| First store | **RuStore** (Google Play is a later variant — see below) | +| Update model | **Bundle** (`dist/` inside the APK) **+ a client-version gate** (no OTA, no remote-URL wrapper) | +| First-launch offline | **Offline-first in the MVP**: enter as a local guest, play vs_ai + hotseat with zero network | +| Login surface | Guest + email only (already the case — `Login.svelte` shows only these; VK/Telegram auth runs only inside their Mini Apps) | +| Payments in MVP | Hidden (deferred; reuse the distribution flag) | +| appId (permanent) | **`ru.eruditgame.app`** | +| App display name | **`Эрудит`** (Cyrillic) | +| Toolchain (locked) | **Capacitor 8** (`@capacitor/*` `^8`); **compileSdk/targetSdk 36, minSdk 24**; **JDK 21** (required — `@capacitor/android` compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK `platforms;android-36` + `build-tools;36.x` | + +**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code, +commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo +rejects standalone spec artifacts, so `ANDROID_PLAN.md` is the only new plan file; do not create +`docs/superpowers/specs/*`. Branch model: `feature/android-native` from `development`, PR into +`development` (contour review), then promote `development → master`, tag, dispatch the Android build. +The gate change is **wire-additive and contour-safe** (a new HTTP header + a new envelope +`result_code` string; no FBS/proto regen, no schema migration). The offline-first change is +client-only (no server change). + +--- + +## Progress (as-built) + +Kept current as parts land so a fresh session resumes without re-deriving. Verify against code. + +- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated + under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in + `ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build` → `cap sync` → `./gradlew assembleDebug` + builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator. + `svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in + `.claude/CLAUDE.md` → "Native Android build (Capacitor)". + **Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э» + brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive + icons + splashes. Superseded by the icon rebrand below. +- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts` + `gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL, + `screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left + as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP + via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus + a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note + (`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in + `vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean; + `?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch + browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs). +- **C. Client-version gate — ✅ DONE & verified (2026-07-12).** Server: new `gateway/internal/clientver` + (parse + compare), `GATEWAY_MIN_CLIENT_VERSION` config (empty ⇒ dormant, validated at load), and the + 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_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 — ✅ 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/@.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 +the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed +square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable +(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present. +RuStore publication prerequisites gate release only. + +--- + +## Prerequisites to start DEVELOPMENT (on the other device) + +Have all of these before writing code: + +- **JDK 21** (Temurin/OpenJDK) — **required** by Capacitor 8: `@capacitor/android` sets + `sourceCompatibility/targetCompatibility = VERSION_21`, so a JDK 17 Gradle run fails with + `invalid source release: 21`. (Homebrew: `brew install openjdk@21`.) +- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or* + cmdline-tools. Capacitor 8 targets **compileSdk/targetSdk 36, minSdk 24**, so install: + `platform-tools`, `platforms;android-36`, `build-tools;36.0.0` (a newer build-tools such as + `36.1.0` is also accepted — AGP treats it as a minimum). Gradle 8.14.3 / AGP 8.13 arrive via the + wrapper `cap add android` generates. +- **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain. +- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login + and the CI watcher exactly as the owner's global setup (`~/.claude` tooling: + `python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`). +- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the + sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the + `go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned, + production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact** + `scrabble-dawg-.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI + job already `curl` exactly that tarball, keyed on the shared Gitea variable `DICT_VERSION`. + Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needs + `DICT_VERSION` + network access to the release, **not** `scrabble-solver`. (`scrabble-solver/dawg/*.dawg` + are the solver's committed test fixtures — byte-identical to a build but pinned to the solver + commit, not the versioned production set.) +- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. a + Pixel AVD; any recent API image). +- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway + is only needed to exercise the version gate locally (optional). + +Publication prerequisites (RuStore account, keystore, listing assets) are listed at the **end** — +they gate *release*, not development. + +--- + +## Identity model (answers "how do guests work offline vs in the DB") + +Split the **local play identity** from the **server account**. This *extends* the current model +(guests are DB rows) with a pre-server local state; existing server-guest semantics are unchanged. + +- **Local guest (device-local, no DB row).** A device-generated id + a default display name, + persisted on the device. Exists from the very first launch, with no network. It fills the human + seat in a local vs_ai game and is the "you" for local games. A purely-offline user never creates a + DB row (consumes no server resources). +- **Server guest (DB row).** Minted lazily via `auth.guest` the first time the app reaches the + network, its session cached and reused. Unlocks online features (matchmaking, friends, online + games). Exactly one per device — guarded by the cached session (never mint if one exists). +- **Registered account.** Email upgrade as today; adopts the current server guest. +- **Local games stay device-only.** Both local vs_ai and local hotseat games already persist only on + the device (`ui/src/lib/localgame/store.ts`); they never sync to the server, regardless of identity + transitions. Reconciliation to a server guest does **not** migrate them. + +**Reconciliation flow:** on gaining network with no server session, silently `auth.guest` in the +background, cache the session, adopt it. This is best-effort — see the gate interaction below for how +a too-old client stays offline instead of being interrupted. + +--- + +## The client-version gate — architecture (read before touching gate code) + +**Principle: the version rides the outermost, permanently-stable layer, never the FlatBuffers +payload.** The transport is two layers: a protobuf Connect envelope +(`ExecuteRequest{message_type, payload, request_id}`, `gateway/proto/edge/v1/edge.proto`) wrapping a +FlatBuffers payload (`pkg/fbs/scrabble.fbs`). Protobuf envelopes and HTTP headers are +version-tolerant by design; the FBS payload is the layer that breaks. So the client version rides in +an **HTTP header `X-Client-Version`**, read by the gateway **before it decodes the payload**. + +**Enforcement is per-call, not a dedicated init RPC.** The client attaches the header to every +Connect call via its single `headers()` builder; the gateway checks it at the top of `Execute` +(before registry lookup / auth / payload decode) and `Subscribe`. The first online call the app makes +(session establish `auth.*`) is thus gated automatically — no `Hello` RPC needed. A too-old client +makes **zero** successful requests but sees a recognizable signal, not a crash. + +**Two return shapes, one meaning:** +- `Execute` → the domain-style envelope `result_code = "update_required"` (HTTP 200); the client + already throws `GatewayError(result_code)` for any non-`ok`. +- `Subscribe` → Connect `CodeFailedPrecondition` (a stream has no `result_code`). + +**Frozen contract (write into `docs/ARCHITECTURE.md` §2):** three things become permanent so any +build, however old, can always recognize "update required": (1) the protobuf envelope field numbers +in `edge.proto` are never renumbered/reused; (2) the `update_required` sentinel (the `result_code` +string + the `FailedPrecondition` code) never changes; (3) the FBS schema stays **additive** +(trailing fields only, `(deprecated)` never delete). Breaking changes happen only *inside* the FBS +payload. + +**Discipline (write into `deploy/README.md`):** the production deploy that ships an incompatible wire +change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same rollout. Until +deliberately set, `GATEWAY_MIN_CLIENT_VERSION` is **empty ⇒ the gate is dormant and web behaviour is +unchanged.** One hard threshold for the MVP; a soft "recommended update" tier is deferred. + +**Interaction with offline-first (important UX rule):** offline mode uses the network kill switch +(`transport.ts assertOnline`), so the gate never fires while playing offline — an old client can +always play local vs_ai/hotseat. The gate matters only for online actions. Therefore the terminal +"update" overlay must be raised **only on a user-initiated online action**, never on the silent +background guest-reconciliation: if reconciliation's `auth.guest` returns `update_required`, swallow +it and stay a local guest (do not overlay). Implementation seam: the reconciliation path catches the +`update_required` code and does not route it to the global overlay trigger; foreground calls do. + +--- + +## Work breakdown + +Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence +is: scaffold → native-correct → gate → offline-first → CI → docs → release. + +### A. Capacitor scaffolding — ✅ DONE + +- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps + `@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`, + `"android:assets": "capacitor-assets generate --android"`. +- `ui/capacitor.config.ts` (new): + ```ts + import type { CapacitorConfig } from '@capacitor/cli'; + const config: CapacitorConfig = { + appId: 'ru.eruditgame.app', + appName: 'Эрудит', + webDir: 'dist', + // Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates + // ship through the store; the client-version gate turns away a build too old to speak the + // current wire contract (docs/ARCHITECTURE.md §2). + }; + export default config; + ``` +- `npx cap add android` → generates and **commits** `ui/android/` (a Gradle project). Neither + `.gitignore` lists it today; keep it tracked so build.gradle + signing edits are reviewable and CI + reproducible. Ensure `ui/android/.gitignore` covers build outputs (`/app/build`, `/build`, + `/.gradle`, `/local.properties`, `*.keystore`, `*.jks`). +- **Android hardware Back** — new `ui/src/lib/native.ts`, dynamically importing `@capacitor/app` so + the web/mock bundle never pulls it: + ```ts + import { clientChannel } from './channel'; + export async function initNativeShell(atNavigationRoot: () => boolean): Promise { + 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(); + }); + } + ``` + Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login` + (mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`). +- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png` + (+ optional `splash.png`); `pnpm android:assets` generates adaptive icons. + +**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`) +`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen. + +### B. Native web-build correctness (file:// origin) — ✅ DONE + +1. **One origin helper** — new `ui/src/lib/origin.ts`: + ```ts + // The absolute origin the client talks to. A packaged native build (file:// origin) sets + // VITE_GATEWAY_URL to the real gateway; web/mock leave it empty and fall back to the page + // origin. Centralised so every absolute-URL construction resolves identically. + export function gatewayOrigin(): string { + const configured = import.meta.env.VITE_GATEWAY_URL ?? ''; + return configured || (typeof location !== 'undefined' ? location.origin : ''); + } + ``` + Fix the two same-origin **landmines** (they would produce `file:///…` on native): + - `ui/src/game/Game.svelte:1214`: `new URL(path, location.origin)` → `new URL(path, gatewayOrigin())`. + - `ui/src/screens/Wallet.svelte:47`: `` `${location.origin}/` `` → `` `${gatewayOrigin()}/` ``. + (`transport.ts:32` already computes the equivalent inline — leave it.) +2. **Skip the service worker on native** — `ui/src/lib/pwa.svelte.ts registerServiceWorker()` (line 85): + add, next to `if (inMiniApp()) return;`: + ```ts + import { clientChannel } from './channel'; + const ch = clientChannel(); + if (ch === 'android' || ch === 'ios') return; + ``` +3. **Hide payments in the MVP build** — `ui/src/lib/distribution.ts`, add: + ```ts + // purchasesHidden: true for the Google Play build (external-payment policy) OR the thin native + // MVP that defers store billing (VITE_PAYMENTS_DISABLED="1"). Every other build sells normally. + export function purchasesHidden(): boolean { + return isGooglePlayBuild() || (import.meta.env as Record).VITE_PAYMENTS_DISABLED === '1'; + } + ``` + Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they + hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub + `isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() && + !isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key + `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_RUSTORE_URL?: string`, + `VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`. +4. **Native env matrix** — see Build & env. + +**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the +purchase actions are absent; `pnpm check` + `pnpm test:unit` pass. + +### C. The client-version gate — ✅ DONE + +#### C1. Backend (gateway) + +- **New package `gateway/internal/clientver`** (`clientver.go` + `_test.go`): dependency-free parse of + the leading `v?MAJOR.MINOR.PATCH` (ignore any `-N-gSHA`/`+meta` suffix) + compare: + ```go + package clientver + import ("strconv"; "strings") + type Version struct{ Major, Minor, Patch int } + func Parse(s string) (Version, bool) { + s = strings.TrimPrefix(strings.TrimSpace(s), "v") + if i := strings.IndexAny(s, "-+"); i >= 0 { s = s[:i] } + p := strings.SplitN(s, ".", 4) + if len(p) < 3 { return Version{}, false } + var v Version; var err error + if v.Major, err = strconv.Atoi(p[0]); err != nil { return Version{}, false } + if v.Minor, err = strconv.Atoi(p[1]); err != nil { return Version{}, false } + if v.Patch, err = strconv.Atoi(p[2]); err != nil { return Version{}, false } + return v, true + } + func Less(a, b Version) bool { + if a.Major != b.Major { return a.Major < b.Major } + if a.Minor != b.Minor { return a.Minor < b.Minor } + return a.Patch < b.Patch + } + ``` + Tests: `"v1.16.0"`, `"1.16.0"`, `"v1.16.0-3-gabc"`, `"dev"`/`""`→!ok, ordering incl. equal. +- **Config `gateway/internal/config/config.go`**: add `MinClientVersion string` (doc: empty ⇒ gate + off); `Load()`: `c.MinClientVersion = os.Getenv("GATEWAY_MIN_CLIENT_VERSION")`; `validate()`: if + non-empty and `!clientver.Parse(...ok)` → return a config error. Extend `config_test.go`. +- **Server `gateway/internal/connectsrv/server.go`**: + - consts `clientVersionHeader = "X-Client-Version"`, `resultUpdateRequired = "update_required"`; + `errors.go`: `errUpdateRequired = errors.New("client too old, update required")`. + - `Server`: add `minClient clientver.Version` + `gateOn bool`. `Deps`: add `MinClientVersion string`. + `NewServer`: parse `d.MinClientVersion` once (set `gateOn` on success; `log.Warn` + off if + unparseable). + - helper `clientTooOld(header string) bool`: `if !s.gateOn { return false }`; parse header, on + `!ok` return false (fail-open); return `clientver.Less(v, s.minClient)`. + - `Execute`: insert **after** the `defer` metrics line (after `server.go:296`, before + `registry.Lookup`), so `msgType` is set and the payload is untouched: + ```go + if s.clientTooOld(req.Header().Get(clientVersionHeader)) { + result = resultUpdateRequired + return connect.NewResponse(&edgev1.ExecuteResponse{ + RequestId: req.Msg.GetRequestId(), ResultCode: resultUpdateRequired, + }), nil + } + ``` + - `Subscribe`: at the top (before `resolve`): `if s.clientTooOld(req.Header().Get(clientVersionHeader)) { return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired) }`. + - `main.go`: add `MinClientVersion: cfg.MinClientVersion,` to the `connectsrv.Deps{...}` literal. + - Tests (`server_test.go`): too-old Execute ⇒ `result_code == "update_required"` and the op handler + never ran; too-old Subscribe ⇒ `FailedPrecondition`; absent header / empty min / unparseable + header / equal version ⇒ pass. + +#### C2. Client (ui) + +- **`ui/src/lib/update.svelte.ts`** (new, terminal — no poll): + ```ts + export const UPDATE_REQUIRED = 'update_required'; + let required = $state(false); + export const updateRequired = { get active(): boolean { return required; } }; + export function reportUpdateRequired(): void { required = true; } + ``` +- **`ui/src/lib/transport.ts`**: + - `headers()` (line 37) always attaches the version header: + ```ts + const headers = (): Record => { + const h: Record = { 'x-client-version': __APP_VERSION__ }; + if (token) h.authorization = `Bearer ${token}`; + return h; + }; + ``` + - result-code branch (line 86): `if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();` + before the throw. + - catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is + `UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`. + - **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant + (swallows `update_required` without raising the overlay) has no caller until D.4, so it is added + there with its caller rather than speculatively in C. In C every foreground call that gets + `update_required` raises the overlay; offline play never trips it (the network kill switch). +- **`ui/src/lib/retry.ts` `toGatewayError()`**: add + `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_RUSTORE_URL, '_system')`; web → `location.reload()`. i18n keys + `update.title/body/action` (add siblings to the maintenance keys). +- **`ui/src/App.svelte`**: import + place `` right after `` (line 139). +- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`. +- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via + Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module, + which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other + `*.svelte.ts` store it is covered by the e2e, not a unit test. + +**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) — ✅ 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:** 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 + online-session rule (they still need a prior session). The native channel always lands the user in the + lobby, online or offline. +- **Soft registration reuses the existing `Profile` screen** as the guest sign-in / account surface (it + already shows the email / Telegram / VK upgrade options for a guest). No new sign-in UI is built in D. +- **Hide the Telegram + VK link buttons on the native build** on Profile: VK ID web-login is a full-page + redirect to `id.vk.com` that cannot return into the Capacitor app (it strands on the web redirect URI), + and the Telegram Login Widget is unreliable in a WebView. **Email works** (pure gateway calls, no + redirect). Native Telegram/VK login (native SDKs / deep-link OAuth — "the pretty native popups") is a + **separate later stage**, consistent with the locked "guest+email" surface and "Out of scope: VK/Telegram + login in the native build". +- **Local-guest display name** = localized `common.guest` ("Гость" / "Guest"). + +1. **Bundle dictionaries in the APK. ✅ DONE.** + - `ui/scripts/bundle-dicts.mjs`: copies the DAWGs from the unpacked **`scrabble-dictionary` release** + (dir via **`DICT_DIR`**; **NOT** `../scrabble-solver/dawg`) → `/dict/@.dawg`. + `dictKey(variant,version)` = `` `${variant}@${version}` `` (`lib/dict/store.ts:31`). `dawgFor` maps + `scrabble_en→en_sowpods`, `scrabble_ru→ru_scrabble`, `erudit_ru→ru_erudit` (mirrors `e2e-dict.mjs`). + Version = `VITE_DICT_VERSION` (default `dev`); `OUT_DIR` overrides the root (the e2e points it at + `dist-e2e`). Run only in the native pipeline (after `pnpm build`, before `cap sync`); web builds skip + it and stay slim. + - Vite `define` `__DICT_VERSION__` (from `VITE_DICT_VERSION`, default `dev`; mirrors `__APP_VERSION__`) + + declared in `vite-env.d.ts`. + - **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: a **bundled tier** sits between the IndexedDB + tier (now tier 1) and the network tier (now tier 3). **CORRECTION vs the original plan:** it is + **native-gated** (`clientChannel()` android/ios), NOT "fetch always, 404 on web". A relative + `fetch('./dict/'+key+'.dawg')` on the web would hit the **gateway's own session-gated `/dict/` route** + (a real server route), not a clean 404 — so the bundled fetch is skipped off-native and only tried in + a packaged app (its own assets). On a hit: build the `Dawg`, cache in memory, seed IndexedDB, return + (no network metric — a bundled hit is a local asset). The e2e simulates native (see Tests). + - Offline creates request the **bundled version**: `NewGame.svelte` offline vs_ai (`~line 84`) and + hotseat (`~line 277`) use `app.profile?.dictVersions?.[v] ?? __DICT_VERSION__`, so a profile-less + local guest gets the bundled `(variant, version)`. +2. **Local-guest identity. ✅ DONE.** `ui/src/lib/localguest.ts`: `localGuestId()` mints + persists a + device-local id in `localStorage` (`scrabble.localGuestId`, prefix `localguest:`, no crypto API for the + old engines) + `isLocalGuestId()`. The **display name is not in the module** — it is `t('common.guest')` + at the call site, so the module stays i18n-free and node-testable. `NewGame.svelte` vs_ai human seat + (`~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 — ✅ 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). 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 — ✅ 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/@.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 +silently establishes a server guest (Profile + online play light up). Emulator smoke recipe: `.claude/CLAUDE.md` +→ "Native Android build", with the extra `DICT_DIR= VITE_DICT_VERSION= node +scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`. + +### 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: +1. Checkout this repo; copy the Node/pnpm setup block from `.gitea/workflows/ci.yaml` (the `ui` job). + Add the **"Fetch dictionary DAWGs"** step verbatim from `ci.yaml` (the `curl … + scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz` + + untar into `${GITHUB_WORKSPACE}/dawg`), keyed on the `DICT_VERSION` Gitea variable — **this**, not + the `scrabble-solver` sibling, is the source of the bundled dicts. (`scrabble-solver` is not needed + by the Android build: `ui` is a Node project outside `go.work`.) +2. `pnpm install --frozen-lockfile`. +3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`, + `VITE_DICT_VERSION` = the release dict version. +4. `DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs` (copy the release `.dawg` + files into `ui/dist/dict/`). +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). +9. `cd ui/android && ./gradlew assembleRelease -PversionCode=$CODE -PversionName=$NAME` with the + signing config reading the keystore + `ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/ + `ANDROID_KEY_PASSWORD` from env. +10. Upload `ui/android/app/build/outputs/apk/release/*.apk` as an artifact. + +Watch to green with `python3 ~/.claude/bin/gitea-ci-watch.py` (background). RuStore upload is manual +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').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) — ✅ 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 / + reconciliation) and the **native Android build** (Capacitor bundle model, bundled dicts, + `versionCode` scheme, RuStore, `file://`-origin implications). +- `docs/FUNCTIONAL.md` (+ `docs/FUNCTIONAL_ru.md` mirror): user stories — offline-first guest launch + (vs_ai + hotseat with no network), soft registration, "update required when too old", native + distribution (no purchases in the MVP). +- `docs/TESTING.md`: the new Go tests (`clientver`, gate), the client tests, the bundled-dict tier + 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_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. +4. Owner runs the on-device smoke checklist (incl. airplane-mode first launch). +5. Owner uploads to RuStore (see publication prerequisites). + +--- + +## 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: `vTesting documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + + @Test + public void useAppContext() throws Exception { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + assertEquals("com.getcapacitor.app", appContext.getPackageName()); + } +} diff --git a/ui/android/app/src/main/AndroidManifest.xml b/ui/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b06ddbf --- /dev/null +++ b/ui/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/android/app/src/main/java/ru/eruditgame/app/MainActivity.java b/ui/android/app/src/main/java/ru/eruditgame/app/MainActivity.java new file mode 100644 index 0000000..4c29e8b --- /dev/null +++ b/ui/android/app/src/main/java/ru/eruditgame/app/MainActivity.java @@ -0,0 +1,5 @@ +package ru.eruditgame.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} diff --git a/ui/android/app/src/main/res/drawable-land-hdpi/splash.png b/ui/android/app/src/main/res/drawable-land-hdpi/splash.png new file mode 100644 index 0000000..40cdea7 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-hdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-ldpi/splash.png b/ui/android/app/src/main/res/drawable-land-ldpi/splash.png new file mode 100644 index 0000000..970a548 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-ldpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-mdpi/splash.png b/ui/android/app/src/main/res/drawable-land-mdpi/splash.png new file mode 100644 index 0000000..6517585 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-mdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-night-hdpi/splash.png b/ui/android/app/src/main/res/drawable-land-night-hdpi/splash.png new file mode 100644 index 0000000..77a9174 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-night-hdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-night-ldpi/splash.png b/ui/android/app/src/main/res/drawable-land-night-ldpi/splash.png new file mode 100644 index 0000000..7ca9963 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-night-ldpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-night-mdpi/splash.png b/ui/android/app/src/main/res/drawable-land-night-mdpi/splash.png new file mode 100644 index 0000000..8ef9d8e Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-night-mdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-night-xhdpi/splash.png b/ui/android/app/src/main/res/drawable-land-night-xhdpi/splash.png new file mode 100644 index 0000000..cbbf9ac Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-night-xhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png b/ui/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png new file mode 100644 index 0000000..6b333a2 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-night-xxhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png b/ui/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png new file mode 100644 index 0000000..2073804 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-night-xxxhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-xhdpi/splash.png b/ui/android/app/src/main/res/drawable-land-xhdpi/splash.png new file mode 100644 index 0000000..6034cb5 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-xhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-xxhdpi/splash.png b/ui/android/app/src/main/res/drawable-land-xxhdpi/splash.png new file mode 100644 index 0000000..ffa80f4 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-xxhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-land-xxxhdpi/splash.png b/ui/android/app/src/main/res/drawable-land-xxxhdpi/splash.png new file mode 100644 index 0000000..af23dbb Binary files /dev/null and b/ui/android/app/src/main/res/drawable-land-xxxhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-night/splash.png b/ui/android/app/src/main/res/drawable-night/splash.png new file mode 100644 index 0000000..7ca9963 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-night/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-hdpi/splash.png b/ui/android/app/src/main/res/drawable-port-hdpi/splash.png new file mode 100644 index 0000000..92cc366 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-hdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-ldpi/splash.png b/ui/android/app/src/main/res/drawable-port-ldpi/splash.png new file mode 100644 index 0000000..86c925e Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-ldpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-mdpi/splash.png b/ui/android/app/src/main/res/drawable-port-mdpi/splash.png new file mode 100644 index 0000000..34c86a4 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-mdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-night-hdpi/splash.png b/ui/android/app/src/main/res/drawable-port-night-hdpi/splash.png new file mode 100644 index 0000000..d126058 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-night-hdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-night-ldpi/splash.png b/ui/android/app/src/main/res/drawable-port-night-ldpi/splash.png new file mode 100644 index 0000000..60147ee Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-night-ldpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-night-mdpi/splash.png b/ui/android/app/src/main/res/drawable-port-night-mdpi/splash.png new file mode 100644 index 0000000..f9b6102 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-night-mdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-night-xhdpi/splash.png b/ui/android/app/src/main/res/drawable-port-night-xhdpi/splash.png new file mode 100644 index 0000000..91d7de8 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-night-xhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png b/ui/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png new file mode 100644 index 0000000..6f95486 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-night-xxhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png b/ui/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png new file mode 100644 index 0000000..c7edd63 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-night-xxxhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-xhdpi/splash.png b/ui/android/app/src/main/res/drawable-port-xhdpi/splash.png new file mode 100644 index 0000000..883f43e Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-xhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-xxhdpi/splash.png b/ui/android/app/src/main/res/drawable-port-xxhdpi/splash.png new file mode 100644 index 0000000..3adf6de Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-xxhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-port-xxxhdpi/splash.png b/ui/android/app/src/main/res/drawable-port-xxxhdpi/splash.png new file mode 100644 index 0000000..d6fdec7 Binary files /dev/null and b/ui/android/app/src/main/res/drawable-port-xxxhdpi/splash.png differ diff --git a/ui/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/ui/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7bd21d --- /dev/null +++ b/ui/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/ui/android/app/src/main/res/drawable/ic_launcher_background.xml b/ui/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d5fccc5 --- /dev/null +++ b/ui/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/android/app/src/main/res/drawable/splash.png b/ui/android/app/src/main/res/drawable/splash.png new file mode 100644 index 0000000..34c86a4 Binary files /dev/null and b/ui/android/app/src/main/res/drawable/splash.png differ diff --git a/ui/android/app/src/main/res/layout/activity_main.xml b/ui/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b5ad138 --- /dev/null +++ b/ui/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..0aa82aa --- /dev/null +++ b/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..0aa82aa --- /dev/null +++ b/ui/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..261f759 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..2744ee0 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ecda4d Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..f4b8a05 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 0000000..eb6ffab Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png new file mode 100644 index 0000000..f4a4ecc Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_background.png differ diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ea6bd7c Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png new file mode 100644 index 0000000..8c7d4ad Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..f53c9b9 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..d09e781 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a33aada Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..fd3b881 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a02e728 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..cde6555 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..45f1836 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..492464c Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..6d66407 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..942fa92 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..238e95f Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b4cd386 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..1cbb47b Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..07b01b7 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..29b081c Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..278b307 Binary files /dev/null and b/ui/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/ui/android/app/src/main/res/values/ic_launcher_background.xml b/ui/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/ui/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/ui/android/app/src/main/res/values/strings.xml b/ui/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b20c99f --- /dev/null +++ b/ui/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Эрудит + Эрудит + ru.eruditgame.app + ru.eruditgame.app + diff --git a/ui/android/app/src/main/res/values/styles.xml b/ui/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..be874e5 --- /dev/null +++ b/ui/android/app/src/main/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/ui/android/app/src/main/res/xml/file_paths.xml b/ui/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bd0c4d8 --- /dev/null +++ b/ui/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/ui/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/ui/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/ui/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/ui/android/build.gradle b/ui/android/build.gradle new file mode 100644 index 0000000..f8f0e43 --- /dev/null +++ b/ui/android/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + classpath 'com.google.gms:google-services:4.4.4' + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/ui/android/capacitor.settings.gradle b/ui/android/capacitor.settings.gradle new file mode 100644 index 0000000..93a57c5 --- /dev/null +++ b/ui/android/capacitor.settings.gradle @@ -0,0 +1,9 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/.pnpm/@capacitor+android@8.4.1_@capacitor+core@8.4.1/node_modules/@capacitor/android/capacitor') + +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') diff --git a/ui/android/gradle.properties b/ui/android/gradle.properties new file mode 100644 index 0000000..2e87c52 --- /dev/null +++ b/ui/android/gradle.properties @@ -0,0 +1,22 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true diff --git a/ui/android/gradle/wrapper/gradle-wrapper.jar b/ui/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/ui/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ui/android/gradle/wrapper/gradle-wrapper.properties b/ui/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7705927 --- /dev/null +++ b/ui/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/ui/android/gradlew b/ui/android/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/ui/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/ui/android/gradlew.bat b/ui/android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/ui/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ui/android/settings.gradle b/ui/android/settings.gradle new file mode 100644 index 0000000..3b4431d --- /dev/null +++ b/ui/android/settings.gradle @@ -0,0 +1,5 @@ +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' \ No newline at end of file diff --git a/ui/android/variables.gradle b/ui/android/variables.gradle new file mode 100644 index 0000000..ee4ba41 --- /dev/null +++ b/ui/android/variables.gradle @@ -0,0 +1,16 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.11.0' + androidxAppCompatVersion = '1.7.1' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.17.0' + androidxFragmentVersion = '1.8.9' + coreSplashScreenVersion = '1.2.0' + androidxWebkitVersion = '1.14.0' + junitVersion = '4.13.2' + androidxJunitVersion = '1.3.0' + androidxEspressoCoreVersion = '3.7.0' + cordovaAndroidVersion = '14.0.1' +} \ No newline at end of file diff --git a/ui/assets/icon.png b/ui/assets/icon.png new file mode 100644 index 0000000..7489ca3 Binary files /dev/null and b/ui/assets/icon.png differ diff --git a/ui/capacitor.config.ts b/ui/capacitor.config.ts new file mode 100644 index 0000000..839931e --- /dev/null +++ b/ui/capacitor.config.ts @@ -0,0 +1,12 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'ru.eruditgame.app', + appName: 'Эрудит', + webDir: 'dist', + // Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates + // ship through the store; the client-version gate turns away a build too old to speak the + // current wire contract (docs/ARCHITECTURE.md §2). +}; + +export default config; diff --git a/ui/e2e/export.spec.ts b/ui/e2e/export.spec.ts index 54eb524..01e1d68 100644 --- a/ui/e2e/export.spec.ts +++ b/ui/e2e/export.spec.ts @@ -38,7 +38,20 @@ async function openFinishedHistory(page: Page): Promise { await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible(); } +// Desktop WebKit (Safari's engine) exposes a working Web Share API, unlike Chromium and WebKit on +// Linux (the CI host). The "plain desktop browser" delivery paths tested below are reached only when +// Web Share is ABSENT — shareUrlAsFile / pickGcgDelivery fall through to the anchor download or the +// clipboard copy — so pin that precondition; otherwise macOS WebKit takes the share branch and no +// download or clipboard copy occurs. Mirrors the inverse stub the share-sheet test sets up. +async function withoutWebShare(page: Page): Promise { + await page.addInitScript(() => { + Object.defineProperty(navigator, 'share', { value: undefined, configurable: true }); + Object.defineProperty(navigator, 'canShare', { value: undefined, configurable: true }); + }); +} + test('the chooser downloads the PNG through the signed URL on a plain browser', async ({ page }) => { + await withoutWebShare(page); await routeDl(page); await openFinishedHistory(page); await page.getByRole('button', { name: 'Export game' }).click(); @@ -49,6 +62,7 @@ test('the chooser downloads the PNG through the signed URL on a plain browser', }); test('the chooser downloads the GCG through the same signed-URL route', async ({ page }) => { + await withoutWebShare(page); await routeDl(page); await openFinishedHistory(page); await page.getByRole('button', { name: 'Export game' }).click(); @@ -198,6 +212,7 @@ test('Telegram on iOS keeps the app chooser and opens the OS share sheet', async }); test('a legacy Telegram client (no downloadFile) hides the image and copies the GCG', async ({ page }) => { + await withoutWebShare(page); await page.addInitScript(() => { // Headless engines deny the real clipboard; a permissive stub keeps the legacy // copy path deterministic in both browsers. diff --git a/ui/e2e/hotseat.spec.ts b/ui/e2e/hotseat.spec.ts index 2e26dab..141132a 100644 --- a/ui/e2e/hotseat.spec.ts +++ b/ui/e2e/hotseat.spec.ts @@ -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 { await page.addInitScript(() => { const orig = window.matchMedia.bind(window); @@ -19,11 +20,9 @@ async function enterLobby(page: Page): Promise { } async function goOffline(page: Page): Promise { - 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); }); }); diff --git a/ui/e2e/native.spec.ts b/ui/e2e/native.spec.ts new file mode 100644 index 0000000..92026da --- /dev/null +++ b/ui/e2e/native.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + }); +}); diff --git a/ui/e2e/offline.spec.ts b/ui/e2e/offline.spec.ts index 591ea2c..82392ed 100644 --- a/ui/e2e/offline.spec.ts +++ b/ui/e2e/offline.spec.ts @@ -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 { await page.addInitScript(() => { const orig = window.matchMedia.bind(window); @@ -15,25 +16,30 @@ async function forceStandalone(page: Page): Promise { // 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 { 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 { + await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline()); +} +async function goOnline(page: Page): Promise { + 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 { 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/ route and the local game replays from - // IndexedDB with every committed tile intact. + // Reload: the hash router restores the /game/ 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); + }); }); diff --git a/ui/e2e/telegram.spec.ts b/ui/e2e/telegram.spec.ts index 073461d..a302e00 100644 --- a/ui/e2e/telegram.spec.ts +++ b/ui/e2e/telegram.spec.ts @@ -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 + +{#if show} +
+ {t('update.available')} + + +
+{/if} + + diff --git a/ui/src/components/UpdateOverlay.svelte b/ui/src/components/UpdateOverlay.svelte new file mode 100644 index 0000000..36753d0 --- /dev/null +++ b/ui/src/components/UpdateOverlay.svelte @@ -0,0 +1,104 @@ + + +{#if netState.versionLocked && !dismissed} +
+
+ +

{t('update.title')}

+

{t('update.body')}

+
+ + +
+
+
+{/if} + + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f2ab090..a7d6a4c 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -28,6 +28,7 @@ import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints'; import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share'; + import { gatewayOrigin } from '../lib/origin'; import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { patchLobbyGame } from '../lib/lobbycache'; @@ -1211,7 +1212,7 @@ const dateLocale = typeof navigator !== 'undefined' ? (navigator.language ?? '') : ''; const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''; const { path, filename } = await gateway.exportUrl(id, kind, dateLocale, labels, timeZone); - const url = new URL(path, location.origin).href; + const url = new URL(path, gatewayOrigin()).href; const mime = kind === 'png' ? 'image/png' : 'text/plain'; if (insideTelegram() && !tgShareSheet && telegramDownloadFile(url, filename)) return; if (insideVK()) { diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 837621d..6a17009 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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 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 { - app.offlinePrompt = true; - return new Promise((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 | null = null; -export function scheduleRecovery(delayMs: number): void { - if (recoveryTimer) { - clearTimeout(recoveryTimer); - recoveryTimer = null; +async function recover(): Promise { + 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 { + 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 { // 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 { // 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 { 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 { } 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(), + }; } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index a79ba4c..6cc7815 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -68,6 +68,10 @@ export interface GatewayClient { * cosmetic seed for a brand-new account). */ authVK(params: string, displayName: string): Promise; authGuest(locale?: string): Promise; + /** 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; authEmailRequest(email: string, language: string, pwa: boolean): Promise; authEmailLogin(email: string, code: string): Promise; /** Confirm a one-tap email deeplink token: a login returns a session to adopt; a diff --git a/ui/src/lib/connection.svelte.ts b/ui/src/lib/connection.svelte.ts index 03b30e5..d97c407 100644 --- a/ui/src/lib/connection.svelte.ts +++ b/ui/src/lib/connection.svelte.ts @@ -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 | null = null; -let probe: (() => Promise) | 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 { - 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 { - if (!probe) return false; - try { - await Promise.race([ - probe(), - new Promise((_, 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 { + 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(); } diff --git a/ui/src/lib/dict/loader.ts b/ui/src/lib/dict/loader.ts index 31c63ce..351c62c 100644 --- a/ui/src/lib/dict/loader.ts +++ b/ui/src/lib/dict/loader.ts @@ -8,6 +8,7 @@ import { Dawg } from './dawg'; import { dictKey, idbGetDawg, idbPutDawg, idbDelDawg, requestPersist } from './store'; import { gateway } from '../gateway'; +import { clientChannel } from '../channel'; import { noteDictFetched, noteDictCacheHit, noteDictMiss as noteDictMissMetric } from '../localeval-metrics'; import type { Variant } from '../model'; @@ -80,11 +81,34 @@ async function load(variant: Variant, version: string, key: string, signal?: Abo } } catch { // A cached blob the reader rejected (a stale or partial entry): evict it so the next - // load re-fetches instead of failing on it forever, then fall through to the network. + // load re-fetches instead of failing on it forever, then fall through to the bundled/network tiers. void idbDelDawg(key); } - // Tier 2: the session-gated download — gated by the bad-connection breaker. A caller's + // Tier 2: a dictionary bundled in a native app's assets (offline-first). Attempted only on a native + // channel, where a packaged app serves ./dict/.dawg from its own assets. Skipped on the web and + // in the Mini Apps — there are no bundled dicts, and the relative path would otherwise hit the + // gateway's own session-gated /dict/ route — so those go straight to the network tier. On a hit, seed + // the persistent cache so later loads take tier 1. A bundled hit is a local asset, not a network + // fetch, so it records no fetch metric. + const channel = clientChannel(); + if (channel === 'android' || channel === 'ios') { + try { + const resp = await fetch(`./dict/${key}.dawg`); + if (resp.ok) { + const buf = await resp.arrayBuffer(); + const reader = new Dawg(new Uint8Array(buf)); + instances.set(key, reader); + void idbPutDawg(key, buf); + void requestPersist(); + return reader; + } + } catch { + // A network-off fetch error or a decode failure — fall through to the network tier. + } + } + + // Tier 3: the session-gated download — gated by the bad-connection breaker. A caller's // signal aborts it (the warm-up giving up at its cap, or the game being left) so a large // download does not keep starving the channel on a slow link; the caller counts the miss. if (dictDisabled) return null; diff --git a/ui/src/lib/dict/offlineready.ts b/ui/src/lib/dict/offlineready.ts deleted file mode 100644 index cc00dd7..0000000 --- a/ui/src/lib/dict/offlineready.ts +++ /dev/null @@ -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 { - if (prof.variantPreferences.length === 0) return false; - const run = preloadDicts(prof.dictVersions, prof.variantPreferences, { - getDawg, - disabled: dictLoadingDisabled, - retries: 1, - }); - return raceOfflineReady(run, budgetMs); -} diff --git a/ui/src/lib/distribution.test.ts b/ui/src/lib/distribution.test.ts index a429e35..ba9d72a 100644 --- a/ui/src/lib/distribution.test.ts +++ b/ui/src/lib/distribution.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { isGooglePlayBuild } from './distribution'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import { isGooglePlayBuild, purchasesHidden } from './distribution'; describe('isGooglePlayBuild', () => { it('is false by default (no VITE_GP_BUILD flag, not the mock ?gp force)', () => { @@ -8,3 +8,21 @@ describe('isGooglePlayBuild', () => { expect(isGooglePlayBuild()).toBe(false); }); }); + +describe('purchasesHidden', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('is false by default (the build sells normally)', () => { + expect(purchasesHidden()).toBe(false); + }); + + it('is true for the native MVP that defers billing (VITE_PAYMENTS_DISABLED)', () => { + vi.stubEnv('VITE_PAYMENTS_DISABLED', '1'); + expect(purchasesHidden()).toBe(true); + }); + + it('is true for the Google Play build (folds isGooglePlayBuild)', () => { + vi.stubEnv('VITE_GP_BUILD', '1'); + expect(purchasesHidden()).toBe(true); + }); +}); diff --git a/ui/src/lib/distribution.ts b/ui/src/lib/distribution.ts index e7b10a0..4ddcef9 100644 --- a/ui/src/lib/distribution.ts +++ b/ui/src/lib/distribution.ts @@ -1,7 +1,9 @@ -// Distribution channel of the native build. Google Play forbids selling in-app currency through -// an external gate, so the Google Play build hides the purchase actions and shows a stub pointing -// the user to the RuStore build; every other build (web, VK, Telegram, RuStore native) sells -// normally. The channel is a build-time flag the Google Play build sets, not a runtime guess. +// Distribution flags for the native builds. Two independent build-time flags hide the in-app-currency +// purchase actions: VITE_GP_BUILD marks the Google Play build (Google forbids selling in-app currency +// through an external gate — it hides the actions and shows a stub pointing at the RuStore build), and +// VITE_PAYMENTS_DISABLED marks the thin native MVP that defers store billing (it hides the actions with +// no store pointer). Every other build (web, VK, Telegram, a billing-enabled RuStore native build) sells +// normally. purchasesHidden() folds both; isGooglePlayBuild() stays separate for the RuStore-pointer stub. /** * isGooglePlayBuild reports whether this is the Google Play native build, where in-app-currency @@ -19,3 +21,22 @@ export function isGooglePlayBuild(): boolean { } return (import.meta.env as Record).VITE_GP_BUILD === '1'; } + +/** + * purchasesHidden reports whether this build hides the in-app-currency purchase actions. It is true + * for the Google Play build (external-payment policy — see isGooglePlayBuild) and for the thin native + * MVP that defers store billing (the build-time flag VITE_PAYMENTS_DISABLED set to "1"). Every other + * build sells normally. Under the mock e2e it can be forced on with a `?nopay` query parameter so the + * hidden-purchases state is exercisable without a separate build. + */ +export function purchasesHidden(): boolean { + if (isGooglePlayBuild()) return true; + if ( + import.meta.env.MODE === 'mock' && + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).has('nopay') + ) { + return true; + } + return (import.meta.env as Record).VITE_PAYMENTS_DISABLED === '1'; +} diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index e755136..990b99f 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -7,7 +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 { reportUpdateRecommended, reportUpdateRequired } from './update.svelte'; const isMock = import.meta.env.MODE === 'mock'; @@ -31,6 +33,25 @@ if (isMock && typeof window !== 'undefined') { off: clearMaintenance, recover: maintenanceRecovered, }; + // 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). ( diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index d3a50de..cb92099 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -7,9 +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.', @@ -31,6 +38,7 @@ export const en = { 'common.loading': 'Loading…', 'common.retry': 'Retry', 'common.you': 'You', + 'common.guest': 'Guest', 'common.save': 'Save', 'login.title': 'Sign in', @@ -215,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', @@ -252,6 +253,7 @@ export const en = { 'wallet.iosBlockedLink': 'other version', 'wallet.iosBlockedPost': ' of the game.', 'wallet.gpStub': 'To buy chips, install the RuStore build.', + 'wallet.purchasesSoon': 'Purchases will be available in a future update.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'votes', 'wallet.cur.XTR': '⭐', @@ -373,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', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b177751..f4c131d 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -8,9 +8,16 @@ export const ru: Record = { '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': 'Ваша учётная запись заблокирована.', @@ -32,6 +39,7 @@ export const ru: Record = { 'common.loading': 'Загрузка…', 'common.retry': 'Повторить', 'common.you': 'Вы', + 'common.guest': 'Гость', 'common.save': 'Сохранить', 'login.title': 'Вход', @@ -215,15 +223,8 @@ export const ru: Record = { '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': 'Кошелёк', @@ -252,6 +253,7 @@ export const ru: Record = { 'wallet.iosBlockedLink': 'другой версией', 'wallet.iosBlockedPost': ' игры.', 'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.', + 'wallet.purchasesSoon': 'Покупки появятся в одном из следующих обновлений.', 'wallet.cur.RUB': '₽', 'wallet.cur.VOTE': 'голосов', 'wallet.cur.XTR': '⭐', @@ -373,6 +375,10 @@ export const ru: Record = { 'new.auto': 'Быстрая игра', 'new.withFriends': 'Игра с друзьями', + 'new.playRemote': 'Пригласить друга', + 'new.playLocal': 'На одном устройстве', + 'new.needsNetwork': 'Для игры онлайн нужна сеть.', + 'new.dictUnavailable': 'Этот словарь пока недоступен офлайн.', 'new.pickFriends': 'Кого пригласить', 'new.searchFriends': 'Поиск друзей', 'new.gameType': 'Вариант', diff --git a/ui/src/lib/lobbycache.test.ts b/ui/src/lib/lobbycache.test.ts index 1754a96..9e21401 100644 --- a/ui/src/lib/lobbycache.test.ts +++ b/ui/src/lib/lobbycache.test.ts @@ -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']); }); }); diff --git a/ui/src/lib/lobbycache.ts b/ui/src/lib/lobbycache.ts index 9ffc0e1..86e302a 100644 --- a/ui/src/lib/lobbycache.ts +++ b/ui/src/lib/lobbycache.ts @@ -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. */ diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index 792535b..c8f758f 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -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(); }); }); diff --git a/ui/src/lib/lobbysort.ts b/ui/src/lib/lobbysort.ts index 09696d5..53d70c6 100644 --- a/ui/src/lib/lobbysort.ts +++ b/ui/src/lib/lobbysort.ts @@ -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, ): 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. diff --git a/ui/src/lib/localguest.ts b/ui/src/lib/localguest.ts new file mode 100644 index 0000000..a73f113 --- /dev/null +++ b/ui/src/lib/localguest.ts @@ -0,0 +1,46 @@ +// The device-local play identity: a persisted id that exists from the very first launch with no +// network and no DB row. It fills the human seat's accountId in a local vs_ai game when there is no +// server session yet (so the seat is recognised as "you" across reloads); hotseat seats keep their +// own independent local identities. A purely-offline user never mints a server guest and consumes no +// server resources — when the app first reaches the network the reconciliation (app.svelte.ts) mints +// a server guest and adopts its session, but the local games created under this id stay device-only. +// +// The display name is not persisted here — it is a UI concern the caller localises (t('common.guest')), +// so this module stays free of i18n and unit-tests in the node env alongside the other localgame libs. + +const ID_KEY = 'scrabble.localGuestId'; + +/** LOCAL_GUEST_PREFIX marks an account id as a device-local guest (no DB row). */ +export const LOCAL_GUEST_PREFIX = 'localguest:'; + +function mintId(): string { + // No crypto API, so it also works on the older engines the SPA still supports (mirrors + // localgame/id.ts newLocalGameId). + return `${LOCAL_GUEST_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * localGuestId returns the persisted device-local guest id, minting and storing one on first use. + * Best-effort persistence: where storage is unavailable it returns a fresh ephemeral id each call (a + * device that cannot persist still plays, it just does not carry a stable local identity across + * reloads). + */ +export function localGuestId(): string { + try { + if (typeof localStorage !== 'undefined') { + const existing = localStorage.getItem(ID_KEY); + if (existing) return existing; + const minted = mintId(); + localStorage.setItem(ID_KEY, minted); + return minted; + } + } catch { + /* storage unavailable — fall through to an ephemeral id */ + } + return mintId(); +} + +/** isLocalGuestId reports whether an account id is a device-local guest id. */ +export function isLocalGuestId(id: string): boolean { + return id.startsWith(LOCAL_GUEST_PREFIX); +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 43bd128..84b0e4e 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -180,6 +180,12 @@ export class MockGateway implements GatewayClient { async authGuest(): Promise { return { ...SESSION }; } + async authGuestSilent(): Promise { + // 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 {} async confirmEmailLink(_token: string): Promise { return { purpose: 'link', status: 'confirmed', session: null }; diff --git a/ui/src/lib/native.ts b/ui/src/lib/native.ts new file mode 100644 index 0000000..f25b85e --- /dev/null +++ b/ui/src/lib/native.ts @@ -0,0 +1,55 @@ +// Native shell integration for the Capacitor Android/iOS builds. Kept in its own +// module behind a dynamic import so the web / Telegram / VK / mock bundles never load +// @capacitor/app: initNativeShell early-returns off the native channels, so the import +// 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 + * Capacitor app. Currently it binds the Android hardware Back button: at the navigation + * root it exits the app, otherwise it steps the in-app (hash) history back. It is a + * no-op on the web/Telegram/VK channels and never loads @capacitor/app there. + * + * atNavigationRoot reports whether the current route is a navigation root (the lobby or + * login screen) — mirror App.svelte's routeDepth(...) === 0. + */ +export async function initNativeShell(atNavigationRoot: () => boolean): Promise { + const ch = clientChannel(); + if (ch !== 'android' && ch !== 'ios') return; + 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 { + 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. + } +} + diff --git a/ui/src/lib/netstate.svelte.ts b/ui/src/lib/netstate.svelte.ts new file mode 100644 index 0000000..939a4d8 --- /dev/null +++ b/ui/src/lib/netstate.svelte.ts @@ -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({ ...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) | null = null; +let recoverFn: (() => Promise) | 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 { + 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 { + 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 | 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 { + 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 { + if (!probeFn) return false; + try { + await Promise.race([ + probeFn(), + new Promise((_, 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 }; +} diff --git a/ui/src/lib/netstate.test.ts b/ui/src/lib/netstate.test.ts new file mode 100644 index 0000000..6aa4e3c --- /dev/null +++ b/ui/src/lib/netstate.test.ts @@ -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 => ({ + 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({ state: 'connecting', fails: 1, connectingSince: 5 }); + expect(() => reduce(prev, e('probeFailed', 10), cfg)).not.toThrow(); + expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 }); + }); +}); diff --git a/ui/src/lib/netstate.ts b/ui/src/lib/netstate.ts new file mode 100644 index 0000000..1ed6ead --- /dev/null +++ b/ui/src/lib/netstate.ts @@ -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: [] }; +} diff --git a/ui/src/lib/offline.svelte.ts b/ui/src/lib/offline.svelte.ts index b355ee7..6991c9c 100644 --- a/ui/src/lib/offline.svelte.ts +++ b/ui/src/lib/offline.svelte.ts @@ -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 { - 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; diff --git a/ui/src/lib/offline.test.ts b/ui/src/lib/offline.test.ts index 895e863..f6b2337 100644 --- a/ui/src/lib/offline.test.ts +++ b/ui/src/lib/offline.test.ts @@ -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(); (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 => new Promise(() => {}); - // A sleep that elapses immediately: the budget always wins the race, exercising the timeout. - const now = (): Promise => 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(); }); }); diff --git a/ui/src/lib/offline.ts b/ui/src/lib/offline.ts index f72ec08..77ffc99 100644 --- a/ui/src/lib/offline.ts +++ b/ui/src/lib/offline.ts @@ -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 = (ms) => new Promise((r) => setTimeout(r, ms)), -): Promise { - 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; -} diff --git a/ui/src/lib/origin.test.ts b/ui/src/lib/origin.test.ts new file mode 100644 index 0000000..5fe23d5 --- /dev/null +++ b/ui/src/lib/origin.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { gatewayOrigin } from './origin'; + +describe('gatewayOrigin', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it('returns the configured VITE_GATEWAY_URL (the packaged native build)', () => { + vi.stubEnv('VITE_GATEWAY_URL', 'https://erudit-game.ru'); + // Even with a page origin present, the configured gateway wins (native loads from a local origin). + vi.stubGlobal('location', { origin: 'https://localhost' }); + expect(gatewayOrigin()).toBe('https://erudit-game.ru'); + }); + + it('falls back to the page origin when VITE_GATEWAY_URL is unset (web/mock)', () => { + vi.stubEnv('VITE_GATEWAY_URL', ''); + vi.stubGlobal('location', { origin: 'https://erudit-game.ru' }); + expect(gatewayOrigin()).toBe('https://erudit-game.ru'); + }); + + it('returns an empty string with neither a configured gateway nor a location (server-side)', () => { + vi.stubEnv('VITE_GATEWAY_URL', ''); + // No location stub — location is undefined in the node test env, so the path degrades to relative. + expect(gatewayOrigin()).toBe(''); + }); +}); diff --git a/ui/src/lib/origin.ts b/ui/src/lib/origin.ts new file mode 100644 index 0000000..644fc75 --- /dev/null +++ b/ui/src/lib/origin.ts @@ -0,0 +1,17 @@ +// The absolute origin the client talks to. A packaged native build (Capacitor) loads the SPA from +// its own local origin (https://localhost / file://), so an absolute URL built from location.origin +// would point at the app itself, not the gateway. The native build therefore sets VITE_GATEWAY_URL +// to the real gateway; web/mock leave it empty and fall back to the page origin. Centralised so every +// absolute-URL construction resolves identically. transport.ts computes the same fallback inline for +// the Connect base URL and is intentionally left untouched. + +/** + * gatewayOrigin returns the absolute origin every client build talks to: the configured + * VITE_GATEWAY_URL when set (the native build), otherwise the current page origin. It returns an + * empty string when there is no location (server-side), so callers appending a path degrade to a + * relative URL. + */ +export function gatewayOrigin(): string { + const configured = import.meta.env.VITE_GATEWAY_URL ?? ''; + return configured || (typeof location !== 'undefined' ? location.origin : ''); +} diff --git a/ui/src/lib/pwa.svelte.ts b/ui/src/lib/pwa.svelte.ts index 0357a6e..472b7bf 100644 --- a/ui/src/lib/pwa.svelte.ts +++ b/ui/src/lib/pwa.svelte.ts @@ -5,6 +5,7 @@ import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa'; import { insideTelegram } from './telegram'; import { insideVK } from './vk'; +import { clientChannel } from './channel'; /** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web * install makes no sense (the platform manages its own launcher). Kept here, out of the pure @@ -79,12 +80,16 @@ export async function promptInstall(): Promise { * registerServiceWorker registers the app-shell service worker (built from ui/src/sw.ts to * dist/sw.js by vite-plugin-pwa): it satisfies Chromium's installability requirement and precaches * the shell + assets so an installed PWA cold-launches offline. Web-only: skipped in the mock build - * (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini App. Best-effort — - * a failed registration only means the app is not installable and cannot launch offline. + * (a real worker would perturb the Playwright run), inside a Telegram/VK Mini App, and inside the + * Capacitor native shell (the bundle is already local, and a worker would risk serving stale assets + * across store updates). Best-effort — a failed registration only means the app is not installable + * and cannot launch offline. */ export function registerServiceWorker(): void { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; if (import.meta.env.MODE === 'mock') return; if (inMiniApp()) return; + const ch = clientChannel(); + if (ch === 'android' || ch === 'ios') return; void navigator.serviceWorker.register('sw.js').catch(() => {}); } diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index ec3ad3a..8fe89b9 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -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: '🏅' }); }); }); diff --git a/ui/src/lib/result.ts b/ui/src/lib/result.ts index ea83fb5..e2a2e01 100644 --- a/ui/src/lib/result.ts +++ b/ui/src/lib/result.ts @@ -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); } diff --git a/ui/src/lib/retry.test.ts b/ui/src/lib/retry.test.ts index fb13066..65c9057 100644 --- a/ui/src/lib/retry.test.ts +++ b/ui/src/lib/retry.test.ts @@ -21,6 +21,10 @@ describe('toGatewayError', () => { expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal'); expect(isConnectionCode('internal')).toBe(false); }); + + it('maps the Subscribe FailedPrecondition to the update_required sentinel (client too old)', () => { + expect(toGatewayError(new ConnectError('x', Code.FailedPrecondition)).code).toBe('update_required'); + }); }); describe('retryable', () => { diff --git a/ui/src/lib/retry.ts b/ui/src/lib/retry.ts index 4bcf542..6c1b562 100644 --- a/ui/src/lib/retry.ts +++ b/ui/src/lib/retry.ts @@ -33,6 +33,9 @@ export function toGatewayError(e: unknown): GatewayError { return new GatewayError('unavailable', e.message); case Code.NotFound: return new GatewayError('not_found', e.message); + case Code.FailedPrecondition: + // The Subscribe stream's counterpart of the update_required envelope: the client is too old. + return new GatewayError('update_required', e.message); default: return new GatewayError('internal', e.message); } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 2c066da..216bc78 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -17,6 +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, reportUpdateRecommended, reportUpdateRequired } from './update.svelte'; const MAX_RETRIES = 6; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); @@ -30,12 +31,32 @@ 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; - const headers = (): Record | undefined => - token ? { authorization: `Bearer ${token}` } : undefined; + // Every call carries the build version so the edge's client-version gate can turn away a build + // too old to speak the current wire contract, before it decodes the payload (the header rides the + // outermost stable layer; see docs/ARCHITECTURE.md §2). + const headers = (): Record => { + const h: Record = { 'x-client-version': __APP_VERSION__ }; + if (token) h.authorization = `Bearer ${token}`; + return h; + }; // The reachability probe the connection watcher fires while offline: a cheap authenticated read // (it must reject when there is no session, so the watcher keeps waiting rather than reporting up). @@ -54,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 { - assertOnline(); + async function exec( + messageType: string, + payload: Uint8Array, + signal?: AbortSignal, + opts?: { silent?: boolean; allowOffline?: boolean }, + ): Promise { + // 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 { @@ -71,6 +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; 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)); @@ -83,7 +116,10 @@ export function createTransport(baseUrl: string): GatewayClient { // A read got through: if the maintenance overlay was up, the deploy window has ended — // reload to pick up the (possibly incompatible) fresh client (maintenance.svelte.ts). maintenanceRecovered(); - if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode); + if (res.resultCode && res.resultCode !== 'ok') { + if (res.resultCode === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired(); + throw new GatewayError(res.resultCode); + } return res.payload; } } @@ -133,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)); }, @@ -363,7 +411,9 @@ export function createTransport(baseUrl: string): GatewayClient { if (!ctrl.signal.aborted) { const maintMs = maintenanceRetryMs(e); if (maintMs !== null) reportMaintenance(maintMs); - onError?.(toGatewayError(e)); + const err = toGatewayError(e); + if (err.code === UPDATE_REQUIRED) reportUpdateRequired(); + onError?.(err); } } })(); diff --git a/ui/src/lib/update.svelte.ts b/ui/src/lib/update.svelte.ts new file mode 100644 index 0000000..14d560d --- /dev/null +++ b/ui/src/lib/update.svelte.ts @@ -0,0 +1,76 @@ +// 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'; + +/** 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(); + } +} + +/** 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 recommended; + }, + /** dismissed is true once the user has closed the nudge for this session. */ + get dismissed(): boolean { + return nudgeDismissed; + }, +}; + +/** 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'); +} diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 0363a67..e8df9ea 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -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(); 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 @@
- {#if invitations.length} + {#if invitations.length && !offlineMode.active}

{t('lobby.invitations')}

{#each invitations as inv (inv.id)} @@ -319,7 +346,7 @@
{#each group.list as g (g.id)} {@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)} -
+
{#if group.finished || g.hotseat} {/if} @@ -336,7 +363,7 @@ {#if badge}{/if} {#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myId, s)}{#if i > 0}{' : '}{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myIds, s)}{#if i > 0}{' : '}{/if} {#key blinkNonce.get(g.id) ?? 0} - {g.hotseat ? '' : resultBadge(g, myId).emoji} + {g.hotseat ? '' : resultBadge(g, myIds).emoji} {/key} {#if group.finished || g.hotseat} @@ -397,6 +424,7 @@ {/if} {#snippet tabbar()} + (limitOpen = false)} onlogin={() => { limitOpen = false; navigate('/profile'); }} /> - {:else if offlineMode.active} + {:else} + + {#if canPlayOffline} +
+ + +
+ {#if offlineMode.active}

{t('new.needsNetwork')}

{/if} + {/if} + {#if friendsMode === 'offline'}
@@ -431,9 +492,10 @@ + {t('hotseat.addPlayer')} {/if} + {#if dictBlocked}{/if} {:else if friends.length === 0} @@ -482,6 +544,7 @@ {/if}
+ {/if} {/if} {#if pad} @@ -705,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; diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index f4a9bd0..bb7644e 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -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); 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). diff --git a/ui/src/screens/Settings.svelte b/ui/src/screens/Settings.svelte index 84e94ec..befd95f 100644 --- a/ui/src/screens/Settings.svelte +++ b/ui/src/screens/Settings.svelte @@ -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 = { 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 { - if (offlineMode.active || checking) return; - needsData = false; - checking = true; - try { - const ready = await requestOffline(app.profile); - if (!ready) needsData = true; - } finally { - checking = false; - } - }
@@ -119,33 +91,6 @@ {/if}
- {#if offlineEligible} -
-

{t('settings.offlineMode')}

-
- - -
- {#if checking} -

{t('settings.offlineChecking')}

- {:else if needsData} - - {/if} -
- {/if} - @@ -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; diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 1dbbe42..ff4c1c5 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -6,8 +6,9 @@ import { normalizeSubtype } from '../lib/platform'; import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte'; import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet'; - import { isGooglePlayBuild } from '../lib/distribution'; + import { isGooglePlayBuild, purchasesHidden } from '../lib/distribution'; import { onExternalLinkClick, openExternalUrl } from '../lib/links'; + import { gatewayOrigin } from '../lib/origin'; import { vkPlatform, vkShowOrderBox } from '../lib/vk'; import { telegramOpenInvoice } from '../lib/telegram'; import { rewardedReady, showRewarded } from '../lib/ads'; @@ -16,8 +17,9 @@ // The Wallet section: a compact chip balance, the active benefits, and the storefront. The store // splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips" - // (values bought with chips — an instant exchange). On the Google Play build the money purchases - // are hidden behind a RuStore stub (store policy), while spending already-earned chips still works. + // (values bought with chips — an instant exchange). When purchases are hidden — the Google Play + // build (behind a RuStore stub, store policy) or the native MVP that defers billing (a neutral + // note) — the money purchases disappear while spending already-earned chips still works. let wallet = $state(null); let catalog = $state(null); @@ -38,13 +40,16 @@ ); const gpBuild = isGooglePlayBuild(); + // Money purchases are hidden in the Google Play build (policy) and in the native MVP that defers + // billing; the RuStore-pointer stub shows only for the former (gpBuild). + const noPurchases = purchasesHidden(); // Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown // muted and taps explain why, rather than hiding the storefront. VK's device family is not on the // client platformSubtype (server-derived) — read it from the signed vk_platform launch param. const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios'; // The "other version" link points at this deployment's own site root (the landing lists every // platform), so it follows prod vs the contour without a hardcoded host. - const siteRoot = typeof location !== 'undefined' ? `${location.origin}/` : '/'; + const siteRoot = `${gatewayOrigin()}/`; const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []); const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []); @@ -237,8 +242,12 @@ >
{/if} - {#if gpBuild} -

{t('wallet.gpStub')}

+ {#if noPurchases} + {#if gpBuild} +

{t('wallet.gpStub')}

+ {:else} +

{t('wallet.purchasesSoon')}

+ {/if} {:else} {#each packs as p (p.productId)}
diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index 02e18e8..de27400 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -2,8 +2,16 @@ /// interface ImportMetaEnv { - /** Base URL of the gateway Connect endpoint. Empty in dev (same-origin proxy). */ + /** Base URL of the gateway Connect endpoint. Empty in dev (same-origin proxy); set to the real + * gateway origin in a packaged native build. */ 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; + /** 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; } interface ImportMeta { @@ -12,3 +20,7 @@ interface ImportMeta { /** App version string, injected by Vite's define from `git describe` at build time. */ declare const __APP_VERSION__: string; + +/** Bundled-dictionary version, injected by Vite's define from VITE_DICT_VERSION (default "dev"). The + * native offline path requests this (variant, version) so it matches the packaged ./dict files. */ +declare const __DICT_VERSION__: string; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5a36909..f5af8c3 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -54,6 +54,7 @@ export default defineConfig(({ mode }) => ({ // via a Docker build-arg. Falls back to "dev" for a plain local/mock build, // so a missing build-arg never breaks the build. __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || 'dev'), + __DICT_VERSION__: JSON.stringify(process.env.VITE_DICT_VERSION || 'dev'), }, // emitPolyfills ships dist/polyfills.js (loaded only by old engines; see its docstring + the // index.html boot guard). injectBootVersion stamps the app version into that guard's diagnostic.