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>
125 lines
4.6 KiB
TypeScript
125 lines
4.6 KiB
TypeScript
// 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}`);
|
|
});
|