feat(offline): local game engine (Phase B1)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
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 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
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
The offline vs_ai game engine — a faithful TS port of backend/internal/engine that drives a whole local game with no backend. Composes with the move generator (#188) and robot strategy (#189) from Phase A; not yet wired into the UI (Phase B2/B3). - ui/src/lib/localgame/ruleset.ts: static per-variant tile values / bag counts / blank count, mirrored from rules.go (offline scoring is self-contained; online uses the server alphabet). Pinned by ruleset.parity.test.ts against a Go fixture. - bag.ts: the tile bag (fill from counts/blanks, draw-from-end, return+reshuffle) on a deterministic in-house PRNG — a game replays from its seed, not bit-identical to a server game (per plan). - board.ts: the mutable board, satisfying the validator/generator read view + set(). - engine.ts: LocalGame — deal / play (reusing validate.ts) / pass / exchange / resign, scoreless(6) & out-of-tiles end detection, end-of-game rack penalties, winner; mirrors game.go. The end-game math is exported as pure functions, pinned against the Go engine (engine.parity.test.ts, 9 constructed positions). - engine.test.ts: a full-loop smoke — two robots play a whole vs_ai game to completion via generateMoves + decide, and it is reproducible from the seed. - backend: movegen now dumps the per-variant rulesets; a new in-package engine emitter (endfixture_test.go, env-gated) produces the end-game golden. Pure additive library code; no runtime behavior change (unused at runtime, bundle unchanged).
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
// 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[];
|
||||
count?: 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 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.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);
|
||||
}
|
||||
/** 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, 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<number, number>();
|
||||
for (const t of this.hands[player]) avail.set(t, (avail.get(t) ?? 0) + 1);
|
||||
const need = new Map<number, number>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user