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 { serializeGame, replayGame, type Seat } from './serialize'; import { decide } from '../robot/strategy'; import type { Move } from '../dict/validate'; // A game reconstructed from its record (seed + journal) must be identical to the original — same // board, racks, bag, scores, turn and log — since the bag is deterministic from the seed and the // replayed sequence of operations. Uses the committed sample dictionary. const dawg = new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url)))); const seats: Seat[] = [ { kind: 'human', name: 'You' }, { kind: 'robot', name: 'Robot' }, ]; 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; } // drive plays one turn with the robot's choice (legal plays only — see engine.test.ts). function drive(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(); } function snapshot(game: LocalGame): unknown { const scores: number[] = []; const hands: number[][] = []; for (let i = 0; i < game.playerCount; i++) { scores.push(game.scoreOf(i)); hands.push(game.handOf(i).slice().sort((a, b) => a - b)); } return { scores, hands, toMove: game.currentPlayer, bagLen: game.bagLength, over: game.isOver, reason: game.endReason, history: game.history }; } function makeGame(seed: bigint): LocalGame { return new LocalGame({ variant: 'scrabble_en', version: 'sample', seed, players: 2, dawg, multipleWords: true }); } const meta = { id: 'g1', seats, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 }; describe('local game serialize + replay', () => { it('reconstructs a mid-game position by replay', () => { const g = makeGame(777n); for (let i = 0; i < 15 && !g.isOver; i++) drive(g, g.seed); const rec = serializeGame(g, meta); const r = replayGame(rec, dawg); expect(snapshot(r)).toEqual(snapshot(g)); }); it('reconstructs a finished game by replay', () => { const g = makeGame(2024n); let t = 0; while (!g.isOver && t < 2000) { drive(g, g.seed); t++; } expect(g.isOver).toBe(true); const rec = serializeGame(g, meta); expect(rec.status).toBe('finished'); const r = replayGame(rec, dawg); expect(snapshot(r)).toEqual(snapshot(g)); }); it('serialises the seed, rules and journal', () => { const g = makeGame(9999999999n); for (let i = 0; i < 5 && !g.isOver; i++) drive(g, g.seed); const rec = serializeGame(g, meta); expect(rec.seed).toBe('9999999999'); expect(rec.variant).toBe('scrabble_en'); expect(rec.multipleWords).toBe(true); expect(rec.players).toBe(2); expect(rec.seats).toEqual(seats); expect(rec.journal.length).toBe(g.moveCount); expect(replayGame(rec, dawg).seed).toBe(9999999999n); }); it('serialises hotseat metadata: the mode, master PIN and per-seat PINs', async () => { const { newLock } = await import('../pin'); const hostPin = await newLock('9999'); const seatPin = await newLock('1234'); const hotseatSeats: Seat[] = [ { kind: 'human', name: 'Ann', pin: seatPin }, { kind: 'human', name: 'Bob' }, ]; const g = makeGame(5n); const rec = serializeGame(g, { id: 'h1', seats: hotseatSeats, hotseat: true, hostPin, createdAtUnix: 1, updatedAtUnix: 2, hintUnlockAtMs: 0 }); expect(rec.hotseat).toBe(true); expect(rec.hostPin).toEqual(hostPin); expect(rec.seats[0].pin).toEqual(seatPin); expect(rec.seats[1].pin).toBeUndefined(); }); });