8c67d679d9
Extend the local game to offline pass-and-play (hotseat): - serialize.ts: Seat.pin, record hotseat + hostPin (all optional; old vs_ai records read hotseat as false). - source.ts: create() takes hotseat/hostPin + per-seat pins; stateView reveals the seat-to-move's rack and withholds it (locked) when that seat is PIN-locked, until unlockSeat; ephemeral per-turn unlock re-locks on advance; new unlockSeat / verifyHostPin / hostAction (skip/resign/terminate); gameView sets vsAi=!hotseat + the hotseat flag. - model.ts: GameView.hotseat, StateView.locked (offline-only, optional). Tests: source.hotseat (lock/unlock/re-lock, host skip/resign/terminate, master-PIN gate, natural-end persistence) + serialize hotseat shape.
103 lines
4.1 KiB
TypeScript
103 lines
4.1 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 { 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();
|
|
});
|
|
});
|