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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user