release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212

Merged
developer merged 79 commits from development into master 2026-07-07 14:40:43 +00:00
2 changed files with 31 additions and 13 deletions
Showing only changes of commit fc8143758a - Show all commits
+12 -6
View File
@@ -13,6 +13,7 @@
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { offlineMode } from '../lib/offline.svelte';
import { GatewayError } from '../lib/client';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
@@ -52,6 +53,11 @@
// screen calls the game-loop methods (state/history/submit/pass/exchange/resign/hint/evaluate/
// draft) through it, so the same UI drives a local vs_ai game and an online one alike.
const source = $derived(gameSource(id));
// A local (offline) game plays entirely on-device, so it is always ready; an online game needs a
// live connection — and offline mode's kill switch refuses its calls — so its network actions are
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
// toasts a blocked call would otherwise raise).
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
let localUnsub: (() => void) | null = null;
@@ -1481,19 +1487,19 @@
/>
</div>
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{/snippet}
{#snippet controlButtons()}
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={openExchange}>
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
onconfirm={doHint}
>
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
@@ -1551,7 +1557,7 @@
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
<button class="danger" onclick={doResign} disabled={!netReady}>{t('game.dropGame')}</button>
</div>
</Modal>
{/if}
@@ -1563,7 +1569,7 @@
<button
class="confirm"
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
disabled={!connection.online}
disabled={!netReady}
>
{t('game.exportImageOpt')}
</button>
@@ -1571,7 +1577,7 @@
<button
class={exportImageAvailable ? 'export-alt' : 'confirm'}
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
disabled={!connection.online}
disabled={!netReady}
>
{t('game.exportGcgOpt')}
</button>
+19 -7
View File
@@ -304,7 +304,9 @@ export function handleError(err: unknown): void {
void enterBlocked();
return;
}
if (isConnectionCode(code) || !connection.online) return;
// A blocked call in offline mode ('offline', the transport kill switch) is expected, not an error
// to surface — the offline chrome already signals the state; never a red toast.
if (isConnectionCode(code) || code === 'offline' || !connection.online) return;
haptic('error');
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
}
@@ -585,18 +587,28 @@ function promptOfflineChoice(): Promise<boolean> {
* cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
*/
// tryReturnOnline is fired from the online event while in auto-offline: the interface being back does
// not mean the gateway is reachable yet (DNS/routing settle for a moment after flight mode), so it
// re-checks a few times with backoff before returning online, stopping if the player takes over the
// state meanwhile. This bounded retry is what actually gets the app back online after flight mode off.
async function tryReturnOnline(): Promise<void> {
for (let attempt = 0; attempt < 4; attempt++) {
if (!offlineMode.active || !offlineMode.auto) return;
if (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS)) {
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
return;
}
await new Promise((r) => setTimeout(r, 600 * (attempt + 1)));
}
}
export function initNetworkReactivity(): void {
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
window.addEventListener('offline', () => {
if (!offlineMode.active) setOfflineMode(true, false); // auto (session) — self-heals below
});
window.addEventListener('online', () => {
if (offlineMode.active && offlineMode.auto) {
void checkReachable(BOOT_REACHABILITY_TIMEOUT_MS).then((reachable) => {
// Re-read the flags — the player may have chosen offline meanwhile — before returning online.
if (reachable && offlineMode.active && offlineMode.auto) setOfflineMode(false);
});
}
if (offlineMode.active && offlineMode.auto) void tryReturnOnline();
});
}