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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, Live>();
|
||||
private readonly listeners = new Map<string, Set<(e: PushEvent) => 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<StateView> {
|
||||
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<StateView> {
|
||||
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<boolean> {
|
||||
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<StateView | null> {
|
||||
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<HintResult> {
|
||||
// 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user