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
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user