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
4 changed files with 105 additions and 18 deletions
Showing only changes of commit d80d28a402 - Show all commits
+26 -11
View File
@@ -8,6 +8,7 @@
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway';
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
@@ -47,6 +48,13 @@
let { id }: { id: string } = $props();
// The game's source: the local engine for an offline game id, otherwise the network gateway. The
// 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));
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
let localUnsub: (() => void) | null = null;
let view = $state<StateView | null>(null);
let moves = $state<MoveRecord[]>([]);
let placement = $state<Placement>(newPlacement([]));
@@ -190,9 +198,9 @@
// Fetch the saved draft alongside state and history (best-effort) so the composition is
// applied in the same tick the board appears — never as a second, visible rack→board step.
const [st, hist, draft] = await Promise.all([
gateway.gameState(id, includeAlphabet),
gateway.gameHistory(id),
gateway.draftGet(id).catch(() => ''),
source.gameState(id, includeAlphabet),
source.gameHistory(id),
source.draftGet(id).catch(() => ''),
]);
view = st;
syncWallet(st.walletBalance);
@@ -220,7 +228,7 @@
setCachedDraft(id, json);
if (draftSaveTimer) clearTimeout(draftSaveTimer);
draftSaveTimer = setTimeout(() => {
void gateway.draftSave(id, json).catch(() => {});
void source.draftSave(id, json).catch(() => {});
}, 500);
}
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
@@ -264,6 +272,12 @@
dict = m;
});
}
// A local (offline) game has no live stream: route the source's robot-reply events through the
// same app event hub the network stream feeds, so the event effect above reacts to
// opponent_moved / game_over identically.
if (isLocalGameId(id)) {
localUnsub = localSource.events(id, (e) => (app.lastEvent = e));
}
});
// Warm the game's dictionary for the local move preview once, when both the game and the
@@ -611,8 +625,9 @@
// Flush a pending draft save so leaving mid-composition still persists it.
if (draftSaveTimer) {
clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
}
localUnsub?.();
});
function onCell(row: number, col: number) {
@@ -740,7 +755,7 @@
evalCtrl = ctrl;
previewTimer = setTimeout(async () => {
try {
preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal);
preview = await source.evaluate(id, sub.tiles, variant, ctrl.signal);
notePreviewNetwork();
} catch {
/* best-effort (or aborted) */
@@ -781,7 +796,7 @@
if (!sub) return;
busy = true;
try {
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
haptic('success');
zoomed = false;
} catch (e) {
@@ -800,7 +815,7 @@
async function doPass() {
busy = true;
try {
applyMoveResult(await gateway.pass(id));
applyMoveResult(await source.pass(id));
} catch (e) {
handleError(e);
} finally {
@@ -821,7 +836,7 @@
resignOpen = false;
busy = true;
try {
applyMoveResult(await gateway.resign(id));
applyMoveResult(await source.resign(id));
// Reveal the final board once the game is resigned: close the move-history drawer
// (portrait — the landscape dock is unaffected) and zoom out if it was magnified.
historyOpen = false;
@@ -834,7 +849,7 @@
}
async function doHint() {
try {
const h = await gateway.hint(id);
const h = await source.hint(id);
if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack);
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
@@ -906,7 +921,7 @@
exchangeOpen = false;
busy = true;
try {
applyMoveResult(await gateway.exchange(id, tiles, variant));
applyMoveResult(await source.exchange(id, tiles, variant));
} catch (e) {
handleError(e);
} finally {
+65
View File
@@ -0,0 +1,65 @@
// Dispatch a game id to its source: the local engine (an offline vs_ai game) or the network
// gateway. The same game screen (game/Game.svelte) drives both through the shared game-loop subset
// (GameLoopSource); a game id decides which. A network game's calls are unchanged — gameSource
// returns the gateway itself for it — so the seam adds the local path without touching online play.
//
// The offline engine is kept OUT of the app entry bundle: this module statically imports only the
// gateway, the tiny id helper and types; the LocalSource is dynamically imported on first use of a
// local game, so it loads as a separate chunk only when someone plays offline.
import { gateway } from './gateway';
import { isLocalGameId } from './localgame/id';
import type { GameLoopSource, LocalSource } from './localgame/source';
let loaded: Promise<LocalSource> | null = null;
function load(): Promise<LocalSource> {
if (!loaded) loaded = import('./localgame/source').then((m) => new m.LocalSource());
return loaded;
}
/**
* localSource is a lazy handle on the single offline LocalSource. It is obtainable synchronously,
* but each method dynamically imports the engine on first use, so the offline code stays out of the
* app entry bundle until a local game is actually played. It exposes the game-loop methods plus the
* local-only create() and the robot-reply event subscription events().
*/
export const localSource = {
// Offline scoring is self-contained, so the local source ignores includeAlphabet, the evaluate
// abort signal, and the draft (drafts are not persisted offline); the proxy accepts the full
// gateway signatures but forwards only what the local source uses.
gameState: (id, _includeAlphabet) => load().then((s) => s.gameState(id)),
gameHistory: (id) => load().then((s) => s.gameHistory(id)),
submitPlay: (id, tiles, variant) => load().then((s) => s.submitPlay(id, tiles, variant)),
pass: (id) => load().then((s) => s.pass(id)),
exchange: (id, tiles, variant) => load().then((s) => s.exchange(id, tiles, variant)),
resign: (id) => load().then((s) => s.resign(id)),
hint: (id) => load().then((s) => s.hint(id)),
evaluate: (id, tiles, variant, _signal) => load().then((s) => s.evaluate(id, tiles, variant)),
checkWord: (id, word, variant) => load().then((s) => s.checkWord(id, word, variant)),
draftGet: (_id) => load().then((s) => s.draftGet()),
draftSave: (_id, _json) => load().then((s) => s.draftSave()),
create: (opts) => load().then((s) => s.create(opts)),
// events must return the unsubscribe synchronously (the screen subscribes in onMount), so it wires
// the real subscription once the engine loads and, until then, cancels a pending subscribe.
events: (id, onEvent) => {
let unsub = (): void => {};
let cancelled = false;
void load().then((s) => {
if (!cancelled) unsub = s.events(id, onEvent);
});
return () => {
cancelled = true;
unsub();
};
},
} satisfies GameLoopSource & Pick<LocalSource, 'events' | 'create'>;
/**
* gameSource returns the source that runs the game with the given id: the local engine for a local
* id, otherwise the network gateway (which satisfies the same game-loop interface).
*/
export function gameSource(id: string): GameLoopSource {
return isLocalGameId(id) ? localSource : gateway;
}
export { isLocalGameId } from './localgame/id';
+12
View File
@@ -0,0 +1,12 @@
// Local game id helpers, in a tiny standalone module with no engine/generator imports, so the
// dispatcher (lib/gamesource) can recognise a local game id — and the game screen can branch on it
// — without eagerly bundling the whole offline engine. The engine is dynamically imported only when
// a local game is actually played (keeping the app entry bundle within its size budget).
/** LOCAL_ID_PREFIX marks a game id as a local (offline) game. */
export const LOCAL_ID_PREFIX = 'local:';
/** isLocalGameId reports whether an id belongs to a local game. */
export function isLocalGameId(id: string): boolean {
return id.startsWith(LOCAL_ID_PREFIX);
}
+2 -7
View File
@@ -13,6 +13,7 @@ import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat
import { getLocalGame, saveLocalGame } from './store';
import { RULESETS } from './ruleset';
import { getDawg } from '../dict';
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
import { decide } from '../robot/strategy';
import { GatewayError, type PlacedTile } from '../client';
import type {
@@ -31,13 +32,7 @@ import type {
} from '../model';
import type { Move } from '../dict/validate';
/** LOCAL_ID_PREFIX marks a game id as a local (offline) game, so the app dispatches it here. */
export const LOCAL_ID_PREFIX = 'local:';
/** isLocalGameId reports whether an id belongs to a local game. */
export function isLocalGameId(id: string): boolean {
return id.startsWith(LOCAL_ID_PREFIX);
}
export { LOCAL_ID_PREFIX, isLocalGameId };
/** HINT_GATE_MS is the idle time since the robot's last move before an offline hint unlocks. */
export const HINT_GATE_MS = 30 * 60 * 1000;