From 1654131904415ca118304b1f7ea75c66ae0ab7fe Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 6 Jul 2026 09:24:55 +0200 Subject: [PATCH] feat(offline): wire the local game source into the game screen (Phase B3.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game screen now drives a local vs_ai game through the offline engine, dispatched by game id — completing the playable local game (on top of the source, #193). Online play is unchanged. - gamesource.ts: gameSource(id) returns the local source for a `local:` id, else the gateway (the same game-loop interface). The offline engine stays OUT of the app entry bundle — it is dynamically imported on first use (a separate chunk), so online-only users never pay for it (the app entry stays within its size budget). - localgame/id.ts: the tiny id helper (no engine imports) the dispatcher branches on. - Game.svelte: the game-loop calls (state/history/submit/pass/exchange/resign/hint/ evaluate/draft) go through gameSource(id) instead of the gateway directly; a local game's robot-reply events route through the same app event hub the network stream feeds, so the screen reacts to opponent_moved / game_over identically. Behaviour-preserving for network games (gameSource returns the gateway for them). Local verify green: check + test:unit + build + bundle-size gate + e2e (196 passed). --- ui/src/game/Game.svelte | 37 +++++++++++++------ ui/src/lib/gamesource.ts | 65 ++++++++++++++++++++++++++++++++++ ui/src/lib/localgame/id.ts | 12 +++++++ ui/src/lib/localgame/source.ts | 9 ++--- 4 files changed, 105 insertions(+), 18 deletions(-) create mode 100644 ui/src/lib/gamesource.ts create mode 100644 ui/src/lib/localgame/id.ts diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 2268457..735c3ff 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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(null); let moves = $state([]); let placement = $state(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 { diff --git a/ui/src/lib/gamesource.ts b/ui/src/lib/gamesource.ts new file mode 100644 index 0000000..a8ecd1a --- /dev/null +++ b/ui/src/lib/gamesource.ts @@ -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 | null = null; +function load(): Promise { + 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; + +/** + * 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'; diff --git a/ui/src/lib/localgame/id.ts b/ui/src/lib/localgame/id.ts new file mode 100644 index 0000000..cbeda4b --- /dev/null +++ b/ui/src/lib/localgame/id.ts @@ -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); +} diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 721e329..213b12f 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -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;