// 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; /** 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(); private listeners = new Map 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 = { ...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; 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; 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; }