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:
+12
-2
@@ -163,7 +163,17 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
|
|||||||
- **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch).
|
- **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch).
|
||||||
Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`,
|
Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`,
|
||||||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
|
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
|
||||||
- **F–G — pending.**
|
- **F. Docs — ✅ DONE (2026-07-12).** Baked the Android work into the live docs: `docs/ARCHITECTURE.md` §2
|
||||||
|
(the client-version gate + the frozen wire contract + the gate × offline rule), §3 (the local-guest /
|
||||||
|
server-guest / reconciliation identity model) and §13 (the native Capacitor build — bundle model, bundled
|
||||||
|
dicts, `versionCode` scheme, `file://` origin, RuStore); `docs/FUNCTIONAL.md` (+ `_ru` mirror) a new **Native
|
||||||
|
app (Android)** domain (offline-first guest launch, email soft-registration, no TG/VK login, hidden
|
||||||
|
purchases, update-required); `docs/TESTING.md` (the `clientver`/gate Go tests, the `native`/`update` e2e +
|
||||||
|
the `retry` mapping, and the manual **on-device Android smoke checklist**); `deploy/README.md` (the
|
||||||
|
`GATEWAY_MIN_CLIENT_VERSION` var + the wire-break bump discipline + the Android build/release runbook —
|
||||||
|
keystore, secrets, dispatch, RuStore upload); `ui/README.md` (the native `VITE_*` build vars). Every
|
||||||
|
referenced test file was confirmed to exist.
|
||||||
|
- **G — pending.**
|
||||||
|
|
||||||
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
|
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
|
||||||
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
|
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
|
||||||
@@ -655,7 +665,7 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
|||||||
(`> Value is null`). The signing block attaches only when `ANDROID_KEYSTORE_FILE` exists, so a keyless
|
(`> Value is null`). The signing block attaches only when `ANDROID_KEYSTORE_FILE` exists, so a keyless
|
||||||
`assembleRelease` (and every `assembleDebug`) builds UNSIGNED instead of failing.
|
`assembleRelease` (and every `assembleDebug`) builds UNSIGNED instead of failing.
|
||||||
|
|
||||||
### F. Docs (bake in the same PR)
|
### F. Docs (bake in the same PR) — ✅ DONE
|
||||||
|
|
||||||
- `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** +
|
- `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 /
|
the gate×offline rule. New sections — the **identity model** (local guest / server guest /
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
|
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
|
||||||
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
|
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
|
||||||
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
|
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
|
||||||
|
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | Minimum client build the gateway will serve — the native client-version gate (ARCHITECTURE.md §2). Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind. Validated at load; a non-empty unparseable value fails startup. |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||||
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
@@ -236,6 +237,32 @@ redeploy the matching old tag. This dump is a belt-and-braces net for a bad migr
|
|||||||
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
|
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
|
||||||
that survives losing the host.
|
that survives losing the host.
|
||||||
|
|
||||||
|
## Android app build & release (RuStore)
|
||||||
|
|
||||||
|
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
|
||||||
|
web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md).
|
||||||
|
|
||||||
|
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
|
||||||
|
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
|
||||||
|
but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`,
|
||||||
|
`versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`.
|
||||||
|
- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an
|
||||||
|
**unsigned** release APK otherwise (a dry run still proves the pipeline).
|
||||||
|
- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned** —
|
||||||
|
install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the
|
||||||
|
`runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the
|
||||||
|
`ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if
|
||||||
|
the SDK is missing or unreadable.
|
||||||
|
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
|
||||||
|
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
|
||||||
|
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||||
|
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
||||||
|
variable to the store listing once published (empty until then — the in-app update button no-ops).
|
||||||
|
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
|
||||||
|
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
|
||||||
|
away cleanly instead of failing blind.
|
||||||
|
- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP).
|
||||||
|
|
||||||
## Point-in-time recovery (PITR)
|
## Point-in-time recovery (PITR)
|
||||||
|
|
||||||
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
||||||
|
|||||||
@@ -140,6 +140,30 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
(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
|
client's in-app stream while the app is open. Out-of-app delivery uses
|
||||||
platform-native push via the platform side-service.
|
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
|
## 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.
|
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
||||||
Platform and email users are auto-provisioned **durable** accounts with an
|
Platform and email users are auto-provisioned **durable** accounts with an
|
||||||
identity.
|
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
|
> **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
|
> 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
|
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
|
||||||
contour activates; the prod main host omits it.
|
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
|
## 14. CI & branches
|
||||||
|
|
||||||
- **Two long-lived branches**: **`development`** is the integration
|
- **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
|
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.
|
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
|
### Social: friends, block, chat, nudge
|
||||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
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
|
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
|
### Социальное: друзья, блок, чат, 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`
|
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
|
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
|
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
|
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
`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
|
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
|
`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
|
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.
|
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
|
## Principles
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ enables the "Link Telegram" web sign-in (the Login Widget) — inert until the s
|
|||||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||||
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
|
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
|
||||||
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
||||||
|
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
|
||||||
|
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
|
||||||
|
in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and
|
||||||
|
`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by
|
||||||
|
`.gitea/workflows/android-build.yaml`; see `ANDROID_PLAN.md`.
|
||||||
|
|
||||||
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||||
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
|
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
|
||||||
|
|||||||
Reference in New Issue
Block a user