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()); }); });