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:
@@ -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