Files
scrabble-game/ANDROID_PLAN.md
T
Ilia Denisov d0e473920c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
docs(android): refresh the ANDROID_PLAN RESUME block
Keystore + its Gitea secrets are set (owner) under the RuStore-specific ANDROID_RUSTORE_* names; the payments E10/E11 (#266/#267) + the signing-secret rename (#268) landed on development; the release gate is now the RuStore account + the promote/tag chain. Adds a native-notifications (push) track and the test-APK note for the next session.
2026-07-14 20:24:26 +02:00

94 KiB
Raw Permalink Blame History

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 AG 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.

▶ RESUME HERE — 2026-07-14 (keystore secrets set; payments landed; gate = RuStore account + merge/tag)

Where we are. The legal-documents track, the release-prep hygiene, the icon rebrand and the release keystore are DONE (the keystore + its Gitea secrets set by the owner — see below). The RuStore MVP is now gated on one owner-side item — the RuStore developer account (owner going ИП; RuStore updated its requirements) — plus the agent's promote/tag → signed-build chain. Also landed on development since: the payments E10 multi-shop split (#266) + E11 kill switch (#267), both dormant / fail-open (no bearing on the Android build), and the signing-secret rename ANDROID_*ANDROID_RUSTORE_* (#268). Do not re-do the finished work or re-ask the locked decisions.

Branches / PRs. All merged to development (contour-green): legal documents (#253); release-prep hygiene + the re-enabled android-build workflow (#254); the payments E10/E11 (#266/#267); the signing-secret rename (#268). No open Android branch.

Done this session.

  • Legal documents (satisfies the RuStore hosted privacy-policy URL prerequisite): authored ui/legal/{privacy,eula,offer}_{ru,en}.md; served as bilingual (RU/EN) standalone pages by the render sidecar at /privacy/, /eula/, /offer/ via the shared ui/src/lib/offer.ts renderLegalHtml, with a client-side language + theme switcher (🌐 + ☼/☾, no "back", default Russian + persisted, mirroring the landing). The offer's EN view transliterates the Russian product names and translates the price-list section headings + column headers (PRICE_TR). Edge: @legal caddy matcher (/offer/ /privacy/ /eula/) + CI probes (size-independent head-only for the legal pages, retry for the offer). Landing footer links: Пользовательское соглашение | Политика конфиденциальности | Публичная оферта | Обратная связь (→ the Telegram bot). Docs baked into docs/ARCHITECTURE.md, docs/FUNCTIONAL.md (+_ru), renderer/README.md. Locked legal decisions — do NOT re-litigate: seller INN 290210610742; EULA §14 leads with RF law + jurisdiction as residence tiers (France / England&Wales retained as foreign tiers); the "only the English version is legally binding" clause is removed; data collection is kept but scoped to voluntary/support provision (passport/payment/ID only when the user provides them); «Компания» is not shout-cased; the offer's EN catalog product names are transliterated (owner-chosen), while its headings/columns are translated.
  • Release-prep hygiene (PR #254): de-anchored ANDROID_PLAN.md from all code/docs — no reference to this file remains anywhere outside it; re-enabled the android-build workflow (.gitea/workflows/android-build.yaml.disabled.yaml; it is workflow_dispatch-only + gated if master, so it never auto-runs; the CI host already has the Android SDK at /opt/android-sdk); added docs/ICONS.md (the single-master icon/logo format reference).

Remaining to release (owner-gated unless the agent is named).

  1. Icon rebrand — DONE. The SVG-tracer export failed, so the icon was designed collaboratively from scratch (wooden «Э» tile + ✻ subscript, Spectral Bold). The construction is fully specified in fractions of the side in docs/ICON_BRANDBOOK.md, with the reference generator + pinned font + masters in assets/icons/brand/. One master → the whole set sliced by brand/build-set.mjs (+ build-android-res.mjs): web (favicon light+dark, apple-touch, PWA any + maskable, og-image reskinned to «Эрудит» / Игра в слова), the Capacitor layers ui/assets/{icon,icon-foreground,icon-background}.png, the Android launcher res (all densities, no inset, + monochrome themed layer), and manual-upload art (brand/{vk,tg,store}). Primary variant light; dark where the platform can theme (favicon.svg). The old LiberationSans generator (assets/icons/build/) is removed; targets are mapped in docs/ICONS.md.
  2. Merge PR #254development (owner approval), then promote developmentmaster and tag vX.Y.Z for the release (the version stamps the APK versionName/versionCode and the X-Client-Version header).
  3. Release keystore — DONE (owner, 2026-07-14). erudit-release.jks (RSA 4096, alias erudit, PKCS12) generated + backed up off-host; the four Gitea secrets are set under the RuStore-specific names ANDROID_RUSTORE_KEYSTORE_BASE64 / ANDROID_RUSTORE_KEYSTORE_PASSWORD / ANDROID_RUSTORE_KEY_ALIAS / ANDROID_RUSTORE_KEY_PASSWORD (loss ⇒ the app can never be updated — the cert is pinned at the first RuStore upload). Google Play, later, would use its own ANDROID_PLAY_* upload key (Play App Signing) — a separate key.
  4. RuStore developer account. OWNER (verified legal entity or self-employed / самозанятый). Gates publication, not the build.
  5. Dispatch the signed build. AGENT: on master, dispatch android-build (confirm=build) via tea, watch to green, retrieve the signed APK artifact (verify Gitea 1.26 upload-artifact@v4 at the dispatch).
  6. On-device smoke. OWNER installs the signed APK and runs the airplane-mode first-launch checklist (docs/TESTING.md). The AGENT can pre-run the emulator smoke.
  7. Store listing. Reserve ru.eruditgame.app, title «Эрудит», RU description, phone screenshots, icon / feature graphic, age rating, the privacy-policy URL https://erudit-game.ru/privacy/ (now available), the content declaration.
  8. Upload to RuStore. OWNER. After publication, set the ANDROID_RUSTORE_URL Gitea variable to the store deep-link so the update button works on future gated releases (empty until then — the gate is dormant in the MVP).

Next tracks (design in a future session — interview the owner first).

  • Native notifications (push). Promote FCM/push from "Out of scope" to a real track: a Capacitor push plugin, the Android send path (FCM — but check RuStore's own push service vs FCM on RuStore devices, since Google Play Services may be absent), token registration, and reusing the existing server notification dispatcher. Owner will bring specifics.
  • A test APK for the emulator (owner asked): the signed APK comes from android-build (needs the promote/tag chain, step 2). A quick debug APK is buildable without the release chain if the host has the Android SDK + JDK 21 (pnpm buildcap syncassembleDebug).
  • Possibly other native polish (owner to flag).
  • 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 buildcap 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: the temporary placeholder was superseded by the icon rebrand — the launcher now uses the two-layer adaptive icon (background + foreground + monochrome) generated from the brand master; see the RESUME block (item 1), docs/ICON_BRANDBOOK.md and docs/ICONS.md.
  • 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 connectsrvExecute 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.1D.6 done, 2026-07-12). The offline path is proven on-device (Pixel_10 / API 37, airplane mode): cold-boot → offline guest lobby, a full local vs_ai turn (bundled dawg — Hint placed "FEZ", the robot replied "NEEDFIRE"), New Game offering both modes; the smoke also surfaced + fixed a native-chrome edge-to-edge safe-area bug (below). Remaining for D: the reconcile-online leg on-device (deferred — it hits prod, minting a guest) and the deferred local-game-visibility decision (below). D.5 is also covered by e2e/native.spec.ts (vs_ai move + hotseat).
    • D.1 + D.2 (foundations) — DONE & committed bcd5a1d (2026-07-12). ui/scripts/bundle-dicts.mjs (release DAWGs → dist/dict/<variant>@<version>.dawg, keyed on VITE_DICT_VERSION, OUT_DIR override for the e2e); the dict/loader.ts bundled tier (between IndexedDB and network, native-gated — see the §D.1 correction); __DICT_VERSION__ vite define + declaration; lib/localguest.ts (persisted device-local guest id, no DB row) + common.guest i18n; NewGame.svelte offline vs_ai/hotseat creates fall back to __DICT_VERSION__ and localGuestId()/t('common.guest') when there is no session.
    • D.3 (cold boot) + D.4 (reconciliation + silent seam) — DONE & verified (2026-07-12). The native no-session cold boot lands in the offline lobby as a device-local guest (app.svelte.ts bootstrap else if (native) branch — localGuestId() + setOfflineMode(true,false) + scheduleRecovery(0)); lazy reconcileServerGuest() mints + adopts a server guest and clears the auto-offline when the gateway is reachable (kicked at boot + by the recovery poll + the online event). Silent seam: transport.ts exec gained { silent, allowOffline } (suppress the update overlay and bypass the kill switch), plus authGuestSilent(locale) on the GatewayClient interface, the real transport and the mock. native.ts initNativeShell made bridge-tolerant. New e2e/native.spec.ts (Capacitor injected; boot→offline-lobby, local vs_ai move, hotseat start, reconcile→online) + playwright.config.ts bundles the dawgs into dist-e2e/dict/. svelte-check 0, vitest 591, web + native builds clean; e2e runs in CI. See §D.3/§D.4 for the as-built corrections (shouldBootOffline not changed; allowOffline + checkReachable-token findings; the __native.reconcile e2e hook).
    • D.6 (Profile tg/vk hide on native) — DONE & verified (2026-07-12). Profile.svelte gates telegramLinkable/vkLinkable on !nativeShell (clientChannel() android/ios), so the Telegram + VK LINK buttons are hidden on the native build; email + account management (incl. any existing link's UNLINK, a redirect-free gateway call) stay. Covered by the e2e/native.spec.ts reconcile test (Profile → no Link Telegram / Link VK buttons, email present).
    • Native chrome: Android 15+ edge-to-edge safe-area — FIXED & on-device-verified (2026-07-12, owner-confirmed). targetSdk 36 forces edge-to-edge, so the WebView draws behind the system bars; the app chrome overlapped the status bar (top nav, untappable) and the gesture-nav home indicator (the game's centre Hint button — side buttons fine). Two-part CSS fix consuming the SystemBars core plugin's injected --safe-area-inset-*: the bottom via the --tg-safe-* token (app.css), the header top via a native-only .bar rule (Header.svelte — its top inset had been Telegram-fullscreen-scoped only, so the token alone didn't reach it). No dep/config (SystemBars ships in @capacitor/core v8). Verified on Pixel_10 / API 37 by live-WebView CDP measurement (header title y=11→65, --safe-area-inset-* = 54/24px) and owner re-test on their device. Full gotcha + CDP recipe in .claude/CLAUDE.md. Commits 4a0689a (bottom) + 49c5379 (top).
    • DEFERRED DECISION (owner-agreed 2026-07-12) — native local-game visibility. The online lobby lists only server games (Lobby.svelte:42/54), so a native guest's device-local vs_ai/hotseat games hide from the lobby once reconciled online (the pre-existing offline↔online split). For native this IS the primary flow, so the behaviour must change — a native guest should still see / resume their local games when online. Owner agreed it needs changing but wants to design it at the very end of the Android work (before release — see §G); out of D.3/D.4 scope, tracked here so it is not lost.
  • E. CI — signed APK artifact — CODE-COMPLETE & locally-proven (2026-07-12). New manual workflow .gitea/workflows/android-build.yaml (workflow_dispatch + confirm=build, if gated to master — mirrors prod-deploy.yaml; never on a PR) builds the native-flavoured SPA, bundles the offline dicts, and assembles a release APK uploaded as a run artifact. Like prod-deploy, it is provable only by a dispatch from master (that is §G), so E was verified as far as possible now: locally via assembleDebug + a keyless assembleRelease (→ app-release-unsigned.apk, versionCode 1017000 / versionName 1.17.0 for a v1.17.0 tag — confirmed in output-metadata.json), svelte-check 0, YAML structure checked. As-built:
    • ui/android/app/build.gradle: versionCode/versionName read -PversionCode/-PversionName (defaults keep local assembleDebug building). CORRECTION vs the plan snippet — use = assignment (versionCode = (…).toInteger()), NOT the command form versionCode (…).toInteger(): the latter binds .toInteger() to the DSL setter's null return (> Value is null). A guarded signingConfigs.release reads the keystore from env and is attached only when the keystore file exists — so no keystore ⇒ an UNSIGNED release APK, not a failure (a dispatch proves the pipeline before the keystore exists).
    • Toolchain: the CI runner is a host-executor on a remote Debian host. JDK 21 comes from actions/setup-java; the Android SDK is host-provisioned (pre-installed at ANDROID_SDK_DIR, default /opt/android-sdk), with a fail-fast Verify the host Android SDK pre-flight that also checks the runner user's read/exec access (platforms;android-36 + build-tools). Unit tests need no new tooling (they ride the existing unit/ui jobs).
    • Env: VITE_GATEWAY_URL reuses vars.PROD_PUBLIC_BASE_URL (trailing slash stripped); VITE_DICT_VERSION / DICT_VERSION = the DICT_VERSION var; VITE_PAYMENTS_DISABLED=1. VITE_STORE_URL was renamed to VITE_RUSTORE_URL (owner) — left empty via the (unset) vars.ANDROID_RUSTORE_URL, so the update button no-ops until publication (UpdateOverlay.svelte already guards if (url); the gate is dormant in the MVP).
    • versionCode from an exact tag: the workflow refuses anything but a clean git describe --exact-match vX.Y.Z (deterministic + monotonic store versionCode); §G tags before dispatch.
    • Delivery: actions/upload-artifact@v4 (assumes Gitea 1.26 artifact support — verify at the §G dispatch). Secrets to set before a signed build (§G / publication): ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD.
  • F. Docs — DONE (2026-07-12). Baked the Android work into the live docs: docs/ARCHITECTURE.md §2 (the client-version gate + the frozen wire contract + the gate × offline rule), §3 (the local-guest / server-guest / reconciliation identity model) and §13 (the native Capacitor build — bundle model, bundled dicts, versionCode scheme, file:// origin, RuStore); docs/FUNCTIONAL.md (+ _ru mirror) a new Native app (Android) domain (offline-first guest launch, email soft-registration, no TG/VK login, hidden purchases, update-required); docs/TESTING.md (the clientver/gate Go tests, the native/update e2e + the retry mapping, and the manual on-device Android smoke checklist); deploy/README.md (the GATEWAY_MIN_CLIENT_VERSION var + the wire-break bump discipline + the Android build/release runbook — keystore, secrets, dispatch, RuStore upload); ui/README.md (the native VITE_* build vars). Every referenced test file was confirmed to exist.
  • G — pending; gated on the offline-model redesign (was G-step-0). The deferred native local-game-visibility decision grew into an owner-approved cross-cutting change (web + native + a small additive backend/wire bit): remove the explicit offline toggle → one netState machine, unify the lobby, two-tier version gate. Designed + staged (O1O7) in the "Offline-model redesign" section below. ➡ Next actionable work: implement O1 (the pure netState reducer, test-first) via stage-implementation, then O2O7; the release chain (PR→development→contour→master→tag→dispatch android-build) follows. None of O1O7 is started. Branch feature/android-native, local/unpushed.

Icon rebrand — DONE (was "one master → every icon"): a single Spectral «Э» master now drives web (favicon light+dark / PWA any + maskable / apple-touch / og), the Capacitor layers and the Android adaptive res (no inset, + monochrome), plus VK/TG/store art. Specced in docs/ICON_BRANDBOOK.md, mapped in docs/ICONS.md, generated from assets/icons/brand/. ~/.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):
    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:
    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: DONE — generated from the brand master into the Capacitor layers ui/assets/{icon,icon-foreground,icon-background}.png and the Android res; see docs/ICONS.md.

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:
    // 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 nativeui/src/lib/pwa.svelte.ts registerServiceWorker() (line 85): add, next to if (inMiniApp()) return;:
    import { clientChannel } from './channel';
    const ch = clientChannel();
    if (ch === 'android' || ch === 'ios') return;
    
  3. Hide payments in the MVP buildui/src/lib/distribution.ts, add:
    // 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:
    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:
      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):
    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:
      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 hookui/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.1D.6 are done & verified (foundations committed bcd5a1d; boot + reconciliation + Profile + the Android edge-to-edge safe-area fix committed on feature/android-native, 2026-07-12), with e2e/native.spec.ts green (chromium + webkit) AND an on-device emulator smoke (Pixel_10 / API 37, airplane mode): cold-boot → offline guest lobby, offline vs_ai played end-to-end (bundled dawg — Hint placed "FEZ", the robot replied "NEEDFIRE"), New Game offers both modes. The smoke surfaced a native-chrome bug on Android 15+ edge-to-edge (WebView < 140 reports env(safe-area-inset-*)=0 → the top nav draws under the status bar and the game's centre Hint button under the gesture-nav home indicator) — fixed by consuming the SystemBars core plugin's injected --safe-area-inset-*: the bottom via the --tg-safe-* token (app.css), and the header's top inset via a native-only .bar rule (Header.svelte — its top inset was Telegram-fullscreen-scoped only, so the token alone did not reach it). No dep/config (the plugin ships in @capacitor/core v8); full story in .claude/CLAUDE.md. Remaining for D: the reconcile-online leg on-device (hits prod — a guest mint) and the deferred local-game-visibility decision (§G). Verify every line ref below against current code.

Decisions locked this session (owner-approved) — bake these before implementing:

  • The blocking Login is bypassed on native only. Web / PWA / Telegram / VK keep the current 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 tierui/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.mjsdist-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.01017000), 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

Status 2026-07-13 — see the ▶ RESUME block at the top of §Progress. Since this chain was written, a legal-documents track was added and merged (PR #253 — the RuStore hosted privacy-policy URL and the /privacy/ /eula/ /offer/ pages), and a release-prep hygiene patch is in flight (PR #254, open), and the icon rebrand is DONE (brand master → full set; see the RESUME block). The remaining gates are the keystore and the RuStore account (owner-side). The steps below are the original release chain; the resume block supersedes them with the current, exhaustive state.

  1. Land the offline-model redesign — DONE (O1O7, 2026-07-13; staged detail below). Resolved the deferred native local-game-visibility decision — a native guest now sees / resumes their device-local games once online (the unified lobby, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline is an implicit net-state machine (O1/O2), the lobby is unified (O5), and the two-tier version gate is in (O4). Cross-cutting (web + native + a small additive backend/wire bit); contour-safe (both version vars empty ⇒ dormant; the wire add is additive). The remaining G steps below are the release chain.
  2. Merge feature/android-nativedevelopment; verify on the contour (contour-safe; gate dormant).
  3. Promote developmentmaster; tag vX.Y.0.
  4. Dispatch android-build; watch green; retrieve the signed APK.
  5. Owner runs the on-device smoke checklist (incl. airplane-mode first launch).
  6. 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 versionRejectedofflineVersionLocked 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 < minupdate_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 netStateconnection.online = the online state, offlineMode.active = the machine offline; reportOfflinecallFailed once from online, reportOnlinecallOk), ui/src/lib/update.svelte.ts (reportUpdateRequired also emits versionRejected), ui/src/lib/native.ts (+initNativeNetwork@capacitor/networkosOnline/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()reconcileServerGuestemit('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: stringmyIds: readonly string[] self-set across isMyTurn/scoreStanding/ gamePhase/groupGames/resultBadge; game seat-matching uses myIds.includes(...)), ui/src/lib/lobbycache.ts (dropped the offline snapshot key — one unified snapshot; getLobby() no arg; offline reuses its server games), ui/src/screens/NewGame.svelte (getLobby() + filter to server games — device-local games do not count toward the server per-kind limit), ui/src/lib/localguest/telegram/vk imports in the lobby.
      • Identity split: game functions take the self-set myIds = [session.userId, localGuestId()] (a reconciled guest's local games seat you under localGuestId, server games under userId, ids never collide); invitations keep session.userId (a server concept). TG/VK server-only: the local merge is gated on insideTelegram()/insideVK() (the shared-origin local store must not leak into a mini-app).
    • Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache (un-openable); a reconciled guest's local games stay visible online (closes G-step-0); invitations hidden offline; TG/VK server-only. Verified.
    • Targeted tests: lobbysort.test.ts + result.test.ts extended (two-id "self" recognition), lobbycache.test.ts reworked (unified snapshot, mode-aware tests removed); e2e/native.spec.ts proves the local vs_ai game stays listed online (G-step-0); e2e/offline.spec.ts + hotseat.spec.ts + update.spec.ts updated for greyed-from-cache server games offline. Full suite 122/122 on chromium + webkit; svelte-check 0/0; vitest 617.
  • O6 — Create flows. — DONE (2026-07-13).

    • Files (as built) — ui/src/screens/NewGame.svelte: with-friends now carries a friendsMode segmented control (new.playRemote = online invite / new.playLocal = offline hotseat); the online segment is disabled when offlineMode.active and an $effect forces friendsMode='offline' there (a new.needsNetwork note explains it); the friends markup is restructured from the mode-exclusive {:else if offlineMode.active} chain into {:else} <segment> {#if friendsMode==='offline'} hotseat {:else if !friends} noFriends {:else} invite. vs_ai unchanged (verified: find() still offline→localSource.create, online→lobbyEnqueue). Dict guard (owner-chosen async getDawg): a dawgReady/dictBlocked pair — an $effect awaits getDawg(guardVariant, version) while offline (guardVariant = the quick pick or the hotseat pick) and disables create + shows a new.dictUnavailable note only on a confirmed miss (dawgReady === false, never while null/checking, so a still-warming dawg does not false-block). ui/src/lib/i18n/{en,ru}.ts (new.{playRemote,playLocal,needsNetwork,dictUnavailable}).
    • Owner-chosen (interview): the dict guard uses the async getDawg precheck (no reliable sync signal; native bundles every variant so it mostly guards web offline); guest gating unchanged — an online guest still sees vs_ai only (no new online-guest hotseat); a guest offline keeps its hotseat.
    • Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline preselected, create works; vs_ai offline→local / online→server unchanged; create disabled with a reason on an unavailable offline dawg. Verified.
    • Targeted tests: e2e/hotseat.spec.ts asserts the offline segment (online disabled, hotseat forced); offline.spec.ts
      • native.spec.ts exercise vs_ai-local + hotseat create through the new segment. Full suite 122/122 on chromium + webkit; svelte-check 0/0; vitest 617.
  • O7 — Docs + native e2e. — DONE (2026-07-13).

    • Files (as built) — docs/ARCHITECTURE.md: §2 rewrote the connectivity signal into the net-state machine (four states, implicit offline, hysteresis, self-heal, TG/VK-exempt), the version gate into the two tiers (hard → offlineVersionLocked + dismissable notice, not terminal; soft → X-Update-Recommended nudge), and the gate×offline rule; §13 (PWA/offline) rewrote the offline model from the deliberate toggle/dialog to implicit detection + the unified lobby (greyed cached server games) + the with-friends segment + the dict guard. docs/FUNCTIONAL.md (+_ru mirror): rewrote Offline mode (implicit, unified lobby, segment) and added a Staying up to date / «Актуальная версия» story (Update/Play-offline notice + the soft nudge). docs/TESTING.md: the netstate.test.ts reducer layer, the __net-driven offline spec, the two-tier update.spec, the soft-tier Go server/config tests, and the native G-step-0 assertion. deploy/README.md: a GATEWAY_RECOMMENDED_CLIENT_VERSION row (soft tier; validated ≥ MIN). ui/e2e/native.spec.ts already reflects the new boot/offline (done in O5).
    • Acceptance: the docs describe both tiers + the implicit model + the unified lobby; deploy/README lists GATEWAY_RECOMMENDED_CLIENT_VERSION. Verified (a full-doc sweep leaves no stale deliberate-offline / terminal / toggle / dialog reference). Code unchanged from O6 (docs only): gateway Go green, svelte-check 0/0, vitest 617, e2e 122/122 chromium + webkit.

Offline-model redesign COMPLETE (O1O7). G-step-0 is resolved — the redesign is contour-safe (both version vars empty ⇒ dormant; the wire add is additive). Next is the normal G chain: PR → development → contour verify → master → tag → dispatch android-build.


Google Play variant (later — design now so we don't repaint)

One codebase, two build variants selected by flags:

  • 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=1purchasesHidden() 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 buildbundle-dictscap 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.