diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 6d516db..2505052 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -99,7 +99,11 @@ README. - **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. + `create` receives them. A game with one way to play declares none. An + option that changes how many can sit (a smaller map) says so through + `GameSpec.seatsFor`. The server, the tally and the ledger replay all read + a room line through `cleanOptions` and size a table with `tableSize`, + both in `spec.ts`, so each creates the same game from the same ledger. ## The engine diff --git a/template/deploy/replay-ledgers.ts b/template/deploy/replay-ledgers.ts index 5624fa7..9a54795 100644 --- a/template/deploy/replay-ledgers.ts +++ b/template/deploy/replay-ledgers.ts @@ -8,7 +8,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { game } from '../src/lib/game'; -import type { SeatId } from '../src/lib/game/spec'; +import { cleanOptions, type SeatId } from '../src/lib/game/spec'; interface Seat { id: SeatId; @@ -34,8 +34,8 @@ async function main(): Promise { const seats: Seat[] = []; let state: ReturnType | null = null; let turns = 0; - // The host's table options ride the room line; a game created without them is a different game. - const options = (lines[0]?.t === 'room' ? lines[0].options : undefined) as Record | undefined; + // The host's table options ride the room line, read exactly as the server reads them. + const options = cleanOptions(game, lines[0]?.t === 'room' ? lines[0].options : undefined); try { for (const line of lines) { if (line.t === 'seat') seats.push(line); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 5d3604c..aa9d719 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, type TableOptions } from '../../src/lib/game/spec'; +import { cleanOptions, SPECTATOR, tableSize, 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'; @@ -182,21 +182,12 @@ export class Rooms { 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.`); + const options = cleanOptions(this.game, rawOptions); + const most = tableSize(this.game, options); + if (!Number.isInteger(size) || size < minSeats || size > most) { + throw new RoomError(`A table seats ${minSeats} to ${most} players.`); } - 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; + return this.open(name, size, options); } /** A table with nobody seated yet. */ @@ -528,7 +519,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 ?? [], this.cleanOptions(first.options), lines.length); + const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], cleanOptions(this.game, 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/tally.ts b/template/server/src/tally.ts index f356db5..c8a23d4 100644 --- a/template/server/src/tally.ts +++ b/template/server/src/tally.ts @@ -3,7 +3,7 @@ // costs nothing and a busy hall costs one scan a minute. The counts are // the kit's; a game may add its own lines through GameSpec.tally. -import type { GameSpec, SeatId } from '../../src/lib/game/spec'; +import { cleanOptions, type GameSpec, type SeatId } from '../../src/lib/game/spec'; import type { Tally } from '../../src/lib/net/view'; import type { LedgerLine, Store } from './store'; @@ -57,7 +57,7 @@ export class Tallies { if (hasBot) t.gamesWithBot += 1; if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt; try { - state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, lines[0].options); + state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, cleanOptions(this.game, lines[0].options)); } catch { state = null; } diff --git a/template/src/lib/components/Hall.svelte b/template/src/lib/components/Hall.svelte index 5ccf828..8ac901a 100644 --- a/template/src/lib/components/Hall.svelte +++ b/template/src/lib/components/Hall.svelte @@ -14,6 +14,7 @@ import type { Report, RoomView, Tally } from '$lib/net/view'; import type { State } from '$lib/game'; import { game } from '$lib/game'; + import { tableSize } from '$lib/game/spec'; let name = $state(playerName()); let code = $state(''); @@ -219,7 +220,7 @@ {/if}
- +
or join one diff --git a/template/src/lib/game/spec.test.ts b/template/src/lib/game/spec.test.ts new file mode 100644 index 0000000..a8956fb --- /dev/null +++ b/template/src/lib/game/spec.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { cleanOptions, tableSize, type TableOption } from './spec'; + +const options: TableOption[] = [ + { key: 'length', label: 'Length', choices: [{ value: 'short', label: 'Short' }, { value: 'long', label: 'Long' }], default: 'short' } +]; + +describe('table options', () => { + it('keeps only declared options and offered choices, the rest at their defaults', () => { + expect(cleanOptions({ options }, { length: 'long', colour: 'red' })).toEqual({ length: 'long' }); + expect(cleanOptions({ options }, { length: 'endless' })).toEqual({ length: 'short' }); + expect(cleanOptions({ options }, null)).toEqual({ length: 'short' }); + }); + + it('seats as many as the options allow, never more than the seats there are', () => { + const seatIds = ['A', 'B', 'C', 'D']; + expect(tableSize({ seatIds, options })).toBe(4); + const seatsFor = (o: Record) => (o.length === 'short' ? 2 : 9); + expect(tableSize({ seatIds, options, seatsFor }, { length: 'short' })).toBe(2); + expect(tableSize({ seatIds, options, seatsFor }, { length: 'long' })).toBe(4); + }); +}); diff --git a/template/src/lib/game/spec.ts b/template/src/lib/game/spec.ts index 98e0f8c..1e2e0c3 100644 --- a/template/src/lib/game/spec.ts +++ b/template/src/lib/game/spec.ts @@ -48,6 +48,8 @@ export interface Preference { export interface GameSpec { /** Seats in table order; the table takes at most this many. */ seatIds: SeatId[]; + /** The most seats a table with these options takes, when an option makes it fewer than `seatIds`. */ + seatsFor?(options: TableOptions): number; /** What the host may choose when opening a table; empty for a game with one way to play. */ options?: TableOption[]; /** The game's own settings for the preferences panel. */ @@ -83,3 +85,24 @@ export interface GameSpec { /** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */ tally?(state: State): Record; } + +/** + * A table's options as the game will play them: only the options it + * declares, only the choices it offers, the rest at their defaults. The + * server, the tally and the ledger replay all read a room line through this, + * so each creates the same game from the same ledger. + */ +export function cleanOptions(game: Pick, 'options'>, raw: unknown): TableOptions { + const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record; + const options: TableOptions = {}; + for (const o of 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; +} + +/** The most seats a table with these options takes. */ +export function tableSize(game: Pick, 'options' | 'seatIds' | 'seatsFor'>, raw?: unknown): number { + return Math.min(game.seatIds.length, game.seatsFor?.(cleanOptions(game, raw)) ?? game.seatIds.length); +}