Files
scrabble-game/ANDROID_PLAN.md
T
Ilia Denisov e077258567 feat(ui): offline-first native boot + lazy server-guest reconciliation
Native (Capacitor) cold boot with no cached session now enters as a
device-local guest in auto-offline mode and lands straight in the lobby
(never /login), so the app opens and plays local vs_ai / hotseat with the
APK's bundled dictionaries and zero network. When the gateway becomes
reachable, reconcileServerGuest silently mints + adopts a server guest and
clears the auto-offline, lighting up online features. Web / PWA / Telegram /
VK are byte-for-byte unchanged.

- transport: exec gains { silent, allowOffline } so background reconciliation
  bypasses the offline kill switch (like the reachability probe) and never
  raises the terminal update overlay (a too-old client stays a local guest);
  new authGuestSilent on the client interface, the real transport and the mock.
- app: native no-session boot branch; reconcileServerGuest fired at boot, by
  the recovery poll and the online event; the poll routes the session-less
  guest through reconciliation, since checkReachable needs a token it lacks.
- native: initNativeShell tolerates a missing Capacitor bridge.
- e2e: new native.spec.ts (inject window.androidBridge; boot -> offline lobby,
  local vs_ai move, reconcile -> online, hotseat start) + playwright.config
  bundles the dawgs into dist-e2e/dict for the loader's bundled tier.
2026-07-12 18:03:52 +02:00

49 KiB
Raw 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.

  • 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: 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 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_STORE_URL, web → reload), retry.ts maps FailedPrecondition → update_required, the __update mock hook. gofmt/vet clean, Go clientver/config/connectsrv tests green, svelte-check 0, vitest 591, e2e 232 (incl. update.spec.ts), client build clean. Silent reconciliation seam deferred to D (owner); in-app store-update SDK noted Out of scope.
  • D. Offline-first — 🚧 IN PROGRESS (D.1D.4 done; D.6 + the D-close verification remain).
    • 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) — ▢ TODO (owner deferred to a follow-up this session).
    • 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.
  • EG — pending.

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):
    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 (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:
    // 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_STORE_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_STORE_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) — 🚧 IN PROGRESS

Both offline modes already exist and are gated on offlineMode.active: local vs_ai and 2-4-player hotseat (pass-and-play with a host PIN referee) — see ui/src/lib/localgame/source.hotseat.test.ts and ui/src/screens/NewGame.svelte:177-431. This phase makes them reachable on a cold first launch with no network.

Status: the additive foundations (D.1, D.2) are done & committed (bcd5a1d) and inert on the web; the boot rewrite (D.3), reconciliation + the silent seam (D.4), the Profile soft-sign-in (D.6) and the tests remain. Verify every line ref below against current code (they are as of 2026-07-12).

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 — mostly (verify at D close). NewGame gates the offline flows on guest || offlineMode.active; the local guest makes offlineMode.active true, so both "quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id (D.2).
  6. Soft registration = reuse the Profile screen — ▢ TODO.
    • Profile is already reachable for an online guest (guests keep the Profile tab — SettingsHub.svelte:27 only hides friends/wallet for guests; :33 hides profile/friends/wallet in offline mode). So once the native guest is online and reconciled (D.4 clears the auto-offline), Profile shows and email sign-in works. Offline you cannot register anyway (every method needs the server), so the offline-hidden behaviour is correct — no change is needed to reach Profile, provided D.4 clears the auto-offline when online.
    • Hide tg/vk on native: Profile.svelte — the Telegram link button (~line 485, gated on telegramLinkable = loginWidgetAvailable(), :62) and the VK link button (~line 499). Add a clientChannel() android/ios check to hide both on the native build; keep email + account management. (See the decision block above for why.)

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

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.
  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'), plus a signingConfigs.release reading env/props (guarded so a local assembleDebug still builds unsigned).

F. Docs (bake in the same PR)

  • 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_STORE_URL, VITE_PAYMENTS_DISABLED, VITE_DICT_VERSION, and the native VITE_GATEWAY_URL value.
  • 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

  1. Resolve the deferred native local-game-visibility decision (Progress §D): a native guest must still see / resume their device-local vs_ai / hotseat games once reconciled online (today the online lobby hides them — Lobby.svelte:42/54). Design + implement, or explicitly accept the split for the MVP, before release.
  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).

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=https://erudit-game.ru — absolute origin (the native-critical var).
  • VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app — the update-overlay target.
  • VITE_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.