// The local game source: a GatewayClient-shaped facade over the offline engine, so the SAME game // screen drives a local vs_ai game with no backend. It implements the game-loop subset of the // gateway (GameLoopSource) for a local game id, backed by LocalGame + the IndexedDB store + the // real robot (lib/robot/strategy). Unlike the network flow there is no live stream: after the // human's move the robot plays synchronously in the same call, its move persisted atomically and // delivered to the screen through this source's own per-game event emitter (events()). // // It translates between the UI's glyph space (PlacedTile.letter, rack strings) and the engine's // alphabet-index space using the static letters table (ruleset.ts), so it needs no server alphabet. import { LocalGame, type LocalMove } from './engine'; import { serializeGame, replayGame, type LocalGameRecord, type Seat as LocalSeat } from './serialize'; import { getLocalGame, saveLocalGame, listLocalGames, deleteLocalGame } from './store'; import { RULESETS } from './ruleset'; import { getDawg } from '../dict'; import { HINT_GATE_MS } from '../hints'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { decide } from '../robot/strategy'; import { GatewayError, type PlacedTile } from '../client'; import type { EvalResult, GameView, History, HintResult, MoveRecord, MoveResult, PushEvent, Seat, StateView, Tile, Variant, WordCheckResult, } from '../model'; import type { Move } from '../dict/validate'; export { LOCAL_ID_PREFIX, isLocalGameId }; /** * GameLoopSource is the subset of GatewayClient the game screen calls to run a game. The real * gateway satisfies it structurally; the local source implements it for offline play. */ export interface GameLoopSource { gameState(gameId: string, includeAlphabet: boolean): Promise; gameHistory(gameId: string): Promise; submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise; pass(gameId: string): Promise; exchange(gameId: string, tiles: string[], variant: Variant): Promise; resign(gameId: string): Promise; hint(gameId: string): Promise; evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise; checkWord(gameId: string, word: string, variant: Variant): Promise; draftGet(gameId: string): Promise; draftSave(gameId: string, json: string): Promise; } const BLANK_GLYPH = '?'; // letterToIndex builds a glyph -> index map for a variant's alphabet (upper-cased, matching the // static letters table and lib/alphabet.ts). const indexCache = new Map>(); function letterToIndex(variant: Variant, glyph: string): number { if (glyph === BLANK_GLYPH) return 255; // BLANK_INDEX let m = indexCache.get(variant); if (!m) { m = new Map(); RULESETS[variant].letters.forEach((l, i) => m!.set(l, i)); indexCache.set(variant, m); } const i = m.get(glyph.toUpperCase()); if (i === undefined) throw new GatewayError('illegal_play', `no index for "${glyph}" in ${variant}`); return i; } // indexToLetter decodes an alphabet index to its display glyph; the blank sentinel maps to "?". function indexToLetter(variant: Variant, index: number): string { if (index === 255) return BLANK_GLYPH; return RULESETS[variant].letters[index] ?? ''; } // encodePlacements turns submitted glyph tiles into the engine's index-space placements. function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number; col: number; letter: number; blank: boolean }[] { return tiles.map((t) => ({ row: t.row, col: t.col, letter: letterToIndex(variant, t.letter), blank: t.blank })); } // A live local game held in memory for the session, so calls do not re-replay from the store. interface Live { game: LocalGame; record: LocalGameRecord; } /** * LocalSource runs local games. One instance backs every local game the app has open; it caches * the live engine per id and persists after each move. */ export class LocalSource implements GameLoopSource { private readonly live = new Map(); private readonly listeners = new Map void>>(); /** create starts a new local vs_ai game, persists it and returns its state. */ async create(opts: { id: string; variant: Variant; dictVersion: string; seed: bigint; multipleWords: boolean; seats: LocalSeat[]; }): Promise { const dawg = await getDawg(opts.variant, opts.dictVersion); if (!dawg) throw new GatewayError('dict_unavailable'); const game = new LocalGame({ variant: opts.variant, version: opts.dictVersion, seed: opts.seed, players: opts.seats.length, dawg, multipleWords: opts.multipleWords, }); const now = nowUnix(); const record = serializeGame(game, { id: opts.id, seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })), createdAtUnix: now, updatedAtUnix: now, hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once }); const entry: Live = { game, record }; this.live.set(opts.id, entry); await saveLocalGame(record); return this.stateView(entry); } /** * list returns a lobby GameView for every stored local game, most-recently-updated first, so the * offline lobby renders them with the same card machinery as the online games. Each is * reconstructed by replay; a record whose dictionary is unavailable (e.g. offline without its * dawg) is skipped, since it cannot be rendered — never throws. */ async list(): Promise { const records = await listLocalGames(); records.sort((a, b) => b.updatedAtUnix - a.updatedAtUnix); const views: GameView[] = []; for (const record of records) { try { views.push(this.gameView(await this.load(record.id))); } catch { /* a record that cannot be replayed (its dawg is not cached offline) is left out */ } } return views; } /** delete removes a local game from the store and the in-memory cache — the offline lobby's * finished-game delete. Best-effort: a failed store delete still drops it from this session. */ async delete(gameId: string): Promise { this.live.delete(gameId); this.listeners.delete(gameId); await deleteLocalGame(gameId); } async gameState(gameId: string): Promise { return this.stateView(await this.load(gameId)); } async gameHistory(gameId: string): Promise { const { game, record } = await this.load(gameId); return { gameId, moves: game.history.map((m) => this.moveRecord(record.variant, m)) }; } async submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise { return this.humanMove(gameId, (g) => g.submitPlay(encodePlacements(variant, tiles))); } async pass(gameId: string): Promise { return this.humanMove(gameId, (g) => g.pass()); } async exchange(gameId: string, tiles: string[], variant: Variant): Promise { const idx = tiles.map((t) => letterToIndex(variant, t)); return this.humanMove(gameId, (g) => g.exchange(idx)); } async resign(gameId: string): Promise { // A resignation ends a two-player game immediately; no robot reply follows. const entry = await this.load(gameId); const seat = entry.game.currentPlayer; const move = entry.game.resign(); await this.persist(entry); return this.moveResult(entry, move, seat); } async hint(gameId: string): Promise { // The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints): // a wall-clock timestamp here could be defeated by changing the device clock. This just serves // the engine's top-ranked move; the client only calls it once the gate is open. const entry = await this.load(gameId); const top = this.topMove(entry.game); if (!top) throw new GatewayError('no_hint'); return { move: this.moveFromGen(entry.record.variant, entry.game.currentPlayer, top), hintsRemaining: 1, walletBalance: 0 }; } async evaluate(gameId: string, tiles: PlacedTile[], variant: Variant): Promise { const { game } = await this.load(gameId); const r = game.evaluatePlay(encodePlacements(variant, tiles)); return { legal: r.legal, score: r.score, words: r.words.map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()), dir: r.legal ? (r.dir === 0 ? 'H' : 'V') : '', }; } async checkWord(gameId: string, word: string, variant: Variant): Promise { const { game } = await this.load(gameId); const idx = Array.from(word, (ch) => letterToIndex(variant, ch)); return { word, legal: game.dictionaryHas(idx) }; } // Drafts (the in-progress composition) are not persisted for local games — the arrangement is // rebuilt from the rack on reopen. The screen tolerates an empty draft. async draftGet(): Promise { return ''; } async draftSave(): Promise { /* no-op offline */ } /** events subscribes to a local game's push events (the robot's reply, game over). Returns an * unsubscribe. The network path uses the gateway's global stream instead. */ events(gameId: string, onEvent: (e: PushEvent) => void): () => void { let set = this.listeners.get(gameId); if (!set) { set = new Set(); this.listeners.set(gameId, set); } set.add(onEvent); return () => set!.delete(onEvent); } // --- internals ------------------------------------------------------------- private async load(gameId: string): Promise { const cached = this.live.get(gameId); if (cached) return cached; const record = await getLocalGame(gameId); if (!record) throw new GatewayError('game_not_found'); const dawg = await getDawg(record.variant, record.dictVersion); if (!dawg) throw new GatewayError('dict_unavailable'); const entry: Live = { game: replayGame(record, dawg), record }; this.live.set(gameId, entry); return entry; } // humanMove applies the human's action, persists, returns their MoveResult, then (if the game is // still on and it is the robot's turn) schedules the robot's synchronous reply — already applied // and saved here, but emitted to subscribers on a microtask so the human's move renders first. private async humanMove(gameId: string, act: (g: LocalGame) => LocalMove): Promise { const entry = await this.load(gameId); const seat = entry.game.currentPlayer; const move = act(entry.game); const robotMoves = this.runRobots(entry); await this.persist(entry); const result = this.moveResult(entry, move, seat); if (robotMoves.length > 0) { queueMicrotask(() => this.emitRobot(entry, robotMoves)); } return result; } // runRobots plays every consecutive robot seat until it is a human's turn or the game ends, // returning the robot moves in order. For a two-player vs_ai game this is a single move. private runRobots(entry: Live): LocalMove[] { const out: LocalMove[] = []; while (!entry.game.isOver && this.isRobotSeat(entry, entry.game.currentPlayer)) { const g = entry.game; const seat = g.currentPlayer; const cands = this.legalMoves(g); const dec = decide(g.seed, g.moveCount, cands, g.scoreOf(seat), this.bestOpponent(g, seat), g.handOf(seat), g.bagLength); if (dec.kind === 'play') out.push(g.play(dec.move.dir, dec.move.tiles)); else if (dec.kind === 'exchange' && g.bagLength >= RULESETS[entry.record.variant].rackSize) out.push(g.exchange(dec.exchange)); else out.push(g.pass()); } // Arm the idle hint gate from the robot's reply: a wall-clock unlock time, persisted so the wait // survives a relaunch. Read back through a sanitiser (lib/hints / stateView) so a clock set back // cannot freeze it. if (out.length > 0) entry.record.hintUnlockAtMs = Date.now() + HINT_GATE_MS; return out; } private emitRobot(entry: Live, moves: LocalMove[]): void { const set = this.listeners.get(entry.record.id); if (!set) return; const hintUnlockAtMs = this.hintUnlock(entry); for (const m of moves) { const game = this.gameView(entry); const ev: PushEvent = { kind: 'opponent_moved', gameId: entry.record.id, move: this.moveRecord(entry.record.variant, m), game, bagLen: entry.game.bagLength, hintUnlockAtMs }; for (const cb of set) cb(ev); } if (entry.game.isOver) { const game = this.gameView(entry); const ev: PushEvent = { kind: 'game_over', gameId: entry.record.id, result: entry.game.endReason, scoreLine: '', game }; for (const cb of set) cb(ev); } } private async persist(entry: Live): Promise { entry.record = serializeGame(entry.game, { id: entry.record.id, seats: entry.record.seats, createdAtUnix: entry.record.createdAtUnix, updatedAtUnix: nowUnix(), hintUnlockAtMs: entry.record.hintUnlockAtMs, }); await saveLocalGame(entry.record); } private isRobotSeat(entry: Live, seat: number): boolean { return entry.record.seats[seat]?.kind === 'robot'; } private humanSeat(entry: Live): number { const i = entry.record.seats.findIndex((s) => s.kind === 'human'); return i >= 0 ? i : 0; } private bestOpponent(game: LocalGame, seat: number): number { let best = 0; for (let i = 0; i < game.playerCount; i++) if (i !== seat) best = Math.max(best, game.scoreOf(i)); return best; } private legalMoves(game: LocalGame): Move[] { // The generator can emit a one-letter play only for a dictionary that has one-letter words; // real dictionaries have none. Filtering to main length >= 2 keeps the robot to legal plays. return game.generateMoves().filter((m) => m.main.letters.length >= 2); } private topMove(game: LocalGame): Move | null { const moves = this.legalMoves(game); return moves.length > 0 ? moves[0] : null; } // --- shape builders -------------------------------------------------------- // The vs_ai idle-hint unlock instant, sanitised on read: capped at now + the window so a device // clock set back cannot push it far ahead and freeze the gate (the client counts down from here). // 0 while open (a human-first opening, no robot move yet). private hintUnlock(entry: Live): number { return entry.record.hintUnlockAtMs ? Math.min(entry.record.hintUnlockAtMs, Date.now() + HINT_GATE_MS) : 0; } private stateView(entry: Live): StateView { const seat = this.humanSeat(entry); const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); return { game: this.gameView(entry), seat, rack, bagLen: entry.game.bagLength, hintsRemaining: 1, walletBalance: 0, hintUnlockAtMs: this.hintUnlock(entry), }; } private gameView(entry: Live): GameView { const { game, record } = entry; const seats: Seat[] = record.seats.map((s, i) => ({ seat: i, accountId: s.accountId ?? `${LOCAL_ID_PREFIX}${s.kind}:${i}`, displayName: s.name, score: game.scoreOf(i), hintsUsed: 0, isWinner: game.isOver && i === game.winnerIndex, })); return { id: record.id, variant: record.variant, dictVersion: record.dictVersion, status: game.isOver ? 'finished' : 'active', players: game.playerCount, toMove: game.currentPlayer, turnTimeoutSecs: 0, multipleWordsPerTurn: record.multipleWords, moveCount: game.moveCount, endReason: game.isOver ? game.endReason : '', lastActivityUnix: record.updatedAtUnix, vsAi: true, unreadChat: false, unreadMessages: false, seats, }; } private moveResult(entry: Live, move: LocalMove, seat: number): MoveResult { return { move: this.moveRecord(entry.record.variant, move), game: this.gameView(entry), rack: entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)), bagLen: entry.game.bagLength, }; } private moveRecord(variant: Variant, m: LocalMove): MoveRecord { const tiles: Tile[] = (m.tiles ?? []).map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank })); const words = (m.words ?? []).map((w) => w.map((i) => indexToLetter(variant, i)).join('').toLowerCase()); return { player: m.player, action: m.action, dir: m.dir === undefined ? '' : m.dir === 0 ? 'H' : 'V', mainRow: m.mainRow ?? 0, mainCol: m.mainCol ?? 0, tiles, words, count: m.count ?? 0, score: m.score, total: m.total, }; } private moveFromGen(variant: Variant, player: number, gm: Move): MoveRecord { const tiles: Tile[] = gm.tiles.map((t) => ({ row: t.row, col: t.col, letter: indexToLetter(variant, t.letter), blank: t.blank })); const words = [gm.main, ...gm.cross].map((w) => w.letters.map((i) => indexToLetter(variant, i)).join('').toLowerCase()); return { player, action: 'play', dir: gm.dir === 0 ? 'H' : 'V', mainRow: gm.main.row, mainCol: gm.main.col, tiles, words, count: 0, score: gm.score, total: 0, }; } } function nowUnix(): number { return Math.floor(Date.now() / 1000); }