feat(ui): offline-first native boot + lazy server-guest reconciliation

Native (Capacitor) cold boot with no cached session now enters as a
device-local guest in auto-offline mode and lands straight in the lobby
(never /login), so the app opens and plays local vs_ai / hotseat with the
APK's bundled dictionaries and zero network. When the gateway becomes
reachable, reconcileServerGuest silently mints + adopts a server guest and
clears the auto-offline, lighting up online features. Web / PWA / Telegram /
VK are byte-for-byte unchanged.

- transport: exec gains { silent, allowOffline } so background reconciliation
  bypasses the offline kill switch (like the reachability probe) and never
  raises the terminal update overlay (a too-old client stays a local guest);
  new authGuestSilent on the client interface, the real transport and the mock.
- app: native no-session boot branch; reconcileServerGuest fired at boot, by
  the recovery poll and the online event; the poll routes the session-less
  guest through reconciliation, since checkReachable needs a token it lacks.
- native: initNativeShell tolerates a missing Capacitor bridge.
- e2e: new native.spec.ts (inject window.androidBridge; boot -> offline lobby,
  local vs_ai move, reconcile -> online, hotseat start) + playwright.config
  bundles the dawgs into dist-e2e/dict for the loader's bundled tier.
This commit is contained in:
Ilia Denisov
2026-07-12 18:03:52 +02:00
parent a035edfb54
commit e077258567
9 changed files with 318 additions and 79 deletions
+64 -4
View File
@@ -58,6 +58,8 @@ import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
import { offlineMode, setOfflineMode } from './offline.svelte';
import { shouldBootOffline } from './offline';
import { clientChannel } from './channel';
import { localGuestId } from './localguest';
import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
@@ -612,14 +614,24 @@ export function scheduleRecovery(delayMs: number): void {
clearTimeout(recoveryTimer);
recoveryTimer = null;
}
if (typeof window === 'undefined' || !offlineMode.active || !offlineMode.auto) return;
// Web / native only; under the mock the e2e drives connectivity + reconciliation directly (cf.
// initNetworkReactivity), so the poll must not race a mock reconcile to online before the spec can
// observe the offline lobby.
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock' || !offlineMode.active || !offlineMode.auto) return;
recoveryTimer = setTimeout(async () => {
recoveryTimer = null;
if (!offlineMode.active || !offlineMode.auto) return;
const interfaceUp = typeof navigator === 'undefined' || navigator.onLine;
if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) {
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
return;
if (interfaceUp) {
if (!app.session) {
// A native local guest has no session token for checkReachable's authenticated probe, so the
// reconciliation attempt itself is the reachability check: it mints + adopts a server guest and
// clears the auto-offline on success (a no-op off the native channel or once a session exists).
await reconcileServerGuest();
} else if (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS)) {
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
}
if (!offlineMode.active) return; // came back online
}
scheduleRecovery(4000);
}, delayMs);
@@ -640,6 +652,38 @@ export function initNetworkReactivity(): void {
});
}
// A concurrency guard: the boot kick, the recovery poll and the online event can all try to reconcile
// at once — only the first proceeds; the rest see the in-flight flag (or the adopted session) and return.
let reconcileInFlight = false;
/**
* reconcileServerGuest lazily mints a server guest for a native local-guest launch once the gateway is
* reachable: with no cached session it silently calls auth.guest, adopts the returned session and clears
* the auto-offline so online features (matchmaking, friends, the Profile sign-in surface) light up. It is
* idempotent and best-effort — it never mints a second server guest (guarded on the absence of a session
* and the in-flight flag), and a too-old client (a swallowed update_required — no overlay) or an
* unreachable gateway just leaves the device a local guest until the next attempt. Local (device-only)
* games are never migrated. Native channel only; a no-op elsewhere.
*/
async function reconcileServerGuest(): Promise<void> {
if (reconcileInFlight || app.session) return;
const channel = clientChannel();
if (channel !== 'android' && channel !== 'ios') return;
reconcileInFlight = true;
try {
const s = await gateway.authGuestSilent(app.locale);
// The gateway answered, so leave auto-offline BEFORE adopting: adoptSession's profile fetch and live
// stream ride the normal (online) transport, which the offline kill switch would otherwise refuse.
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
await adoptSession(s);
} catch {
// A too-old client (update_required, swallowed — stay a local guest, no overlay) or an unreachable
// gateway: remain offline; the recovery poll / online event retries when the network returns.
} finally {
reconcileInFlight = false;
}
}
/**
* applyLinkResult applies a completed account link or merge: it adopts a
* switched session (a guest initiator whose durable counterpart won, so the active
@@ -986,6 +1030,16 @@ export async function bootstrap(): Promise<void> {
} else if (router.route.name === 'login') {
navigate('/');
}
} else if (clientChannel() === 'android' || clientChannel() === 'ios') {
// Native offline-first cold boot: no cached server session, so enter as a device-local guest in a
// non-sticky (auto) offline mode and land straight in the lobby — never /login. reconcileServerGuest
// (kicked here and by the recovery poll) mints a server guest and clears the auto-offline once the
// gateway is reachable; until then the guest plays local vs_ai / hotseat with the APK's bundled
// dictionaries. Web / PWA / Telegram / VK keep the online-session rule below.
localGuestId(); // establish + persist the device-local identity from the very first launch
setOfflineMode(true, false);
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
scheduleRecovery(0); // kick reconciliation now, then poll until the network returns
} else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
navigate('/login');
}
@@ -1370,4 +1424,10 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
navigate,
route: () => router.route.name,
};
// Drive the native offline-first reconciliation from the e2e: the recovery poll that fires it in a
// real build is skipped under the mock (scheduleRecovery), so the spec calls this to simulate "the
// network returned" and prove a server guest is minted + adopted and the auto-offline clears.
(window as unknown as { __native?: { reconcile(): void } }).__native = {
reconcile: () => void reconcileServerGuest(),
};
}