a8f78c7880
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README). Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later. Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
1116 lines
86 KiB
Markdown
1116 lines
86 KiB
Markdown
# 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/<variant>@<version>.dawg`, keyed on `VITE_DICT_VERSION`, `OUT_DIR` override
|
||
for the e2e); the `dict/loader.ts` **bundled tier** (between IndexedDB and network, **native-gated** —
|
||
see the §D.1 correction); `__DICT_VERSION__` vite define + declaration; `lib/localguest.ts` (persisted
|
||
device-local guest id, no DB row) + `common.guest` i18n; `NewGame.svelte` offline vs_ai/hotseat creates
|
||
fall back to `__DICT_VERSION__` and `localGuestId()`/`t('common.guest')` when there is no session.
|
||
- **D.3 (cold boot) + D.4 (reconciliation + silent seam) — ✅ DONE & verified (2026-07-12).** The native
|
||
no-session cold boot lands in the offline lobby as a device-local guest (`app.svelte.ts` bootstrap
|
||
`else if (native)` branch — `localGuestId()` + `setOfflineMode(true,false)` + `scheduleRecovery(0)`);
|
||
lazy `reconcileServerGuest()` mints + adopts a server guest and clears the auto-offline when the gateway
|
||
is reachable (kicked at boot + by the recovery poll + the online event). Silent seam:
|
||
`transport.ts` `exec` gained `{ silent, allowOffline }` (suppress the update overlay **and** bypass the
|
||
kill switch), plus `authGuestSilent(locale)` on the `GatewayClient` interface, the real transport and the
|
||
mock. `native.ts` `initNativeShell` made bridge-tolerant. New `e2e/native.spec.ts` (Capacitor injected;
|
||
boot→offline-lobby, local vs_ai move, hotseat start, reconcile→online) + `playwright.config.ts` bundles
|
||
the dawgs into `dist-e2e/dict/`. `svelte-check` 0, `vitest` 591, web + native builds clean; e2e runs in CI.
|
||
See §D.3/§D.4 for the as-built corrections (shouldBootOffline **not** changed; `allowOffline` +
|
||
checkReachable-token findings; the `__native.reconcile` e2e hook).
|
||
- **D.6 (Profile tg/vk hide on native) — ✅ DONE & verified (2026-07-12).** `Profile.svelte` gates
|
||
`telegramLinkable`/`vkLinkable` on `!nativeShell` (`clientChannel()` android/ios), so the Telegram + VK
|
||
LINK buttons are hidden on the native build; email + account management (incl. any existing link's UNLINK,
|
||
a redirect-free gateway call) stay. Covered by the `e2e/native.spec.ts` reconcile test (Profile → no Link
|
||
Telegram / Link VK buttons, email present).
|
||
- **Native chrome: Android 15+ edge-to-edge safe-area — ✅ FIXED & on-device-verified (2026-07-12, owner-confirmed).**
|
||
targetSdk 36 forces edge-to-edge, so the WebView draws behind the system bars; the app chrome overlapped the
|
||
status bar (top nav, untappable) and the gesture-nav home indicator (the game's centre Hint button — side
|
||
buttons fine). Two-part CSS fix consuming the SystemBars core plugin's injected `--safe-area-inset-*`:
|
||
the **bottom** via the `--tg-safe-*` token (`app.css`), the **header top** via a native-only `.bar` rule
|
||
(`Header.svelte` — its top inset had been Telegram-fullscreen-scoped only, so the token alone didn't reach
|
||
it). No dep/config (SystemBars ships in `@capacitor/core` v8). Verified on Pixel_10 / API 37 by live-WebView
|
||
CDP measurement (header title y=11→65, `--safe-area-inset-*` = 54/24px) **and** owner re-test on their device.
|
||
Full gotcha + CDP recipe in `.claude/CLAUDE.md`. Commits `4a0689a` (bottom) + `49c5379` (top).
|
||
- **DEFERRED DECISION (owner-agreed 2026-07-12) — native local-game visibility.** The online lobby lists
|
||
only server games (`Lobby.svelte:42/54`), so a native guest's **device-local vs_ai/hotseat games hide from
|
||
the lobby once reconciled online** (the pre-existing offline↔online split). For native this IS the primary
|
||
flow, so the behaviour **must change** — a native guest should still see / resume their local games when
|
||
online. Owner agreed it needs changing but wants to design it **at the very end of the Android work (before
|
||
release — see §G)**; out of D.3/D.4 scope, tracked here so it is not lost.
|
||
- **E. CI — signed APK artifact — ✅ CODE-COMPLETE & locally-proven (2026-07-12).** New manual workflow
|
||
`.gitea/workflows/android-build.yaml` (`workflow_dispatch` + `confirm=build`, `if` gated to `master` —
|
||
mirrors `prod-deploy.yaml`; never on a PR) builds the native-flavoured SPA, bundles the offline dicts, and
|
||
assembles a release APK uploaded as a run artifact. Like `prod-deploy`, it is **provable only by a dispatch
|
||
from `master`** (that is §G), so E was verified as far as possible now: locally via `assembleDebug` + a
|
||
**keyless** `assembleRelease` (→ `app-release-unsigned.apk`, `versionCode 1017000` / `versionName 1.17.0`
|
||
for a `v1.17.0` tag — confirmed in `output-metadata.json`), `svelte-check` 0, YAML structure checked.
|
||
As-built:
|
||
- **`ui/android/app/build.gradle`:** `versionCode`/`versionName` read `-PversionCode`/`-PversionName` (defaults
|
||
keep local `assembleDebug` building). **CORRECTION vs the plan snippet — use `=` assignment**
|
||
(`versionCode = (…).toInteger()`), NOT the command form `versionCode (…).toInteger()`: the latter binds
|
||
`.toInteger()` to the DSL setter's null return (`> Value is null`). A guarded `signingConfigs.release` reads
|
||
the keystore from env and is attached **only when the keystore file exists** — so **no keystore ⇒ an UNSIGNED
|
||
release APK, not a failure** (a dispatch proves the pipeline before the keystore exists).
|
||
- **Toolchain:** the **CI runner is a host-executor on a remote Debian host**. JDK 21 comes from
|
||
`actions/setup-java`; the **Android SDK is host-provisioned** (pre-installed at `ANDROID_SDK_DIR`, default
|
||
`/opt/android-sdk`), with a fail-fast **Verify the host Android SDK** pre-flight that also checks the `runner`
|
||
user's read/exec access (`platforms;android-36` + `build-tools`). Unit tests need no new tooling (they ride
|
||
the existing `unit`/`ui` jobs).
|
||
- **Env:** `VITE_GATEWAY_URL` reuses `vars.PROD_PUBLIC_BASE_URL` (trailing slash stripped); `VITE_DICT_VERSION` /
|
||
`DICT_VERSION` = the `DICT_VERSION` var; `VITE_PAYMENTS_DISABLED=1`. **`VITE_STORE_URL` was renamed to
|
||
`VITE_RUSTORE_URL`** (owner) — left **empty** via the (unset) `vars.ANDROID_RUSTORE_URL`, so the update button
|
||
no-ops until publication (`UpdateOverlay.svelte` already guards `if (url)`; the gate is dormant in the MVP).
|
||
- **versionCode from an exact tag:** the workflow refuses anything but a clean `git describe --exact-match`
|
||
`vX.Y.Z` (deterministic + monotonic store versionCode); §G tags before dispatch.
|
||
- **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch).
|
||
Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`,
|
||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
|
||
- **F. Docs — ✅ DONE (2026-07-12).** Baked the Android work into the live docs: `docs/ARCHITECTURE.md` §2
|
||
(the client-version gate + the frozen wire contract + the gate × offline rule), §3 (the local-guest /
|
||
server-guest / reconciliation identity model) and §13 (the native Capacitor build — bundle model, bundled
|
||
dicts, `versionCode` scheme, `file://` origin, RuStore); `docs/FUNCTIONAL.md` (+ `_ru` mirror) a new **Native
|
||
app (Android)** domain (offline-first guest launch, email soft-registration, no TG/VK login, hidden
|
||
purchases, update-required); `docs/TESTING.md` (the `clientver`/gate Go tests, the `native`/`update` e2e +
|
||
the `retry` mapping, and the manual **on-device Android smoke checklist**); `deploy/README.md` (the
|
||
`GATEWAY_MIN_CLIENT_VERSION` var + the wire-break bump discipline + the Android build/release runbook —
|
||
keystore, secrets, dispatch, RuStore upload); `ui/README.md` (the native `VITE_*` build vars). Every
|
||
referenced test file was confirmed to exist.
|
||
- **G — pending; gated on the offline-model redesign (was G-step-0).** The deferred native
|
||
local-game-visibility decision grew into an owner-approved cross-cutting change (web + native + a small
|
||
additive backend/wire bit): remove the explicit offline toggle → one `netState` machine, unify the lobby,
|
||
two-tier version gate. **Designed + staged (O1–O7)** in the "Offline-model redesign" section below.
|
||
**➡ Next actionable work: implement O1** (the pure `netState` reducer, test-first) via `stage-implementation`,
|
||
then O2–O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
|
||
O1–O7 is started. Branch `feature/android-native`, local/unpushed.
|
||
|
||
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
|
||
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
|
||
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-<DICT_VERSION>.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<void> {
|
||
const ch = clientChannel();
|
||
if (ch !== 'android' && ch !== 'ios') return;
|
||
const { App } = await import('@capacitor/app');
|
||
App.addListener('backButton', () => {
|
||
if (atNavigationRoot()) void App.exitApp();
|
||
else history.back();
|
||
});
|
||
}
|
||
```
|
||
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<string, string | undefined>).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<string, string> => {
|
||
const h: Record<string, string> = { '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 `<UpdateOverlay />` right after `<MaintenanceOverlay />` (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`) → `<OUT_DIR|dist>/dict/<variant>@<version>.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/<variant>@<version>.dawg` (version flows from
|
||
`VITE_DICT_VERSION`, default `dev`, matching `__DICT_VERSION__`). **Ran locally green (chromium + webkit, 4/4)
|
||
against the installed browsers + the sibling `scrabble-solver/dawg`; also runs in CI.**
|
||
- **GOTCHA resolved:** `initNativeShell` was made **bridge-tolerant** (`try/catch` around the `@capacitor/app`
|
||
import + `addListener`) rather than stubbing a fake Capacitor bridge — cleaner and defensive for a real WebView
|
||
that lacks the App plugin.
|
||
|
||
**Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and
|
||
plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on
|
||
silently establishes a server guest (Profile + online play light up). Emulator smoke recipe: `.claude/CLAUDE.md`
|
||
→ "Native Android build", with the extra `DICT_DIR=<release> VITE_DICT_VERSION=<ver> node
|
||
scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`.
|
||
|
||
### E. CI — signed APK artifact — ✅ 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<min` → notice → "Play offline" → offline lobby; `min≤v<recommended` → dismissible banner, online continues;
|
||
`v≥recommended`/both empty → nothing; fail-open on an absent/garbled header. **Verified.**
|
||
- Targeted tests: Go `config_test` (`TestLoadRecommendedClientVersion`) + `server_test` (`TestExecuteUpdateRecommended` — 3
|
||
bands + at-min/at-recommended boundaries + fail-open + recommended-alone + dormant); `e2e/update.spec.ts` rewritten (notice
|
||
+ both actions; Update reloads; Play offline → offline lobby; the soft nudge shows in the lobby + dismisses). **Full gateway
|
||
go-test/build/vet/gofmt clean; e2e 122/122 chromium + webkit; svelte-check 0/0; vitest 616.**
|
||
|
||
- **O5 — Unified lobby. — ✅ DONE (2026-07-13).**
|
||
- Files (as built) — `ui/src/screens/Lobby.svelte` (`load()` merges `localSource.list()` + `gateway.gamesList()`;
|
||
online ⇒ `[...local, ...server]`; offline ⇒ `[...localFresh, ...cachedServer]` greyed; the mode-exclusive branch is
|
||
gone; `grey(g) = offline && !isLocalGameId` drives a `.rowwrap.greyed` dim + an un-openable `openGame` that toasts
|
||
`net.offline`; **invitations hidden offline** — owner addition — via `{#if invitations.length && !offline}`), `ui/src/lib/lobbysort.ts`
|
||
+ `ui/src/lib/result.ts` (`myId: string` → `myIds: readonly string[]` self-set across `isMyTurn`/`scoreStanding`/
|
||
`gamePhase`/`groupGames`/`resultBadge`; game seat-matching uses `myIds.includes(...)`), `ui/src/lib/lobbycache.ts`
|
||
(dropped the `offline` snapshot key — **one unified snapshot**; `getLobby()` no arg; offline reuses its server games),
|
||
`ui/src/screens/NewGame.svelte` (`getLobby()` + filter to server games — device-local games do not count toward the
|
||
server per-kind limit), `ui/src/lib/localguest`/`telegram`/`vk` imports in the lobby.
|
||
- **Identity split:** game functions take the self-set `myIds = [session.userId, localGuestId()]` (a reconciled
|
||
guest's local games seat you under `localGuestId`, server games under `userId`, ids never collide); **invitations
|
||
keep `session.userId`** (a server concept). **TG/VK server-only:** the local merge is gated on
|
||
`insideTelegram()`/`insideVK()` (the shared-origin local store must not leak into a mini-app).
|
||
- Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache (un-openable);
|
||
a reconciled guest's local games stay visible online (**closes G-step-0**); invitations hidden offline; TG/VK
|
||
server-only. **Verified.**
|
||
- Targeted tests: `lobbysort.test.ts` + `result.test.ts` extended (two-id "self" recognition), `lobbycache.test.ts`
|
||
reworked (unified snapshot, mode-aware tests removed); `e2e/native.spec.ts` proves the local vs_ai game stays listed
|
||
online (G-step-0); `e2e/offline.spec.ts` + `hotseat.spec.ts` + `update.spec.ts` updated for greyed-from-cache server
|
||
games offline. **Full suite 122/122 on chromium + webkit; svelte-check 0/0; vitest 617.**
|
||
|
||
- **O6 — Create flows. — ✅ DONE (2026-07-13).**
|
||
- Files (as built) — `ui/src/screens/NewGame.svelte`: **with-friends** now carries a `friendsMode` segmented control
|
||
(`new.playRemote` = online invite / `new.playLocal` = offline hotseat); the online segment is `disabled` when
|
||
`offlineMode.active` and an `$effect` forces `friendsMode='offline'` there (a `new.needsNetwork` note explains it);
|
||
the friends markup is restructured from the mode-exclusive `{:else if offlineMode.active}` chain into
|
||
`{:else} <segment> {#if friendsMode==='offline'} hotseat {:else if !friends} noFriends {:else} invite`. **vs_ai
|
||
unchanged** (verified: `find()` still offline→`localSource.create`, online→`lobbyEnqueue`). **Dict guard** (owner-chosen
|
||
**async getDawg**): a `dawgReady`/`dictBlocked` pair — an `$effect` awaits `getDawg(guardVariant, version)` while
|
||
offline (`guardVariant` = the quick pick or the hotseat pick) and disables create + shows a `new.dictUnavailable` note
|
||
only on a **confirmed** miss (`dawgReady === false`, never while `null`/checking, so a still-warming dawg does not
|
||
false-block). `ui/src/lib/i18n/{en,ru}.ts` (`new.{playRemote,playLocal,needsNetwork,dictUnavailable}`).
|
||
- Owner-chosen (interview): the dict guard uses the **async getDawg** precheck (no reliable sync signal; native bundles
|
||
every variant so it mostly guards web offline); **guest gating unchanged** — an online guest still sees vs_ai only (no
|
||
new online-guest hotseat); a guest offline keeps its hotseat.
|
||
- Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline preselected,
|
||
create works; vs_ai offline→local / online→server unchanged; create disabled with a reason on an unavailable offline
|
||
dawg. **Verified.**
|
||
- Targeted tests: `e2e/hotseat.spec.ts` asserts the offline segment (online disabled, hotseat forced); `offline.spec.ts`
|
||
+ `native.spec.ts` exercise vs_ai-local + hotseat create through the new segment. **Full suite 122/122 on chromium +
|
||
webkit; svelte-check 0/0; vitest 617.**
|
||
|
||
- **O7 — Docs + native e2e. — ✅ DONE (2026-07-13).**
|
||
- Files (as built) — `docs/ARCHITECTURE.md`: **§2** rewrote the connectivity signal into the **net-state machine**
|
||
(four states, implicit offline, hysteresis, self-heal, TG/VK-exempt), the version gate into the **two tiers** (hard →
|
||
`offlineVersionLocked` + dismissable notice, *not* terminal; soft → `X-Update-Recommended` nudge), and the gate×offline
|
||
rule; **§13** (PWA/offline) rewrote the offline model from the deliberate toggle/dialog to implicit detection + the
|
||
**unified lobby** (greyed cached server games) + the with-friends **segment** + the dict guard. `docs/FUNCTIONAL.md`
|
||
(+`_ru` mirror): rewrote *Offline mode* (implicit, unified lobby, segment) and added a *Staying up to date* /
|
||
*«Актуальная версия»* story (Update/Play-offline notice + the soft nudge). `docs/TESTING.md`: the `netstate.test.ts`
|
||
reducer layer, the `__net`-driven offline spec, the two-tier `update.spec`, the soft-tier Go server/config tests, and
|
||
the native G-step-0 assertion. `deploy/README.md`: a **`GATEWAY_RECOMMENDED_CLIENT_VERSION`** row (soft tier;
|
||
validated ≥ `MIN`). `ui/e2e/native.spec.ts` already reflects the new boot/offline (done in O5).
|
||
- Acceptance: the docs describe both tiers + the implicit model + the unified lobby; `deploy/README` lists
|
||
`GATEWAY_RECOMMENDED_CLIENT_VERSION`. **Verified** (a full-doc sweep leaves no stale deliberate-offline / terminal /
|
||
toggle / dialog reference). Code unchanged from O6 (docs only): gateway Go green, svelte-check 0/0, vitest 617,
|
||
e2e 122/122 chromium + webkit.
|
||
|
||
**Offline-model redesign COMPLETE (O1–O7).** G-step-0 is resolved — the redesign is contour-safe (both version vars empty
|
||
⇒ dormant; the wire add is additive). Next is the normal G chain: PR → `development` → contour verify → `master` → tag →
|
||
dispatch `android-build`.
|
||
|
||
---
|
||
|
||
## Google Play variant (later — design now so we don't repaint)
|
||
|
||
One codebase, two build variants selected by flags:
|
||
- **RuStore build** (this MVP): `VITE_PAYMENTS_DISABLED=1` (MVP), `VITE_GP_BUILD` unset. Later, when
|
||
RuStore billing lands, RuStore sells normally.
|
||
- **Google Play build** (later): `VITE_GP_BUILD=1` ⇒ `purchasesHidden()` true. The **only** functional
|
||
difference is that purchases are hidden — the flag structure (`purchasesHidden()`) already carries
|
||
both cases, so no repaint is needed.
|
||
|
||
**Google Play compliance — decided: no RuStore steering.** Google forbids not just external billing
|
||
but *steering* users to other payment methods or app stores. So the Google Play build hides purchases
|
||
with **no** pointer to RuStore: the existing `isGooglePlayBuild()` RuStore-pointer stub is **dropped**
|
||
in the GP variant (show nothing, or a neutral pointer-free message — never name or link RuStore or any
|
||
other store). This does not affect the RuStore MVP (there `isGooglePlayBuild()` is false, so the stub
|
||
never shows). Google Play also requires an **AAB** (not APK) and Play App Signing — a separate CI
|
||
target, built later.
|
||
|
||
---
|
||
|
||
## Build & env matrix
|
||
|
||
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
|
||
- `VITE_GATEWAY_URL` — absolute gateway origin; the workflow reuses `vars.PROD_PUBLIC_BASE_URL`
|
||
(`https://erudit-game.ru`, trailing slash stripped). The native-critical var.
|
||
- `VITE_RUSTORE_URL` — the update-overlay store target. **Empty in the MVP** (set the `ANDROID_RUSTORE_URL`
|
||
Gitea variable once published); the gate is dormant so the button never fires yet.
|
||
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
|
||
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
|
||
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
|
||
|
||
**Web/contour/prod builds:** unchanged. `VITE_GATEWAY_URL` empty (same-origin),
|
||
`GATEWAY_MIN_CLIENT_VERSION` unset (gate dormant). Nothing about web behaviour changes until a future
|
||
breaking release deliberately sets the min version.
|
||
|
||
---
|
||
|
||
## Prerequisites to start PUBLICATION (RuStore)
|
||
|
||
- **RuStore developer account** — a registered legal entity or self-employed (самозанятый) RU status,
|
||
verified. Gates publication (not the build).
|
||
- **Release keystore** — generate once and **back up immutably** (losing it means never updating this
|
||
app again):
|
||
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`.
|
||
Set Gitea repo secrets: `ANDROID_KEYSTORE_BASE64` (base64 of the .jks),
|
||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
|
||
- **The signed APK** — from the `android-build` artifact.
|
||
- **Store listing** — reserve `ru.eruditgame.app`; title «Эрудит»; RU description; phone screenshots;
|
||
icon/feature graphic; category; age rating; a hosted **privacy-policy URL**; the RuStore content
|
||
declaration.
|
||
- (For the later **Google Play** variant: a Google Play Console account ($25), the GP build (payments
|
||
hidden + the anti-steering fix), an **AAB**, Play App Signing, and the Play Data-safety form.)
|
||
|
||
## Risks & mitigations
|
||
|
||
- **Keystore loss** → app can never be updated. Back up off-host immediately.
|
||
- **Gate dormant until first bump** → its real firing only happens on a future breaking release. The
|
||
Go `server_test.go` tests + the Playwright `__update` e2e prove the whole path now.
|
||
- **Update overlay interrupting offline play** → the gate×offline rule (overlay only on user-initiated
|
||
online actions; silent reconciliation swallows `update_required`).
|
||
- **`X-Client-Version` stripped by the edge** → Caddy forwards request headers by default (the RPC
|
||
route already reaches the gateway); no Caddyfile change. Confirm via the smoke (bump min → overlay).
|
||
- **APK size from bundled dicts** → a few MB per variant; acceptable. Could trim to RU-only later.
|
||
- **RuStore account gate** (legal status) → surface to the owner before release.
|
||
|
||
## Out of scope (deferred)
|
||
|
||
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app
|
||
purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in
|
||
the native build, a soft "recommended update" tier, **in-app store-update flows** (Google Play In-App
|
||
Updates API / RuStore In-App Update SDK — a future enhancement that would swap the update overlay's
|
||
action from opening the store listing to a store-driven immediate in-app update; needs a Capacitor
|
||
plugin bridge, and is orthogonal to the server-driven gate), native splash/status-bar plugins, push
|
||
notifications (FCM), and automated RuStore-API upload.
|
||
|
||
## End-to-end verification
|
||
|
||
- **Unit/integration:** `go test ./gateway/...` (clientver + gate), `cd ui && pnpm check &&
|
||
pnpm test:unit && pnpm test:e2e`, `gofmt -l .` clean, `go vet ./gateway/...`.
|
||
- **Gate proof:** gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` ⇒ overlay; unset ⇒ normal.
|
||
- **Offline-first proof:** native `assembleDebug`, install, **airplane mode**, cold launch → guest
|
||
lobby → play a local vs_ai move and a 2-player hotseat game; network on → server guest established.
|
||
- **Native build proof:** guest sign-in online, a move, Back navigates then exits at root, a
|
||
share/export link points at `erudit-game.ru`, purchases absent.
|
||
- **Signed pipeline:** dispatch `android-build`; watcher green; the release APK installs and launches.
|
||
- **Contour:** the `feature/android-native` PR deploys cleanly (no schema/wire regen; gate dormant);
|
||
web/VK/TG behaviour unchanged.
|