Files
scrabble-game/ui/src/lib/gamesource.ts
T
Ilia Denisov ef832b823d
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
feat(offline): offline lobby lists + creates local vs_ai games
In offline mode the lobby now shows only the device-local games and its
New-vs-AI entry creates one through the in-browser engine — the visible
payoff of the offline mode.

- LocalSource.list() reconstructs a lobby GameView per stored local game by
  replay, exposed through the lazy gamesource proxy; unit-tested via an
  in-memory store.
- Lobby.load() branches on offlineMode: lists local games and skips every
  gateway call (no online games/invitations/incoming); the Stats tab is
  disabled offline.
- NewGame offline: find() creates a device-local vs_ai game via
  LocalSource.create using the profile's advertised dict version + a local
  seed; the friends flow and the random-opponent option are hidden, and the
  variant picker / Start are enabled offline (were gated on connection).
- id.ts: newLocalGameId + randomSeed (tested).

Docs: ARCHITECTURE + FUNCTIONAL(+ru) offline-mode section.

Deferred to fast-follow: the Settings Friends/Profile/Feedback affordance
gating, the flip-to-offline readiness wait, the offline mock e2e (needs
mock-dawg support), and the local-hint UI. The offline flow is verified on
the test contour — the mock e2e cannot enter offline mode (the toggle is
gated to an installed PWA).
2026-07-06 11:35:23 +02:00

67 lines
3.4 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, 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)),
list: () => load().then((s) => s.list()),
// 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'>;
/**
* 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';