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
+31 -22
View File
@@ -1,33 +1,29 @@
<script lang="ts">
// Non-dismissable "update required" cover. Shown when the gateway has refused a foreground call
// as too old (update.svelte.ts — the client-version gate). Unlike the maintenance overlay this is
// terminal: an installed build cannot become compatible without an actual update, so its one
// action takes the user to the fix — the store listing on a native build (VITE_RUSTORE_URL, opened
// in the system browser / store app) or a plain reload on the web (which fetches the current
// client). Mirrors MaintenanceOverlay.svelte's look.
import { updateRequired } from '../lib/update.svelte';
import { clientChannel } from '../lib/channel';
// The "update required" notice (the hard version tier). Shown when the gateway has refused a
// foreground call as too old, driving the net-state machine into offlineVersionLocked. Unlike the
// old terminal cover it is dismissable: "Update" takes the user to the fix (the store on native,
// a reload on the web), while "Play offline" dismisses the notice and leaves the app in the offline
// lobby (the version lock is an offline state, sticky until an actual update on the next launch).
// Mirrors MaintenanceOverlay.svelte's look.
import { netState } from '../lib/netstate.svelte';
import { openUpdate } from '../lib/update.svelte';
import { t } from '../lib/i18n/index.svelte';
function onAction(): void {
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') {
// '_system' hands the URL to the OS (the store app / external browser) rather than the WebView.
const url = import.meta.env.VITE_RUSTORE_URL;
if (url) window.open(url, '_system');
} else {
location.reload();
}
}
// Dismissed for the session once the user chooses "Play offline". The lock is sticky until a fresh
// launch, so the notice does not reappear this session; the app stays in the offline lobby.
let dismissed = $state(false);
</script>
{#if updateRequired.active}
{#if netState.versionLocked && !dismissed}
<div class="scrim" role="alertdialog" aria-modal="true" aria-labelledby="update-title" aria-describedby="update-body">
<div class="card">
<div class="tile" aria-hidden="true">Э</div>
<h1 id="update-title">{t('update.title')}</h1>
<p id="update-body">{t('update.body')}</p>
<button type="button" onclick={onAction}>{t('update.action')}</button>
<div class="acts">
<button type="button" class="primary" onclick={openUpdate}>{t('update.action')}</button>
<button type="button" onclick={() => (dismissed = true)}>{t('update.playOffline')}</button>
</div>
</div>
</div>
{/if}
@@ -36,8 +32,7 @@
.scrim {
position: fixed;
inset: 0;
/* Above the game (z 60) and toasts (z 50) — a terminal update block covers everything the user
could otherwise interact with; below the dev DebugPanel (z 10000). */
/* Above the game (z 60) and toasts (z 50); below the dev DebugPanel (z 10000). */
z-index: 100;
background: rgba(0, 0, 0, 0.55);
display: grid;
@@ -79,6 +74,11 @@
color: var(--text-muted);
line-height: 1.5;
}
.acts {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
button {
font: inherit;
padding: 0.55rem 1.4rem;
@@ -92,4 +92,13 @@
border-color: var(--accent);
color: var(--accent);
}
.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.primary:hover {
color: var(--accent-text);
opacity: 0.9;
}
</style>