diff --git a/ui/src/lib/localgame/serialize.test.ts b/ui/src/lib/localgame/serialize.test.ts index 3905505..6e9525c 100644 --- a/ui/src/lib/localgame/serialize.test.ts +++ b/ui/src/lib/localgame/serialize.test.ts @@ -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(); + }); }); diff --git a/ui/src/lib/localgame/serialize.ts b/ui/src/lib/localgame/serialize.ts index b528d82..d635c66 100644 --- a/ui/src/lib/localgame/serialize.ts +++ b/ui/src/lib/localgame/serialize.ts @@ -12,6 +12,7 @@ import { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine'; import type { Dawg } from '../dict/dawg'; import type { Variant } from '../model'; +import type { PinLock } from '../pin'; /** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */ export interface Seat { @@ -20,6 +21,9 @@ export interface Seat { /** The player's real account id for a human seat, so the client identifies "you" and the correct * turn exactly as in an online game; absent for the robot (a synthetic id is derived). */ accountId?: string; + /** Offline hotseat only: the seat's optional PIN lock. When set, the seat's rack stays hidden + * until the PIN is entered (a social lock for a shared device — see lib/pin). */ + pin?: PinLock; } /** LocalGameRecord is the durable form of one local game — everything needed to reconstruct it. */ @@ -36,6 +40,12 @@ export interface LocalGameRecord { /** The dictionary-independent, alphabet-index-space move journal. */ journal: LocalMove[]; status: 'active' | 'finished'; + /** Offline pass-and-play (hotseat): 2-4 humans share one device. Absent on vs_ai records (and on + * older records predating the feature), which the source reads as false. */ + hotseat?: boolean; + /** Offline hotseat only: the mandatory master (host/referee) PIN lock, needed to skip/resign a + * seat, exclude a player or terminate the game (see lib/pin). Absent on vs_ai records. */ + hostPin?: PinLock; createdAtUnix: number; updatedAtUnix: number; /** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0 @@ -49,6 +59,8 @@ export interface LocalGameRecord { export interface RecordMeta { id: string; seats: Seat[]; + hotseat?: boolean; + hostPin?: PinLock; createdAtUnix: number; updatedAtUnix: number; hintUnlockAtMs: number; @@ -71,6 +83,8 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor seats: meta.seats, journal: game.history, status: game.isOver ? 'finished' : 'active', + hotseat: meta.hotseat, + hostPin: meta.hostPin, createdAtUnix: meta.createdAtUnix, updatedAtUnix: meta.updatedAtUnix, hintUnlockAtMs: meta.hintUnlockAtMs, diff --git a/ui/src/lib/localgame/source.hotseat.test.ts b/ui/src/lib/localgame/source.hotseat.test.ts new file mode 100644 index 0000000..f7e8a0d --- /dev/null +++ b/ui/src/lib/localgame/source.hotseat.test.ts @@ -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 { + 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'); + }); +}); diff --git a/ui/src/lib/localgame/source.ts b/ui/src/lib/localgame/source.ts index 0060d69..909d0f1 100644 --- a/ui/src/lib/localgame/source.ts +++ b/ui/src/lib/localgame/source.ts @@ -17,6 +17,7 @@ import { setAlphabet, hasAlphabet } from '../alphabet'; import { HINT_GATE_MS } from '../hints'; import { LOCAL_ID_PREFIX, isLocalGameId } from './id'; import { decide } from '../robot/strategy'; +import { verifyPin, type PinLock } from '../pin'; import { GatewayError, type PlacedTile } from '../client'; import type { EvalResult, @@ -87,6 +88,9 @@ function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number; interface Live { game: LocalGame; record: LocalGameRecord; + /** Offline hotseat only: which seat is currently unlocked (its PIN was entered this turn), or null. + * Ephemeral — never persisted; cleared on every turn advance so each turn re-locks. */ + unlockedSeat: number | null; } /** @@ -97,7 +101,8 @@ export class LocalSource implements GameLoopSource { private readonly live = new Map(); private readonly listeners = new Map void>>(); - /** create starts a new local vs_ai game, persists it and returns its state. */ + /** create starts a new local game — a vs_ai game or an offline pass-and-play (hotseat) game when + * hotseat is set — persists it and returns its state. hostPin is the hotseat master PIN. */ async create(opts: { id: string; variant: Variant; @@ -105,6 +110,8 @@ export class LocalSource implements GameLoopSource { seed: bigint; multipleWords: boolean; seats: LocalSeat[]; + hotseat?: boolean; + hostPin?: PinLock; }): Promise { const dawg = await getDawg(opts.variant, opts.dictVersion); if (!dawg) throw new GatewayError('dict_unavailable'); @@ -119,12 +126,14 @@ export class LocalSource implements GameLoopSource { const now = nowUnix(); const record = serializeGame(game, { id: opts.id, - seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId })), + seats: opts.seats.map((s) => ({ kind: s.kind, name: s.name, accountId: s.accountId, pin: s.pin })), + hotseat: opts.hotseat, + hostPin: opts.hostPin, createdAtUnix: now, updatedAtUnix: now, hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once }); - const entry: Live = { game, record }; + const entry: Live = { game, record, unlockedSeat: null }; this.live.set(opts.id, entry); await saveLocalGame(record); return this.stateView(entry); @@ -185,10 +194,59 @@ export class LocalSource implements GameLoopSource { const entry = await this.load(gameId); const seat = entry.game.currentPlayer; const move = entry.game.resign(); + entry.unlockedSeat = null; await this.persist(entry); return this.moveResult(entry, move, seat); } + /** + * unlockSeat reveals the current hotseat seat's rack once its PIN is entered. It verifies pin + * against the seat's stored lock (a seat with no lock unlocks with any pin) and, on success, marks + * the seat unlocked for this turn only — a subsequent move re-locks it. Throws 'wrong_pin' on a + * mismatch. Returns the freshly unlocked state (rack revealed). + */ + async unlockSeat(gameId: string, pin: string): Promise { + const entry = await this.load(gameId); + const seat = entry.game.currentPlayer; + const lock = entry.record.seats[seat]?.pin; + if (lock && !(await verifyPin(pin, lock))) throw new GatewayError('wrong_pin'); + entry.unlockedSeat = seat; + return this.stateView(entry); + } + + /** verifyHostPin reports whether pin matches the hotseat master (host/referee) PIN. */ + async verifyHostPin(gameId: string, pin: string): Promise { + const entry = await this.load(gameId); + return !!entry.record.hostPin && (await verifyPin(pin, entry.record.hostPin)); + } + + /** + * hostAction runs a host (referee) override on a hotseat game, gated by the master PIN: + * 'skip' — pass the current seat's turn; + * 'resign' — resign seat targetSeat (exclude a player / concede on their behalf); + * 'terminate' — end the game with no result and delete it from the store. + * Returns the updated StateView, or null when the game was terminated. Throws 'wrong_pin' on a bad + * PIN. + */ + async hostAction( + gameId: string, + pin: string, + action: 'skip' | 'resign' | 'terminate', + targetSeat?: number, + ): Promise { + const entry = await this.load(gameId); + if (!entry.record.hostPin || !(await verifyPin(pin, entry.record.hostPin))) throw new GatewayError('wrong_pin'); + if (action === 'terminate') { + await this.delete(gameId); + return null; + } + if (action === 'skip') entry.game.pass(); + else entry.game.resignSeat(targetSeat ?? entry.game.currentPlayer); + entry.unlockedSeat = null; + await this.persist(entry); + return this.stateView(entry); + } + async hint(gameId: string): Promise { // The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints): // a wall-clock timestamp here could be defeated by changing the device clock. This just serves @@ -246,7 +304,7 @@ export class LocalSource implements GameLoopSource { if (!record) throw new GatewayError('game_not_found'); const dawg = await getDawg(record.variant, record.dictVersion); if (!dawg) throw new GatewayError('dict_unavailable'); - const entry: Live = { game: replayGame(record, dawg), record }; + const entry: Live = { game: replayGame(record, dawg), record, unlockedSeat: null }; this.live.set(gameId, entry); return entry; } @@ -258,6 +316,7 @@ export class LocalSource implements GameLoopSource { const entry = await this.load(gameId); const seat = entry.game.currentPlayer; const move = act(entry.game); + entry.unlockedSeat = null; // the turn has advanced; a hotseat seat re-locks (no effect for vs_ai) const robotMoves = this.runRobots(entry); await this.persist(entry); const result = this.moveResult(entry, move, seat); @@ -306,6 +365,8 @@ export class LocalSource implements GameLoopSource { entry.record = serializeGame(entry.game, { id: entry.record.id, seats: entry.record.seats, + hotseat: entry.record.hotseat, + hostPin: entry.record.hostPin, createdAtUnix: entry.record.createdAtUnix, updatedAtUnix: nowUnix(), hintUnlockAtMs: entry.record.hintUnlockAtMs, @@ -351,9 +412,14 @@ export class LocalSource implements GameLoopSource { return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0; } + // stateView is the seated player's private view. For a vs_ai game that is the single human seat; for + // a hotseat game it is the seat to move — and when that seat carries a PIN, the rack is withheld + // (empty, locked=true) until unlockSeat is called this turn. private stateView(entry: Live): StateView { - const seat = this.humanSeat(entry); - const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); + const hotseat = !!entry.record.hotseat; + const seat = hotseat ? entry.game.currentPlayer : this.humanSeat(entry); + const locked = hotseat && !!entry.record.seats[seat]?.pin && entry.unlockedSeat !== seat; + const rack = locked ? [] : entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t)); return { game: this.gameView(entry), seat, @@ -362,6 +428,7 @@ export class LocalSource implements GameLoopSource { hintsRemaining: 1, walletBalance: 0, hintUnlockLeftSeconds: this.hintUnlockLeft(entry), + locked, }; } @@ -401,7 +468,8 @@ export class LocalSource implements GameLoopSource { moveCount: game.moveCount, endReason: game.isOver ? game.endReason : '', lastActivityUnix: record.updatedAtUnix, - vsAi: true, + vsAi: !record.hotseat, + hotseat: !!record.hotseat, unreadChat: false, unreadMessages: false, seats, diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 7dbe543..8b22cbb 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -47,6 +47,10 @@ export interface GameView { lastActivityUnix: number; /** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */ vsAi: boolean; + /** true = a local offline pass-and-play (hotseat) game: 2-4 humans share one device. Set only by + * the local source; the online gateway never sets it. Drives the per-seat PIN-lock UI and the host + * menu (which replaces the hint control). A hotseat game is never vsAi. */ + hotseat?: boolean; /** Per-viewer flag: the requesting player has at least one unread chat entry (message or * nudge) in this game. Set on the authoritative REST views (lobby list, game state, * move result); the live-event GameView leaves it false (events bump unread instead). */ @@ -87,6 +91,9 @@ export interface StateView { * non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt, * so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */ hintUnlockLeftSeconds?: number; + /** Offline hotseat only: the seat to move is PIN-locked, so its rack is withheld (empty) until the + * seat's PIN is entered (see LocalSource.unlockSeat). Absent/false everywhere else. */ + locked?: boolean; } export interface MoveResult {