2a045a5b37
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 1m41s
In a local game the human seat's account id was a synthetic 'local:human:0',
so seatName's `accountId === session.userId` check never matched: the game
header rendered BOTH seats as 🤖 (the vs_ai fallback), and the lobby's
groupGames could not find the viewer's seat, so a human's turn read as
'Their turn' with the hourglass. Carry the real account id on the human seat
(create -> record -> GameView); the robot keeps its synthetic id.
Local games created before this fix keep the old display (no migration).
416 lines
16 KiB
TypeScript
416 lines
16 KiB
TypeScript
// 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 } 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 {
|
|
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 };
|
|
|
|
/** 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, accountId: s.accountId })),
|
|
createdAtUnix: now,
|
|
updatedAtUnix: now,
|
|
robotLastMoveAtUnix: now,
|
|
});
|
|
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<GameView[]> {
|
|
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;
|
|
}
|
|
|
|
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: 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);
|
|
}
|