// The offline game engine — a faithful port of backend/internal/engine/game.go. It owns the // board, the bag, each seat's hand, the scores, whose turn it is and the move log, applies plays // (reusing the already-ported validator lib/dict/validate.ts and generator lib/dict/generate.ts), // and detects the end of a game (out-of-tiles / scoreless / resign) with the standard end-of-game // rack adjustment. It is pure in-memory logic with no I/O; a local vs_ai game is driven by feeding // it the robot's choice (lib/robot/strategy.ts) each turn. The bag RNG is our own deterministic // PRNG (see bag.ts), so a game replays from its seed but is not bit-identical to a server game. // // End-of-game scoring, the winner rule and the rack value are exported as pure functions so they // can be pinned against the Go engine (engine.parity.test.ts) on constructed positions. import { LocalBoard } from './board'; import { Bag } from './bag'; import { RULESETS } from './ruleset'; import { generateMoves, GenRack, Both } from '../dict/generate'; import { validatePlay, type Direction, type Placement, type Ruleset, type Move } from '../dict/validate'; import { premiumGrid, centre, BOARD_SIZE, RACK_SIZE, BINGO, type Premium } from '../premiums'; import { BLANK_INDEX } from '../alphabet'; import type { Dawg } from '../dict/dawg'; import type { Variant } from '../model'; /** scorelessLimit is the number of consecutive scoreless turns (passes and exchanges) that ends * a game, mirroring engine.scorelessLimit. */ export const scorelessLimit = 6; /** EndReason explains why a game finished, using the engine's stable labels. */ export type EndReason = 'not_over' | 'out_of_tiles' | 'scoreless' | 'resign' | 'aborted'; /** Disposition of a dropped-out (resigned/timed-out) seat's tiles in a 3-4 player game. */ export type DropoutTiles = 'remove' | 'return'; /** LocalMove is one recorded turn. tiles/dir are set on a play, count on an exchange. */ export interface LocalMove { player: number; action: 'play' | 'pass' | 'exchange' | 'resign'; dir?: Direction; tiles?: Placement[]; /** ActionExchange only: number of tiles swapped. */ count?: number; /** ActionExchange only: the swapped tiles (alphabet-index bytes, BLANK_INDEX for a blank), * recorded so the game can be reconstructed by replaying the journal (see localgame/serialize). */ exchanged?: number[]; score: number; total: number; } /** GameError carries a stable code for the engine's rejection reasons. */ export class GameError extends Error { constructor(public readonly code: string) { super(code); this.name = 'GameError'; } } function letterMultOf(p: Premium): number { return p === 'DL' ? 2 : p === 'TL' ? 3 : 1; } function wordMultOf(p: Premium): number { return p === 'DW' ? 2 : p === 'TW' ? 3 : 1; } /** * buildRuleset assembles the validator/generator ruleset for a variant from the static offline * tile values (ruleset.ts) and the board geometry (premiums.ts). multipleWords false selects the * single-word-per-turn rule (perpendicular cross-words ignored). Mirrors the server engine's * ruleset for the same variant; online scoring instead reads the server-sent alphabet values. */ export function buildRuleset(variant: Variant, multipleWords: boolean): Ruleset { const prem = premiumGrid(variant); const ctr = centre(variant); return { cols: BOARD_SIZE, center: ctr.row * BOARD_SIZE + ctr.col, rackSize: RACK_SIZE, bingo: BINGO[variant], values: RULESETS[variant].values, letterMult: (r, c) => letterMultOf(prem[r][c]), wordMult: (r, c) => wordMultOf(prem[r][c]), ignoreCrossWords: !multipleWords, }; } /** rackValue sums the tile values left on a hand; blanks (BLANK_INDEX) count zero. Mirrors * engine (*Game).rackValue. */ export function rackValue(hand: readonly number[], values: readonly number[]): number { let v = 0; for (const t of hand) if (t !== BLANK_INDEX) v += values[t]; return v; } /** * applyEndAdjustment settles the unplayed racks and returns the adjusted scores. Out-of-tiles: the * player who went out (toMove) gains the sum of every opponent's rack value and each opponent loses * their own. Scoreless: everyone loses their own rack value. Resign/aborted: no adjustment. * Mirrors engine (*Game).applyEndAdjustment. */ export function applyEndAdjustment( reason: EndReason, hands: readonly (readonly number[])[], scores: readonly number[], toMove: number, values: readonly number[], ): number[] { const out = [...scores]; if (reason === 'out_of_tiles') { let bonus = 0; for (let i = 0; i < hands.length; i++) { if (i === toMove) continue; const v = rackValue(hands[i], values); out[i] -= v; bonus += v; } out[toMove] += bonus; } else if (reason === 'scoreless') { for (let i = 0; i < hands.length; i++) out[i] -= rackValue(hands[i], values); } return out; } /** * winner returns the index of the single highest-scoring seat, or -1 on a tie for the lead, an * unfinished game or an aborted (draw) game. Resigned seats are excluded, so a two-player game * returns the remaining player even when the resigner led. Mirrors engine (*Game).winner. */ export function winner(over: boolean, reason: EndReason, scores: readonly number[], resigned: readonly boolean[]): number { if (!over || reason === 'aborted') return -1; let best = -1; let tie = false; for (let i = 0; i < scores.length; i++) { if (resigned[i]) continue; if (best === -1 || scores[i] > scores[best]) { best = i; tie = false; } else if (scores[i] === scores[best]) { tie = true; } } return tie ? -1 : best; } /** Options for a new local game. */ export interface LocalGameOptions { variant: Variant; version: string; /** Seeds the bag; the whole game replays from it. Also the strategy seed the caller uses. */ seed: bigint; players: number; dawg: Dawg; multipleWords: boolean; dropoutTiles?: DropoutTiles; } // placementTiles maps placements to the tiles they consume (BLANK_INDEX for a blank). function placementTiles(tiles: readonly Placement[]): number[] { return tiles.map((p) => (p.blank ? BLANK_INDEX : p.letter)); } /** * LocalGame is the in-memory state of one local match and the rules engine over it. Construct it * with a loaded dictionary; drive it with play/pass/exchange/resign. It performs no I/O. */ export class LocalGame { readonly variant: Variant; readonly version: string; readonly seed: bigint; private readonly vrs: Ruleset; private readonly values: readonly number[]; private readonly rackSize: number; private readonly dawg: Dawg; private readonly multipleWords: boolean; private readonly dropoutTiles: DropoutTiles; private readonly board: LocalBoard; private readonly bag: Bag; private readonly hands: number[][]; private readonly scores: number[]; private readonly resigned: boolean[]; private toMove = 0; private scorelessRun = 0; private over = false; private reason: EndReason = 'not_over'; private readonly log: LocalMove[] = []; constructor(opts: LocalGameOptions) { if (opts.players < 2 || opts.players > 4) { throw new GameError('players_out_of_range'); } this.variant = opts.variant; this.version = opts.version; this.seed = opts.seed; this.dawg = opts.dawg; this.multipleWords = opts.multipleWords; this.dropoutTiles = opts.dropoutTiles ?? 'remove'; this.vrs = buildRuleset(opts.variant, opts.multipleWords); this.values = RULESETS[opts.variant].values; this.rackSize = RULESETS[opts.variant].rackSize; this.board = new LocalBoard(); this.bag = new Bag(opts.variant, Number(BigInt.asUintN(32, opts.seed))); this.hands = []; this.scores = []; this.resigned = []; for (let i = 0; i < opts.players; i++) { this.hands.push(this.bag.draw(this.rackSize)); this.scores.push(0); this.resigned.push(false); } } // --- queries --------------------------------------------------------------- get playerCount(): number { return this.hands.length; } get currentPlayer(): number { return this.toMove; } get isOver(): boolean { return this.over; } get endReason(): EndReason { return this.reason; } get bagLength(): number { return this.bag.length; } get moveCount(): number { return this.log.length; } scoreOf(player: number): number { return this.scores[player]; } /** rackOf returns a copy of a seat's hand (alphabet-index bytes, BLANK_INDEX for blanks). */ handOf(player: number): number[] { return [...this.hands[player]]; } /** winnerIndex is the finished game's winner, or -1 (tie / in progress / aborted). */ get winnerIndex(): number { return winner(this.over, this.reason, this.scores, this.resigned); } /** config returns the rule settings the game was created with — the bits, alongside the seed and * journal, needed to reconstruct it (see localgame/serialize). */ get config(): { multipleWords: boolean; dropoutTiles: DropoutTiles } { return { multipleWords: this.multipleWords, dropoutTiles: this.dropoutTiles }; } /** history returns a copy of the move log. */ get history(): LocalMove[] { return this.log.map((m) => ({ ...m })); } /** generateMoves returns every legal play for the current player, ranked by descending score. */ generateMoves(): Move[] { return generateMoves(this.dawg, this.board, this.rackOf(this.toMove), this.vrs, Both); } // --- turns ----------------------------------------------------------------- /** play validates and applies the current player's placement, scores it, refills the rack and * advances the turn (or ends the game). Throws GameError on an illegal play. */ play(dir: Direction, tiles: Placement[]): LocalMove { if (this.over) throw new GameError('game_over'); const player = this.toMove; const used = placementTiles(tiles); if (!this.holds(player, used)) throw new GameError('tiles_not_on_rack'); const res = validatePlay(this.board, this.vrs, this.dawg, dir, tiles); if (!res.legal || !res.move) throw new GameError('illegal_play'); const move = res.move; for (const t of tiles) this.board.set(t.row, t.col, t.letter, t.blank); this.removeFromHand(player, used); this.scores[player] += move.score; this.refill(player); this.scorelessRun = 0; const rec: LocalMove = { player, action: 'play', dir, tiles: tiles.map((t) => ({ ...t })), score: move.score, total: this.scores[player], }; this.log.push(rec); if (this.hands[player].length === 0 && this.bag.length === 0) this.finish('out_of_tiles'); else this.advance(); return rec; } /** pass forfeits the current turn, extending the scoreless run (which may end the game). */ pass(): LocalMove { if (this.over) throw new GameError('game_over'); const player = this.toMove; this.scorelessRun++; const rec: LocalMove = { player, action: 'pass', score: 0, total: this.scores[player] }; this.log.push(rec); this.endTurnAfterScoreless(); return rec; } /** exchange swaps the given tiles (alphabet-index bytes, BLANK_INDEX for blanks) for fresh ones. * Legal only while the bag holds at least a full rack; the fresh tiles are drawn before the * swapped ones return, so a player cannot draw back their own. Extends the scoreless run. */ exchange(tiles: number[]): LocalMove { if (this.over) throw new GameError('game_over'); if (tiles.length === 0) throw new GameError('nothing_to_exchange'); if (this.bag.length < this.rackSize) throw new GameError('not_enough_tiles_to_exchange'); const player = this.toMove; if (!this.holds(player, tiles)) throw new GameError('tiles_not_on_rack'); this.removeFromHand(player, tiles); const drawn = this.bag.draw(tiles.length); for (const t of drawn) this.hands[player].push(t); this.bag.return(tiles); this.scorelessRun++; const rec: LocalMove = { player, action: 'exchange', count: tiles.length, exchanged: [...tiles], score: 0, total: this.scores[player] }; this.log.push(rec); this.endTurnAfterScoreless(); return rec; } /** resign drops the current player out of the game (they forfeit the win, keep their score). */ resign(): LocalMove { return this.resignSeat(this.toMove); } /** resignSeat resigns a specific seat regardless of whose turn it is. */ resignSeat(seat: number): LocalMove { if (this.over) throw new GameError('game_over'); if (seat < 0 || seat >= this.hands.length || this.resigned[seat]) throw new GameError('game_over'); this.resigned[seat] = true; this.disposeHand(seat); const rec: LocalMove = { player: seat, action: 'resign', score: 0, total: this.scores[seat] }; this.log.push(rec); if (this.activeCount() <= 1) this.finish('resign'); else if (seat === this.toMove) this.advance(); return rec; } // --- internals ------------------------------------------------------------- private rackOf(player: number): GenRack { const letters: number[] = []; let blanks = 0; for (const t of this.hands[player]) { if (t === BLANK_INDEX) blanks++; else letters.push(t); } return GenRack.from(this.values.length, letters, blanks); } private finish(reason: EndReason): void { this.over = true; this.reason = reason; const adjusted = applyEndAdjustment(reason, this.hands, this.scores, this.toMove, this.values); for (let i = 0; i < adjusted.length; i++) this.scores[i] = adjusted[i]; } private endTurnAfterScoreless(): void { if (this.scorelessRun >= scorelessLimit) this.finish('scoreless'); else this.advance(); } private advance(): void { const n = this.hands.length; for (let i = 1; i <= n; i++) { const next = (this.toMove + i) % n; if (!this.resigned[next]) { this.toMove = next; return; } } } private activeCount(): number { return this.resigned.reduce((n, r) => (r ? n : n + 1), 0); } private disposeHand(player: number): void { if (this.dropoutTiles === 'return') this.bag.return(this.hands[player]); this.hands[player] = []; } private holds(player: number, want: readonly number[]): boolean { const avail = new Map(); for (const t of this.hands[player]) avail.set(t, (avail.get(t) ?? 0) + 1); const need = new Map(); for (const t of want) need.set(t, (need.get(t) ?? 0) + 1); for (const [t, n] of need) if ((avail.get(t) ?? 0) < n) return false; return true; } private removeFromHand(player: number, used: readonly number[]): void { const hand = this.hands[player]; for (const t of used) { const i = hand.indexOf(t); if (i >= 0) hand.splice(i, 1); } } private refill(player: number): void { const need = this.rackSize - this.hands[player].length; if (need > 0) for (const t of this.bag.draw(need)) this.hands[player].push(t); } }