feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 12s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 22s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README). Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later. Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
This commit is contained in:
+61
-49
@@ -100,15 +100,24 @@ dropped). Horizontal scaling is explicit future work.
|
||||
auth operations are unauthenticated and return the minted token. A unary
|
||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||
surface as Connect error codes. The client treats a connectivity edge failure as
|
||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
||||
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
|
||||
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
|
||||
response was lost — its button is disabled while offline and the player re-issues it on
|
||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
|
||||
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
|
||||
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
|
||||
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
|
||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
||||
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
||||
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
||||
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
||||
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
|
||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online, never fed an offline
|
||||
signal, so those channels never enter an offline state or show a local lobby.
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
@@ -140,18 +149,26 @@ 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
|
||||
- **Client-version gate — two tiers** (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.
|
||||
that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
|
||||
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
|
||||
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
|
||||
vars empty ⇒ off, web behaviour unchanged).
|
||||
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
|
||||
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
|
||||
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
|
||||
`offlineVersionLocked` (offline mode) and shows a **dismissable notice** — **"Update"** (native → the
|
||||
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
|
||||
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
|
||||
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
|
||||
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
|
||||
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
|
||||
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **≥ `min`**
|
||||
(validated at config load); empty ⇒ the soft tier is off.
|
||||
- **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`
|
||||
@@ -160,10 +177,12 @@ dropped). Horizontal scaling is explicit future work.
|
||||
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.
|
||||
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
|
||||
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
|
||||
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
|
||||
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
|
||||
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
|
||||
rather than interrupting local play.
|
||||
|
||||
## 3. Authentication & sessions
|
||||
|
||||
@@ -1369,13 +1388,16 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
|
||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
|
||||
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
|
||||
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
||||
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
||||
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
||||
invitations are hidden while offline. New Game's *with friends* carries an **online/offline segmented
|
||||
control** — online = a friend invite, offline = **local pass-and-play (hotseat)** — with the online
|
||||
segment disabled and offline forced when there is no network; a `vs_ai` or hotseat create is guarded when
|
||||
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
|
||||
the in-browser engine and driven by the same game screen, the robot replying locally.
|
||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
||||
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||
@@ -1403,27 +1425,17 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
|
||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||
mode** boots from the persisted session and
|
||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
||||
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
||||
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
||||
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
||||
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
||||
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
||||
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
||||
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
||||
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
||||
while offline. The gateway registers the `.webmanifest` MIME type
|
||||
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
|
||||
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
|
||||
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
|
||||
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
|
||||
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
|
||||
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
|
||||
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
|
||||
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
|
||||
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
|
||||
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
|
||||
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
|
||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||
|
||||
+30
-23
@@ -277,22 +277,25 @@ add-friend are off. AI games are **practice** — they never count toward a play
|
||||
statistics.
|
||||
|
||||
### Offline mode
|
||||
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
||||
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
||||
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
||||
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
||||
A New Game against the robot creates a device-local **vs_ai** game — it
|
||||
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
||||
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
||||
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
|
||||
(once installed) launches from a precached shell even with no network. Switching **to** offline first
|
||||
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
|
||||
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
|
||||
(the download keeps running in the background, so a later switch is instant). The mode is
|
||||
device-scoped and sticky across launches.
|
||||
The app plays **offline automatically** — there is **no on/off switch**. When it cannot reach the
|
||||
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
|
||||
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
|
||||
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
|
||||
app never touches the network.
|
||||
|
||||
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
|
||||
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||
The **lobby stays unified**: your device-local games are active, while your server games are still
|
||||
listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
|
||||
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
|
||||
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
|
||||
backend; local games are kept on the device and never sync to the account, so a game can be created and
|
||||
played with no connection. The app quietly preloads the dictionaries for the player's enabled variants
|
||||
while still online, and (once installed, or on the native app) launches from bundled/precached data even
|
||||
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
|
||||
a short note.
|
||||
|
||||
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
|
||||
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
|
||||
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||||
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
||||
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
||||
@@ -305,14 +308,18 @@ pass-and-play game. A game that finishes normally is saved to the offline lobby
|
||||
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
|
||||
so no one can wipe a game the moment they have moved.
|
||||
|
||||
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
|
||||
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
||||
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
||||
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
||||
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
|
||||
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.
|
||||
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
|
||||
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
|
||||
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
|
||||
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||||
offline, so you are never interrupted for a hiccup.
|
||||
|
||||
### Staying up to date
|
||||
When a new client version is required, the app does not lock you out. On the **native** app a
|
||||
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
|
||||
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
|
||||
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
|
||||
required — version exists; you can update or dismiss it, and play continues either way.
|
||||
|
||||
### Native app (Android)
|
||||
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
|
||||
|
||||
+29
-23
@@ -283,22 +283,24 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||
сохраняется между запусками.
|
||||
Приложение играет **офлайн автоматически** — никакого переключателя нет. Когда оно не может достучаться
|
||||
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
||||
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
||||
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
||||
сети.
|
||||
|
||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
|
||||
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
|
||||
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
|
||||
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
|
||||
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
|
||||
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
|
||||
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
|
||||
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
||||
|
||||
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди. В
|
||||
игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||
@@ -312,14 +314,18 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||
своего хода.
|
||||
|
||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
|
||||
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
|
||||
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
|
||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||
вас не прерывают из-за короткой икоты.
|
||||
|
||||
### Актуальная версия
|
||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
|
||||
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
|
||||
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
|
||||
|
||||
### Нативное приложение (Android)
|
||||
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
|
||||
|
||||
+22
-13
@@ -17,14 +17,17 @@ tests or touching CI.
|
||||
- **UI** — Vitest (unit) + Playwright
|
||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
||||
derivation, the GCG share/copy/download choice and the **net-state reducer**
|
||||
(`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
|
||||
hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
|
||||
plus Playwright specs against the
|
||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||
invitations section, the stats screen, profile editing, and the export chooser's
|
||||
finished-only visibility + its signed-URL download flow (route-intercepted). The
|
||||
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
|
||||
end-to-end: it forces the installed-PWA display mode, enters offline through the
|
||||
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
|
||||
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
||||
end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
|
||||
auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
|
||||
Stats tab), self-heals back online, then creates and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
||||
rack is deterministic and the human can tap out a precomputed opening), asserting the
|
||||
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
|
||||
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
|
||||
@@ -36,15 +39,21 @@ tests or touching CI.
|
||||
`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`.
|
||||
`window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
|
||||
**unified lobby** (closing G-step-0) — and Profile hides the Telegram/VK link buttons.
|
||||
The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
|
||||
hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
|
||||
dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
|
||||
update_required` mapping.
|
||||
- **Client-version gate** (Go, two tiers) — `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 the **hard** tier — 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) — and the **soft** tier (`TestExecuteUpdateRecommended`):
|
||||
a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
|
||||
too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
|
||||
unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
|
||||
minimum.
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user