Compare commits

..

1 Commits

Author SHA1 Message Date
Ilia Denisov 2d1fadb50c 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.
2026-07-13 02:50:41 +02:00
6 changed files with 96 additions and 18 deletions
+9 -5
View File
@@ -116,8 +116,10 @@ dropped). Horizontal scaling is explicit future work.
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.
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
local lobby, no transport kill switch and no device-local create paths.
**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
@@ -1393,9 +1395,11 @@ for install on Android) and powers **offline play**. Offline is **implicit** —
(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
invitations are hidden while offline. On offline-capable channels (native / plain web) 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;
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. 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` +
+4 -1
View File
@@ -277,7 +277,9 @@ add-friend are off. AI games are **practice** — they never count toward a play
statistics.
### Offline mode
The app plays **offline automatically** — there is **no on/off switch**. When it cannot reach the
The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
native apps only). 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
@@ -295,6 +297,7 @@ 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 the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
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
+6 -3
View File
@@ -283,7 +283,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
Приложение играет **офлайн автоматически** — никакого переключателя нет. Когда оно не может достучаться
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
приложениям). Когда оно не может достучаться
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
@@ -299,8 +301,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди. В
игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
+48
View File
@@ -122,6 +122,54 @@ test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
});
test('inside Telegram (online-only), an offline signal never switches the app to offline mode', async ({
page,
}) => {
// The Telegram mini-app is online-only: the offline model (implicit offline, device-local games,
// the transport kill switch) must stay inert there. Even when the net-state machine is driven to an
// offline state, offlineMode must remain false — otherwise the chrome turns blue, the lobby greys the
// server games and, critically, a vs_ai start would create a device-local game instead of enqueuing.
await page.addInitScript((stub) => {
Object.assign(window, stub);
}, webAppStub());
await page.goto('/');
await expect(page.getByText('Your turn')).toBeVisible(); // the durable mini-app session, in the lobby
// Drive the net-state machine to an offline state through the mock hook.
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
// Because the channel is online-only, offlineMode stays inert: on the lobby the chrome never turns blue
// and no server game is greyed…
await expect(page.locator('header.nav.offline')).toHaveCount(0);
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
// …and New Game's quick match still offers the opponent choice (AI / random), which is hidden only in
// real offline mode. Its presence is the positive proof that offlineMode is false — so the vs_ai start,
// whose device-local branch is gated on offlineMode, enqueues on the server instead of creating a
// device-local game.
await page.locator('button.tab').nth(0).click();
await expect(page.locator('button.opt', { hasText: '🤖' })).toBeVisible();
});
test('inside Telegram (online-only), New Game with friends offers remote invites only', async ({
page,
}) => {
// A Telegram launch authenticates a durable account, so New Game shows the auto/with-friends selector.
// "Play with friends" must offer only the remote invite — never the online/offline segment, whose
// "Pass and play" is the device-local hotseat flow that has no place in an online-only mini-app.
await page.addInitScript((stub) => {
Object.assign(window, stub);
}, webAppStub());
await page.goto('/');
await expect(page.getByText('Your turn')).toBeVisible();
await page.locator('button.tab').nth(0).click(); // the New tab opens the create screen
await page.getByRole('button', { name: 'Play with friends' }).click();
// The online/offline segment (Invite a friend / Pass and play) is absent — only the invite form shows.
await expect(page.getByRole('button', { name: 'Pass and play' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Invite a friend' })).toHaveCount(0);
});
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
+15 -2
View File
@@ -10,11 +10,24 @@ import { insideTelegram } from './telegram';
import { insideVK } from './vk';
import type { Profile } from './model';
/**
* offlineCapable reports whether this channel supports the offline model — implicit offline mode,
* device-local games (vs_ai and hotseat) and the transport kill switch. Native builds and the plain web
* app do; the Telegram and VK mini-apps are online-only wrappers, so the whole offline model stays inert
* there (offlineMode.active is forced false regardless of the detected net state, and the create flow
* hides its device-local segment).
*/
export function offlineCapable(): boolean {
return !insideTelegram() && !insideVK();
}
/** offlineMode exposes the reactive offline flags; read them in markup / $derived. */
export const offlineMode = {
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked). */
/** active is true while the app is offline (the machine's offlineNoNetwork / offlineVersionLocked) on a
* channel that supports offline play; it is always false in the online-only Telegram/VK mini-apps, so
* every offline-model consumer (chrome, lobby, kill switch, device-local create) stays inert there. */
get active(): boolean {
return netState.offline;
return netState.offline && offlineCapable();
},
};
+14 -7
View File
@@ -10,7 +10,7 @@
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { offlineMode, offlineCapable } from '../lib/offline.svelte';
import { localSource, isLocalGameId } from '../lib/gamesource';
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
import { localGuestId } from '../lib/localguest';
@@ -51,10 +51,14 @@
];
const guest = $derived(app.profile?.isGuest ?? true);
// Whether this channel supports device-local play. Native and plain web do; the Telegram/VK mini-apps
// are online-only, so they offer no offline/hotseat segment and never create a device-local game. The
// channel is fixed for the session, so a plain const (not reactive) is enough.
const canPlayOffline = offlineCapable();
let mode = $state<'auto' | 'friends'>('auto');
// Within "with friends": online = a remote invite, offline = a pass-and-play (hotseat) game on this
// device. Both exist regardless of connectivity, but with no network the online segment is disabled
// and offline is forced — so the create flow always works.
// Within "with friends" (offline-capable channels only): online = a remote invite, offline = a
// pass-and-play (hotseat) game on this device. With no network the online segment is disabled and
// offline is forced — so the create flow always works.
let friendsMode = $state<'online' | 'offline'>('online');
$effect(() => {
if (offlineMode.active) friendsMode = 'offline';
@@ -410,14 +414,17 @@
onlogin={() => { limitOpen = false; navigate('/profile'); }}
/>
{:else}
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this
device. Both exist regardless of connectivity, but with no network the online segment is
disabled and offline is forced (friendsMode). -->
<!-- With friends: an online remote invite, or an offline pass-and-play (hotseat) on this device.
The online/offline segment is offered only on offline-capable channels (native / plain web);
the online-only Telegram/VK mini-apps skip it and show the remote invite directly. With no
network the online segment is disabled and offline is forced (friendsMode). -->
{#if canPlayOffline}
<div class="seg modes">
<button class="opt" class:active={friendsMode === 'online'} disabled={offlineMode.active} onclick={() => (friendsMode = 'online')}>{t('new.playRemote')}</button>
<button class="opt" class:active={friendsMode === 'offline'} onclick={() => (friendsMode = 'offline')}>{t('new.playLocal')}</button>
</div>
{#if offlineMode.active}<p class="dictnote">{t('new.needsNetwork')}</p>{/if}
{/if}
{#if friendsMode === 'offline'}
<!-- Offline pass-and-play (hotseat): a device-local 2-4 human game. The host sets a master
PIN first (it gates the roster); each seat may add its own PIN. -->