The game kit: the shared infrastructure of Wiz-War and Waving Hands as a template

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 11:00:05 -04:00
co-authored by Claude Fable 5.1
commit 1cd24e3ddd
59 changed files with 7420 additions and 0 deletions
+315
View File
@@ -0,0 +1,315 @@
// 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 { randomBytes } from 'node:crypto';
import { SPECTATOR, type GameSpec, type SeatId } 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;
token: string;
bot: boolean;
}
export interface Room<State> {
id: string;
size: number;
createdAt: number;
seats: Seat[];
state: State | null;
/** Inputs received for the round in progress, by seat. */
pending: Record<SeatId, unknown>;
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;
}
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<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>();
constructor(
private game: GameSpec<State, Input>,
private store: Store
) {
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<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) 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. */
seatOf(room: Room<State>, token: string | undefined): Seat {
const seat = room.seats.find((s) => !s.bot && s.token === token);
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
return seat;
}
create(name: string, size: number): { room: Room<State>; seat: Seat } {
const { minSeats } = this.game;
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
}
let id = newCode();
while (this.rooms.has(id)) id = newCode();
const room: Room<State> = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
this.rooms.set(id, room);
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
const seat = this.sit(room, name, false);
return { room, seat };
}
join(room: Room<State>, name: string): Seat {
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
return this.sit(room, name, false);
}
/** The host, who opened the table, begins once enough are seated. A full table begins by itself. */
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 });
}
addBot(room: Room<State>, token: string | undefined): Seat {
this.seatOf(room, token);
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);
}
private sit(room: Room<State>, rawName: string, bot: boolean): Seat {
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
const name = cleanName(rawName);
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
const seat: Seat = { id: this.game.seatIds[room.seats.length], name, token: bot ? '' : newId(18), bot };
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot });
if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
return seat;
}
/** Record a seat's move; resolve the round once nobody else is awaited. */
submit(room: Room<State>, 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);
room.pending[seat.id] = this.game.cleanInput(raw);
room.updatedAt = Date.now();
room.seq += 1;
if (this.waitingOn(room).length === 0) this.resolve(room);
this.notify(room);
}
private resolve(room: Room<State>): void {
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
const inputs: Record<SeatId, unknown> = { ...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<SeatId, unknown> = {};
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<State>, token: string | undefined, raw: unknown): void {
const seat = this.seatOf(room, token);
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.');
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text });
}
/** Seats whose input the next resolution needs and has not yet received. */
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))
.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> {
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
};
}
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':
case 'over':
break;
case 'seat':
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
break;
case 'start':
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
break;
case 'turn':
if (!room.state) return;
room.state = this.game.resolve(room.state, line.inputs as Record<SeatId, Input>);
room.pending = {};
break;
case 'chat':
room.chat.push({ id: line.id, text: line.text, at: line.at });
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: Room<State> = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
for (const line of lines) {
this.apply(room, line);
if (line.t === 'turn') room.updatedAt = line.at;
}
return room;
}
}