docs(android): bake the version gate, identity model + native build into the docs
ARCHITECTURE §2 (client-version gate + frozen wire contract + gate x offline), §3 (local-guest / server-guest / reconciliation identity), §13 (native Capacitor build). FUNCTIONAL (+_ru) a new "Native app (Android)" domain. TESTING (the clientver/gate Go tests, the native/update e2e + retry mapping, and the manual on-device Android smoke checklist). deploy/README (GATEWAY_MIN_CLIENT_VERSION + the wire-break bump discipline + the Android build/release runbook). ui/README native VITE_* vars. Mark F done in ANDROID_PLAN.md.
This commit is contained in:
@@ -140,6 +140,30 @@ dropped). Horizontal scaling is explicit future work.
|
||||
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
||||
client's in-app stream while the app is open. Out-of-app delivery uses
|
||||
platform-native push via the platform side-service.
|
||||
- **Client-version gate** (native long-tail protection): a bundled native install (§13) can be
|
||||
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
|
||||
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
|
||||
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
|
||||
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
|
||||
that breaks. Enforcement is **per-call, not a Hello RPC**: `Execute` and `Subscribe` compare the header to
|
||||
`GATEWAY_MIN_CLIENT_VERSION` at the top (before registry lookup / auth / decode), so the first online call
|
||||
an app makes is already gated. A too-old client gets the domain envelope `result_code = "update_required"`
|
||||
(HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`, and makes **zero** successful
|
||||
requests. The gate **fails open** (an absent or unparseable header passes) and is **dormant** until
|
||||
`GATEWAY_MIN_CLIENT_VERSION` is deliberately set (empty ⇒ off, so web behaviour is unchanged). The client
|
||||
raises a terminal *update* overlay — native → the store listing (`VITE_RUSTORE_URL`), web → reload.
|
||||
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
|
||||
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
|
||||
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
|
||||
code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never
|
||||
delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside*
|
||||
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
|
||||
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
|
||||
rollout (see [`../deploy/README.md`](../deploy/README.md)).
|
||||
- **Gate × offline** (UX rule): deliberate offline uses the network kill switch, so the gate never fires
|
||||
while playing offline — an old install can always play local vs_ai / hotseat. The terminal *update*
|
||||
overlay is raised **only on a user-initiated online action**; the silent background guest reconciliation
|
||||
(§3) swallows an `update_required` and stays a local guest rather than interrupting local play.
|
||||
|
||||
## 3. Authentication & sessions
|
||||
|
||||
@@ -224,6 +248,19 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
||||
Platform and email users are auto-provisioned **durable** accounts with an
|
||||
identity.
|
||||
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
|
||||
identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
|
||||
id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
|
||||
launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
|
||||
games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
|
||||
row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
|
||||
cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
|
||||
unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
|
||||
session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
|
||||
`update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
|
||||
**Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
|
||||
transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
|
||||
prior online-session rule.
|
||||
|
||||
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
|
||||
> two-bot model (one bot per service language, with `accounts.service_language`, a
|
||||
@@ -1468,6 +1505,24 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
|
||||
contour activates; the prod main host omits it.
|
||||
|
||||
**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8,
|
||||
appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is
|
||||
a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there
|
||||
is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to
|
||||
speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an
|
||||
**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is
|
||||
the cache). It is **offline-first**:
|
||||
`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into
|
||||
`dist/dict/<variant>@<version>.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network
|
||||
enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are
|
||||
hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow
|
||||
(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles
|
||||
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
|
||||
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
|
||||
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
|
||||
with a host-provisioned Android SDK; RuStore upload is manual. Full plan + runbook:
|
||||
[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md).
|
||||
|
||||
## 14. CI & branches
|
||||
|
||||
- **Two long-lived branches**: **`development`** is the integration
|
||||
|
||||
@@ -314,6 +314,19 @@ itself. A self-entered offline is temporary: it reverts to online the moment the
|
||||
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
|
||||
dialog — is the player's choice: it is kept, and never undone automatically.
|
||||
|
||||
### Native app (Android)
|
||||
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
|
||||
client. Everything it needs is bundled, so it opens with **no network**: a first launch offline drops you
|
||||
straight into the lobby as a **guest** (no sign-in wall) and you can play right away — against the robot, or
|
||||
a **pass-and-play** game with friends on one device — using the dictionaries shipped inside the app. When the
|
||||
network is available the app quietly establishes a guest in the background and online features (random
|
||||
opponents, friends, statistics) light up without interrupting play; local games you started offline stay on
|
||||
the device. To keep a durable account you sign in with **email** from Profile (the guest becomes your
|
||||
account); Telegram and VK sign-in are not offered in the native app. In-app purchases are hidden in this
|
||||
first release. Because an installed app can be far older than the server, a build that is **too old** to talk
|
||||
to the current server shows a single, non-dismissable *update* screen whose button opens the store listing —
|
||||
this appears only on an online action, never while you are playing offline.
|
||||
|
||||
### Social: friends, block, chat, nudge
|
||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||
digits, valid for twelve hours), or send a **request to someone you have played
|
||||
|
||||
@@ -321,6 +321,20 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||
|
||||
### Нативное приложение (Android)
|
||||
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
|
||||
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
|
||||
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
|
||||
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
|
||||
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
|
||||
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
|
||||
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
|
||||
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
|
||||
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
|
||||
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
|
||||
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
|
||||
офлайн-игры.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||
|
||||
+24
-1
@@ -31,7 +31,20 @@ tests or touching CI.
|
||||
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
|
||||
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
|
||||
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
|
||||
production build.
|
||||
production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects
|
||||
`window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare
|
||||
`Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
|
||||
in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
|
||||
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
|
||||
`window.__native.reconcile()` to prove online lights up and Profile hides the Telegram/VK link buttons.
|
||||
The **update overlay** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove the terminal
|
||||
update cover; `retry.test.ts` covers the `FailedPrecondition → update_required` mapping.
|
||||
- **Client-version gate** (Go) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH` parse
|
||||
(with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering; `connectsrv`'s server tests
|
||||
assert a too-old `Execute` returns `result_code = "update_required"` (and the op handler never ran), a
|
||||
too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min / unparseable header
|
||||
/ equal version all pass (fail-open); `config` tests reject a non-empty, unparseable
|
||||
`GATEWAY_MIN_CLIENT_VERSION`.
|
||||
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||
real self-played 35-move game) must rasterize to a plausible PNG
|
||||
@@ -175,6 +188,16 @@ tests or touching CI.
|
||||
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
|
||||
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
|
||||
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
|
||||
- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device /
|
||||
emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and
|
||||
store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in
|
||||
**airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a
|
||||
**2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the
|
||||
root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on**
|
||||
lights up online play (a server guest is established); Profile offers **email** sign-in but no
|
||||
Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action
|
||||
raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by
|
||||
eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`).
|
||||
|
||||
## Principles
|
||||
|
||||
|
||||
Reference in New Issue
Block a user