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 11s
CI / integration (pull_request) Successful in 19s
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 1m50s

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.

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; svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
This commit is contained in:
Ilia Denisov
2026-07-13 01:44:28 +02:00
parent 09e05eef18
commit 14918cc94f
54 changed files with 1987 additions and 976 deletions
+12 -10
View File
@@ -1,8 +1,9 @@
import { test, expect, type Page } from './fixtures';
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
// Settings offline toggle shows.
// Offline pass-and-play (hotseat) is offered only in offline mode, which is now implicit (the net-state
// machine detects it — no toggle). The e2e drives the machine through the mock's __net hook. Standalone
// display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
// installed PWA.
async function forceStandalone(page: Page): Promise<void> {
await page.addInitScript(() => {
const orig = window.matchMedia.bind(window);
@@ -19,11 +20,9 @@ async function enterLobby(page: Page): Promise<void> {
}
async function goOffline(page: Page): Promise<void> {
await page.locator('button.tab').nth(2).click(); // Settings
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
// The machine auto-detects offline (no toggle, no dialog); the lobby stays mounted and turns blue.
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
@@ -46,9 +45,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
// New game -> the offline mode selector now offers "with friends" (hotseat).
// New game -> "with friends" offers an online (invite) / offline (hotseat) segment. Offline, the
// online segment is disabled and the offline (pass-and-play) one is forced, so the hotseat form shows.
await page.locator('button.tab').nth(0).click();
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
await expect(page.getByRole('button', { name: /Invite a friend|Пригласить друга/i })).toBeDisabled();
await expect(page.locator('.hostpin')).toBeVisible();
// The player rows are disabled until the mandatory host (master) PIN is set.
await expect(page.locator('.pname').first()).toBeDisabled();
@@ -115,12 +117,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
await page.getByRole('button', { name: /Back|Назад/i }).click();
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
await expect(page.locator('.rowwrap')).toHaveCount(1);
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(1);
await page.locator('.kebab').first().click();
const del = page.locator('.rowwrap.revealed .del');
await expect(del).toBeVisible();
await del.click();
await typePin(page, '9999');
await expect(page.locator('.rowwrap')).toHaveCount(0);
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(0);
});
});