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

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.

Wire the version gate through the deploy: GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION are plain (unprefixed) Gitea vars, passed via compose + ci.yaml (test) + prod-deploy.yaml + write-prod-env.sh (prod) + .env.example, so the gate is live when set (a 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.

Tests: gateway go green (incl. two-tier gate over a real HTTP handler), 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 872804e02e
53 changed files with 1979 additions and 973 deletions
+66 -5
View File
@@ -11,7 +11,7 @@
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 { localSource, isLocalGameId } from '../lib/gamesource';
import { newLocalGameId, randomSeed } from '../lib/localgame/id';
import { localGuestId } from '../lib/localguest';
import { navigate } from '../lib/router.svelte';
@@ -52,6 +52,13 @@
const guest = $derived(app.profile?.isGuest ?? true);
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.
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 +70,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 +135,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 +396,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 +409,16 @@
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. Both exist regardless of connectivity, but with no network the online segment is
disabled and offline is forced (friendsMode). -->
<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 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 +485,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 +537,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 +761,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;