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
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:
@@ -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: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).
|
||||||
+533
@@ -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 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<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.
|
||||||
@@ -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/`
|
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
||||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
`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
|
||||||
|
|||||||
Reference in New Issue
Block a user