A room's link without a seat is now a place in the gallery: a tokenless view that withholds any gesture hidden from anyone at the table, counted for the table and the lobby. Seated wizards can talk; each line is a ledger line, shown in the rail and in the lobby, and the hall counts what was said while you were away. The hall's pitch no longer says two wizards. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
335 lines
13 KiB
TypeScript
335 lines
13 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, SPECTATOR, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
|
|
import { viewFor, type ChatLine, type RoomView } from '../../src/lib/game/view';
|
|
import type { LedgerLine, Store } from './store';
|
|
|
|
/** A dozen seats at most: a turn resolves only when every seat has moved, and the slowest sets the pace. */
|
|
const SEAT_IDS: WizardId[] = 'ABCDEFGHJKLM'.split('');
|
|
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura', 'Bellamy', 'Hesper', 'Lucan', 'Wynne'];
|
|
export const MAX_SEATS = SEAT_IDS.length;
|
|
export { SPECTATOR };
|
|
const CHAT_MAX_LENGTH = 300;
|
|
/** Lines of talk kept and sent; the ledger keeps them all. */
|
|
const CHAT_KEEP = 200;
|
|
|
|
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>;
|
|
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 class Rooms {
|
|
private rooms = new Map<string, Room>();
|
|
private listeners = new Map<string, Set<(room: Room) => void>>();
|
|
/** Sockets watching from the gallery, by room. */
|
|
private gallery = new Map<string, number>();
|
|
|
|
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)) ?? this.load(normalizeCode(id));
|
|
if (!room) throw new RoomError('No duel 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 = 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;
|
|
}
|
|
|
|
/** How many rooms are held in memory. */
|
|
get loaded(): number {
|
|
return this.rooms.size;
|
|
}
|
|
|
|
/** 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 table 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: {}, 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, name: string): Seat {
|
|
if (room.state) throw new RoomError('The duel has begun; no more seats are taken.', 409);
|
|
return this.sit(room, name, false);
|
|
}
|
|
|
|
/** The host, who opened the table, begins the duel once at least two are seated. A full table begins by itself. */
|
|
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 wizard who opened the table may begin the duel.', 403);
|
|
if (room.state) throw new RoomError('The duel has already begun.', 409);
|
|
if (room.seats.length < 2) throw new RoomError('A duel needs at least two wizards.', 409);
|
|
this.commit(room, { t: 'start', seed: newSeed() });
|
|
}
|
|
|
|
addBot(room: Room, token: string | undefined): Seat {
|
|
this.seatOf(room, token);
|
|
if (room.state) throw new RoomError('The duel has begun; no more seats are taken.', 409);
|
|
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: newSeed() });
|
|
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) } });
|
|
}
|
|
if (room.state?.over) this.commit(room, { t: 'over', at: Date.now(), winner: room.state.over.winner, reason: room.state.over.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);
|
|
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 });
|
|
}
|
|
|
|
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
|
|
view(room: Room, seatId: WizardId): 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: waitingOn(room),
|
|
state: room.state ? viewFor(room.state, seatId) : null,
|
|
chat: room.chat,
|
|
audience: this.gallery.get(room.id) ?? 0
|
|
};
|
|
}
|
|
|
|
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);
|
|
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;
|
|
case 'over':
|
|
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;
|
|
}
|
|
}
|
|
|
|
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: {}, chat: [], 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;
|
|
}
|