diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 69d247f..a88f95d 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -64,6 +64,11 @@ README. where the keeper roosts. It lives in the about panel; the colophons, the rules page and the guide each carry a one-line mail link. +- **Table options** are the host's choices when opening a table (a + variant, a side, a length): the game declares them in `GameSpec.options`, + the hall renders them as selects, the room line records them, and + `create` receives them. A game with one way to play declares none. + ## The engine - Pure and deterministic: `create(names, seed, rules)` and diff --git a/template/server/src/index.ts b/template/server/src/index.ts index 480f739..8d93e06 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -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 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) }); } diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 115cfa8..b89f9a9 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -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 { 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; @@ -158,26 +160,37 @@ export class Rooms { return seat; } - create(name: string, size: number): { room: Room; seat: Seat; token: string } { + create(name: string, size: number, rawOptions?: unknown): { room: Room; 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; + 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 { - 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 { + 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; seat: Seat; token: string } { + private open(name: string, size: number, options: TableOptions, follows?: { rematchOf: string; expected: string[] }): { room: Room; 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 { 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 { 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 { 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 { private replay(id: string, lines: LedgerLine[]): Room | 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; diff --git a/template/server/src/store.ts b/template/server/src/store.ts index 8a8166e..e90af79 100644 --- a/template/server/src/store.ts +++ b/template/server/src/store.ts @@ -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 } /** 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. */ diff --git a/template/src/lib/components/Hall.svelte b/template/src/lib/components/Hall.svelte index afe4165..44b3cfc 100644 --- a/template/src/lib/components/Hall.svelte +++ b/template/src/lib/components/Hall.svelte @@ -13,6 +13,8 @@ let name = $state(playerName()); let code = $state(''); + /** The host's choices for the next table, from the game's declared options. */ + let options = $state>(Object.fromEntries((game.options ?? []).map((o) => [o.key, o.default]))); let busy = $state(false); let error = $state(''); let seats = $state(allSeats()); @@ -157,9 +159,21 @@ Your name - + {#if game.options?.length} +
+ {#each game.options as o (o.key)} + + {/each} +
+ {/if} +
- +
or join one @@ -551,4 +565,17 @@ .family li + li { margin-top: 0.3rem; } + .options { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.2rem; + margin: 0.2rem 0 0.6rem; + } + + .option { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + } diff --git a/template/src/lib/game/index.ts b/template/src/lib/game/index.ts index 2b488e8..6485635 100644 --- a/template/src/lib/game/index.ts +++ b/template/src/lib/game/index.ts @@ -5,7 +5,7 @@ // withholds the round in progress, and a rules revision. Replace this file // with your own game and keep the GameSpec shape. -import { SPECTATOR, type GameSpec, type Outcome, type SeatId } from './spec'; +import { SPECTATOR, type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec'; export const CURRENT_RULES = 1; export const TARGET = 3; @@ -41,7 +41,7 @@ function random(seed: number): number { return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } -export function createGame(names: Record, seed = Date.now(), rules = CURRENT_RULES): State { +export function createGame(names: Record, seed = Date.now(), rules = CURRENT_RULES, _options?: TableOptions): State { const seats = Object.keys(names); return { rules, diff --git a/template/src/lib/game/spec.ts b/template/src/lib/game/spec.ts index 06a7e64..c79a2d6 100644 --- a/template/src/lib/game/spec.ts +++ b/template/src/lib/game/spec.ts @@ -21,9 +21,23 @@ export interface Outcome { reason: string; } +/** A choice the host makes when opening a table: a variant, a side, a length. */ +export interface TableOption { + key: string; + label: string; + choices: { value: string; label: string }[]; + /** The value a table gets when the host chooses nothing. */ + default: string; +} + +/** The host's choices for a table, by option key; only keys the game declares get through. */ +export type TableOptions = Record; + export interface GameSpec { /** Seats in table order; the table takes at most this many. */ seatIds: SeatId[]; + /** What the host may choose when opening a table; empty for a game with one way to play. */ + options?: TableOption[]; minSeats: number; /** Names the server draws for bots, in preference order. */ botNames: string[]; @@ -35,7 +49,7 @@ export interface GameSpec { */ currentRules: number; - create(names: Record, seed: number, rules: number): State; + create(names: Record, seed: number, rules: number, options?: TableOptions): State; /** Resolve one round. Must be a pure function of its arguments: the ledger is replayed through it. */ resolve(state: State, inputs: Record): State; /** Whether this seat's input is needed before the next resolution. */ diff --git a/template/src/lib/net/client.ts b/template/src/lib/net/client.ts index 007ccaa..d7192ae 100644 --- a/template/src/lib/net/client.ts +++ b/template/src/lib/net/client.ts @@ -1,7 +1,7 @@ // The browser's side of the game server: a few JSON calls, and the seats this // browser holds, remembered so a shared link never has to carry a token. -import type { SeatId } from '$lib/game/spec'; +import type { SeatId, TableOptions } from '$lib/game/spec'; import type { RoomView } from './view'; export interface HeldSeat { @@ -132,7 +132,7 @@ export function makeApi() { type Seated = { seat: SeatId; token: string; view: RoomView }; type View = RoomView; return { - create: (name: string, size: number) => call('POST', '/api/rooms', { name, size }), + create: (name: string, size: number, options?: TableOptions) => call('POST', '/api/rooms', { name, size, options }), join: (roomId: string, name: string) => call('POST', `/api/rooms/${roomId}/join`, { name }), addBot: (roomId: string, token: string) => call('POST', `/api/rooms/${roomId}/bot`, { token }), begin: (roomId: string, token: string) => call('POST', `/api/rooms/${roomId}/begin`, { token }), diff --git a/template/src/lib/net/room.svelte.ts b/template/src/lib/net/room.svelte.ts index 5a82d97..d53eae2 100644 --- a/template/src/lib/net/room.svelte.ts +++ b/template/src/lib/net/room.svelte.ts @@ -3,7 +3,7 @@ // through GameSpec; a game's own store wraps or extends this one. import { game } from '$lib/game'; -import { SPECTATOR, type SeatId } from '$lib/game/spec'; +import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec'; import type { Input, State } from '$lib/game'; import { makeApi, rememberSeat, watchRoom, type HeldSeat } from './client'; import { markTalkSeen } from './talk'; @@ -152,15 +152,15 @@ export class Room { } /** Open a table on the server, seated as its host; it begins when the host says. */ - static async create(name: string, size: number): Promise { - const seated = await api.create(name, size); + static async create(name: string, size: number, options?: TableOptions): Promise { + const seated = await api.create(name, size, options); rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token }); return new Room(seated.view, seated.token); } /** A room with a bot seated and the game begun, so a solo game is a ledger like any other. */ - static async createWithBot(name: string): Promise { - const seated = await api.create(name, 2); + static async createWithBot(name: string, options?: TableOptions): Promise { + const seated = await api.create(name, 2, options); rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token }); await api.addBot(seated.view.roomId, seated.token); return new Room(await api.begin(seated.view.roomId, seated.token), seated.token); diff --git a/template/src/lib/net/view.ts b/template/src/lib/net/view.ts index 2283205..443cb5e 100644 --- a/template/src/lib/net/view.ts +++ b/template/src/lib/net/view.ts @@ -1,7 +1,7 @@ // What the server tells a viewer about a room. The game's own state is // whatever GameSpec.view returned for that viewer; nothing else leaves. -import type { Outcome, SeatId } from '../game/spec'; +import type { Outcome, SeatId, TableOptions } from '../game/spec'; /** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`). */ export interface ChatLine { @@ -40,6 +40,8 @@ export interface RoomView { 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; /** The keeper of the site, and where they live, for a table that calls them. */ keeper: string; keeperZone: string;