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}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user