feat(offline): wire the local game source into the game screen (Phase B3.2)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m9s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
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).
This commit is contained in:
+26
-11
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user