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:
co-authored by
Claude Fable 5.1
parent
95a1915cae
commit
8366c9d7ff
@@ -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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user