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:
Ilia Denisov
2026-07-07 11:20:39 +02:00
parent 69673e7727
commit 8c67d679d9
5 changed files with 244 additions and 7 deletions
+16
View File
@@ -83,4 +83,20 @@ describe('local game serialize + replay', () => {
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();
});
});