Merge pull request 'feat(offline): wire the local game source into the game screen (Phase B3.2)' (#194) from feature/offline-game-seam-wire into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m8s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m39s
This commit was merged in pull request #194.
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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user