ebd1f05da8
A local (offline) game persisted no draft: draftGet returned an empty string and draftSave was a no-op, on the assumption that the arrangement is only ever rebuilt from the rack when the game is reopened. The game screen, however, refetches the state on several triggers, and every one of them applied that empty draft over the live composition — dropping the player's pending tiles back into the rack and resetting the rack order. Offline the most visible trigger fires on its own: with no network the live-stream watchdog declares the stream dead after 25 s, the reconnect 4 s later flips app.streamAlive back to true, and the game screen's reconnect effect refetches. Composing a move offline was therefore reset roughly every half minute. Store the draft in the game's own record instead, so a reload restores it and it survives leaving and reopening the app, and clear it wherever the turn advances (a committed move, a resignation, a host skip) so a hotseat seat never inherits the previous player's arrangement. Gate the two stream-driven refetches on the game being a network one: a local game takes its events from the source, so reacting to the stream only cost it a pointless reload.
77 lines
4.1 KiB
TypeScript
77 lines
4.1 KiB
TypeScript
// 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 and the evaluate
|
|
// abort signal; 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(id)),
|
|
draftSave: (id, json) => load().then((s) => s.draftSave(id, json)),
|
|
create: (opts) => load().then((s) => s.create(opts)),
|
|
list: () => load().then((s) => s.list()),
|
|
delete: (id) => load().then((s) => s.delete(id)),
|
|
// Offline hotseat controls (no network equivalent): reveal a PIN-locked seat, and the host
|
|
// (referee) overrides gated by the master PIN.
|
|
unlockSeat: (id, pin) => load().then((s) => s.unlockSeat(id, pin)),
|
|
verifyHostPin: (id, pin) => load().then((s) => s.verifyHostPin(id, pin)),
|
|
hostAction: (id, pin, action, targetSeat) => load().then((s) => s.hostAction(id, pin, action, targetSeat)),
|
|
// relock is fire-and-forget: the engine is already loaded (the game was open), and re-locking a
|
|
// cached game before the screen is left needs no result.
|
|
relock: (id) => void load().then((s) => s.relock(id)),
|
|
// 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' | 'list' | 'delete' | 'unlockSeat' | 'verifyHostPin' | 'hostAction' | 'relock'>;
|
|
|
|
/**
|
|
* 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';
|