Begin Hnefatafl from the game kit
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
// The front door for games between people. Plain HTTP for actions, a
|
||||
// websocket only to say "something changed, fetch the view again".
|
||||
//
|
||||
// POST /api/rooms {name, size?, options?} create a room and take seat A; options are the game's table choices
|
||||
// POST /api/rooms/:id/join {name} take the next seat
|
||||
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
|
||||
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
||||
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
|
||||
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
|
||||
// POST /api/rooms/:id/turn {token, input} this seat's move
|
||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
|
||||
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
|
||||
// POST /api/reports/mine {seats: [{roomId, token}]} your reports and the keeper's replies
|
||||
// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply
|
||||
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
|
||||
// a seat in the Peanut Gallery, counted for the table
|
||||
//
|
||||
// Caddy serves the static site and proxies /api and /ws here.
|
||||
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { dirname } from 'node:path';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { game } from '../../src/lib/game';
|
||||
import { RateLimit } from './ratelimit';
|
||||
import { IMAGE_MAX_BYTES, Reports } from './reports';
|
||||
import { RoomError, Rooms, SPECTATOR } from './rooms';
|
||||
import { Store } from './store';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? '8789');
|
||||
const HOST = process.env.HOST ?? '127.0.0.1';
|
||||
const DATA_DIR = process.env.DATA_DIR ?? '../data/rooms';
|
||||
const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://hnefatafl.kestrelsnest.social').replace(/\/$/, '');
|
||||
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
|
||||
const KEEPER = process.env.KEEPER ?? 'the keeper';
|
||||
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
|
||||
const BODY_LIMIT = 16 * 1024;
|
||||
|
||||
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({ dsn: process.env.SENTRY_DSN, environment: 'production', tracesSampleRate: 0 });
|
||||
}
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error(reason);
|
||||
Sentry.captureException(reason);
|
||||
});
|
||||
|
||||
const rooms = new Rooms(game, new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ });
|
||||
/** Reports live beside the room ledgers, not among them. */
|
||||
const reports = new Reports(dirname(DATA_DIR));
|
||||
/** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */
|
||||
const doors = new RateLimit(40, 60 * 60 * 1000);
|
||||
/** Table talk: a lively table says a few lines a minute, not hundreds. */
|
||||
const voices = new RateLimit(240, 60 * 60 * 1000);
|
||||
/** Reports, answers and pictures: a few an hour from one address; each rings the keeper's phone. */
|
||||
const desk = new RateLimit(6, 60 * 60 * 1000);
|
||||
/** The gallery talks under a tighter rein than the table: no seat, no token, one address. */
|
||||
const galleryVoices = new RateLimit(30, 10 * 60 * 1000);
|
||||
/** Calls to the keeper: a real person's phone rings for each. */
|
||||
const bells = new RateLimit(3, 60 * 60 * 1000);
|
||||
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
|
||||
const claims = new RateLimit(10, 10 * 60 * 1000);
|
||||
const MAX_AUDIENCE = 30;
|
||||
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
const n = rooms.evictIdle(IDLE_ROOM_MS);
|
||||
doors.prune();
|
||||
voices.prune();
|
||||
desk.prune();
|
||||
galleryVoices.prune();
|
||||
bells.prune();
|
||||
claims.prune();
|
||||
if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
/** The visitor's address as Caddy reports it, or the socket's when unproxied. */
|
||||
function clientOf(req: IncomingMessage): string {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0].trim();
|
||||
return first || req.socket.remoteAddress || '?';
|
||||
}
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(status === 204 ? undefined : JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readBytes(req: IncomingMessage, limit: number, tooBig: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const declared = Number(req.headers['content-length'] ?? 0);
|
||||
if (declared > limit) {
|
||||
reject(new RoomError(tooBig, 413));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > limit) {
|
||||
reject(new RoomError(tooBig, 413));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const bytes = await readBytes(req, BODY_LIMIT, 'That is more than a move needs.');
|
||||
if (bytes.length === 0) return {};
|
||||
try {
|
||||
return JSON.parse(bytes.toString('utf8')) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new RoomError('The request was not JSON.');
|
||||
}
|
||||
}
|
||||
|
||||
/** A report, an answer, or a call to the table is worth waking the keeper for; the desk's own replies are not. */
|
||||
function ringBell(title: string, detail: { fingerprint: string[]; tags: Record<string, string>; extra: Record<string, unknown> }): void {
|
||||
Sentry.captureMessage(title, { level: 'error', ...detail });
|
||||
}
|
||||
|
||||
/** The seats a browser claims, kept to the ones its tokens prove. */
|
||||
function provenSeats(raw: unknown): { roomId: string; seat: string }[] {
|
||||
const claims = Array.isArray(raw) ? raw.slice(0, 50) : [];
|
||||
const proven: { roomId: string; seat: string }[] = [];
|
||||
for (const claim of claims) {
|
||||
if (typeof claim !== 'object' || claim === null) continue;
|
||||
const c = claim as Record<string, unknown>;
|
||||
try {
|
||||
const room = rooms.get(String(c.roomId ?? ''));
|
||||
proven.push({ roomId: room.id, seat: rooms.seatOf(room, typeof c.token === 'string' ? c.token : undefined).id });
|
||||
} catch {
|
||||
// A seat this browser cannot prove is not its business.
|
||||
}
|
||||
}
|
||||
return proven;
|
||||
}
|
||||
|
||||
const DESK_BUSY = 'The desk has plenty from here for now; more in an hour.';
|
||||
|
||||
async function handleReports(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
|
||||
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
|
||||
if (parts[2] === 'mine' && parts.length === 3) {
|
||||
const body = await readBody(req);
|
||||
return send(res, 200, { reports: reports.mine(provenSeats(body.seats)) });
|
||||
}
|
||||
const report = reports.find(parts[2] ?? '');
|
||||
if (!report) throw new RoomError('No such report.', 404);
|
||||
if (parts[3] === 'image') {
|
||||
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
||||
reports.attachImage(report, await readBytes(req, IMAGE_MAX_BYTES, 'A picture of 2.5 MB at most.'));
|
||||
return send(res, 204, {});
|
||||
}
|
||||
if (parts[3] === 'answer') {
|
||||
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
||||
const body = await readBody(req);
|
||||
const room = rooms.get(String(body.roomId ?? report.roomId));
|
||||
const seat = rooms.seatOf(room, typeof body.token === 'string' ? body.token : undefined);
|
||||
if (room.id !== report.roomId) throw new RoomError('That report is not yours to answer.', 403);
|
||||
reports.answer(report, seat, body.text);
|
||||
ringBell(`${seat.name} answers on report ${report.id} (${room.id}): ${String(body.text ?? '').slice(0, 100)}`, {
|
||||
fingerprint: ['report-answer', report.id, new Date().toISOString()],
|
||||
tags: { room: room.id, report: report.id, player: seat.name },
|
||||
extra: { text: body.text, link: `${PUBLIC_URL}/join/${room.id}` }
|
||||
});
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
throw new RoomError('Not here.', 404);
|
||||
}
|
||||
|
||||
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] === 'reports') return handleReports(req, res, parts);
|
||||
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
|
||||
if (!claims.allow(clientOf(req))) throw new RoomError('Too many claims from here just now; try again later.', 429);
|
||||
const body = await readBody(req);
|
||||
return send(res, 200, rooms.claim(body.phrase));
|
||||
}
|
||||
if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
|
||||
|
||||
if (parts.length === 2 && req.method === 'POST') {
|
||||
if (!doors.allow(clientOf(req))) throw new RoomError('Too many rooms opened from here just now; try again later.', 429);
|
||||
const body = await readBody(req);
|
||||
const { room, seat, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats), body.options);
|
||||
return send(res, 201, { seat: seat.id, token, view: rooms.view(room, seat.id) });
|
||||
}
|
||||
|
||||
const room = rooms.get(parts[2] ?? '');
|
||||
const action = parts[3];
|
||||
if (!action && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return send(res, 200, rooms.view(room, SPECTATOR));
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
|
||||
const body = await readBody(req);
|
||||
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
|
||||
if (action === 'join') {
|
||||
if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429);
|
||||
const { seat, token: minted } = rooms.join(room, String(body.name ?? ''));
|
||||
return send(res, 200, { seat: seat.id, token: minted, view: rooms.view(room, seat.id) });
|
||||
}
|
||||
if (action === 'bot') {
|
||||
rooms.addBot(room, token);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'unseat') {
|
||||
rooms.unseat(room, token, body.seat);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'begin') {
|
||||
rooms.begin(room, token);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'turn') {
|
||||
rooms.submit(room, token, body.input);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'say') {
|
||||
if (!token) {
|
||||
if (!galleryVoices.allow(clientOf(req))) throw new RoomError('The gallery has said plenty from here for now.', 429);
|
||||
rooms.sayFromGallery(room, body.name, body.text);
|
||||
return send(res, 200, rooms.view(room, SPECTATOR));
|
||||
}
|
||||
if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429);
|
||||
rooms.say(room, token, body.text);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'gallery') {
|
||||
rooms.setGalleryTalk(room, token, body.on === true);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'challenge') {
|
||||
if (!bells.allow(clientOf(req))) throw new RoomError('The keeper has been called enough from here for one hour.', 429);
|
||||
const seat = rooms.seatOf(room, token);
|
||||
rooms.callKeeper(room, token, KEEPER);
|
||||
const link = `${PUBLIC_URL}/join/${room.id}`;
|
||||
// One issue per table, so each call rings once, with the door in the message.
|
||||
ringBell(`${seat.name} challenges ${KEEPER} to a game — ${link}`, {
|
||||
fingerprint: ['challenge', room.id],
|
||||
tags: { room: room.id, challenger: seat.name },
|
||||
extra: { link, players: room.seats.map((s) => s.name).join(', ') }
|
||||
});
|
||||
return send(res, 200, rooms.view(room, seat.id));
|
||||
}
|
||||
if (action === 'rematch') {
|
||||
const next = rooms.rematch(room, token);
|
||||
return send(res, next.created ? 201 : 200, { roomId: next.roomId, seat: next.seat?.id ?? null, token: next.token, created: next.created });
|
||||
}
|
||||
if (action === 'transfer') {
|
||||
return send(res, 200, rooms.transfer(room, token));
|
||||
}
|
||||
if (action === 'report') {
|
||||
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
||||
const seat = token ? rooms.seatOf(room, token) : null;
|
||||
const turn = room.state ? game.turn(room.state) : null;
|
||||
const report = reports.file(room, seat, turn, body.happened, body.expected);
|
||||
ringBell(`Report from ${report.player} in ${room.id}: ${report.happened.slice(0, 100)}`, {
|
||||
fingerprint: ['report', report.id],
|
||||
tags: { room: room.id, report: report.id, player: report.player },
|
||||
extra: { happened: report.happened, expected: report.expected, turn: report.turn, seq: report.seq, link: `${PUBLIC_URL}/join/${room.id}` }
|
||||
});
|
||||
return send(res, 201, { id: report.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);
|
||||
Sentry.captureException(err, { extra: { url: req.url, method: req.method } });
|
||||
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 room: ReturnType<typeof rooms.get>;
|
||||
try {
|
||||
room = rooms.get(url.searchParams.get('room') ?? '');
|
||||
const token = url.searchParams.get('token');
|
||||
seatId = token ? rooms.seatOf(room, token).id : SPECTATOR;
|
||||
if (seatId === SPECTATOR && rooms.audience(room) >= MAX_AUDIENCE) throw new RoomError('The gallery is full.', 429);
|
||||
} catch {
|
||||
return socket.destroy();
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
|
||||
const onChange = (changed: ReturnType<typeof rooms.get>) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: changed.seq }));
|
||||
};
|
||||
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
|
||||
ws.on('close', unsubscribe);
|
||||
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`hnefatafl server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// A sliding-window count per key, for the doors a stranger can knock on.
|
||||
|
||||
export class RateLimit {
|
||||
private hits = new Map<string, number[]>();
|
||||
|
||||
constructor(
|
||||
private limit: number,
|
||||
private windowMs: number
|
||||
) {}
|
||||
|
||||
/** True if the key may act now; the act is recorded when it is allowed. */
|
||||
allow(key: string, now = Date.now()): boolean {
|
||||
const since = now - this.windowMs;
|
||||
const recent = (this.hits.get(key) ?? []).filter((t) => t > since);
|
||||
if (recent.length >= this.limit) {
|
||||
this.hits.set(key, recent);
|
||||
return false;
|
||||
}
|
||||
recent.push(now);
|
||||
this.hits.set(key, recent);
|
||||
if (this.hits.size > 10000) this.prune(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
prune(now = Date.now()): void {
|
||||
const since = now - this.windowMs;
|
||||
for (const [key, times] of this.hits) {
|
||||
if (!times.some((t) => t > since)) this.hits.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// The reports desk: what a player says went wrong, pinned to the room and
|
||||
// the turn so the moment can be replayed. One JSONL file beside the room
|
||||
// ledgers; replies from the keeper and answers from the player fold onto
|
||||
// the report they name, and a screenshot sits in a folder next to it.
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { SeatId } from '../../src/lib/game/spec';
|
||||
import { RoomError, type Room, type Seat } from './rooms';
|
||||
|
||||
export const TEXT_MAX = 2000;
|
||||
export const IMAGE_MAX_BYTES = 2_500_000;
|
||||
/** How long after filing a picture may still be attached. */
|
||||
const IMAGE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export interface ReportLine {
|
||||
at: string;
|
||||
text: string;
|
||||
from: 'desk' | 'player';
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
at: string;
|
||||
roomId: string;
|
||||
/** The reporter's name, or "(gallery)" for a watcher. */
|
||||
player: string;
|
||||
seat: SeatId | null;
|
||||
/** The round being written when the report was filed, and the ledger's length. */
|
||||
turn: number | null;
|
||||
seq: number;
|
||||
happened: string;
|
||||
expected: string;
|
||||
/** The whole exchange in order: the keeper's replies and the player's answers. */
|
||||
thread: ReportLine[];
|
||||
/** A screenshot's file name under images/, when one was sent. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export function cleanText(raw: unknown): string {
|
||||
return String(raw ?? '')
|
||||
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ')
|
||||
.trim()
|
||||
.slice(0, TEXT_MAX);
|
||||
}
|
||||
|
||||
export class Reports {
|
||||
private path: string;
|
||||
private imageDir: string;
|
||||
|
||||
constructor(dir: string) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
this.path = join(dir, 'feedback.jsonl');
|
||||
this.imageDir = join(dir, 'feedback-images');
|
||||
}
|
||||
|
||||
private append(entry: Record<string, unknown>): void {
|
||||
appendFileSync(this.path, JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
read(): Report[] {
|
||||
if (!existsSync(this.path)) return [];
|
||||
const reports = new Map<string, Report>();
|
||||
for (const raw of readFileSync(this.path, 'utf8').split('\n')) {
|
||||
if (!raw.trim()) continue;
|
||||
let line: Record<string, unknown>;
|
||||
try {
|
||||
line = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof line.reportId === 'string') {
|
||||
const report = reports.get(line.reportId);
|
||||
if (!report) continue;
|
||||
if (typeof line.image === 'string') report.image = line.image;
|
||||
else if (line.from === 'player') report.thread.push({ at: String(line.at ?? ''), text: String(line.text ?? ''), from: 'player' });
|
||||
else report.thread.push({ at: String(line.at ?? ''), text: String(line.text ?? ''), from: 'desk', status: String(line.status ?? 'open') });
|
||||
continue;
|
||||
}
|
||||
if (typeof line.id !== 'string') continue;
|
||||
reports.set(line.id, {
|
||||
id: line.id,
|
||||
at: String(line.at ?? ''),
|
||||
roomId: String(line.roomId ?? ''),
|
||||
player: String(line.player ?? '?'),
|
||||
seat: typeof line.seat === 'string' ? line.seat : null,
|
||||
turn: typeof line.turn === 'number' ? line.turn : null,
|
||||
seq: Number(line.seq ?? 0),
|
||||
happened: String(line.happened ?? ''),
|
||||
expected: String(line.expected ?? ''),
|
||||
thread: []
|
||||
});
|
||||
}
|
||||
return [...reports.values()];
|
||||
}
|
||||
|
||||
find(id: string): Report | undefined {
|
||||
return this.read().find((r) => r.id === id);
|
||||
}
|
||||
|
||||
/** File a report from a seat, or from the gallery when there is none, pinned to the round being written. */
|
||||
file(room: Room<unknown>, seat: Seat | null, turn: number | null, rawHappened: unknown, rawExpected: unknown): Report {
|
||||
const happened = cleanText(rawHappened);
|
||||
if (!happened) throw new RoomError('Say what happened.');
|
||||
const report: Report = {
|
||||
id: randomBytes(4).toString('hex'),
|
||||
at: new Date().toISOString(),
|
||||
roomId: room.id,
|
||||
player: seat?.name ?? '(gallery)',
|
||||
seat: seat?.id ?? null,
|
||||
turn,
|
||||
seq: room.seq,
|
||||
happened,
|
||||
expected: cleanText(rawExpected),
|
||||
thread: []
|
||||
};
|
||||
const { thread: _thread, ...line } = report;
|
||||
this.append(line);
|
||||
return report;
|
||||
}
|
||||
|
||||
/** The player's word under the keeper's reply; only the seat that filed it may answer. */
|
||||
answer(report: Report, seat: Seat, rawText: unknown): void {
|
||||
if (report.seat !== seat.id) throw new RoomError('That report is not yours to answer.', 403);
|
||||
const text = cleanText(rawText);
|
||||
if (!text) throw new RoomError('Say something.');
|
||||
this.append({ reportId: report.id, from: 'player', player: seat.name, text, at: new Date().toISOString() });
|
||||
}
|
||||
|
||||
/** One picture per report, soon after filing, a PNG, JPEG or WebP by its own first bytes. */
|
||||
attachImage(report: Report, body: Buffer): string {
|
||||
if (report.image) throw new RoomError('That report has its picture.', 409);
|
||||
if (Date.now() - Date.parse(report.at) > IMAGE_WINDOW_MS) throw new RoomError('Too late for a picture.', 410);
|
||||
if (body.length > IMAGE_MAX_BYTES) throw new RoomError('A picture of 2.5 MB at most.', 413);
|
||||
const ext = body.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
||||
? 'png'
|
||||
: body.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))
|
||||
? 'jpg'
|
||||
: body.subarray(0, 4).toString('ascii') === 'RIFF' && body.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
? 'webp'
|
||||
: null;
|
||||
if (!ext) throw new RoomError('A PNG, JPEG or WebP.', 415);
|
||||
mkdirSync(this.imageDir, { recursive: true });
|
||||
const name = `${report.id}.${ext}`;
|
||||
writeFileSync(join(this.imageDir, name), body);
|
||||
this.append({ reportId: report.id, image: name, at: new Date().toISOString() });
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Reports filed from the seats a browser can prove it holds. */
|
||||
mine(seats: { roomId: string; seat: SeatId }[]): Report[] {
|
||||
return this.read().filter((r) => seats.some((s) => s.roomId === r.roomId && s.seat === r.seat));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// Rooms: who sits where, whose move it is, and the game 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 round resolves at once, bots included. Nothing here knows the
|
||||
// game beyond the GameSpec it is given.
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { SPECTATOR, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
|
||||
import type { ChatLine, RoomView } from '../../src/lib/net/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
const CHAT_MAX_LENGTH = 300;
|
||||
/** Lines of talk kept and sent; the ledger keeps them all. */
|
||||
const CHAT_KEEP = 200;
|
||||
export { SPECTATOR };
|
||||
|
||||
export interface Seat {
|
||||
id: SeatId;
|
||||
name: string;
|
||||
/** SHA-256 of the seat's token, hex; empty for a bot. */
|
||||
tokenHash: string;
|
||||
bot: boolean;
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export interface Room<State> {
|
||||
id: string;
|
||||
size: number;
|
||||
createdAt: number;
|
||||
seats: Seat[];
|
||||
/** The host lets the Peanut Gallery talk at this table. */
|
||||
galleryTalk: boolean;
|
||||
/** The keeper of the site was called to a seat, and by whom. */
|
||||
challenge: { by: SeatId; at: number } | null;
|
||||
/** Names with a seat held for them: the players of the last table, or the keeper. */
|
||||
expected: string[];
|
||||
/** A finished table that called for a rematch: where it went, and who called. */
|
||||
rematch: { to: string; by: SeatId } | null;
|
||||
/** The table this one is the rematch of. */
|
||||
rematchOf: string | null;
|
||||
/** The host's choices for this table, every declared option filled. */
|
||||
options: TableOptions;
|
||||
state: State | null;
|
||||
/** Inputs received for the round in progress, by seat. */
|
||||
pending: Record<SeatId, unknown>;
|
||||
chat: ChatLine[];
|
||||
/** Bumped on every change a client might want to see. */
|
||||
seq: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export class RoomError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status = 400
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function newId(bytes: number): string {
|
||||
return randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
/** Room codes: four letters or digits, none that read alike, easy to say aloud. */
|
||||
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
function newCode(): string {
|
||||
const bytes = randomBytes(4);
|
||||
return [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
|
||||
}
|
||||
|
||||
function newSeed(): number {
|
||||
return (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0;
|
||||
}
|
||||
|
||||
/** Phrases that carry a seat between devices: four plain words, ten minutes, one use. */
|
||||
const TRANSFER_WORDS = [
|
||||
'ember', 'raven', 'flagon', 'wand', 'rune', 'moss', 'torch', 'frost', 'amber', 'wisp', 'cellar', 'gable',
|
||||
'onyx', 'briar', 'tome', 'cinder', 'gloom', 'spiral', 'mirror', 'lantern', 'thistle', 'harbor', 'quill', 'saddle',
|
||||
'willow', 'copper', 'meadow', 'anvil', 'beacon', 'orchard', 'pebble', 'cloister', 'walnut', 'tallow', 'heron', 'marble',
|
||||
'ivy', 'kestrel', 'fennel', 'velvet', 'canyon', 'lattice', 'ledger', 'bramble', 'sable', 'tundra', 'juniper', 'compass'
|
||||
];
|
||||
const TRANSFER_TTL_MS = 10 * 60 * 1000;
|
||||
const TRANSFER_FAIL_LIMIT = 20;
|
||||
|
||||
export function normalizeCode(raw: string): string {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function cleanName(raw: unknown): string {
|
||||
const name = String(raw ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 24);
|
||||
if (!name) throw new RoomError('A player needs a name.');
|
||||
return name;
|
||||
}
|
||||
|
||||
export class Rooms<State, Input> {
|
||||
private rooms = new Map<string, Room<State>>();
|
||||
private listeners = new Map<string, Set<(room: Room<State>) => void>>();
|
||||
/** Sockets watching from the gallery, by room. */
|
||||
private gallery = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private game: GameSpec<State, Input>,
|
||||
private store: Store,
|
||||
/** The keeper of the site: the name a table may call to a seat, and where they live. */
|
||||
private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' }
|
||||
) {
|
||||
for (const id of store.roomIds()) {
|
||||
const room = this.replay(id, store.read(id));
|
||||
if (room) this.rooms.set(id, room);
|
||||
}
|
||||
}
|
||||
|
||||
get maxSeats(): number {
|
||||
return this.game.seatIds.length;
|
||||
}
|
||||
|
||||
get(id: string): Room<State> {
|
||||
const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id)) ?? this.load(normalizeCode(id));
|
||||
if (!room) throw new RoomError('No game answers to that code.', 404);
|
||||
return room;
|
||||
}
|
||||
|
||||
/** A room evicted from memory comes back from its ledger on the next visit. */
|
||||
private load(id: string): Room<State> | undefined {
|
||||
if (!/^[A-Z0-9]{4}$/.test(id)) return undefined;
|
||||
const room = this.replay(id, this.store.read(id));
|
||||
if (room) this.rooms.set(id, room);
|
||||
return room ?? undefined;
|
||||
}
|
||||
|
||||
/** Forget rooms nobody has touched or watched for a while; their ledgers stay on disk. */
|
||||
evictIdle(olderThanMs: number, now = Date.now()): number {
|
||||
let n = 0;
|
||||
for (const [id, room] of this.rooms) {
|
||||
if (now - room.updatedAt < olderThanMs) continue;
|
||||
if ((this.listeners.get(id)?.size ?? 0) > 0) continue;
|
||||
this.rooms.delete(id);
|
||||
n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
get loaded(): number {
|
||||
return this.rooms.size;
|
||||
}
|
||||
|
||||
/** The seat a token unlocks, compared by hash in constant time. */
|
||||
seatOf(room: Room<State>, token: string | undefined): Seat {
|
||||
if (!token) throw new RoomError('That token opens no seat at this game.', 403);
|
||||
const given = Buffer.from(hashToken(token), 'hex');
|
||||
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
|
||||
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
|
||||
return seat;
|
||||
}
|
||||
|
||||
create(name: string, size: number, rawOptions?: unknown): { room: Room<State>; seat: Seat; token: string } {
|
||||
const { minSeats } = this.game;
|
||||
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
|
||||
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
|
||||
}
|
||||
return this.open(name, size, this.cleanOptions(rawOptions));
|
||||
}
|
||||
|
||||
/** The host's choices, kept to the options the game declares and the choices it offers; the rest default. */
|
||||
private cleanOptions(raw: unknown): TableOptions {
|
||||
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const options: TableOptions = {};
|
||||
for (const o of this.game.options ?? []) {
|
||||
const v = given[o.key];
|
||||
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** A table with nobody seated yet. */
|
||||
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room<State> {
|
||||
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, state: null, pending: {}, chat: [], seq, updatedAt: createdAt };
|
||||
}
|
||||
|
||||
/** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */
|
||||
private open(name: string, size: number, options: TableOptions, follows?: { rematchOf: string; expected: string[] }): { room: Room<State>; seat: Seat; token: string } {
|
||||
let id = newCode();
|
||||
while (this.rooms.has(id) || this.store.exists(id)) id = newCode();
|
||||
const room = this.blank(id, size, Date.now(), follows?.rematchOf ?? null, follows?.expected ?? [], options);
|
||||
this.rooms.set(id, room);
|
||||
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt, options, ...(follows ? { rematchOf: follows.rematchOf, expected: follows.expected } : {}) });
|
||||
return { room, ...this.sit(room, name, false) };
|
||||
}
|
||||
|
||||
join(room: Room<State>, name: string): { seat: Seat; token: string } {
|
||||
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
|
||||
return this.sit(room, name, false);
|
||||
}
|
||||
|
||||
/** Names with a seat held who have not yet sat. */
|
||||
private awaited(room: Room<State>): string[] {
|
||||
return room.expected.filter((n) => !room.seats.some((s) => s.name.toLowerCase() === n.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Host only: let the Peanut Gallery talk at this table, or hush it. Written to the ledger. */
|
||||
setGalleryTalk(room: Room<State>, token: string | undefined, on: boolean): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table decides whether the gallery may talk.', 403);
|
||||
if (room.galleryTalk === on) return;
|
||||
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
|
||||
}
|
||||
|
||||
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
|
||||
sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void {
|
||||
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
|
||||
const name = cleanName(rawName);
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('A player at the table has that name.', 409);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: this.cleanTalk(raw) });
|
||||
}
|
||||
|
||||
/**
|
||||
* A seated player calls the keeper of the site to the table: a seat is
|
||||
* held under the keeper's name and the call goes on the ledger. Once per
|
||||
* table, before the game begins. The caller rings the keeper's bell.
|
||||
*/
|
||||
callKeeper(room: Room<State>, token: string | undefined, keeper: string): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (room.state) throw new RoomError('The game has begun.', 409);
|
||||
if (room.challenge) throw new RoomError(`${keeper} has already been called to this table.`, 409);
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === keeper.toLowerCase())) throw new RoomError(`${keeper} is already here.`, 409);
|
||||
if (room.seats.length + this.awaited(room).length >= room.size) throw new RoomError('Every seat at this table is taken or held.', 409);
|
||||
this.commit(room, { t: 'challenge', at: Date.now(), by: seat.id, keeper });
|
||||
}
|
||||
|
||||
/**
|
||||
* A finished table calls for a rematch: a new table of the same size with
|
||||
* the same bots, seats held for the same people, the caller seated as its
|
||||
* host. Anyone at the old table may call; a second call finds the table
|
||||
* already open.
|
||||
*/
|
||||
rematch(room: Room<State>, token: string | undefined): { roomId: string; seat: Seat | null; token: string | null; created: boolean } {
|
||||
const caller = this.seatOf(room, token);
|
||||
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game is not over yet.', 409);
|
||||
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
|
||||
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
|
||||
const next = this.open(caller.name, room.size, room.options, { rematchOf: room.id, expected: others });
|
||||
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true);
|
||||
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
|
||||
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
|
||||
}
|
||||
|
||||
/** Phrases waiting to be claimed, with the seat and the token they carry. */
|
||||
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
|
||||
private failedClaims = 0;
|
||||
private failWindowStart = 0;
|
||||
|
||||
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
|
||||
transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } {
|
||||
const seat = this.seatOf(room, token);
|
||||
const now = Date.now();
|
||||
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
|
||||
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
|
||||
let phrase: string;
|
||||
do {
|
||||
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
|
||||
} while (this.transfers.has(phrase));
|
||||
const expiresAt = now + TRANSFER_TTL_MS;
|
||||
this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt });
|
||||
return { phrase, expiresAt };
|
||||
}
|
||||
|
||||
/** Claim a phrase: the seat and its token, once. Too many misses void every pending phrase. */
|
||||
claim(raw: unknown): { roomId: string; seat: SeatId; token: string } {
|
||||
const now = Date.now();
|
||||
if (now - this.failWindowStart > TRANSFER_TTL_MS) {
|
||||
this.failWindowStart = now;
|
||||
this.failedClaims = 0;
|
||||
}
|
||||
if (this.failedClaims >= TRANSFER_FAIL_LIMIT) throw new RoomError('Too many failed claims; transfers are cooling off. Mint a fresh phrase.', 429);
|
||||
const phrase = String(raw ?? '').trim().toLowerCase().replace(/\s+/g, '-');
|
||||
const t = this.transfers.get(phrase);
|
||||
if (!t || t.expiresAt < now) {
|
||||
this.failedClaims += 1;
|
||||
if (this.failedClaims >= TRANSFER_FAIL_LIMIT) this.transfers.clear();
|
||||
throw new RoomError('That phrase is unknown or has expired.', 404);
|
||||
}
|
||||
this.transfers.delete(phrase);
|
||||
const room = this.get(t.roomId);
|
||||
const seat = this.seatOf(room, t.token);
|
||||
return { roomId: room.id, seat: seat.id, token: t.token };
|
||||
}
|
||||
|
||||
/** The host, who opened the table, begins once enough are seated, however full the table. */
|
||||
begin(room: Room<State>, token: string | undefined): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may begin.', 403);
|
||||
if (room.state) throw new RoomError('The game has already begun.', 409);
|
||||
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409);
|
||||
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
|
||||
}
|
||||
|
||||
/** The host seats a bot in an empty chair. */
|
||||
addBot(room: Room<State>, token: string | undefined): Seat {
|
||||
const host = this.seatOf(room, token);
|
||||
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may seat a bot.', 403);
|
||||
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
|
||||
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
|
||||
const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase()));
|
||||
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
|
||||
return this.sit(room, name, true).seat;
|
||||
}
|
||||
|
||||
/** Take the next free seat. The token is returned once and never stored; the ledger keeps its hash. */
|
||||
private sit(room: Room<State>, rawName: string, bot: boolean): { seat: Seat; token: string } {
|
||||
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
|
||||
const name = cleanName(rawName);
|
||||
const awaited = this.awaited(room);
|
||||
if (!awaited.some((n) => n.toLowerCase() === name.toLowerCase()) && room.seats.length + awaited.length >= room.size) {
|
||||
throw new RoomError('The seats left are held for players expected at this table.', 409);
|
||||
}
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
|
||||
const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!;
|
||||
const token = bot ? '' : newId(18);
|
||||
const seat: Seat = { id, name, tokenHash: bot ? '' : hashToken(token), bot };
|
||||
this.commit(room, { t: 'seat', id: seat.id, name, tokenHash: seat.tokenHash, bot });
|
||||
return { seat, token };
|
||||
}
|
||||
|
||||
/** The host sends a bot away before the game begins. People leave by not coming back. */
|
||||
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
|
||||
const host = this.seatOf(room, token);
|
||||
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may send a bot away.', 403);
|
||||
if (room.state) throw new RoomError('The game has begun; the table is set.', 409);
|
||||
const seat = room.seats.find((s) => s.id === seatId);
|
||||
if (!seat || !seat.bot) throw new RoomError('Only a bot can be sent away.', 400);
|
||||
this.commit(room, { t: 'unseat', id: seat.id });
|
||||
}
|
||||
|
||||
/** Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. */
|
||||
submit(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
|
||||
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
|
||||
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
|
||||
const input = this.game.cleanInput(raw);
|
||||
const why = this.game.validate?.(room.state, seat.id, input);
|
||||
if (why) throw new RoomError(why);
|
||||
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input });
|
||||
if (this.waitingOn(room).length === 0) this.resolve(room);
|
||||
}
|
||||
|
||||
private resolve(room: Room<State>): void {
|
||||
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
|
||||
const inputs: Record<SeatId, unknown> = { ...room.pending };
|
||||
for (const seat of bots(room.state!)) inputs[seat.id] = this.game.botInput(room.state!, seat.id);
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
// A round only bots owe (an extra turn, say) resolves at once; a human's waits for them.
|
||||
while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) {
|
||||
const inputs: Record<SeatId, unknown> = {};
|
||||
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id);
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
}
|
||||
const outcome = room.state && this.game.over(room.state);
|
||||
if (outcome) this.commit(room, { t: 'over', at: Date.now(), winner: outcome.winner, reason: outcome.reason });
|
||||
}
|
||||
|
||||
/** Table talk, from a seat to everyone at the table and in the gallery. */
|
||||
say(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw) });
|
||||
}
|
||||
|
||||
private cleanTalk(raw: unknown): string {
|
||||
const text = String(raw ?? '')
|
||||
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, CHAT_MAX_LENGTH);
|
||||
if (!text) throw new RoomError('Nothing to say.');
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Seats whose input the next resolution needs and has not yet received. */
|
||||
waitingOn(room: Room<State>): SeatId[] {
|
||||
if (!room.state || this.game.over(room.state)) return [];
|
||||
return room.seats
|
||||
.filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending))
|
||||
.map((s) => s.id);
|
||||
}
|
||||
|
||||
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
|
||||
view(room: Room<State>, seatId: SeatId): RoomView<State> {
|
||||
return {
|
||||
roomId: room.id,
|
||||
seq: room.seq,
|
||||
size: room.size,
|
||||
me: seatId,
|
||||
host: room.seats[0]?.id ?? seatId,
|
||||
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
|
||||
waitingOn: this.waitingOn(room),
|
||||
turn: room.state ? this.game.turn(room.state) : null,
|
||||
over: room.state ? this.game.over(room.state) : null,
|
||||
state: room.state ? this.game.view(room.state, seatId) : null,
|
||||
chat: room.chat,
|
||||
audience: this.gallery.get(room.id) ?? 0,
|
||||
galleryTalk: room.galleryTalk,
|
||||
challenge: room.challenge,
|
||||
expected: this.awaited(room),
|
||||
rematch: room.rematch,
|
||||
rematchOf: room.rematchOf,
|
||||
options: room.options,
|
||||
keeper: this.keeper.name,
|
||||
keeperZone: this.keeper.zone
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(roomId: string, fn: (room: Room<State>) => 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);
|
||||
}
|
||||
|
||||
/** Subscribe from the gallery; the table is told when its audience changes. */
|
||||
watch(room: Room<State>, fn: (room: Room<State>) => void): () => void {
|
||||
const unsubscribe = this.subscribe(room.id, fn);
|
||||
this.gallery.set(room.id, (this.gallery.get(room.id) ?? 0) + 1);
|
||||
room.seq += 1;
|
||||
this.notify(room);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
this.gallery.set(room.id, Math.max(0, (this.gallery.get(room.id) ?? 1) - 1));
|
||||
room.seq += 1;
|
||||
this.notify(room);
|
||||
};
|
||||
}
|
||||
|
||||
audience(room: Room<State>): number {
|
||||
return this.gallery.get(room.id) ?? 0;
|
||||
}
|
||||
|
||||
/** Write to the ledger, then apply; the file is the truth a restart returns to. */
|
||||
private commit(room: Room<State>, line: LedgerLine): void {
|
||||
this.store.append(room.id, line);
|
||||
this.apply(room, line);
|
||||
room.seq += 1;
|
||||
room.updatedAt = Date.now();
|
||||
this.notify(room);
|
||||
}
|
||||
|
||||
private notify(room: Room<State>): void {
|
||||
for (const fn of this.listeners.get(room.id) ?? []) fn(room);
|
||||
}
|
||||
|
||||
private apply(room: Room<State>, line: LedgerLine): void {
|
||||
switch (line.t) {
|
||||
case 'room':
|
||||
case 'over':
|
||||
break;
|
||||
case 'seat':
|
||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
|
||||
break;
|
||||
case 'galleryTalk':
|
||||
room.galleryTalk = line.on;
|
||||
break;
|
||||
case 'challenge':
|
||||
room.challenge = { by: line.by, at: line.at };
|
||||
if (!room.expected.some((n) => n.toLowerCase() === line.keeper.toLowerCase())) room.expected = [...room.expected, line.keeper];
|
||||
break;
|
||||
case 'rematch':
|
||||
room.rematch = { to: line.to, by: line.by };
|
||||
break;
|
||||
case 'input':
|
||||
room.pending[line.id] = this.game.cleanInput(line.input);
|
||||
break;
|
||||
case 'unseat':
|
||||
room.seats = room.seats.filter((s) => s.id !== line.id);
|
||||
break;
|
||||
case 'start':
|
||||
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, room.options);
|
||||
break;
|
||||
case 'turn':
|
||||
if (!room.state) return;
|
||||
room.state = this.game.resolve(room.state, line.inputs as Record<SeatId, Input>);
|
||||
room.pending = {};
|
||||
break;
|
||||
case 'chat':
|
||||
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
|
||||
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private replay(id: string, lines: LedgerLine[]): Room<State> | null {
|
||||
const first = lines[0];
|
||||
if (!first || first.t !== 'room') return null;
|
||||
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], this.cleanOptions(first.options), lines.length);
|
||||
for (const line of lines) {
|
||||
this.apply(room, line);
|
||||
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
|
||||
}
|
||||
return room;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// 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 { SeatId } from '../../src/lib/game/spec';
|
||||
|
||||
export type LedgerLine =
|
||||
/** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */
|
||||
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options?: Record<string, string> }
|
||||
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
|
||||
| { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
|
||||
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
|
||||
| { t: 'input'; at: number; id: SeatId; input: unknown }
|
||||
/** A bot sent away by the host before the game began; its seat id is free again. */
|
||||
| { t: 'unseat'; id: SeatId }
|
||||
/** Ledgers from before revisions were recorded resolve under rules 1. */
|
||||
| { t: 'start'; seed: number; rules?: number }
|
||||
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
|
||||
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
|
||||
| { t: 'over'; at: number; winner: SeatId | null; reason: string }
|
||||
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
||||
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string }
|
||||
/** The host let the gallery talk, or hushed it. */
|
||||
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
|
||||
/** A seat is held for the keeper of the site, called to the table. */
|
||||
| { t: 'challenge'; at: number; by: SeatId; keeper: string }
|
||||
/** A finished table called for a rematch: where it went, and who called. */
|
||||
| { t: 'rematch'; at: number; to: string; by: SeatId };
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Whether a ledger exists on disk, loaded or not; a code must not be reissued over one. */
|
||||
exists(roomId: string): boolean {
|
||||
return existsSync(this.path(roomId));
|
||||
}
|
||||
|
||||
roomIds(): string[] {
|
||||
return readdirSync(this.dir)
|
||||
.filter((f) => f.endsWith('.jsonl'))
|
||||
.map((f) => f.slice(0, -'.jsonl'.length));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user