A duel server: rooms, seat tokens, ledgers, and resolution when all have moved

Plain HTTP for actions and a websocket that only says "fetch again". A
room is its append-only ledger replayed from the top; the engine is
deterministic given the seed and the recorded inputs. Each seat sees a
view filtered to what the rules let it know. Bot seats are filled at
resolution time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 16:54:29 -04:00
co-authored by Claude Fable 5.1
parent 95a1915cae
commit 8366c9d7ff
11 changed files with 1024 additions and 4 deletions
+246
View File
@@ -0,0 +1,246 @@
// 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 } 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;
}
/** What a seated player is told. Other seats' pending inputs and tokens never leave the server. */
export interface View {
roomId: string;
seq: number;
size: number;
me: WizardId;
seats: { id: WizardId; name: string; bot: boolean }[];
/** Seats that still have to move before the turn resolves. */
waitingOn: WizardId[];
state: GameState | null;
}
export class RoomError extends Error {
constructor(
message: string,
public status = 400
) {
super(message);
}
}
function newId(bytes: number): string {
return randomBytes(bytes).toString('base64url');
}
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);
if (!room) throw new RoomError('No such duel.', 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 = newId(6);
while (this.rooms.has(id)) id = newId(6);
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): View {
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;
}