Table options: the host's choices when opening a table, declared by the game, recorded on the room line

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 12:45:42 -04:00
co-authored by Claude Fable 5.1
parent 2ab6dc4b5f
commit 63bd154798
10 changed files with 89 additions and 27 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
// The front door for games between people. Plain HTTP for actions, a
// websocket only to say "something changed, fetch the view again".
//
// POST /api/rooms {name, size?} create a room and take seat A
// POST /api/rooms {name, size?, options?} create a room and take seat A; options are the game's table choices
// POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
@@ -188,7 +188,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (parts.length === 2 && req.method === 'POST') {
if (!doors.allow(clientOf(req))) throw new RoomError('Too many rooms opened from here just now; try again later.', 429);
const body = await readBody(req);
const { room, seat, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats));
const { room, seat, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats), body.options);
return send(res, 201, { seat: seat.id, token, view: rooms.view(room, seat.id) });
}
+25 -11
View File
@@ -5,7 +5,7 @@
// game beyond the GameSpec it is given.
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { SPECTATOR, type GameSpec, type SeatId } from '../../src/lib/game/spec';
import { SPECTATOR, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
import type { ChatLine, RoomView } from '../../src/lib/net/view';
import type { LedgerLine, Store } from './store';
@@ -41,6 +41,8 @@ export interface Room<State> {
rematch: { to: string; by: SeatId } | null;
/** The table this one is the rematch of. */
rematchOf: string | null;
/** The host's choices for this table, every declared option filled. */
options: TableOptions;
state: State | null;
/** Inputs received for the round in progress, by seat. */
pending: Record<SeatId, unknown>;
@@ -158,26 +160,37 @@ export class Rooms<State, Input> {
return seat;
}
create(name: string, size: number): { room: Room<State>; seat: Seat; token: string } {
create(name: string, size: number, rawOptions?: unknown): { room: Room<State>; seat: Seat; token: string } {
const { minSeats } = this.game;
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
}
return this.open(name, size);
return this.open(name, size, this.cleanOptions(rawOptions));
}
/** The host's choices, kept to the options the game declares and the choices it offers; the rest default. */
private cleanOptions(raw: unknown): TableOptions {
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
const options: TableOptions = {};
for (const o of this.game.options ?? []) {
const v = given[o.key];
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
}
return options;
}
/** A table with nobody seated yet. */
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], seq = 0): Room<State> {
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, state: null, pending: {}, chat: [], seq, updatedAt: createdAt };
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room<State> {
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, state: null, pending: {}, chat: [], seq, updatedAt: createdAt };
}
/** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */
private open(name: string, size: number, follows?: { rematchOf: string; expected: string[] }): { room: Room<State>; seat: Seat; token: string } {
private open(name: string, size: number, options: TableOptions, follows?: { rematchOf: string; expected: string[] }): { room: Room<State>; seat: Seat; token: string } {
let id = newCode();
while (this.rooms.has(id) || this.store.exists(id)) id = newCode();
const room = this.blank(id, size, Date.now(), follows?.rematchOf ?? null, follows?.expected ?? []);
const room = this.blank(id, size, Date.now(), follows?.rematchOf ?? null, follows?.expected ?? [], options);
this.rooms.set(id, room);
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt, ...(follows ? { rematchOf: follows.rematchOf, expected: follows.expected } : {}) });
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt, options, ...(follows ? { rematchOf: follows.rematchOf, expected: follows.expected } : {}) });
return { room, ...this.sit(room, name, false) };
}
@@ -232,7 +245,7 @@ export class Rooms<State, Input> {
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game is not over yet.', 409);
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
const next = this.open(caller.name, room.size, { rematchOf: room.id, expected: others });
const next = this.open(caller.name, room.size, room.options, { rematchOf: room.id, expected: others });
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true);
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
@@ -397,6 +410,7 @@ export class Rooms<State, Input> {
expected: this.awaited(room),
rematch: room.rematch,
rematchOf: room.rematchOf,
options: room.options,
keeper: this.keeper.name,
keeperZone: this.keeper.zone
};
@@ -464,7 +478,7 @@ export class Rooms<State, Input> {
room.seats = room.seats.filter((s) => s.id !== line.id);
break;
case 'start':
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, room.options);
break;
case 'turn':
if (!room.state) return;
@@ -481,7 +495,7 @@ export class Rooms<State, Input> {
private replay(id: string, lines: LedgerLine[]): Room<State> | null {
const first = lines[0];
if (!first || first.t !== 'room') return null;
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], lines.length);
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], this.cleanOptions(first.options), lines.length);
for (const line of lines) {
this.apply(room, line);
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
+1 -1
View File
@@ -8,7 +8,7 @@ import type { SeatId } from '../../src/lib/game/spec';
export type LedgerLine =
/** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[] }
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options?: Record<string, string> }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
| { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */