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
+75 -7
View File
@@ -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,