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 13s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Failing after 15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 13s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Failing after 15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
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, and the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 pure net-state reducer (test-first). O2 reactive store + event wiring (connection/offline become thin 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 (ARCHITECTURE, FUNCTIONAL +_ru, TESTING, deploy/README). Also disable the manual android-build CI workflow for now (rename to .disabled); the Android APK release comes later. Tests: gateway go green, svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
This commit is contained in:
+59
-27
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user