From edaf2dfd9ea558a66011d56e5529b55c84f5a047 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 22:42:41 +0200 Subject: [PATCH 1/2] =?UTF-8?q?docs:=20add=20ANDROID=5FPLAN.md=20=E2=80=94?= =?UTF-8?q?=20native=20Android=20(Capacitor)=20RuStore=20MVP=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ANDROID_PLAN.md | 532 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 532 insertions(+) create mode 100644 ANDROID_PLAN.md diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md new file mode 100644 index 0000000..3b49d49 --- /dev/null +++ b/ANDROID_PLAN.md @@ -0,0 +1,532 @@ +# Native Android (Capacitor) — RuStore MVP: bundle + client-version gate + offline-first + +> Self-contained implementation plan for the standalone Android app, to be executed in a fresh +> session (another device). Work through the breakdown A–G in order; each part states its own +> "Done when". `PLAN.md` at the repo root is the precedent for a top-level plan doc here. + +--- + +## Context (why) + +`erudit-game.ru` ships today as a web SPA (Svelte + Vite) served by the gateway, plus Telegram and +VK Mini Apps. The owner wants a **standalone Android app**, first on **RuStore**. The stack already +declares Capacitor as the target and is pre-seamed for it (feature-detected `window.Capacitor`, +`file://`-safe hash router, relative asset base, a reserved native share branch, +`viewport-fit=cover`/safe-area layout, a build-time gateway origin var), but **nothing is +scaffolded** — no `@capacitor/*` dep, no `capacitor.config.*`, no `android/`. + +Two problems native introduces that the web never had: + +1. **An installed build can be arbitrarily old.** On web every load fetches the current client, so + client and server are always paired. In a store a user may run a months-old bundle; if the + FlatBuffers wire schema changes incompatibly, an old bundle cannot speak to the server at all. + Today there is **no** client↔server version contract — the only "update" path is the web-only + `location.reload()`, useless to a bundled APK. → build a **minimum-supported-client gate**. + +2. **First launch must work with no network.** The app must open straight into a guest experience + and let the user play locally (vs_ai *and* 2-4-player pass-and-play / hotseat) even if the + internet is off on first launch. The current offline mode is a *returning-user* feature: it needs + a cached session/profile and server-fetched dictionaries (`offline.ts shouldBootOffline` requires + `hasSession && hasProfile`; dicts come from `gateway.fetchDict`). → build **offline-first**: + bundle dictionaries in the APK and boot as a device-local guest with no server session. + +**Intended outcome:** a signed APK on RuStore that (a) loads the packaged SPA against the production +gateway, (b) is protected by the version gate so future incompatible server changes turn old +installs away cleanly, and (c) opens offline-first as a soft guest with local play. + +## Locked decisions (owner interview) + +| Decision | Choice | +|---|---| +| First store | **RuStore** (Google Play is a later variant — see below) | +| Update model | **Bundle** (`dist/` inside the APK) **+ a client-version gate** (no OTA, no remote-URL wrapper) | +| First-launch offline | **Offline-first in the MVP**: enter as a local guest, play vs_ai + hotseat with zero network | +| Login surface | Guest + email only (already the case — `Login.svelte` shows only these; VK/Telegram auth runs only inside their Mini Apps) | +| Payments in MVP | Hidden (deferred; reuse the distribution flag) | +| appId (permanent) | **`ru.eruditgame.app`** | +| App display name | **`Эрудит`** (Cyrillic) | + +**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). + +--- + +## Prerequisites to start DEVELOPMENT (on the other device) + +Have all of these before writing code: + +- **JDK 17** (Temurin/OpenJDK). +- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or* + cmdline-tools. Install: `platform-tools`, `platforms;android-34`, `build-tools;34.0.0`. +- **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 sibling holds the committed dictionaries + (`scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`) that offline-first bundles, and + the solver library the backend/CI use. +- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. Pixel, + API 34). +- **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 + +- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps + `@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`, + `"android:assets": "capacitor-assets generate --android"`. +- `ui/capacitor.config.ts` (new): + ```ts + import type { CapacitorConfig } from '@capacitor/cli'; + const config: CapacitorConfig = { + appId: 'ru.eruditgame.app', + appName: 'Эрудит', + webDir: 'dist', + // Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates + // ship through the store; the client-version gate turns away a build too old to speak the + // current wire contract (docs/ARCHITECTURE.md §2). + }; + export default config; + ``` +- `npx cap add android` → generates and **commits** `ui/android/` (a Gradle project). Neither + `.gitignore` lists it today; keep it tracked so build.gradle + signing edits are reviewable and CI + reproducible. Ensure `ui/android/.gitignore` covers build outputs (`/app/build`, `/build`, + `/.gradle`, `/local.properties`, `*.keystore`, `*.jks`). +- **Android hardware Back** — new `ui/src/lib/native.ts`, dynamically importing `@capacitor/app` so + the web/mock bundle never pulls it: + ```ts + import { clientChannel } from './channel'; + export async function initNativeShell(atNavigationRoot: () => boolean): Promise { + const ch = clientChannel(); + if (ch !== 'android' && ch !== 'ios') return; + const { App } = await import('@capacitor/app'); + App.addListener('backButton', () => { + if (atNavigationRoot()) void App.exitApp(); + else history.back(); + }); + } + ``` + Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login` + (mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`). +- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png` + (+ optional `splash.png`); `pnpm android:assets` generates adaptive icons. + +**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`) +`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen. + +### B. Native web-build correctness (file:// origin) + +1. **One origin helper** — new `ui/src/lib/origin.ts`: + ```ts + // The absolute origin the client talks to. A packaged native build (file:// origin) sets + // VITE_GATEWAY_URL to the real gateway; web/mock leave it empty and fall back to the page + // origin. Centralised so every absolute-URL construction resolves identically. + export function gatewayOrigin(): string { + const configured = import.meta.env.VITE_GATEWAY_URL ?? ''; + return configured || (typeof location !== 'undefined' ? location.origin : ''); + } + ``` + Fix the two same-origin **landmines** (they would produce `file:///…` on native): + - `ui/src/game/Game.svelte:1214`: `new URL(path, location.origin)` → `new URL(path, gatewayOrigin())`. + - `ui/src/screens/Wallet.svelte:47`: `` `${location.origin}/` `` → `` `${gatewayOrigin()}/` ``. + (`transport.ts:32` already computes the equivalent inline — leave it.) +2. **Skip the service worker on native** — `ui/src/lib/pwa.svelte.ts registerServiceWorker()` (line 85): + add, next to `if (inMiniApp()) return;`: + ```ts + import { clientChannel } from './channel'; + const ch = clientChannel(); + if (ch === 'android' || ch === 'ios') return; + ``` +3. **Hide payments in the MVP build** — `ui/src/lib/distribution.ts`, add: + ```ts + // purchasesHidden: true for the Google Play build (external-payment policy) OR the thin native + // MVP that defers store billing (VITE_PAYMENTS_DISABLED="1"). Every other build sells normally. + export function purchasesHidden(): boolean { + return isGooglePlayBuild() || (import.meta.env as Record).VITE_PAYMENTS_DISABLED === '1'; + } + ``` + Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they + hide the buy actions (grep `isGooglePlayBuild` under `ui/src`). Keep the "go to RuStore" stub + `isGooglePlayBuild()`-only. 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 + +#### C1. Backend (gateway) + +- **New package `gateway/internal/clientver`** (`clientver.go` + `_test.go`): dependency-free parse of + the leading `v?MAJOR.MINOR.PATCH` (ignore any `-N-gSHA`/`+meta` suffix) + compare: + ```go + package clientver + import ("strconv"; "strings") + type Version struct{ Major, Minor, Patch int } + func Parse(s string) (Version, bool) { + s = strings.TrimPrefix(strings.TrimSpace(s), "v") + if i := strings.IndexAny(s, "-+"); i >= 0 { s = s[:i] } + p := strings.SplitN(s, ".", 4) + if len(p) < 3 { return Version{}, false } + var v Version; var err error + if v.Major, err = strconv.Atoi(p[0]); err != nil { return Version{}, false } + if v.Minor, err = strconv.Atoi(p[1]); err != nil { return Version{}, false } + if v.Patch, err = strconv.Atoi(p[2]); err != nil { return Version{}, false } + return v, true + } + func Less(a, b Version) bool { + if a.Major != b.Major { return a.Major < b.Major } + if a.Minor != b.Minor { return a.Minor < b.Minor } + return a.Patch < b.Patch + } + ``` + Tests: `"v1.16.0"`, `"1.16.0"`, `"v1.16.0-3-gabc"`, `"dev"`/`""`→!ok, ordering incl. equal. +- **Config `gateway/internal/config/config.go`**: add `MinClientVersion string` (doc: empty ⇒ gate + off); `Load()`: `c.MinClientVersion = os.Getenv("GATEWAY_MIN_CLIENT_VERSION")`; `validate()`: if + non-empty and `!clientver.Parse(...ok)` → return a config error. Extend `config_test.go`. +- **Server `gateway/internal/connectsrv/server.go`**: + - consts `clientVersionHeader = "X-Client-Version"`, `resultUpdateRequired = "update_required"`; + `errors.go`: `errUpdateRequired = errors.New("client too old, update required")`. + - `Server`: add `minClient clientver.Version` + `gateOn bool`. `Deps`: add `MinClientVersion string`. + `NewServer`: parse `d.MinClientVersion` once (set `gateOn` on success; `log.Warn` + off if + unparseable). + - helper `clientTooOld(header string) bool`: `if !s.gateOn { return false }`; parse header, on + `!ok` return false (fail-open); return `clientver.Less(v, s.minClient)`. + - `Execute`: insert **after** the `defer` metrics line (after `server.go:296`, before + `registry.Lookup`), so `msgType` is set and the payload is untouched: + ```go + if s.clientTooOld(req.Header().Get(clientVersionHeader)) { + result = resultUpdateRequired + return connect.NewResponse(&edgev1.ExecuteResponse{ + RequestId: req.Msg.GetRequestId(), ResultCode: resultUpdateRequired, + }), nil + } + ``` + - `Subscribe`: at the top (before `resolve`): `if s.clientTooOld(req.Header().Get(clientVersionHeader)) { return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired) }`. + - `main.go`: add `MinClientVersion: cfg.MinClientVersion,` to the `connectsrv.Deps{...}` literal. + - Tests (`server_test.go`): too-old Execute ⇒ `result_code == "update_required"` and the op handler + never ran; too-old Subscribe ⇒ `FailedPrecondition`; absent header / empty min / unparseable + header / equal version ⇒ pass. + +#### C2. Client (ui) + +- **`ui/src/lib/update.svelte.ts`** (new, terminal — no poll): + ```ts + export const UPDATE_REQUIRED = 'update_required'; + let required = $state(false); + export const updateRequired = { get active(): boolean { return required; } }; + export function reportUpdateRequired(): void { required = true; } + ``` +- **`ui/src/lib/transport.ts`**: + - `headers()` (line 37) always attaches the version header: + ```ts + const headers = (): Record => { + const h: Record = { 'x-client-version': __APP_VERSION__ }; + if (token) h.authorization = `Bearer ${token}`; + return h; + }; + ``` + - result-code branch (line 86): `if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();` + before the throw. + - catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is + `UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`. + - **Reconciliation exception:** provide a variant used by background guest-reconciliation that does + NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean + on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the + seam. Foreground `auth.*` keep the overlay behaviour. +- **`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 `` right after `` (line 139). +- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`. +- **Tests:** `update.svelte.test.ts`; extend `retry.test.ts` (`FailedPrecondition → 'update_required'`); + Playwright `update.spec.ts` driving `__update.on()`. + +**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) + +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**. + +1. **Bundle dictionaries in the APK.** + - New build step `ui/scripts/bundle-dicts.mjs`: copy `../scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg` + → `ui/dist/dict/@.dawg` (dictKey naming). Run only in the native pipeline + (after `pnpm build`, before `cap sync`). Web builds stay slim. + - Vite `define`: add `__DICT_VERSION__` from `VITE_DICT_VERSION` (mirrors `__APP_VERSION__`); + declare it in `vite-env.d.ts`. + - **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: between the IndexedDB tier (line 73-85) + and the network tier (line 87), add a **bundled tier** that `fetch('./dict/'+key+'.dawg')` + (relative — served from app assets on native, 404 on web → falls through). On a hit, build the + `Dawg`, cache in memory, seed IndexedDB (`idbPutDawg`), and return. This is the only change that + lets a never-online device obtain a dictionary. + - Offline local-game creation must request the **bundled version**: where `NewGame.svelte` + offline-create and `localgame` resolve the dict version, use `__DICT_VERSION__` so the requested + `(variant, version)` matches the bundled file (grep the offline `localSource.create` calls in + `NewGame.svelte:83-96,289-296` and the dict-version resolution in `gamesource.ts`/`localgame`). +2. **Local-guest identity** — new `ui/src/lib/localguest.ts`: a device-local id + default display + name (e.g. localized "Гость"), persisted (IndexedDB `scrabble` DB / localStorage). Used as the + human seat's account id in a local vs_ai game when there is no server session (today that id comes + from the session — see `localgame/source.list.test.ts:64`). Hotseat seats stay independent local + identities (already so). +3. **Cold offline-first boot** — `ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`: + - Extend `shouldBootOffline` (offline.ts:85) so that on a **native** channel, no session may still + boot offline as the local guest (dicts are bundled). Keep the current web rule intact (web still + needs a prior online session). + - In `bootstrap()` (app.svelte.ts): when native and (no cached session) and (offline preference on + OR the network is unreachable at boot), set `setOfflineMode(true, …)`, establish the local guest, + preload the bundled dicts, and land in the **lobby** (not `Login`). Preserve the existing online + path (guest/email) when reachable. + - `offlinePreloadEligible` (offline.ts:69): treat a native channel as eligible (drop the + `standalone && hasEmail` web gate for native), or bypass preload entirely on native since the + dicts are bundled. +4. **Lazy server-guest reconciliation** — when native and online and no server session: background + `auth.guest` (the silent variant from C2), cache the session, adopt it; unlock online features. + Guard against duplicates (only when no cached session). Local games remain device-only. On + `update_required`, stay offline silently (no overlay). +5. **Ensure both offline modes render for the local guest.** `NewGame.svelte` gates the offline + flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both + the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the + local-guest id. +6. **Soft registration** stays: the local/server guest can register by email → upgrade (existing + flow), adopting the server guest first if present. + +**Tests:** extend the local-game/offline unit tests for the bundled-dict tier (a `fetch` mock +returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a +Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and +starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the +gate's server path in Go tests.) + +**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 and lights up online play. + +### 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 **and the sibling `scrabble-solver`** — copy the checkout + Node/pnpm setup + block from `.gitea/workflows/ci.yaml` (the `ui` job). The sibling is needed for the bundled dicts. +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. `node ui/scripts/bundle-dicts.mjs` (copy the `.dawg` into `ui/dist/dict/`). +5. Setup JDK 17 (`actions/setup-java@v4`, temurin 17). +6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-34`, + `build-tools;34.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.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`. +Wire in `ui/android/app/build.gradle` `defaultConfig`: +`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`, +`versionName (project.findProperty('versionName') ?: '0.0.0')`, 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. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant). +2. Promote `development` → `master`; tag `vX.Y.0`. +3. Dispatch `android-build`; watch green; retrieve the signed APK. +4. Owner runs the on-device smoke checklist (incl. airplane-mode first launch). +5. Owner uploads to RuStore (see publication prerequisites). + +--- + +## Google Play variant (later — design now so we don't repaint) + +One codebase, two build variants selected by flags: +- **RuStore build** (this MVP): `VITE_PAYMENTS_DISABLED=1` (MVP), `VITE_GP_BUILD` unset. Later, when + RuStore billing lands, RuStore sells normally. +- **Google Play build** (later): `VITE_GP_BUILD=1` ⇒ `purchasesHidden()` true. The **only** functional + difference is that purchases are hidden — the flag structure (`purchasesHidden()`) already carries + both cases, so no repaint is needed. + +**Open compliance item to settle when the GP build is actually built (deferred, not now):** Google +forbids not just external billing but *steering* users to other payment methods or app stores. The +existing `isGooglePlayBuild()` stub "points the user to the RuStore build" — that steering likely +violates Google policy. When building for GP, change the stub to a neutral "not available here" with +no pointer to RuStore. Tracked here so it is not forgotten; it does not affect the RuStore MVP. Google +Play also requires an **AAB** (not APK) and its own signing/Play App Signing — a separate CI target. + +--- + +## Build & env matrix + +**Native Android build** (`pnpm build` → `bundle-dicts` → `cap 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=` — 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, 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. From 0c5e40c50914ec7d49330f561ddd350a9745918a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 11 Jul 2026 22:53:26 +0200 Subject: [PATCH 2/2] docs: capture agent field notes; drop RuStore-steering for the GP variant --- .claude/CLAUDE.md | 177 ++++++++++++++++++++++++++++++++++++++++++++++ ANDROID_PLAN.md | 13 ++-- CLAUDE.md | 8 +++ 3 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 .claude/CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..9588de4 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,177 @@ +# Agent field notes — scrabble-game + +Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`, +`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working +memory, not a spec — **verify any named file / flag / function against the current code before acting +on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up. + +## Codegen & build + +- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may + reorder output. After running it, revert the churn on tables you did not touch and commit only your + table's change. +- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different + flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with + another flatc version. +- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust + `go build` / `go test`, not the editor squiggles. +- **go-jet type mapping:** SQL `numeric` → `float64`, `interval` → `string`. Never store money as + `numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as + **int seconds**, not `interval`. +- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a + bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a + prod bump goes through a solver **PR → master + a published tag**, not a local replace. +- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`, + Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would. +- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to + `${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise). +- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack + pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/`. + +## Wire / schema evolution (client ↔ gateway ↔ backend) + +- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never + delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs + and breaks older readers. +- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion, + and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value. +- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing + both the FBS schema and the backend DTO. +- **Seat display name is built in two places** — `game.Service` live events **and** the server REST + DTOs. Change both or they drift. +- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST, + then bumps it from the live event. Don't expect it on the shared broadcast. + +## UI / Svelte 5 + +- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store + subscription. Rename (e.g. `view`). +- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression: + `{' : '}`. +- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design + tokens (mirror `NewGame`'s `.invite`). +- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site + with `$state.snapshot(...)` before storing. + +## Platform / WebView quirks (Telegram, VK, iOS, native, old Android) + +- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a + `showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs. +- **iOS Telegram `` navigates away** and strands the SPA. Deliver files by **Web + Share on mobile**, and only use a `` Blob path on **desktop**. +- **Android TG/VK WebViews** lack `navigator.share` and ignore ``. Deliver a + client-generated file by **copying it to the clipboard**. +- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts` + (routes to `vk.com/away.php`). Verify on-device. +- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery + matrix; do not re-litigate it without new on-device facts. +- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers + syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot + gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin` + glyph fallback for old rendering. +- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify + those live on the deployed contour, not in the e2e. +- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived + Subscribe stream, cosmetic, deliberately left as-is. + +## Testing + +- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock + e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage. +- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it + intercepts Playwright taps. +- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to + eyeball per-variant tiles. +- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a + plugin-config gotcha in wiring them). +- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env). + Use **testcontainers** for container-backed tests. +- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600) + or the service crash-loops on start. + +## Deploy / test contour (operational) + +- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side + `curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way. +- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in + prod, not a bug — which is why the IP ban / blocklist are **prod-only**. +- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one + PR, one deploy) so deploys don't clobber each other. +- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a + prerelease step in `PRERELEASE.md`. +- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then + syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing + locale. +- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the + bot-link silently dies. Diagnose from inside the netns. +- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns + socket-inspect trick (single-service recreate). +- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for + caddy; don't trust "deploy green" for an edge-config change. +- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through + to the landing catch-all. Add a CI probe for the route. +- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request" + telemetry log or Tempo, not caddy. +- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside + `/assets/main-*.js`. +- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open; + clients cache it ~30 days. Fix with `Alt-Svc: clear`. +- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source + produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir. +- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans + the whole roll, the SPA overlay reloads on recovery. Don't break these invariants. +- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep + their pinned version. The **owner** does the upload. +- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes — + re-run, it's not your code. + +## Repo workflow + +- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it* + / add a plan line — not file a tracker issue. +- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The + agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the + stale-mergeable trap (re-check mergeability right before merging). +- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched + **bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the + gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten + case — watch the post-merge runs too. +- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the + local feature branch. +- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a + wedged contour can be recovered by recreating the host container set. + +## Domain semantics (not obvious from the code) + +- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the + owner on every deletion point before wiring it. +- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email + account row is created at the **code-request** step, not at confirmation. +- **The robot has two distinct time windows:** a **sleep window** (~00:00–07:00, gates its moves and + nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the + **sleep** window. + +## Production topology + +- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina). + SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling + + health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there — + the source of truth for IPs/roles). +- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every + owner-side step **before** a deploy, not after. + +## Feature-area pointers (state lives in the linked docs/plans, not here) + +- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in + `docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies. +- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in + `deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with + `--pg1-user=scrabble`. +- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports** + of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native + offline-first bundles the dictionaries (see `ANDROID_PLAN.md`). +- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini + App, server-side confidential code exchange. +- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts. +- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate, + offline-first, RuStore). diff --git a/ANDROID_PLAN.md b/ANDROID_PLAN.md index 3b49d49..959ea6f 100644 --- a/ANDROID_PLAN.md +++ b/ANDROID_PLAN.md @@ -459,12 +459,13 @@ One codebase, two build variants selected by flags: difference is that purchases are hidden — the flag structure (`purchasesHidden()`) already carries both cases, so no repaint is needed. -**Open compliance item to settle when the GP build is actually built (deferred, not now):** Google -forbids not just external billing but *steering* users to other payment methods or app stores. The -existing `isGooglePlayBuild()` stub "points the user to the RuStore build" — that steering likely -violates Google policy. When building for GP, change the stub to a neutral "not available here" with -no pointer to RuStore. Tracked here so it is not forgotten; it does not affect the RuStore MVP. Google -Play also requires an **AAB** (not APK) and its own signing/Play App Signing — a separate CI target. +**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. --- diff --git a/CLAUDE.md b/CLAUDE.md index dc03378..27db4a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,3 +156,11 @@ The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` j the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/` (regenerate with `pnpm codegen`); pnpm build-script approval lives in `ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`). + +## Agent field notes + +Non-obvious, hard-won knowledge the agent has accumulated that is **not** captured in the docs above, +kept in the repo so it travels with a clone. Verify any named file/flag against current code before +acting on it. + +@.claude/CLAUDE.md