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