# 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: `v