release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0 #212
+12
-6
@@ -13,6 +13,7 @@
|
|||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
|
import { offlineMode } from '../lib/offline.svelte';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
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/
|
// 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.
|
// draft) through it, so the same UI drives a local vs_ai game and an online one alike.
|
||||||
const source = $derived(gameSource(id));
|
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).
|
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
|
||||||
let localUnsub: (() => void) | null = null;
|
let localUnsub: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -1481,19 +1487,19 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet controlButtons()}
|
{#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>
|
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||||
</button>
|
</button>
|
||||||
<TapConfirm
|
<TapConfirm
|
||||||
triggerClass="tab"
|
triggerClass="tab"
|
||||||
label={t('game.hint')}
|
label={t('game.hint')}
|
||||||
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
|
||||||
onconfirm={doHint}
|
onconfirm={doHint}
|
||||||
>
|
>
|
||||||
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
<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)}>
|
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
<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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1563,7 +1569,7 @@
|
|||||||
<button
|
<button
|
||||||
class="confirm"
|
class="confirm"
|
||||||
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
||||||
disabled={!connection.online}
|
disabled={!netReady}
|
||||||
>
|
>
|
||||||
{t('game.exportImageOpt')}
|
{t('game.exportImageOpt')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1571,7 +1577,7 @@
|
|||||||
<button
|
<button
|
||||||
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
||||||
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
||||||
disabled={!connection.online}
|
disabled={!netReady}
|
||||||
>
|
>
|
||||||
{t('game.exportGcgOpt')}
|
{t('game.exportGcgOpt')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -304,7 +304,9 @@ export function handleError(err: unknown): void {
|
|||||||
void enterBlocked();
|
void enterBlocked();
|
||||||
return;
|
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');
|
haptic('error');
|
||||||
showToast(t(code ? errorKey(code) : 'error.generic'), '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
|
* 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).
|
* 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 {
|
export function initNetworkReactivity(): void {
|
||||||
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
|
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
|
||||||
window.addEventListener('offline', () => {
|
window.addEventListener('offline', () => {
|
||||||
if (!offlineMode.active) setOfflineMode(true, false); // auto (session) — self-heals below
|
if (!offlineMode.active) setOfflineMode(true, false); // auto (session) — self-heals below
|
||||||
});
|
});
|
||||||
window.addEventListener('online', () => {
|
window.addEventListener('online', () => {
|
||||||
if (offlineMode.active && offlineMode.auto) {
|
if (offlineMode.active && offlineMode.auto) void tryReturnOnline();
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user