e4cf143e9f
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).
79 lines
3.2 KiB
TypeScript
79 lines
3.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { readFileSync } from 'node:fs';
|
|
import { Dawg } from '../dict/dawg';
|
|
import { RACK_SIZE } from '../premiums';
|
|
import { LocalGame } from './engine';
|
|
import { decide } from '../robot/strategy';
|
|
import type { Move } from '../dict/validate';
|
|
|
|
// A full-loop smoke: two robots play a whole local vs_ai game to completion over the committed
|
|
// sample dictionary, exercising deal / play / refill / exchange / pass / end detection / winner.
|
|
// (The rich full-dictionary robot behaviour is pinned elsewhere by the generator + strategy parity
|
|
// suites; this test proves the engine drives to a valid finish.)
|
|
const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
|
|
|
function bestOpponentScore(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;
|
|
}
|
|
|
|
// robotTurn plays the seat to move: it picks the robot's action from the ranked legal plays and
|
|
// dispatches it. The sample dict has a one-letter word ("a") the generator emits but the validator
|
|
// rejects (len < 2) — real dictionaries have none, so filtering to main length >= 2 is a no-op in
|
|
// production; an exchange the bag is too small to satisfy falls back to a pass.
|
|
function robotTurn(game: LocalGame, seed: bigint): void {
|
|
const seat = game.currentPlayer;
|
|
const cands: Move[] = game.generateMoves().filter((m) => m.main.letters.length >= 2);
|
|
const dec = decide(seed, game.moveCount, cands, game.scoreOf(seat), bestOpponentScore(game, seat), game.handOf(seat), game.bagLength);
|
|
if (dec.kind === 'play') {
|
|
game.play(dec.move.dir, dec.move.tiles);
|
|
} else if (dec.kind === 'exchange' && game.bagLength >= RACK_SIZE) {
|
|
game.exchange(dec.exchange);
|
|
} else {
|
|
game.pass();
|
|
}
|
|
}
|
|
|
|
describe('offline engine full-game smoke', () => {
|
|
it('plays a whole 2-player vs_ai game to completion', () => {
|
|
const game = new LocalGame({
|
|
variant: 'scrabble_en',
|
|
version: 'sample',
|
|
seed: 123456789n,
|
|
players: 2,
|
|
dawg,
|
|
multipleWords: true,
|
|
});
|
|
|
|
let turns = 0;
|
|
while (!game.isOver && turns < 2000) {
|
|
robotTurn(game, game.seed);
|
|
turns++;
|
|
}
|
|
|
|
expect(game.isOver).toBe(true);
|
|
expect(turns).toBeLessThan(2000);
|
|
expect(['out_of_tiles', 'scoreless', 'resign']).toContain(game.endReason);
|
|
|
|
const w = game.winnerIndex;
|
|
expect(w === -1 || (w >= 0 && w < game.playerCount)).toBe(true);
|
|
for (let i = 0; i < game.playerCount; i++) expect(Number.isInteger(game.scoreOf(i))).toBe(true);
|
|
// The move log recorded every turn.
|
|
expect(game.history.length).toBe(turns);
|
|
});
|
|
|
|
it('is reproducible from the seed', () => {
|
|
const play = (): { reason: string; scores: number[]; turns: number } => {
|
|
const g = new LocalGame({ variant: 'scrabble_en', version: 'sample', seed: 42n, players: 2, dawg, multipleWords: true });
|
|
let t = 0;
|
|
while (!g.isOver && t < 2000) {
|
|
robotTurn(g, g.seed);
|
|
t++;
|
|
}
|
|
return { reason: g.endReason, scores: [g.scoreOf(0), g.scoreOf(1)], turns: t };
|
|
};
|
|
expect(play()).toEqual(play());
|
|
});
|
|
});
|