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
+124
View File
@@ -0,0 +1,124 @@
// The front door for duels between people. Plain HTTP for actions, a
// websocket only to say "something changed, fetch the view again".
//
// POST /api/rooms {name, size?} create a duel and take seat A
// POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may)
// GET /api/rooms/:id?token= the view for that seat
// POST /api/rooms/:id/turn {token, input} this seat's move
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes
//
// Caddy serves the static site and proxies /api and /ws here.
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import { RoomError, Rooms } from './rooms';
import { Store } from './store';
const PORT = Number(process.env.PORT ?? 8788);
const HOST = process.env.HOST ?? '127.0.0.1';
const DATA_DIR = process.env.WH_DATA_DIR ?? './data/rooms';
const BODY_LIMIT = 16 * 1024;
const rooms = new Rooms(new Store(DATA_DIR));
function send(res: ServerResponse, status: number, body: unknown): void {
const json = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(json);
}
function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > BODY_LIMIT) {
reject(new RoomError('That is more than a turn needs.', 413));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (chunks.length === 0) return resolve({});
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new RoomError('The request was not JSON.'));
}
});
req.on('error', reject);
});
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
if (parts.length === 2 && req.method === 'POST') {
const body = await readBody(req);
const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2));
return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
}
const room = rooms.get(parts[2] ?? '');
const action = parts[3];
if (!action && req.method === 'GET') {
const seat = rooms.seatOf(room, url.searchParams.get('token') ?? undefined);
return send(res, 200, rooms.view(room, seat.id));
}
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
const body = await readBody(req);
if (action === 'join') {
const seat = rooms.join(room, String(body.name ?? ''));
return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
}
if (action === 'bot') {
rooms.addBot(room, typeof body.token === 'string' ? body.token : undefined);
const seat = rooms.seatOf(room, body.token as string);
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'turn') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.submit(room, token, body.input);
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
throw new RoomError('Not here.', 404);
}
const server = createServer((req, res) => {
handle(req, res).catch((err: unknown) => {
if (err instanceof RoomError) return send(res, err.status, { error: err.message });
console.error(err);
send(res, 500, { error: 'Something went wrong on the server.' });
});
});
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (url.pathname !== '/ws') return socket.destroy();
let seatId: string;
let roomId: string;
try {
roomId = url.searchParams.get('room') ?? '';
seatId = rooms.seatOf(rooms.get(roomId), url.searchParams.get('token') ?? undefined).id;
} catch {
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
const unsubscribe = rooms.subscribe(roomId, (room) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: room.seq }));
});
ws.on('close', unsubscribe);
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
});
});
server.listen(PORT, HOST, () => {
console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
});
+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;
}
+42
View File
@@ -0,0 +1,42 @@
// One append-only JSONL file per room. A room is its ledger replayed from the
// top: the engine is deterministic given the seed and the recorded inputs, so
// nothing else needs saving.
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import type { TurnInput, WizardId } from '../../src/lib/game/state';
export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number }
| { t: 'seat'; id: WizardId; name: string; token: string; bot: boolean }
| { t: 'start'; seed: number }
| { t: 'turn'; at: number; inputs: Record<WizardId, TurnInput> };
export class Store {
constructor(private dir: string) {
mkdirSync(dir, { recursive: true });
}
private path(roomId: string): string {
return join(this.dir, `${roomId}.jsonl`);
}
append(roomId: string, line: LedgerLine): void {
appendFileSync(this.path(roomId), JSON.stringify(line) + '\n');
}
read(roomId: string): LedgerLine[] {
const path = this.path(roomId);
if (!existsSync(path)) return [];
return readFileSync(path, 'utf8')
.split('\n')
.filter((l) => l.trim())
.map((l) => JSON.parse(l) as LedgerLine);
}
roomIds(): string[] {
return readdirSync(this.dir)
.filter((f) => f.endsWith('.jsonl'))
.map((f) => f.slice(0, -'.jsonl'.length));
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "../src/lib/game/**/*.ts"],
"exclude": ["../src/lib/game/*.test.ts", "../src/lib/game/*.svelte.ts", "../src/lib/game/test-helpers.ts"]
}