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
+59 -27
View File
@@ -3,9 +3,10 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import UpdateNudge from '../components/UpdateNudge.svelte';
import Modal from '../components/Modal.svelte';
import PinPad from '../components/PinPad.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread } from '../lib/app.svelte';
import { app, handleError, refreshFeedbackBadge, seedChatUnread, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
@@ -16,6 +17,9 @@
import { preloadGames } from '../lib/preload';
import { kickDictPreload, offlineMode } from '../lib/offline.svelte';
import { localSource, isLocalGameId } from '../lib/gamesource';
import { localGuestId } from '../lib/localguest';
import { insideTelegram } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort';
import type { AccountRef, GameView, Invitation } from '../lib/model';
import { VARIANT_FLAG, VARIANT_RULES } from '../lib/variants';
@@ -36,27 +40,37 @@
let loadSeq = 0;
async function load() {
const seq = ++loadSeq;
// Deliberate offline mode: never touch the network. The lobby shows only the device-local
// vs_ai games (reconstructed from the store) plus the New-vs-AI entry; there are no online
// games, invitations or incoming requests to fetch.
if (offlineMode.active) {
const local = await localSource.list();
if (seq !== loadSeq) return;
games = local;
const offline = offlineMode.active;
// Device-local games (vs_ai / hotseat) merge into the unified lobby everywhere except the
// always-online Telegram/VK mini-apps, which are server-only (the shared-origin local store must
// not leak device-local games into a mini-app).
const serverOnly = insideTelegram() || insideVK();
const local = serverOnly ? [] : await localSource.list();
if (seq !== loadSeq) return;
if (offline) {
// Offline: device-local games are active; the last-known server games ride along greyed from the
// cache (un-openable). No network — and no invitations/incoming (a server concept, hidden until
// back online).
const cachedServer = (getLobby()?.games ?? []).filter((g) => !isLocalGameId(g.id));
games = [...local, ...cachedServer];
invitations = [];
incoming = [];
app.notifications = 0;
setLobby({ games, invitations, incoming, offline: offlineMode.active });
setLobby({ games, invitations, incoming });
app.lobbyReady = true;
return;
}
try {
const list = await gateway.gamesList();
if (seq !== loadSeq) return;
games = list.games;
// Seed the per-game unread badges from the authoritative list. The live stream only raises
const server = list.games;
// Seed the per-game unread badges from the authoritative server list. The live stream only raises
// unread (it never seeds it from a GameView), so a persisted unread survives a reload here.
for (const g of games) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
for (const g of server) seedChatUnread(g.id, g.unreadChat, g.unreadMessages);
// The unified list: device-local games alongside the server ones (so a reconciled guest's local
// games stay visible online). Ids never collide.
games = [...local, ...server];
if (!guest) {
const [inv, inc] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
if (seq !== loadSeq) return;
@@ -67,12 +81,12 @@
app.notifications = incoming.length;
void refreshFeedbackBadge();
}
setLobby({ games, invitations, incoming, offline: offlineMode.active });
// Warm the cache for the ongoing games so opening one from the lobby is instant. The list
// just loaded, so the connection is up; the call is non-blocking and re-running it on each
// lobby refresh cheaply warms any newly appeared game (already-cached ones are skipped).
setLobby({ games, invitations, incoming });
// Warm the cache for the ongoing SERVER games so opening one from the lobby is instant (local
// games open from the device). The list just loaded, so the connection is up; non-blocking and
// cheap (already-cached ones are skipped).
if (connection.online) {
void preloadGames(games);
void preloadGames(server);
// Warm the offline dictionaries for an eligible install (PWA + email) so a later switch to
// offline mode already has the data; a no-op elsewhere, and cheap once cached. The first
// lobby entry surfaces a notice in place of the ad banner if a fetch fails.
@@ -90,7 +104,7 @@
onMount(() => {
// Render instantly from the cached lists (so the screen does not "draw in" during
// the back-slide), then refresh in the background.
const cached = getLobby(offlineMode.active);
const cached = getLobby();
if (cached) {
games = cached.games;
invitations = cached.invitations;
@@ -118,8 +132,16 @@
}
});
// myId is the server user id, used for the (server-only) invitation "from me" checks. myIds is the
// full "you" set — the server user id plus the device-local guest id — used for game seat matching,
// since a reconciled guest's device-local games are seated under localGuestId while server games are
// seated under the user id (the ids never collide). guestId is read once (it mints on first launch).
const guestId = localGuestId();
const myId = $derived(app.session?.userId ?? '');
const groups = $derived(groupGames(games, myId, app.chatUnread));
const myIds = $derived([app.session?.userId, guestId].filter((x): x is string => !!x));
// grey marks a server game shown only from the offline cache: dimmed and un-openable until online.
const grey = (g: GameView): boolean => offlineMode.active && !isLocalGameId(g.id);
const groups = $derived(groupGames(games, myIds, app.chatUnread));
// Per-card "look here" blink. When a game changes lobby bucket into "your turn" or "finished"
// while the lobby is on screen, its status emoji blinks twice (an opponent's-turn change is
@@ -152,7 +174,7 @@
const seen = new Set<string>();
for (const g of games) {
seen.add(g.id);
const phase = gamePhase(g, myId);
const phase = gamePhase(g, myIds);
if (shouldBlink(prevPhase.get(g.id), phase)) blink(g.id);
prevPhase.set(g.id, phase);
}
@@ -169,7 +191,7 @@
// An honest-AI game shows the robot opponent as 🤖, never its (pooled) name.
if (g.vsAi) return '🤖';
return g.seats
.filter((s) => s.accountId !== myId)
.filter((s) => !myIds.includes(s.accountId))
.map((s) => s.displayName)
.join(', ');
}
@@ -213,6 +235,11 @@
revealedId = null;
return;
}
// A server game shown only from the offline cache is un-openable — a toast explains why.
if (grey(g)) {
showToast(t('net.offline'));
return;
}
navigate(`/game/${g.id}`);
}
function toggleReveal(id: string): void {
@@ -222,7 +249,7 @@
revealedId = null;
const prev = games;
games = games.filter((g) => g.id !== id); // optimistic; the backend already filters it out
setLobby({ games, invitations, incoming, offline: offlineMode.active });
setLobby({ games, invitations, incoming });
try {
// A local (offline) game is deleted from the device store; an online game is hidden on the
// backend. Routing by id keeps the delete off the network for a local game (the transport
@@ -231,7 +258,7 @@
else await gateway.hideGame(id);
} catch (e) {
games = prev;
setLobby({ games, invitations, incoming, offline: offlineMode.active });
setLobby({ games, invitations, incoming });
handleError(e);
}
}
@@ -276,7 +303,7 @@
<Screen title={app.profile?.displayName ?? t('app.title')}>
<div class="lobby">
{#if invitations.length}
{#if invitations.length && !offlineMode.active}
<section>
<h2>{t('lobby.invitations')}</h2>
{#each invitations as inv (inv.id)}
@@ -319,7 +346,7 @@
<div class="list">
{#each group.list as g (g.id)}
{@const badge = badgeKind(app.chatUnread[g.id] ?? false, app.messageUnread[g.id] ?? false)}
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id}>
<div class="rowwrap" class:revealed={(group.finished || g.hotseat) && revealedId === g.id} class:greyed={grey(g)}>
{#if group.finished || g.hotseat}
<button class="del" onclick={() => startDelete(g)} disabled={!isLocalGameId(g.id) && !connection.online} aria-label={t('lobby.hideGame')}>❌</button>
{/if}
@@ -336,7 +363,7 @@
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
</span>
<span class="sub scoreline"
>{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myId, s)}{#if i > 0}<span class="sep">{' : '}</span
>{#each orderedSeats(g) as s, i (s.seat)}{@const standing = scoreStanding(g, myIds, s)}{#if i > 0}<span class="sep">{' : '}</span
>{/if}<span
class="num"
class:win={standing === 'win'}
@@ -349,7 +376,7 @@
plaques, and resultBadge is viewer-centric (no seat matches the account). A
vs_ai game keeps its medal (one human, a straightforward win/loss). -->
{#key blinkNonce.get(g.id) ?? 0}
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myId).emoji}</span>
<span class="emoji" class:blink={blinkingIds.has(g.id)}>{g.hotseat ? '' : resultBadge(g, myIds).emoji}</span>
{/key}
</button>
{#if group.finished || g.hotseat}
@@ -397,6 +424,7 @@
{/if}
{#snippet tabbar()}
<UpdateNudge />
<TabBar>
<button class="tab" onclick={() => navigate('/new')}>
<span class="sq" data-coach="lobby-new">🎲</span><span class="lbl">{t('lobby.new')}</span>
@@ -495,6 +523,10 @@
.rowwrap + .rowwrap {
border-top: 1px solid var(--border);
}
/* A server game shown only from the offline cache: dimmed and un-openable (openGame toasts instead). */
.rowwrap.greyed {
opacity: 0.5;
}
.del {
position: absolute;
inset: 0 0 0 auto;
+74 -6
View File
@@ -10,8 +10,8 @@
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 { localSource } from '../lib/gamesource';
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';
import { navigate } from '../lib/router.svelte';
@@ -51,7 +51,18 @@
];
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" (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';
});
// The quick-game opponent: an honest AI (a robot that joins and moves at once, shown as 🤖)
// or a random human via auto-match. AI is the default.
let opponent = $state<'ai' | 'random'>('ai');
@@ -63,7 +74,10 @@
const autoKind = $derived(opponent === 'ai' ? GameKind.vsAi : GameKind.random);
// The player's current games for the per-kind lock: seeded from the lobby snapshot for an instant
// render, then refreshed on mount (below) so a game created since the last lobby visit is counted.
let lobbyGames = $state<GameView[]>(getLobby(false)?.games ?? []);
// Only server games count toward the per-kind server limit (device-local games are unlimited), so
// the merged snapshot is filtered to the server ones here (the on-mount refresh below already reads
// the server list directly).
let lobbyGames = $state<GameView[]>(getLobby()?.games.filter((g) => !isLocalGameId(g.id)) ?? []);
const autoLocked = $derived(!offlineMode.active && isKindLocked(lobbyGames, app.profile?.gameLimits, autoKind));
let limitOpen = $state(false);
@@ -125,6 +139,37 @@
$effect(() => {
if (variants.length === 1 && !inviteVariant) inviteVariant = variants[0].id;
});
// The offline dictionary guard: creating a device-local game (vs_ai or hotseat) needs the variant's
// dawg to be loadable without the network — bundled (native, always) or IndexedDB-cached (web). A
// native build bundles every variant, so this only ever bites a web install whose dawg was not
// cached. dawgReady is null while the async check runs, true/false once resolved; online it stays
// true (the guard is inert).
let dawgReady = $state<boolean | null>(true);
const guardVariant = $derived(mode === 'auto' ? selectedAuto : friendsMode === 'offline' ? inviteVariant : '');
$effect(() => {
const v = guardVariant;
if (!offlineMode.active || !v) {
dawgReady = true;
return;
}
dawgReady = null; // checking
const version = app.profile?.dictVersions?.[v] ?? __DICT_VERSION__;
let cancelled = false;
// getDawg resolves from IndexedDB / the native bundle offline (the network tier is refused by the
// kill switch), so a null result means the dictionary is genuinely unavailable on this device.
void import('../lib/dict')
.then((m) => m.getDawg(v, version))
.then((d) => {
if (!cancelled) dawgReady = !!d;
});
return () => {
cancelled = true;
};
});
// dictBlocked gates the offline create on a genuinely-missing dictionary (never while still checking).
const dictBlocked = $derived(offlineMode.active && dawgReady === false);
let timeoutSecs = $state(86400);
let hints = $state(1);
@@ -355,10 +400,11 @@
{/if}
<p class="movelimit">{opponent === 'ai' ? t('new.aiInactiveLimit') : t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
{#if opponent === 'random'}<p class="searchhint">{t('new.searchHint')}</p>{/if}
{#if dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
<button
class="invite"
class:locked={autoLocked}
disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || starting}
disabled={(autoLocked ? false : !selectedAuto) || (!connection.online && !offlineMode.active) || dictBlocked || starting}
onclick={() => (autoLocked ? (limitOpen = true) : selectedAuto && find(selectedAuto))}
>{#if autoLocked}🔒 {/if}{t('new.start')}</button>
<GameLimitModal
@@ -367,7 +413,19 @@
onclose={() => (limitOpen = false)}
onlogin={() => { limitOpen = false; navigate('/profile'); }}
/>
{:else if offlineMode.active}
{:else}
<!-- 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. -->
<div class="hostpin">
@@ -434,9 +492,10 @@
+ {t('hotseat.addPlayer')}
</button>
{/if}
{#if dictBlocked}<p class="dictnote" role="alert">{t('new.dictUnavailable')}</p>{/if}
<button
class="invite"
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || starting}
disabled={!hostPin || !inviteVariant || !rosterReady(rows) || dictBlocked || starting}
onclick={startHotseat}
>{t('new.start')}</button>
{:else if friends.length === 0}
@@ -485,6 +544,7 @@
{/if}
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{/if}
{#if pad}
@@ -708,6 +768,14 @@
font-size: 0.85rem;
line-height: 1.4;
}
/* The offline-blocker reason (a missing dictionary, or the online segment needing a connection). */
.dictnote {
margin: 0;
text-align: center;
color: var(--warn);
font-size: 0.85rem;
line-height: 1.4;
}
/* --- offline hotseat roster --- */
.hostpin {
display: flex;
-63
View File
@@ -11,22 +11,12 @@
import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels';
import { insideTelegram } from '../lib/telegram';
import { insideVK } from '../lib/vk';
import { offlineMode, setOfflineMode, requestOffline } from '../lib/offline.svelte';
import { isStandalone } from '../lib/pwa';
import InstallApp from '../components/InstallApp.svelte';
// The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit
// the whole board and never auto-zoom), so it is shown only for a coarse pointer.
const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
const offlineEligible = $derived(
isStandalone() && !insideTelegram() && !insideVK() && !!app.profile?.email,
);
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
auto: 'settings.themeAuto',
@@ -41,24 +31,6 @@
none: 'settings.labelsNone',
};
// The offline toggle gates entry on dictionary readiness: flipping to offline fetches the enabled
// variants' dictionaries (cache-first) and waits at most a few seconds; if they cannot be made
// available it stays online and shows the "needs internet" note, while the fetch keeps warming the
// cache in the background so a later flip is instant. Leaving offline (-> online) is never gated.
let checking = $state(false);
let needsData = $state(false);
async function goOffline(): Promise<void> {
if (offlineMode.active || checking) return;
needsData = false;
checking = true;
try {
const ready = await requestOffline(app.profile);
if (!ready) needsData = true;
} finally {
checking = false;
}
}
</script>
<div class="page">
@@ -119,33 +91,6 @@
{/if}
</section>
{#if offlineEligible}
<section>
<h3>{t('settings.offlineMode')}</h3>
<div class="seg">
<button
class="opt"
class:active={!offlineMode.active}
disabled={checking}
onclick={() => {
needsData = false;
setOfflineMode(false);
}}
>
{t('settings.online')}
</button>
<button class="opt" class:active={offlineMode.active} disabled={checking} onclick={goOffline}>
{t('settings.offline')}
</button>
</div>
{#if checking}
<p class="onote">{t('settings.offlineChecking')}</p>
{:else if needsData}
<p class="onote onote--warn" role="alert">{t('settings.offlineNeedsData')}</p>
{/if}
</section>
{/if}
<!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app
is installable here — see components/InstallApp.svelte / lib/pwa). -->
<InstallApp />
@@ -190,14 +135,6 @@
opacity: 0.55;
cursor: default;
}
.onote {
font-size: 0.85rem;
color: var(--text-muted);
margin: 8px 0 0;
}
.onote--warn {
color: var(--warn);
}
.row {
display: flex;
align-items: center;