Operations in wizwar's shape: the determinism gate, backups, the rollup and the pulse

Every deploy first replays every production ledger with the engine about
to ship. The droplet gains a nightly rollup of counts and a nightly
backup to Spaces, a pulse script the pulse skill reads, rate limits on
opening rooms and taking seats, and eviction of idle rooms from memory
with reload from their ledgers on the next visit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-22 22:06:10 -04:00
co-authored by Claude Fable 5.1
parent 885dcf56e6
commit 9bd203ae60
13 changed files with 418 additions and 5 deletions
+18
View File
@@ -12,6 +12,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import { RateLimit } from './ratelimit';
import { RoomError, Rooms } from './rooms';
import { Store } from './store';
@@ -21,6 +22,21 @@ const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms';
const BODY_LIMIT = 16 * 1024;
const rooms = new Rooms(new Store(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);
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS);
doors.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 {
const json = JSON.stringify(body);
@@ -59,6 +75,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
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 duels opened from here just now; try again later.', 429);
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) });
@@ -73,6 +90,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
const body = await readBody(req);
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 = rooms.join(room, String(body.name ?? ''));
return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
}
+33
View File
@@ -0,0 +1,33 @@
// A per-key sliding-window limiter for the doors anyone may walk through
// unseated: opening a room and taking a seat. Keyed by client address; keys
// whose windows have emptied are pruned so memory stays bounded.
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 either way 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);
}
}
}
+26 -1
View File
@@ -71,11 +71,36 @@ export class Rooms {
}
get(id: string): Room {
const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id));
const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id)) ?? this.load(normalizeCode(id));
if (!room) throw new RoomError('No duel 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 | undefined {
if (!/^[A-Z0-9]{4}$/.test(id)) return undefined;
const room = 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;
}
/** How many rooms are held in memory. */
get loaded(): number {
return this.rooms.size;
}
/** The seat a token unlocks. */
seatOf(room: Room, token: string | undefined): Seat {
const seat = room.seats.find((s) => !s.bot && s.token === token);
+13 -3
View File
@@ -7,8 +7,18 @@
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node"]
"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"]
"include": [
"src/**/*.ts",
"../src/lib/game/**/*.ts",
"../deploy/replay-ledgers.ts"
],
"exclude": [
"../src/lib/game/*.test.ts",
"../src/lib/game/*.svelte.ts",
"../src/lib/game/test-helpers.ts"
]
}