Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
668 lines
31 KiB
TypeScript
668 lines
31 KiB
TypeScript
// 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 { 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';
|
|
|
|
/** The longest line of talk. A game of long negotiation raises this, and the talk box's maxlength with it. */
|
|
const CHAT_MAX_LENGTH = 300;
|
|
/** Lines of talk kept and sent; the ledger keeps them all. A game that runs for weeks of talk raises this. */
|
|
const CHAT_KEEP = 200;
|
|
/** Bots' rounds played in one go, when nobody else owes a move, before the rest are paced. */
|
|
const PLAYED_AT_ONCE = 4;
|
|
/** Between paced bots' rounds: long enough to watch the table move. */
|
|
const PACE_MS = 2000;
|
|
/** Bots' rounds played between answers to everyone else, once hurried. */
|
|
const HURRIED_STEP = 3;
|
|
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;
|
|
}
|
|
|
|
function hashToken(token: string): string {
|
|
return createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
export interface Room<State> {
|
|
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;
|
|
/** The over line has been written. */
|
|
over: boolean;
|
|
state: State | null;
|
|
/** Inputs received for the round in progress, by seat. */
|
|
pending: Record<SeatId, unknown>;
|
|
/** Each seat's ranking of the table's choices, where the table asks for one; secret before the deal and after. */
|
|
rankings: Record<SeatId, string[]>;
|
|
/** Each seat's input in the last round it played, kept through rounds it had no part in; each seat is shown only its own. */
|
|
played: Record<SeatId, unknown>;
|
|
chat: ChatLine[];
|
|
/** Rounds played so far; a move names the count it was made against. */
|
|
resolved: number;
|
|
/** 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;
|
|
|
|
function normalizeCode(raw: string): string {
|
|
return raw.trim().toUpperCase();
|
|
}
|
|
|
|
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<State, Input> {
|
|
private rooms = new Map<string, Room<State>>();
|
|
private listeners = new Map<string, Set<(room: Room<State>) => void>>();
|
|
/** Sockets watching from the gallery, by room. */
|
|
private gallery = new Map<string, number>();
|
|
/** Phrases waiting to be claimed, with the seat and the token they carry. */
|
|
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
|
|
private failedClaims = 0;
|
|
private failWindowStart = 0;
|
|
/** Tables whose bots are playing on, paced: the next round's timer. */
|
|
private pacing = new Map<string, ReturnType<typeof setTimeout>>();
|
|
/** Tables asked to play the rest at once. */
|
|
private hurried = new Set<string>();
|
|
|
|
constructor(
|
|
private game: GameSpec<State, Input>,
|
|
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()) {
|
|
let room: Room<State> | null;
|
|
try {
|
|
room = this.replay(id, store.read(id));
|
|
} catch (e) {
|
|
// One unreadable ledger (a torn last line) keeps its room closed, not the server down.
|
|
console.error(`room ${id}: ledger unreadable, left unloaded: ${e instanceof Error ? e.message : String(e)}`);
|
|
continue;
|
|
}
|
|
if (!room) continue;
|
|
this.rooms.set(id, room);
|
|
// A table that stopped with a bot's move owed picks up where it left off.
|
|
if (room.state) this.driveBots(room);
|
|
}
|
|
}
|
|
|
|
get maxSeats(): number {
|
|
return this.game.seatIds.length;
|
|
}
|
|
|
|
get(id: string): Room<State> {
|
|
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<State> | undefined {
|
|
if (!/^[A-Z0-9]{4}$/.test(id)) return undefined;
|
|
const room = this.replay(id, this.store.read(id));
|
|
if (!room) return undefined;
|
|
this.rooms.set(id, room);
|
|
if (room.state) this.driveBots(room);
|
|
return room;
|
|
}
|
|
|
|
/** 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<State>, 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<State>; seat: Seat; token: string } {
|
|
const { minSeats } = this.game;
|
|
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, 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<State> {
|
|
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, played: {}, rankings: {}, chat: [], resolved: 0, 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<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 ?? [], 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<State>, 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<State>): 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<State>, 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<State>, 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<State>, 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<State>, 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 };
|
|
}
|
|
|
|
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
|
|
transfer(room: Room<State>, 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<State>, 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, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) });
|
|
// A game whose first move belongs to a bot must not wait for a human who is not owed a move.
|
|
this.driveBots(room);
|
|
this.botsSpeak(room, 'phase');
|
|
}
|
|
|
|
/**
|
|
* Rounds only bots owe (an opening move, an extra turn, a table the
|
|
* people are out of) resolve without waiting; a human's waits for them.
|
|
* A few are played at once; past that, the bots play on one round at a
|
|
* time, so the table can be watched and the server answers everyone else
|
|
* between rounds. Hurried, they play in larger steps.
|
|
*/
|
|
private driveBots(room: Room<State>, burst = PLAYED_AT_ONCE): void {
|
|
if (this.pacing.has(room.id)) return;
|
|
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
|
|
let played = 0;
|
|
while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) {
|
|
if (played === burst) return this.playOnLater(room);
|
|
const inputs: Record<SeatId, unknown> = {};
|
|
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id, this.heardBy(room, seat.id));
|
|
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
|
played += 1;
|
|
}
|
|
this.hurried.delete(room.id);
|
|
const outcome = room.state && this.game.over(room.state);
|
|
if (outcome && !room.over) this.commit(room, { t: 'over', at: Date.now(), winner: outcome.winner, reason: outcome.reason });
|
|
}
|
|
|
|
/** The bots' next round, after a pause to watch it by, or at once when hurried. */
|
|
private playOnLater(room: Room<State>): void {
|
|
const hurried = this.hurried.has(room.id);
|
|
const next = setTimeout(() => {
|
|
this.pacing.delete(room.id);
|
|
// A room forgotten and loaded again drives itself; this one is no longer the table.
|
|
if (this.rooms.get(room.id) !== room) return;
|
|
this.driveBots(room, hurried ? HURRIED_STEP : 1);
|
|
// Played out: the bots have their say once, as after any round, which ends a game with parting words.
|
|
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
|
|
}, hurried ? 0 : PACE_MS);
|
|
next.unref();
|
|
this.pacing.set(room.id, next);
|
|
}
|
|
|
|
/** A seat at a table the bots are playing on asks for the rest at once. */
|
|
hurry(room: Room<State>, token: string | undefined): void {
|
|
this.seatOf(room, token);
|
|
this.hurried.add(room.id);
|
|
const waiting = this.pacing.get(room.id);
|
|
if (!waiting) return;
|
|
clearTimeout(waiting);
|
|
this.pacing.delete(room.id);
|
|
this.playOnLater(room);
|
|
}
|
|
|
|
/** Whether the bots are playing on by themselves at this table, one paced round at a time. */
|
|
playingOn(room: Room<State>): boolean {
|
|
return this.pacing.has(room.id);
|
|
}
|
|
|
|
/** The host seats a bot in an empty chair. */
|
|
addBot(room: Room<State>, 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<State>, 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<State>, 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 });
|
|
}
|
|
|
|
/** A seat's ranking of the choices the table asks for, best first, before the game begins. */
|
|
rank(room: Room<State>, token: string | undefined, raw: unknown): void {
|
|
const seat = this.seatOf(room, token);
|
|
if (room.state) throw new RoomError('The game has begun; the ranking is closed.', 409);
|
|
const choices = this.game.rankable?.(room.options);
|
|
if (!choices) throw new RoomError('This table asks for no ranking.', 409);
|
|
const ranking = Array.isArray(raw) ? raw.map(String) : [];
|
|
const values = choices.map((c) => c.value);
|
|
if (ranking.length !== values.length || new Set(ranking).size !== values.length || !ranking.every((r) => values.includes(r))) throw new RoomError('Rank every choice once.');
|
|
this.commit(room, { t: 'rank', id: seat.id, ranking });
|
|
}
|
|
|
|
/**
|
|
* Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited.
|
|
* A draft (an input the game does not call settled) may be replaced, and a settled move taken back
|
|
* as a draft, until the round resolves.
|
|
* `resolved` is how many rounds the page had seen played when the move was made: a move that
|
|
* arrives after its round was played (a slow connection, a second click, a stale tab) is refused
|
|
* rather than taken as the move for the next round.
|
|
*/
|
|
submit(room: Room<State>, token: string | undefined, raw: unknown, resolved: 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 (typeof resolved !== 'number') throw new RoomError('This page is out of date. Reload it to see the table as it stands.', 409);
|
|
if (resolved !== room.resolved) throw new RoomError('That move was made for a turn that has already been played. Your page will catch up.', 409);
|
|
|
|
if (!this.game.needsInput(room.state, seat.id)) throw new RoomError('It is not your move.', 409);
|
|
const input = this.game.cleanInput(raw);
|
|
// A settled move is final; a draft may replace it while the round still waits on others.
|
|
if (seat.id in room.pending && this.settled(room.pending[seat.id]) && this.settled(input)) throw new RoomError('Your move is already in.', 409);
|
|
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<State>): void {
|
|
const inputs: Record<SeatId, unknown> = { ...room.pending };
|
|
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id, this.heardBy(room, seat.id));
|
|
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
|
this.driveBots(room);
|
|
this.botsSpeak(room, 'phase');
|
|
}
|
|
|
|
/**
|
|
* Bots' talk, for a game that gives them words: when a round opens, and to
|
|
* a person who has just written to them. Their lines go on the ledger like
|
|
* anyone's, so a replay repeats them rather than asking again.
|
|
*/
|
|
private botsSpeak(room: Room<State>, trigger: 'phase' | 'spoken', only?: SeatId[]): void {
|
|
if (!this.game.botPress || !room.state) return;
|
|
const people = room.seats.filter((s) => !s.bot).map((s) => s.id);
|
|
for (const seat of room.seats) {
|
|
if (!seat.bot || (only && !only.includes(seat.id))) continue;
|
|
for (const line of this.game.botPress(room.state, seat.id, this.heardBy(room, seat.id), people, trigger)) {
|
|
const to = line.to.filter((id) => people.includes(id));
|
|
if (!to.length || !line.text.trim()) continue;
|
|
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(line.text), to, ...(line.note !== undefined ? { note: line.note } : {}) });
|
|
}
|
|
}
|
|
}
|
|
|
|
/** The talk a seat has heard: the table's, and private lines to it or from it. */
|
|
private heardBy(room: Room<State>, seatId: SeatId): ChatLine[] {
|
|
return room.chat.filter((line) => !line.to || line.id === seatId || line.to.includes(seatId));
|
|
}
|
|
|
|
/** Whether an input is final; a game without drafts makes every input final. */
|
|
private settled(input: unknown): boolean {
|
|
return this.game.settled ? this.game.settled(input as Input) : true;
|
|
}
|
|
|
|
/**
|
|
* Table talk, from a seat: to everyone at the table and in the gallery,
|
|
* or, naming seats, privately to them alone. A private line is never
|
|
* shown to anyone else, the gallery included.
|
|
*/
|
|
say(room: Room<State>, token: string | undefined, raw: unknown, rawTo?: unknown): void {
|
|
const seat = this.seatOf(room, token);
|
|
const to = Array.isArray(rawTo)
|
|
? [...new Set(rawTo.filter((id): id is SeatId => typeof id === 'string' && id !== seat.id && room.seats.some((s) => s.id === id)))]
|
|
: [];
|
|
if (Array.isArray(rawTo) && rawTo.length && !to.length) throw new RoomError('Nobody at this table answers to that.');
|
|
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id)?.bot);
|
|
if (botsTo.length && !this.game.botPress) throw new RoomError('The bots at this table read no private talk.');
|
|
// A person's first word of the game, said to everyone, is heard by the bots too: the game chooses who answers.
|
|
const first = !to.length && !room.chat.some((l) => l.id === seat.id);
|
|
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw), ...(to.length ? { to } : {}) });
|
|
if (botsTo.length) this.botsSpeak(room, 'spoken', botsTo);
|
|
else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot).map((s) => s.id));
|
|
}
|
|
|
|
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 settled; a draft still waits. */
|
|
waitingOn(room: Room<State>): 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 && this.settled(room.pending[s.id])))
|
|
.map((s) => s.id);
|
|
}
|
|
|
|
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
|
|
view(room: Room<State>, seatId: SeatId): RoomView<State> {
|
|
const seated = seatId !== SPECTATOR;
|
|
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,
|
|
resolved: room.resolved,
|
|
over: room.state ? this.game.over(room.state) : null,
|
|
state: room.state ? this.game.view(room.state, seatId) : null,
|
|
yourRanking: seated ? (room.rankings[seatId] ?? null) : null,
|
|
ranked: Object.keys(room.rankings).length,
|
|
yourInput: seated && seatId in room.pending ? room.pending[seatId] : null,
|
|
yourLastInput: seated && seatId in room.played ? room.played[seatId] : null,
|
|
// Private lines only to their sender and the seats they name; a note is a bot's own memory, never sent.
|
|
chat: this.heardBy(room, seatId).map(({ note: _, ...line }) => line),
|
|
parting: room.state && seated && this.game.parting && this.game.over(room.state) ? this.game.parting(room.chat, seatId) : [],
|
|
playingOn: this.playingOn(room),
|
|
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<State>) => 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<State>, fn: (room: Room<State>) => 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<State>): 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<State>, 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<State>): void {
|
|
for (const fn of this.listeners.get(room.id) ?? []) fn(room);
|
|
}
|
|
|
|
private apply(room: Room<State>, line: LedgerLine): void {
|
|
switch (line.t) {
|
|
case 'room':
|
|
break;
|
|
case 'over':
|
|
room.over = true;
|
|
break;
|
|
case 'seat':
|
|
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, 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 'rank':
|
|
room.rankings[line.id] = line.ranking;
|
|
break;
|
|
case 'unseat':
|
|
room.seats = room.seats.filter((s) => s.id !== line.id);
|
|
delete room.rankings[line.id];
|
|
break;
|
|
case 'start':
|
|
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options, line.rankings);
|
|
break;
|
|
case 'turn':
|
|
if (!room.state) return;
|
|
room.state = this.game.resolve(room.state, line.inputs as Record<SeatId, Input>);
|
|
room.played = { ...room.played, ...(line.inputs as Record<SeatId, unknown>) };
|
|
room.pending = {};
|
|
room.resolved += 1;
|
|
break;
|
|
case 'chat':
|
|
room.chat.push({
|
|
id: line.id,
|
|
text: line.text,
|
|
at: line.at,
|
|
...(room.state ? { turn: this.game.turn(room.state) } : {}),
|
|
...(line.name ? { name: line.name } : {}),
|
|
...(line.to ? { to: line.to } : {}),
|
|
...(line.note !== undefined ? { note: line.note } : {})
|
|
});
|
|
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
|
break;
|
|
}
|
|
}
|
|
|
|
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 ?? [], 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;
|
|
}
|
|
return room;
|
|
}
|
|
}
|