feat(offline): local game source (Phase B3.1)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
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 1m39s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
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 1m39s
A GatewayClient-shaped facade over the offline engine, so the same game screen can drive a local vs_ai game with no backend (the wiring into Game.svelte is B3.2). - source.ts: LocalSource implements the game-loop subset (GameLoopSource) for a local game id — gameState/gameHistory via replay, submitPlay/pass/exchange/resign apply the human move then run the robot (decide(generateMoves)) synchronously, persisting both and delivering the robot's move through a per-game event emitter (no live stream). hint is gated to >30 min since the robot's last move; evaluate/checkWord are local. It translates the UI's glyph space to the engine's index space with the static letters table. - ruleset.ts: add the static per-variant letters (glyphs), pinned to the Go alphabet — offline is now fully self-contained (no reliance on a warm server alphabet cache). - engine.ts: submitPlay (infers the direction like the server SubmitPlay), evaluatePlay + dictionaryHas for the move preview / word check, and record the main-word coordinate + the words on a play (for the history MoveRecord). - source.test.ts: create -> human pass -> synchronous robot reply via the event, the hint gate, decoded history, and a whole game driven to completion. Pure additive library code; no runtime behavior change (bundle unchanged).
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
// 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 } from './store';
|
||||
import { RULESETS } from './ruleset';
|
||||
import { getDawg } from '../dict';
|
||||
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';
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* 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<StateView>;
|
||||
gameHistory(gameId: string): Promise<History>;
|
||||
submitPlay(gameId: string, tiles: PlacedTile[], variant: Variant): Promise<MoveResult>;
|
||||
pass(gameId: string): Promise<MoveResult>;
|
||||
exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult>;
|
||||
resign(gameId: string): Promise<MoveResult>;
|
||||
hint(gameId: string): Promise<HintResult>;
|
||||
evaluate(gameId: string, tiles: PlacedTile[], variant: Variant, signal?: AbortSignal): Promise<EvalResult>;
|
||||
checkWord(gameId: string, word: string, variant: Variant): Promise<WordCheckResult>;
|
||||
draftGet(gameId: string): Promise<string>;
|
||||
draftSave(gameId: string, json: string): Promise<void>;
|
||||
}
|
||||
|
||||
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<Variant, Map<string, number>>();
|
||||
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<string, Live>();
|
||||
private readonly listeners = new Map<string, Set<(e: PushEvent) => 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<StateView> {
|
||||
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 })),
|
||||
createdAtUnix: now,
|
||||
updatedAtUnix: now,
|
||||
robotLastMoveAtUnix: now,
|
||||
});
|
||||
const entry: Live = { game, record };
|
||||
this.live.set(opts.id, entry);
|
||||
await saveLocalGame(record);
|
||||
return this.stateView(entry);
|
||||
}
|
||||
|
||||
async gameState(gameId: string): Promise<StateView> {
|
||||
return this.stateView(await this.load(gameId));
|
||||
}
|
||||
|
||||
async gameHistory(gameId: string): Promise<History> {
|
||||
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<MoveResult> {
|
||||
return this.humanMove(gameId, (g) => g.submitPlay(encodePlacements(variant, tiles)));
|
||||
}
|
||||
|
||||
async pass(gameId: string): Promise<MoveResult> {
|
||||
return this.humanMove(gameId, (g) => g.pass());
|
||||
}
|
||||
|
||||
async exchange(gameId: string, tiles: string[], variant: Variant): Promise<MoveResult> {
|
||||
const idx = tiles.map((t) => letterToIndex(variant, t));
|
||||
return this.humanMove(gameId, (g) => g.exchange(idx));
|
||||
}
|
||||
|
||||
async resign(gameId: string): Promise<MoveResult> {
|
||||
// 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<HintResult> {
|
||||
const entry = await this.load(gameId);
|
||||
if (nowUnix() * 1000 - entry.record.robotLastMoveAtUnix * 1000 < HINT_GATE_MS) {
|
||||
throw new GatewayError('hint_not_ready');
|
||||
}
|
||||
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<EvalResult> {
|
||||
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<WordCheckResult> {
|
||||
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<string> {
|
||||
return '';
|
||||
}
|
||||
async draftSave(): Promise<void> {
|
||||
/* 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<Live> {
|
||||
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<MoveResult> {
|
||||
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());
|
||||
entry.record.robotLastMoveAtUnix = nowUnix();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private emitRobot(entry: Live, moves: LocalMove[]): void {
|
||||
const set = this.listeners.get(entry.record.id);
|
||||
if (!set) return;
|
||||
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 };
|
||||
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<void> {
|
||||
entry.record = serializeGame(entry.game, {
|
||||
id: entry.record.id,
|
||||
seats: entry.record.seats,
|
||||
createdAtUnix: entry.record.createdAtUnix,
|
||||
updatedAtUnix: nowUnix(),
|
||||
robotLastMoveAtUnix: entry.record.robotLastMoveAtUnix,
|
||||
});
|
||||
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 --------------------------------------------------------
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
private gameView(entry: Live): GameView {
|
||||
const { game, record } = entry;
|
||||
const seats: Seat[] = record.seats.map((s, i) => ({
|
||||
seat: i,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user