Files
waving-hands/server/src/rooms.ts
T
Eric WagonerandClaude Fable 5.1 e1bda87987 Duels between people: rooms, the hall, and a games ledger
The store plays any seat, locally against the bot or remotely through
the server. A room has a four-letter code that is also its invite link;
the hall opens or joins one and lists the seats this browser holds with
whose move it is. The ledger and summary show every seat, threats name
their wizard, and the board is one component shared by the hall and the
room page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 17:07:51 -04:00

246 lines
9.1 KiB
TypeScript

// Rooms: who sits where, whose move it is, and the duel 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 turn resolves at once, bots included.
import { randomBytes } from 'node:crypto';
import { chooseBotTurn } from '../../src/lib/game/bot';
import { resolveTurn } from '../../src/lib/game/resolve';
import { GESTURES, type Gesture } from '../../src/lib/game/spells';
import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
import { viewFor, type RoomView } from '../../src/lib/game/view';
import type { LedgerLine, Store } from './store';
const SEAT_IDS: WizardId[] = ['A', 'B', 'C', 'D'];
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
export const MAX_SEATS = SEAT_IDS.length;
export interface Seat {
id: WizardId;
name: string;
token: string;
bot: boolean;
}
export interface Room {
id: string;
size: number;
createdAt: number;
seats: Seat[];
state: GameState | null;
/** Inputs received for the turn in progress, by seat. */
pending: Record<WizardId, TurnInput>;
/** 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('');
}
export function normalizeCode(raw: string): string {
return raw.trim().toUpperCase();
}
export class Rooms {
private rooms = new Map<string, Room>();
private listeners = new Map<string, Set<(room: Room) => void>>();
constructor(private store: Store) {
for (const id of store.roomIds()) {
const room = replay(id, store.read(id));
if (room) this.rooms.set(id, room);
}
}
get(id: string): Room {
const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id));
if (!room) throw new RoomError('No duel answers to that code.', 404);
return room;
}
/** The seat a token unlocks. */
seatOf(room: Room, 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 duel.', 403);
return seat;
}
create(name: string, size: number): { room: Room; seat: Seat } {
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A duel seats 2 to ${MAX_SEATS} wizards.`);
let id = newCode();
while (this.rooms.has(id)) id = newCode();
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, 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, name: string): Seat {
return this.sit(room, name, false);
}
addBot(room: Room, token: string | undefined): Seat {
this.seatOf(room, token);
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
const pool = BOT_NAMES.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, rawName: string, bot: boolean): Seat {
if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409);
const name = cleanName(rawName);
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409);
const seat: Seat = { id: SEAT_IDS[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: (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0 });
return seat;
}
/** Record a seat's move; resolve the turn 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 duel has not begun: seats are still empty.', 409);
if (room.state.over) throw new RoomError('The duel is over.', 409);
if (!waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
room.pending[seat.id] = cleanInput(raw);
room.updatedAt = Date.now();
room.seq += 1;
if (waitingOn(room).length === 0) this.resolve(room);
this.notify(room);
}
private resolve(room: Room): void {
const state = room.state!;
const inputs: Record<WizardId, TurnInput> = { ...room.pending };
for (const seat of room.seats) {
if (seat.bot && needsInput(state, seat.id)) inputs[seat.id] = chooseBotTurn(state, seat.id);
}
this.commit(room, { t: 'turn', at: Date.now(), inputs });
// A time stop owed to a bot resolves at once; a human's waits for them.
while (room.state && !room.state.over && room.state.timeStops[0] && room.seats.find((s) => s.id === room.state!.timeStops[0])?.bot) {
const actor = room.state.timeStops[0];
this.commit(room, { t: 'turn', at: Date.now(), inputs: { [actor]: chooseBotTurn(room.state, actor) } });
}
}
view(room: Room, seatId: WizardId): RoomView {
return {
roomId: room.id,
seq: room.seq,
size: room.size,
me: seatId,
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
waitingOn: waitingOn(room),
state: room.state ? viewFor(room.state, seatId) : null
};
}
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);
}
/** 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);
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);
}
}
/** Seats whose input the next resolution needs and has not yet received. */
export function waitingOn(room: Room): WizardId[] {
if (!room.state || room.state.over) return [];
return room.seats
.filter((s) => !s.bot && needsInput(room.state!, s.id) && !(s.id in room.pending))
.map((s) => s.id);
}
function needsInput(state: GameState, id: WizardId): boolean {
if (state.timeStops.length > 0) return state.timeStops[0] === id;
return inDuel(state.wizards[id]);
}
function apply(room: Room, line: LedgerLine): void {
switch (line.t) {
case 'room':
break;
case 'seat':
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
break;
case 'start':
room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed);
break;
case 'turn':
if (!room.state) return;
room.state = resolveTurn(room.state, line.inputs);
room.pending = {};
break;
}
}
function replay(id: string, lines: LedgerLine[]): Room | null {
const first = lines[0];
if (!first || first.t !== 'room') return null;
const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, seq: lines.length, updatedAt: first.createdAt };
for (const line of lines) {
apply(room, line);
if (line.t === 'turn') room.updatedAt = line.at;
}
return room;
}
export function cleanName(raw: unknown): string {
const name = String(raw ?? '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 24);
if (!name) throw new RoomError('A wizard needs a name.');
return name;
}
/** Only the shapes the engine understands get through; the engine validates the rest. */
function cleanInput(raw: unknown): TurnInput {
const r = (raw ?? {}) as Record<string, unknown>;
const gesture = (g: unknown): Gesture => (GESTURES.includes(g as Gesture) ? (g as Gesture) : '-');
const casts = Array.isArray(r.casts) ? (r.casts as TurnInput['casts']).slice(0, 4) : [];
const input: TurnInput = { left: gesture(r.left), right: gesture(r.right), casts };
if (r.second && typeof r.second === 'object') {
const s = r.second as Record<string, unknown>;
input.second = { left: gesture(s.left), right: gesture(s.right), casts: Array.isArray(s.casts) ? (s.casts as TurnInput['casts']).slice(0, 4) : [] };
if (typeof s.stabTarget === 'string') input.second.stabTarget = s.stabTarget;
}
if (r.release && typeof r.release === 'object') input.release = r.release as TurnInput['release'];
if (typeof r.stabTarget === 'string') input.stabTarget = r.stabTarget;
if (r.monsterOrders && typeof r.monsterOrders === 'object') input.monsterOrders = r.monsterOrders as TurnInput['monsterOrders'];
if (typeof r.charmGesture === 'string') input.charmGesture = gesture(r.charmGesture);
return input;
}