d7337d24ea
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Russian "Эрудит" treats a word laid on the board as belonging to the game: it cannot be laid again. Neither the solver, the backend nor the offline JS port knew the rule, so a player (and the robot) could replay a word freely. Official Scrabble places no such restriction, so both Scrabble variants keep playing unrestricted. The rule applies in two ways. A play whose main word is already on the board is illegal, and is neither accepted nor generated. A play whose perpendicular cross-word is already there stands — that word is incidental to laying the main word — but scores nothing. The set of played words is the game's own move journal, main words and cross-words alike, compared decoded, so a word spelled with a blank is the same word. It lives in the game layer, not the solver: only a game knows its history, and the solver stays stateless and standard-rules. The backend applies it at submit, at the move preview and over generated moves (filtering and re-ranking them, so neither the robot nor the hint can offer a play the engine would then refuse); the client port does the same for the offline engine and for the on-device preview of an online game. The rule is pinned per game (games.no_repeat_words, set from the variant at creation) rather than keyed on the variant, because a game is replayed from its journal on every open. Applied retroactively it would make an already-played repeat illegal — closing that game as a draw — and would rescore a play whose cross-word repeats an earlier word, shifting a live game's totals. Games created before the rule keep playing without it. The flag rides the wire as a trailing field because the client's preview must score the way the server does, and offline games pin the same answer in their own record.
548 lines
23 KiB
TypeScript
548 lines
23 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, deleteLocalGame } from './store';
|
|
import { RULESETS } from './ruleset';
|
|
import { getDawg } from '../dict';
|
|
import { setAlphabet, hasAlphabet } from '../alphabet';
|
|
import { HINT_GATE_MS } from '../hints';
|
|
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
|
|
import { decide } from '../robot/strategy';
|
|
import { verifyPin, type PinLock } from '../pin';
|
|
import { noRepeatWordsFor } from '../variants';
|
|
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<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;
|
|
/** Offline hotseat only: which seat is currently unlocked (its PIN was entered this turn), or null.
|
|
* Ephemeral — never persisted; cleared on every turn advance so each turn re-locks. */
|
|
unlockedSeat: number | null;
|
|
}
|
|
|
|
/**
|
|
* 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 game — a vs_ai game or an offline pass-and-play (hotseat) game when
|
|
* hotseat is set — persists it and returns its state. hostPin is the hotseat master PIN. */
|
|
async create(opts: {
|
|
id: string;
|
|
variant: Variant;
|
|
dictVersion: string;
|
|
seed: bigint;
|
|
multipleWords: boolean;
|
|
seats: LocalSeat[];
|
|
hotseat?: boolean;
|
|
hostPin?: PinLock;
|
|
}): 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,
|
|
noRepeatWords: noRepeatWordsFor(opts.variant),
|
|
});
|
|
const now = nowUnix();
|
|
const record = serializeGame(game, {
|
|
id: opts.id,
|
|
seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId, pin: s.pin })),
|
|
hotseat: opts.hotseat,
|
|
hostPin: opts.hostPin,
|
|
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, unlockedSeat: null };
|
|
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;
|
|
}
|
|
|
|
/** 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<void> {
|
|
this.live.delete(gameId);
|
|
this.listeners.delete(gameId);
|
|
await deleteLocalGame(gameId);
|
|
}
|
|
|
|
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();
|
|
entry.unlockedSeat = null;
|
|
entry.record.draft = undefined; // the turn is over; the abandoned composition goes with it
|
|
await this.persist(entry);
|
|
return this.moveResult(entry, move, seat);
|
|
}
|
|
|
|
/**
|
|
* unlockSeat reveals the current hotseat seat's rack once its PIN is entered. It verifies pin
|
|
* against the seat's stored lock (a seat with no lock unlocks with any pin) and, on success, marks
|
|
* the seat unlocked for this turn only — a subsequent move re-locks it. Throws 'wrong_pin' on a
|
|
* mismatch. Returns the freshly unlocked state (rack revealed).
|
|
*/
|
|
async unlockSeat(gameId: string, pin: string): Promise<StateView> {
|
|
const entry = await this.load(gameId);
|
|
const seat = entry.game.currentPlayer;
|
|
const lock = entry.record.seats[seat]?.pin;
|
|
if (lock && !(await verifyPin(pin, lock))) throw new GatewayError('wrong_pin');
|
|
entry.unlockedSeat = seat;
|
|
return this.stateView(entry);
|
|
}
|
|
|
|
/** relock drops any per-turn seat unlock, so re-opening a hotseat game re-prompts the current
|
|
* seat's PIN. Called when the game screen is left (returning to the lobby). No-op when the game is
|
|
* not cached, or a seat with no PIN (which is never withheld anyway). */
|
|
relock(gameId: string): void {
|
|
const entry = this.live.get(gameId);
|
|
if (entry) entry.unlockedSeat = null;
|
|
}
|
|
|
|
/** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */
|
|
async verifyHostPin(gameId: string, pin: string): Promise<boolean> {
|
|
const entry = await this.load(gameId);
|
|
return !!entry.record.hostPin && (await verifyPin(pin, entry.record.hostPin));
|
|
}
|
|
|
|
/**
|
|
* hostAction runs a host (referee) override on a hotseat game, gated by the master PIN:
|
|
* 'skip' — pass the current seat's turn;
|
|
* 'resign' — resign seat targetSeat (exclude a player / concede on their behalf);
|
|
* 'terminate' — end the game with no result and delete it from the store.
|
|
* Returns the updated StateView, or null when the game was terminated. Throws 'wrong_pin' on a bad
|
|
* PIN.
|
|
*/
|
|
async hostAction(
|
|
gameId: string,
|
|
pin: string,
|
|
action: 'skip' | 'resign' | 'terminate',
|
|
targetSeat?: number,
|
|
): Promise<StateView | null> {
|
|
const entry = await this.load(gameId);
|
|
if (!entry.record.hostPin || !(await verifyPin(pin, entry.record.hostPin))) throw new GatewayError('wrong_pin');
|
|
if (action === 'terminate') {
|
|
await this.delete(gameId);
|
|
return null;
|
|
}
|
|
if (action === 'skip') entry.game.pass();
|
|
else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer);
|
|
entry.unlockedSeat = null;
|
|
entry.record.draft = undefined; // the host advanced the turn; the seat's composition is stale
|
|
await this.persist(entry);
|
|
return this.stateView(entry);
|
|
}
|
|
|
|
async hint(gameId: string): Promise<HintResult> {
|
|
// 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<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: rack order + pending board tiles) live in the game's own
|
|
// record — a local game has no server to hold them. Persisting them keeps every reload of the
|
|
// state (the game screen refetches on several triggers) from dropping the arrangement back into
|
|
// the rack, and carries it across leaving and reopening the app.
|
|
async draftGet(gameId: string): Promise<string> {
|
|
const entry = await this.load(gameId);
|
|
return entry.record.draft ?? '';
|
|
}
|
|
async draftSave(gameId: string, json: string): Promise<void> {
|
|
const entry = await this.load(gameId);
|
|
entry.record.draft = json || undefined;
|
|
await this.persist(entry);
|
|
}
|
|
|
|
/** 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, unlockedSeat: null };
|
|
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);
|
|
entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai)
|
|
entry.record.draft = undefined; // the composition was committed (or swapped away) with the turn
|
|
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;
|
|
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,
|
|
hotseat: entry.record.hotseat,
|
|
hostPin: entry.record.hostPin,
|
|
createdAtUnix: entry.record.createdAtUnix,
|
|
updatedAtUnix: nowUnix(),
|
|
hintUnlockAtMs: entry.record.hintUnlockAtMs,
|
|
draft: entry.record.draft,
|
|
});
|
|
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 seconds left, from the stored unlock instant (device wall clock, persisted
|
|
// for the offline record) minus now — capped at the window so a device clock set back cannot push
|
|
// it far ahead and freeze the gate, ceiled to whole seconds and floored at 0. The client anchors a
|
|
// monotonic countdown to this; 0 while open (a human-first opening, no robot move yet).
|
|
private hintUnlockLeft(entry: Live): number {
|
|
if (!entry.record.hintUnlockAtMs) return 0;
|
|
const leftMs = Math.min(HINT_GATE_MS, entry.record.hintUnlockAtMs - Date.now());
|
|
return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0;
|
|
}
|
|
|
|
// stateView is the seated player's private view. For a vs_ai game that is the single human seat; for
|
|
// a hotseat game it is the seat to move — and when that seat carries a PIN, the rack is withheld
|
|
// (empty, locked=true) until unlockSeat is called this turn.
|
|
private stateView(entry: Live): StateView {
|
|
const hotseat = !!entry.record.hotseat;
|
|
const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry);
|
|
// A finished game is never locked — its final rack is shown for review, not an unlock button.
|
|
const locked = !entry.game.isOver && hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat;
|
|
const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
|
|
return {
|
|
game: this.gameView(entry),
|
|
seat,
|
|
rack,
|
|
bagLen: entry.game.bagLength,
|
|
hintsRemaining: 1,
|
|
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
|
|
locked,
|
|
};
|
|
}
|
|
|
|
// Offline there is no server to send the per-variant alphabet table (letters + tile values) the
|
|
// rack and board render from (lib/alphabet); seed it from the static offline ruleset so a cold-
|
|
// booted local game shows real tile weights instead of zeros. Idempotent and cheap — a no-op once
|
|
// the table is cached (from here or a prior online session).
|
|
private ensureAlphabet(variant: Variant): void {
|
|
if (hasAlphabet(variant)) return;
|
|
const rs = RULESETS[variant];
|
|
setAlphabet(
|
|
variant,
|
|
rs.letters.map((letter, index) => ({ index, letter, value: rs.values[index] })),
|
|
);
|
|
}
|
|
|
|
private gameView(entry: Live): GameView {
|
|
const { game, record } = entry;
|
|
this.ensureAlphabet(record.variant);
|
|
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,
|
|
resigned: game.resignedOf(i),
|
|
}));
|
|
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,
|
|
noRepeatWords: !!record.noRepeatWords,
|
|
moveCount: game.moveCount,
|
|
endReason: game.isOver ? game.endReason : '',
|
|
lastActivityUnix: record.updatedAtUnix,
|
|
vsAi: !record.hotseat,
|
|
hotseat: !!record.hotseat,
|
|
unreadChat: false,
|
|
unreadMessages: false,
|
|
kind: 0,
|
|
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);
|
|
}
|