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 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s

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, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline 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.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
This commit is contained in:
Ilia Denisov
2026-07-13 01:44:28 +02:00
parent 09e05eef18
commit 2d1fadb50c
55 changed files with 2065 additions and 976 deletions
+8 -7
View File
@@ -7,12 +7,16 @@ export const en = {
'app.brand': 'Erudit',
'dict.loading': 'Loading…',
'connection.connecting': 'Connecting…',
'net.offline': 'You are offline',
'net.online': 'Back online',
'maintenance.title': 'Under maintenance',
'maintenance.body': 'Scrabble is briefly down for an update. It will be back in a moment — no need to reload the page.',
'maintenance.retry': 'Try again',
'update.title': 'Update required',
'update.body': 'This app is out of date and can no longer run. Download the update to keep playing — and winning.',
'update.action': 'Update',
'update.playOffline': 'Play offline',
'update.available': 'Update available',
'blocked.title': 'Account blocked',
'blocked.permanent': 'Your account is blocked.',
@@ -219,15 +223,8 @@ export const en = {
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'settings.zoomBoard': 'Zoom the board',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
'settings.offlineChecking': 'Loading dictionaries…',
'settings.offlineNeedsData': 'Not enough data on the device. Internet access is needed to download it.',
'offline.preloadWarning': 'Poor internet connection. Some features may be unavailable.',
'offline.promptTitle': 'No connection. Enable offline mode?',
'offline.promptYes': 'Enable',
'offline.promptNo': 'Keep trying',
// --- wallet ---
'wallet.title': 'Wallet',
@@ -378,6 +375,10 @@ export const en = {
'new.auto': 'Quick match',
'new.withFriends': 'Play with friends',
'new.playRemote': 'Invite a friend',
'new.playLocal': 'Pass and play',
'new.needsNetwork': 'Online play needs a connection.',
'new.dictUnavailable': "This dictionary isn't available offline yet.",
'new.pickFriends': 'Choose who to invite',
'new.searchFriends': 'Search friends',
'new.gameType': 'Variant',
+8 -7
View File
@@ -8,12 +8,16 @@ export const ru: Record<MessageKey, string> = {
'app.brand': 'Эрудит',
'dict.loading': 'Загрузка…',
'connection.connecting': 'Подключение…',
'net.offline': 'Нет соединения',
'net.online': 'Соединение восстановлено',
'maintenance.title': 'Технические работы',
'maintenance.body': 'Идёт короткое обновление игры. Мы скоро вернёмся — страницу перезагружать не нужно.',
'maintenance.retry': 'Повторить',
'update.title': 'Требуется обновление',
'update.body': 'Приложение устарело и не может продолжить работу. Загрузите обновлённую версию, чтобы продолжить играть и побеждать.',
'update.action': 'Обновить',
'update.playOffline': 'Играть офлайн',
'update.available': 'Доступно обновление',
'blocked.title': 'Учётная запись заблокирована',
'blocked.permanent': 'Ваша учётная запись заблокирована.',
@@ -219,15 +223,8 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'settings.zoomBoard': 'Приближать доску',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
'settings.offlineChecking': 'Загрузка словарей…',
'settings.offlineNeedsData': 'Недостаточно данных. Необходим доступ в интернет для загрузки.',
'offline.preloadWarning': 'Плохое соединение с интернет. Некоторые функции могут быть недоступны.',
'offline.promptTitle': 'Нет связи. Включить офлайн-режим?',
'offline.promptYes': 'Включить',
'offline.promptNo': 'Ждать сеть',
// --- wallet ---
'wallet.title': 'Кошелёк',
@@ -378,6 +375,10 @@ export const ru: Record<MessageKey, string> = {
'new.auto': 'Быстрая игра',
'new.withFriends': 'Игра с друзьями',
'new.playRemote': 'Пригласить друга',
'new.playLocal': 'На одном устройстве',
'new.needsNetwork': 'Для игры онлайн нужна сеть.',
'new.dictUnavailable': 'Этот словарь пока недоступен офлайн.',
'new.pickFriends': 'Кого пригласить',
'new.searchFriends': 'Поиск друзей',
'new.gameType': 'Вариант',