feat(offline): hotseat record schema + source (locks, host actions)
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.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { StateView } from '../model';
|
||||
|
||||
// getDawg is mocked to the committed sample dictionary (the store's IndexedDB is absent under node,
|
||||
// so games live in the source's in-memory cache — created once, then driven without reload).
|
||||
vi.mock('../dict', () => ({
|
||||
getDawg: async () => {
|
||||
const { Dawg } = await import('../dict/dawg');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
return new Dawg(new Uint8Array(readFileSync(new URL('../dict/testdata/sample_en.dawg', import.meta.url))));
|
||||
},
|
||||
}));
|
||||
|
||||
import { LocalSource } from './source';
|
||||
import type { Seat } from './serialize';
|
||||
import { newLock } from '../pin';
|
||||
|
||||
async function hotseat(id: string, seats: Seat[], hostDigits = '9999'): Promise<LocalSource> {
|
||||
const src = new LocalSource();
|
||||
await src.create({
|
||||
id,
|
||||
variant: 'scrabble_en',
|
||||
dictVersion: 'sample',
|
||||
seed: 3n,
|
||||
multipleWords: true,
|
||||
seats,
|
||||
hotseat: true,
|
||||
hostPin: await newLock(hostDigits),
|
||||
});
|
||||
return src;
|
||||
}
|
||||
|
||||
describe('LocalSource hotseat', () => {
|
||||
it('creates a pass-and-play game: not vs_ai, board visible, first seat to move', async () => {
|
||||
const src = await hotseat('local:h1', [
|
||||
{ kind: 'human', name: 'Ann' },
|
||||
{ kind: 'human', name: 'Bob' },
|
||||
]);
|
||||
const st = await src.gameState('local:h1');
|
||||
expect(st.game.vsAi).toBe(false);
|
||||
expect(st.game.hotseat).toBe(true);
|
||||
expect(st.game.toMove).toBe(0);
|
||||
expect(st.locked).toBeFalsy(); // seat 0 has no PIN
|
||||
expect(st.rack.length).toBe(7);
|
||||
expect(st.game.seats.map((s) => s.displayName)).toEqual(['Ann', 'Bob']);
|
||||
});
|
||||
|
||||
it('hides the rack of a PIN-locked seat until the right PIN unlocks it', async () => {
|
||||
const src = await hotseat('local:h2', [
|
||||
{ kind: 'human', name: 'Ann', pin: await newLock('1234') },
|
||||
{ kind: 'human', name: 'Bob' },
|
||||
]);
|
||||
const st = await src.gameState('local:h2');
|
||||
expect(st.locked).toBe(true);
|
||||
expect(st.rack).toEqual([]);
|
||||
|
||||
await expect(src.unlockSeat('local:h2', '0000')).rejects.toMatchObject({ code: 'wrong_pin' });
|
||||
|
||||
const opened = await src.unlockSeat('local:h2', '1234');
|
||||
expect(opened.locked).toBe(false);
|
||||
expect(opened.rack.length).toBe(7);
|
||||
});
|
||||
|
||||
it('re-locks the seat when the turn advances and again when it comes back', async () => {
|
||||
const src = await hotseat('local:h3', [
|
||||
{ kind: 'human', name: 'A', pin: await newLock('1111') },
|
||||
{ kind: 'human', name: 'B' },
|
||||
{ kind: 'human', name: 'C' },
|
||||
]);
|
||||
await src.unlockSeat('local:h3', '1111');
|
||||
await src.pass('local:h3'); // seat 0 -> 1
|
||||
let st = await src.gameState('local:h3');
|
||||
expect(st.game.toMove).toBe(1);
|
||||
expect(st.locked).toBeFalsy(); // seat 1 has no PIN
|
||||
|
||||
await src.pass('local:h3'); // 1 -> 2
|
||||
await src.pass('local:h3'); // 2 -> 0
|
||||
st = await src.gameState('local:h3');
|
||||
expect(st.game.toMove).toBe(0);
|
||||
expect(st.locked).toBe(true); // seat 0 needs re-unlocking
|
||||
});
|
||||
|
||||
it('lets the host skip the current turn, gated by the master PIN', async () => {
|
||||
const src = await hotseat('local:h4', [
|
||||
{ kind: 'human', name: 'A' },
|
||||
{ kind: 'human', name: 'B' },
|
||||
], '4321');
|
||||
expect((await src.gameState('local:h4')).game.toMove).toBe(0);
|
||||
await expect(src.hostAction('local:h4', '0000', 'skip')).rejects.toMatchObject({ code: 'wrong_pin' });
|
||||
const st = await src.hostAction('local:h4', '4321', 'skip');
|
||||
expect(st).not.toBeNull();
|
||||
expect((st as StateView).game.toMove).toBe(1);
|
||||
});
|
||||
|
||||
it('lets the host resign a seat (ending a two-player game)', async () => {
|
||||
const src = await hotseat('local:h5', [
|
||||
{ kind: 'human', name: 'A' },
|
||||
{ kind: 'human', name: 'B' },
|
||||
], '4321');
|
||||
const st = await src.hostAction('local:h5', '4321', 'resign', 1);
|
||||
expect((st as StateView).game.status).toBe('finished');
|
||||
});
|
||||
|
||||
it('lets the host terminate the game, deleting it', async () => {
|
||||
const src = await hotseat('local:h6', [
|
||||
{ kind: 'human', name: 'A' },
|
||||
{ kind: 'human', name: 'B' },
|
||||
], '4321');
|
||||
const r = await src.hostAction('local:h6', '4321', 'terminate');
|
||||
expect(r).toBeNull();
|
||||
await expect(src.gameState('local:h6')).rejects.toMatchObject({ code: 'game_not_found' });
|
||||
});
|
||||
|
||||
it('verifies the master PIN', async () => {
|
||||
const src = await hotseat('local:h7', [
|
||||
{ kind: 'human', name: 'A' },
|
||||
{ kind: 'human', name: 'B' },
|
||||
], '4321');
|
||||
expect(await src.verifyHostPin('local:h7', '4321')).toBe(true);
|
||||
expect(await src.verifyHostPin('local:h7', '0000')).toBe(false);
|
||||
});
|
||||
|
||||
it('persists a naturally finished game (scoreless run) as finished', async () => {
|
||||
const src = await hotseat('local:h8', [
|
||||
{ kind: 'human', name: 'A' },
|
||||
{ kind: 'human', name: 'B' },
|
||||
]);
|
||||
for (let i = 0; i < 6; i++) await src.pass('local:h8');
|
||||
const st = await src.gameState('local:h8');
|
||||
expect(st.game.status).toBe('finished');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user