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(rec.journal.length).toBe(g.moveCount);
|
||||||
expect(replayGame(rec, dawg).seed).toBe(9999999999n);
|
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 { LocalGame, type LocalMove, type DropoutTiles, type LocalGameOptions } from './engine';
|
||||||
import type { Dawg } from '../dict/dawg';
|
import type { Dawg } from '../dict/dawg';
|
||||||
import type { Variant } from '../model';
|
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). */
|
/** Seat describes one player of a local game (forward-compatible with a 2-4 hotseat). */
|
||||||
export interface Seat {
|
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
|
/** 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). */
|
* turn exactly as in an online game; absent for the robot (a synthetic id is derived). */
|
||||||
accountId?: string;
|
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. */
|
/** 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. */
|
/** The dictionary-independent, alphabet-index-space move journal. */
|
||||||
journal: LocalMove[];
|
journal: LocalMove[];
|
||||||
status: 'active' | 'finished';
|
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;
|
createdAtUnix: number;
|
||||||
updatedAtUnix: number;
|
updatedAtUnix: number;
|
||||||
/** Wall-clock ms at which the vs_ai idle hint unlocks (the robot's last move + the window), or 0
|
/** 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 {
|
export interface RecordMeta {
|
||||||
id: string;
|
id: string;
|
||||||
seats: Seat[];
|
seats: Seat[];
|
||||||
|
hotseat?: boolean;
|
||||||
|
hostPin?: PinLock;
|
||||||
createdAtUnix: number;
|
createdAtUnix: number;
|
||||||
updatedAtUnix: number;
|
updatedAtUnix: number;
|
||||||
hintUnlockAtMs: number;
|
hintUnlockAtMs: number;
|
||||||
@@ -71,6 +83,8 @@ export function serializeGame(game: LocalGame, meta: RecordMeta): LocalGameRecor
|
|||||||
seats: meta.seats,
|
seats: meta.seats,
|
||||||
journal: game.history,
|
journal: game.history,
|
||||||
status: game.isOver ? 'finished' : 'active',
|
status: game.isOver ? 'finished' : 'active',
|
||||||
|
hotseat: meta.hotseat,
|
||||||
|
hostPin: meta.hostPin,
|
||||||
createdAtUnix: meta.createdAtUnix,
|
createdAtUnix: meta.createdAtUnix,
|
||||||
updatedAtUnix: meta.updatedAtUnix,
|
updatedAtUnix: meta.updatedAtUnix,
|
||||||
hintUnlockAtMs: meta.hintUnlockAtMs,
|
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 { HINT_GATE_MS } from '../hints';
|
||||||
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
|
import { LOCAL_ID_PREFIX, isLocalGameId } from './id';
|
||||||
import { decide } from '../robot/strategy';
|
import { decide } from '../robot/strategy';
|
||||||
|
import { verifyPin, type PinLock } from '../pin';
|
||||||
import { GatewayError, type PlacedTile } from '../client';
|
import { GatewayError, type PlacedTile } from '../client';
|
||||||
import type {
|
import type {
|
||||||
EvalResult,
|
EvalResult,
|
||||||
@@ -87,6 +88,9 @@ function encodePlacements(variant: Variant, tiles: PlacedTile[]): { row: number;
|
|||||||
interface Live {
|
interface Live {
|
||||||
game: LocalGame;
|
game: LocalGame;
|
||||||
record: LocalGameRecord;
|
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 live = new Map<string, Live>();
|
||||||
private readonly listeners = new Map<string, Set<(e: PushEvent) => void>>();
|
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: {
|
async create(opts: {
|
||||||
id: string;
|
id: string;
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
@@ -105,6 +110,8 @@ export class LocalSource implements GameLoopSource {
|
|||||||
seed: bigint;
|
seed: bigint;
|
||||||
multipleWords: boolean;
|
multipleWords: boolean;
|
||||||
seats: LocalSeat[];
|
seats: LocalSeat[];
|
||||||
|
hotseat?: boolean;
|
||||||
|
hostPin?: PinLock;
|
||||||
}): Promise<StateView> {
|
}): Promise<StateView> {
|
||||||
const dawg = await getDawg(opts.variant, opts.dictVersion);
|
const dawg = await getDawg(opts.variant, opts.dictVersion);
|
||||||
if (!dawg) throw new GatewayError('dict_unavailable');
|
if (!dawg) throw new GatewayError('dict_unavailable');
|
||||||
@@ -119,12 +126,14 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const now = nowUnix();
|
const now = nowUnix();
|
||||||
const record = serializeGame(game, {
|
const record = serializeGame(game, {
|
||||||
id: opts.id,
|
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,
|
createdAtUnix: now,
|
||||||
updatedAtUnix: now,
|
updatedAtUnix: now,
|
||||||
hintUnlockAtMs: 0, // no robot move yet: a human-first opening's hint is open at once
|
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);
|
this.live.set(opts.id, entry);
|
||||||
await saveLocalGame(record);
|
await saveLocalGame(record);
|
||||||
return this.stateView(entry);
|
return this.stateView(entry);
|
||||||
@@ -185,10 +194,59 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const entry = await this.load(gameId);
|
const entry = await this.load(gameId);
|
||||||
const seat = entry.game.currentPlayer;
|
const seat = entry.game.currentPlayer;
|
||||||
const move = entry.game.resign();
|
const move = entry.game.resign();
|
||||||
|
entry.unlockedSeat = null;
|
||||||
await this.persist(entry);
|
await this.persist(entry);
|
||||||
return this.moveResult(entry, move, seat);
|
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> {
|
async hint(gameId: string): Promise<HintResult> {
|
||||||
// The idle gate is enforced client-side with a monotonic clock (see Game.svelte / lib/hints):
|
// 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
|
// 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');
|
if (!record) throw new GatewayError('game_not_found');
|
||||||
const dawg = await getDawg(record.variant, record.dictVersion);
|
const dawg = await getDawg(record.variant, record.dictVersion);
|
||||||
if (!dawg) throw new GatewayError('dict_unavailable');
|
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);
|
this.live.set(gameId, entry);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@@ -258,6 +316,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
const entry = await this.load(gameId);
|
const entry = await this.load(gameId);
|
||||||
const seat = entry.game.currentPlayer;
|
const seat = entry.game.currentPlayer;
|
||||||
const move = act(entry.game);
|
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);
|
const robotMoves = this.runRobots(entry);
|
||||||
await this.persist(entry);
|
await this.persist(entry);
|
||||||
const result = this.moveResult(entry, move, seat);
|
const result = this.moveResult(entry, move, seat);
|
||||||
@@ -306,6 +365,8 @@ export class LocalSource implements GameLoopSource {
|
|||||||
entry.record = serializeGame(entry.game, {
|
entry.record = serializeGame(entry.game, {
|
||||||
id: entry.record.id,
|
id: entry.record.id,
|
||||||
seats: entry.record.seats,
|
seats: entry.record.seats,
|
||||||
|
hotseat: entry.record.hotseat,
|
||||||
|
hostPin: entry.record.hostPin,
|
||||||
createdAtUnix: entry.record.createdAtUnix,
|
createdAtUnix: entry.record.createdAtUnix,
|
||||||
updatedAtUnix: nowUnix(),
|
updatedAtUnix: nowUnix(),
|
||||||
hintUnlockAtMs: entry.record.hintUnlockAtMs,
|
hintUnlockAtMs: entry.record.hintUnlockAtMs,
|
||||||
@@ -351,9 +412,14 @@ export class LocalSource implements GameLoopSource {
|
|||||||
return leftMs > 0 ? Math.ceil(leftMs / 1000) : 0;
|
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 {
|
private stateView(entry: Live): StateView {
|
||||||
const seat = this.humanSeat(entry);
|
const hotseat = !!entry.record.hotseat;
|
||||||
const rack = entry.game.handOf(seat).map((t) => indexToLetter(entry.record.variant, t));
|
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 {
|
return {
|
||||||
game: this.gameView(entry),
|
game: this.gameView(entry),
|
||||||
seat,
|
seat,
|
||||||
@@ -362,6 +428,7 @@ export class LocalSource implements GameLoopSource {
|
|||||||
hintsRemaining: 1,
|
hintsRemaining: 1,
|
||||||
walletBalance: 0,
|
walletBalance: 0,
|
||||||
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
|
hintUnlockLeftSeconds: this.hintUnlockLeft(entry),
|
||||||
|
locked,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +468,8 @@ export class LocalSource implements GameLoopSource {
|
|||||||
moveCount: game.moveCount,
|
moveCount: game.moveCount,
|
||||||
endReason: game.isOver ? game.endReason : '',
|
endReason: game.isOver ? game.endReason : '',
|
||||||
lastActivityUnix: record.updatedAtUnix,
|
lastActivityUnix: record.updatedAtUnix,
|
||||||
vsAi: true,
|
vsAi: !record.hotseat,
|
||||||
|
hotseat: !!record.hotseat,
|
||||||
unreadChat: false,
|
unreadChat: false,
|
||||||
unreadMessages: false,
|
unreadMessages: false,
|
||||||
seats,
|
seats,
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export interface GameView {
|
|||||||
lastActivityUnix: number;
|
lastActivityUnix: number;
|
||||||
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
|
/** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
|
||||||
vsAi: boolean;
|
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
|
/** 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,
|
* 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). */
|
* 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,
|
* 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. */
|
* so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */
|
||||||
hintUnlockLeftSeconds?: number;
|
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 {
|
export interface MoveResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user