Merge pull request 'docs: Android native plan + agent field notes' (#248) from feature/android-native into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / conformance (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Has been skipped

This commit was merged in pull request #248.
This commit is contained in:
2026-07-11 21:02:05 +00:00
3 changed files with 718 additions and 0 deletions
+177
View File
@@ -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/<tool>`.
## 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 `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. 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:0007: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 (E0E9). 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).
+533
View File
@@ -0,0 +1,533 @@
# 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) |
**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<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)
1. **One origin helper** — new `ui/src/lib/origin.ts`:
```ts
// The absolute origin the client talks to. A packaged native build (file:// origin) sets
// VITE_GATEWAY_URL to the real gateway; web/mock leave it empty and fall back to the page
// origin. Centralised so every absolute-URL construction resolves identically.
export function gatewayOrigin(): string {
const configured = import.meta.env.VITE_GATEWAY_URL ?? '';
return configured || (typeof location !== 'undefined' ? location.origin : '');
}
```
Fix the two same-origin **landmines** (they would produce `file:///…` on native):
- `ui/src/game/Game.svelte:1214`: `new URL(path, location.origin)` → `new URL(path, gatewayOrigin())`.
- `ui/src/screens/Wallet.svelte:47`: `` `${location.origin}/` `` → `` `${gatewayOrigin()}/` ``.
(`transport.ts:32` already computes the equivalent inline — leave it.)
2. **Skip the service worker on native** — `ui/src/lib/pwa.svelte.ts registerServiceWorker()` (line 85):
add, next to `if (inMiniApp()) return;`:
```ts
import { clientChannel } from './channel';
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') return;
```
3. **Hide payments in the MVP build** — `ui/src/lib/distribution.ts`, add:
```ts
// purchasesHidden: true for the Google Play build (external-payment policy) OR the thin native
// MVP that defers store billing (VITE_PAYMENTS_DISABLED="1"). Every other build sells normally.
export function purchasesHidden(): boolean {
return isGooglePlayBuild() || (import.meta.env as Record<string, string | undefined>).VITE_PAYMENTS_DISABLED === '1';
}
```
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
hide the buy actions (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<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:** 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 `<UpdateOverlay />` right after `<MaintenanceOverlay />` (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/<variant>@<DICT_VERSION>.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.
**Google Play compliance — decided: no RuStore steering.** Google forbids not just external billing
but *steering* users to other payment methods or app stores. So the Google Play build hides purchases
with **no** pointer to RuStore: the existing `isGooglePlayBuild()` RuStore-pointer stub is **dropped**
in the GP variant (show nothing, or a neutral pointer-free message — never name or link RuStore or any
other store). This does not affect the RuStore MVP (there `isGooglePlayBuild()` is false, so the stub
never shows). Google Play also requires an **AAB** (not APK) and Play App Signing — a separate CI
target, built later.
---
## Build & env matrix
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
- `VITE_GATEWAY_URL=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, 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.
+8
View File
@@ -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