// Rooms: who sits where, whose move it is, and the game itself. Every change // goes to the ledger first and is then applied, so a restart replays to the // same place. Inputs wait in memory until every seat that must move has moved; // then the round resolves at once, bots included. Nothing here knows the // 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 type { ChatLine, RoomView } from '../../src/lib/net/view'; import type { LedgerLine, Store } from './store'; const CHAT_MAX_LENGTH = 300; /** Lines of talk kept and sent; the ledger keeps them all. */ const CHAT_KEEP = 200; export { SPECTATOR }; export interface Seat { id: SeatId; name: string; /** SHA-256 of the seat's token, hex; empty for a bot. */ tokenHash: string; bot: boolean; } export function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } export interface Room { id: string; size: number; createdAt: number; seats: Seat[]; /** The host lets the Peanut Gallery talk at this table. */ galleryTalk: boolean; /** The keeper of the site was called to a seat, and by whom. */ challenge: { by: SeatId; at: number } | null; /** Names with a seat held for them: the players of the last table, or the keeper. */ expected: string[]; /** A finished table that called for a rematch: where it went, and who called. */ 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; chat: ChatLine[]; /** Bumped on every change a client might want to see. */ seq: number; updatedAt: number; } export class RoomError extends Error { constructor( message: string, public status = 400 ) { super(message); } } function newId(bytes: number): string { return randomBytes(bytes).toString('base64url'); } /** Room codes: four letters or digits, none that read alike, easy to say aloud. */ const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; function newCode(): string { const bytes = randomBytes(4); return [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join(''); } function newSeed(): number { return (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0; } /** Phrases that carry a seat between devices: four plain words, ten minutes, one use. */ const TRANSFER_WORDS = [ 'ember', 'raven', 'flagon', 'wand', 'rune', 'moss', 'torch', 'frost', 'amber', 'wisp', 'cellar', 'gable', 'onyx', 'briar', 'tome', 'cinder', 'gloom', 'spiral', 'mirror', 'lantern', 'thistle', 'harbor', 'quill', 'saddle', 'willow', 'copper', 'meadow', 'anvil', 'beacon', 'orchard', 'pebble', 'cloister', 'walnut', 'tallow', 'heron', 'marble', 'ivy', 'kestrel', 'fennel', 'velvet', 'canyon', 'lattice', 'ledger', 'bramble', 'sable', 'tundra', 'juniper', 'compass' ]; const TRANSFER_TTL_MS = 10 * 60 * 1000; const TRANSFER_FAIL_LIMIT = 20; export function normalizeCode(raw: string): string { return raw.trim().toUpperCase(); } export function cleanName(raw: unknown): string { const name = String(raw ?? '') .replace(/\s+/g, ' ') .trim() .slice(0, 24); if (!name) throw new RoomError('A player needs a name.'); return name; } export class Rooms { private rooms = new Map>(); private listeners = new Map) => void>>(); /** Sockets watching from the gallery, by room. */ private gallery = new Map(); constructor( private game: GameSpec, private store: Store, /** The keeper of the site: the name a table may call to a seat, and where they live. */ private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' } ) { for (const id of store.roomIds()) { const room = this.replay(id, store.read(id)); if (room) this.rooms.set(id, room); } } get maxSeats(): number { return this.game.seatIds.length; } get(id: string): Room { const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id)) ?? this.load(normalizeCode(id)); if (!room) throw new RoomError('No game answers to that code.', 404); return room; } /** A room evicted from memory comes back from its ledger on the next visit. */ private load(id: string): Room | undefined { if (!/^[A-Z0-9]{4}$/.test(id)) return undefined; const room = this.replay(id, this.store.read(id)); if (room) this.rooms.set(id, room); return room ?? undefined; } /** Forget rooms nobody has touched or watched for a while; their ledgers stay on disk. */ evictIdle(olderThanMs: number, now = Date.now()): number { let n = 0; for (const [id, room] of this.rooms) { if (now - room.updatedAt < olderThanMs) continue; if ((this.listeners.get(id)?.size ?? 0) > 0) continue; this.rooms.delete(id); n += 1; } return n; } get loaded(): number { return this.rooms.size; } /** The seat a token unlocks, compared by hash in constant time. */ seatOf(room: Room, token: string | undefined): Seat { if (!token) throw new RoomError('That token opens no seat at this game.', 403); const given = Buffer.from(hashToken(token), 'hex'); const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given)); if (!seat) throw new RoomError('That token opens no seat at this game.', 403); return seat; } 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, 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[], 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, 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 ?? [], options); this.rooms.set(id, room); 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) }; } join(room: Room, name: string): { seat: Seat; token: string } { if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409); return this.sit(room, name, false); } /** Names with a seat held who have not yet sat. */ private awaited(room: Room): string[] { return room.expected.filter((n) => !room.seats.some((s) => s.name.toLowerCase() === n.toLowerCase())); } /** Host only: let the Peanut Gallery talk at this table, or hush it. Written to the ledger. */ setGalleryTalk(room: Room, token: string | undefined, on: boolean): void { const seat = this.seatOf(room, token); if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table decides whether the gallery may talk.', 403); if (room.galleryTalk === on) return; this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id }); } /** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */ sayFromGallery(room: Room, rawName: unknown, raw: unknown): void { if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403); const name = cleanName(rawName); if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('A player at the table has that name.', 409); this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: this.cleanTalk(raw) }); } /** * A seated player calls the keeper of the site to the table: a seat is * held under the keeper's name and the call goes on the ledger. Once per * table, before the game begins. The caller rings the keeper's bell. */ callKeeper(room: Room, token: string | undefined, keeper: string): void { const seat = this.seatOf(room, token); if (room.state) throw new RoomError('The game has begun.', 409); if (room.challenge) throw new RoomError(`${keeper} has already been called to this table.`, 409); if (room.seats.some((s) => s.name.toLowerCase() === keeper.toLowerCase())) throw new RoomError(`${keeper} is already here.`, 409); if (room.seats.length + this.awaited(room).length >= room.size) throw new RoomError('Every seat at this table is taken or held.', 409); this.commit(room, { t: 'challenge', at: Date.now(), by: seat.id, keeper }); } /** * A finished table calls for a rematch: a new table of the same size with * the same bots, seats held for the same people, the caller seated as its * host. Anyone at the old table may call; a second call finds the table * already open. */ rematch(room: Room, token: string | undefined): { roomId: string; seat: Seat | null; token: string | null; created: boolean } { const caller = this.seatOf(room, token); 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, 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 }; } /** Phrases waiting to be claimed, with the seat and the token they carry. */ private transfers = new Map(); private failedClaims = 0; private failWindowStart = 0; /** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */ transfer(room: Room, token: string | undefined): { phrase: string; expiresAt: number } { const seat = this.seatOf(room, token); const now = Date.now(); for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase); if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429); let phrase: string; do { phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-'); } while (this.transfers.has(phrase)); const expiresAt = now + TRANSFER_TTL_MS; this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt }); return { phrase, expiresAt }; } /** Claim a phrase: the seat and its token, once. Too many misses void every pending phrase. */ claim(raw: unknown): { roomId: string; seat: SeatId; token: string } { const now = Date.now(); if (now - this.failWindowStart > TRANSFER_TTL_MS) { this.failWindowStart = now; this.failedClaims = 0; } if (this.failedClaims >= TRANSFER_FAIL_LIMIT) throw new RoomError('Too many failed claims; transfers are cooling off. Mint a fresh phrase.', 429); const phrase = String(raw ?? '').trim().toLowerCase().replace(/\s+/g, '-'); const t = this.transfers.get(phrase); if (!t || t.expiresAt < now) { this.failedClaims += 1; if (this.failedClaims >= TRANSFER_FAIL_LIMIT) this.transfers.clear(); throw new RoomError('That phrase is unknown or has expired.', 404); } this.transfers.delete(phrase); const room = this.get(t.roomId); const seat = this.seatOf(room, t.token); return { roomId: room.id, seat: seat.id, token: t.token }; } /** The host, who opened the table, begins once enough are seated, however full the table. */ begin(room: Room, token: string | undefined): void { const seat = this.seatOf(room, token); if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may begin.', 403); if (room.state) throw new RoomError('The game has already begun.', 409); if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409); this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules }); } /** The host seats a bot in an empty chair. */ addBot(room: Room, token: string | undefined): Seat { const host = this.seatOf(room, token); if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may seat a bot.', 403); if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409); const taken = new Set(room.seats.map((s) => s.name.toLowerCase())); const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase())); const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct'; return this.sit(room, name, true).seat; } /** Take the next free seat. The token is returned once and never stored; the ledger keeps its hash. */ private sit(room: Room, rawName: string, bot: boolean): { seat: Seat; token: string } { if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409); const name = cleanName(rawName); const awaited = this.awaited(room); if (!awaited.some((n) => n.toLowerCase() === name.toLowerCase()) && room.seats.length + awaited.length >= room.size) { throw new RoomError('The seats left are held for players expected at this table.', 409); } if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409); const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!; const token = bot ? '' : newId(18); const seat: Seat = { id, name, tokenHash: bot ? '' : hashToken(token), bot }; this.commit(room, { t: 'seat', id: seat.id, name, tokenHash: seat.tokenHash, bot }); return { seat, token }; } /** The host sends a bot away before the game begins. People leave by not coming back. */ unseat(room: Room, token: string | undefined, seatId: unknown): void { const host = this.seatOf(room, token); if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may send a bot away.', 403); if (room.state) throw new RoomError('The game has begun; the table is set.', 409); const seat = room.seats.find((s) => s.id === seatId); if (!seat || !seat.bot) throw new RoomError('Only a bot can be sent away.', 400); this.commit(room, { t: 'unseat', id: seat.id }); } /** Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. */ submit(room: Room, token: string | undefined, raw: unknown): void { const seat = this.seatOf(room, token); if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409); if (this.game.over(room.state)) throw new RoomError('The game is over.', 409); if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409); const input = this.game.cleanInput(raw); const why = this.game.validate?.(room.state, seat.id, input); if (why) throw new RoomError(why); this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input }); if (this.waitingOn(room).length === 0) this.resolve(room); } private resolve(room: Room): void { const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id)); const inputs: Record = { ...room.pending }; for (const seat of bots(room.state!)) inputs[seat.id] = this.game.botInput(room.state!, seat.id); this.commit(room, { t: 'turn', at: Date.now(), inputs }); // A round only bots owe (an extra turn, say) resolves at once; a human's waits for them. while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) { const inputs: Record = {}; for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id); this.commit(room, { t: 'turn', at: Date.now(), inputs }); } const outcome = room.state && this.game.over(room.state); if (outcome) this.commit(room, { t: 'over', at: Date.now(), winner: outcome.winner, reason: outcome.reason }); } /** Table talk, from a seat to everyone at the table and in the gallery. */ say(room: Room, token: string | undefined, raw: unknown): void { const seat = this.seatOf(room, token); this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw) }); } private cleanTalk(raw: unknown): string { const text = String(raw ?? '') .replace(/[\u0000-\u001f\u007f]/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, CHAT_MAX_LENGTH); if (!text) throw new RoomError('Nothing to say.'); return text; } /** Seats whose input the next resolution needs and has not yet received. */ waitingOn(room: Room): SeatId[] { if (!room.state || this.game.over(room.state)) return []; return room.seats .filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending)) .map((s) => s.id); } /** The view for a seat, or for the gallery when the seat is SPECTATOR. */ view(room: Room, seatId: SeatId): RoomView { return { roomId: room.id, seq: room.seq, size: room.size, me: seatId, host: room.seats[0]?.id ?? seatId, seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })), waitingOn: this.waitingOn(room), turn: room.state ? this.game.turn(room.state) : null, over: room.state ? this.game.over(room.state) : null, state: room.state ? this.game.view(room.state, seatId) : null, chat: room.chat, audience: this.gallery.get(room.id) ?? 0, galleryTalk: room.galleryTalk, challenge: room.challenge, expected: this.awaited(room), rematch: room.rematch, rematchOf: room.rematchOf, options: room.options, keeper: this.keeper.name, keeperZone: this.keeper.zone }; } subscribe(roomId: string, fn: (room: Room) => void): () => void { if (!this.listeners.has(roomId)) this.listeners.set(roomId, new Set()); this.listeners.get(roomId)!.add(fn); return () => this.listeners.get(roomId)?.delete(fn); } /** Subscribe from the gallery; the table is told when its audience changes. */ watch(room: Room, fn: (room: Room) => void): () => void { const unsubscribe = this.subscribe(room.id, fn); this.gallery.set(room.id, (this.gallery.get(room.id) ?? 0) + 1); room.seq += 1; this.notify(room); return () => { unsubscribe(); this.gallery.set(room.id, Math.max(0, (this.gallery.get(room.id) ?? 1) - 1)); room.seq += 1; this.notify(room); }; } audience(room: Room): number { return this.gallery.get(room.id) ?? 0; } /** Write to the ledger, then apply; the file is the truth a restart returns to. */ private commit(room: Room, line: LedgerLine): void { this.store.append(room.id, line); this.apply(room, line); room.seq += 1; room.updatedAt = Date.now(); this.notify(room); } private notify(room: Room): void { for (const fn of this.listeners.get(room.id) ?? []) fn(room); } private apply(room: Room, line: LedgerLine): void { switch (line.t) { case 'room': case 'over': break; case 'seat': room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot }); break; case 'galleryTalk': room.galleryTalk = line.on; break; case 'challenge': room.challenge = { by: line.by, at: line.at }; if (!room.expected.some((n) => n.toLowerCase() === line.keeper.toLowerCase())) room.expected = [...room.expected, line.keeper]; break; case 'rematch': room.rematch = { to: line.to, by: line.by }; break; case 'input': room.pending[line.id] = this.game.cleanInput(line.input); break; case 'unseat': 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.options); break; case 'turn': if (!room.state) return; room.state = this.game.resolve(room.state, line.inputs as Record); room.pending = {}; break; case 'chat': room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) }); if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP); break; } } 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); for (const line of lines) { this.apply(room, line); if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at; } return room; } }