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}`);
});