Ready for a crowd: idle rooms sleep, share cards render once
Memory now carries only live tables — a sweep puts finished rooms to bed after 30 minutes and anything untouched after a day, and getRoom wakes a sleeping room from its ledger the moment anyone asks (the seed plus the log IS the game). The room cap counts ledgers on disk, not just rooms awake. Share-card PNGs render once per share id and cache immutable. Rate-limit kicks use terminate() so an abuser cannot hold the socket open by ignoring the closing handshake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
9cc0430ee2
commit
002698085f
@@ -42,6 +42,7 @@ import {
|
|||||||
getRoom,
|
getRoom,
|
||||||
joinRoom,
|
joinRoom,
|
||||||
loadPersistedRooms,
|
loadPersistedRooms,
|
||||||
|
evictIdleRooms,
|
||||||
runningRooms,
|
runningRooms,
|
||||||
addAutomaton,
|
addAutomaton,
|
||||||
addChat,
|
addChat,
|
||||||
@@ -92,6 +93,13 @@ setTimeout(() => {
|
|||||||
for (const room of runningRooms()) runBots(room);
|
for (const room of runningRooms()) runBots(room);
|
||||||
}, 2000); // a beat for clients to reconnect before the clockwork stirs
|
}, 2000); // a beat for clients to reconnect before the clockwork stirs
|
||||||
|
|
||||||
|
// Idle rooms return to their ledgers, so memory carries only live tables;
|
||||||
|
// getRoom wakes a sleeping room the moment anyone asks for it.
|
||||||
|
setInterval(() => {
|
||||||
|
const evicted = evictIdleRooms((roomId) => [...sessions].some((s) => s.roomId === roomId));
|
||||||
|
if (evicted > 0) console.log(`put ${evicted} idle room(s) back to sleep`);
|
||||||
|
}, Number(process.env.WIZWAR_EVICT_SWEEP_MS ?? 10 * 60_000));
|
||||||
|
|
||||||
// One process serves both the built client and the websocket, so production
|
// One process serves both the built client and the websocket, so production
|
||||||
// needs only a TLS proxy in front (or nothing, on a LAN).
|
// needs only a TLS proxy in front (or nothing, on a LAN).
|
||||||
const STATIC_DIR = process.env.WIZWAR_STATIC_DIR ?? join(process.cwd(), "..", "web", "dist");
|
const STATIC_DIR = process.env.WIZWAR_STATIC_DIR ?? join(process.cwd(), "..", "web", "dist");
|
||||||
@@ -217,6 +225,10 @@ function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
|
|||||||
];
|
];
|
||||||
return ogPage(metas);
|
return ogPage(metas);
|
||||||
}
|
}
|
||||||
|
/** Rendered share cards, by share id (immutable once minted). */
|
||||||
|
const ogPngCache = new Map<string, Buffer>();
|
||||||
|
const OG_PNG_CACHE_MAX = 200;
|
||||||
|
|
||||||
const httpServer = createServer((req, res) => {
|
const httpServer = createServer((req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||||
@@ -257,8 +269,17 @@ const httpServer = createServer((req, res) => {
|
|||||||
const data = shareData(watch[1]!);
|
const data = shareData(watch[1]!);
|
||||||
if (!data) { res.writeHead(404).end("no such replay"); return; }
|
if (!data) { res.writeHead(404).end("no such replay"); return; }
|
||||||
if (watch[2]) {
|
if (watch[2]) {
|
||||||
const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
|
// A share never changes, so its card renders once — crawlers
|
||||||
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
|
// re-fetching the unfurl image must not cost CPU every time.
|
||||||
|
let png = ogPngCache.get(watch[1]!);
|
||||||
|
if (!png) {
|
||||||
|
png = renderSharePng(data.steps[data.steps.length - 1]!.view);
|
||||||
|
ogPngCache.set(watch[1]!, png);
|
||||||
|
if (ogPngCache.size > OG_PNG_CACHE_MAX) {
|
||||||
|
ogPngCache.delete(ogPngCache.keys().next().value!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=86400, immutable" });
|
||||||
res.end(png);
|
res.end(png);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -511,7 +532,9 @@ wss.on("connection", (socket) => {
|
|||||||
|
|
||||||
socket.on("message", (data) => {
|
socket.on("message", (data) => {
|
||||||
if (!underRateLimit(session)) {
|
if (!underRateLimit(session)) {
|
||||||
if (++session.overLimitStrikes > 100) socket.close();
|
// terminate, not close: an abuser ignoring the closing handshake
|
||||||
|
// would otherwise hold the socket (and its session slot) open.
|
||||||
|
if (++session.overLimitStrikes > 100) return socket.terminate();
|
||||||
return send(socket, { type: "error", message: "slow down" });
|
return send(socket, { type: "error", message: "slow down" });
|
||||||
}
|
}
|
||||||
let msg: Record<string, unknown>;
|
let msg: Record<string, unknown>;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
type PlayerId,
|
type PlayerId,
|
||||||
CURRENT_RULES_REV,
|
CURRENT_RULES_REV,
|
||||||
} from "@wizwar/engine";
|
} from "@wizwar/engine";
|
||||||
import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store";
|
import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, readAllRooms, readRoom, roomFileExists, type RoomLine } from "./store";
|
||||||
import { recordRoom } from "./stats";
|
import { recordRoom } from "./stats";
|
||||||
|
|
||||||
export interface LoggedCommand {
|
export interface LoggedCommand {
|
||||||
@@ -50,6 +50,8 @@ export interface Room {
|
|||||||
chat: { player: PlayerId; text: string; at: string }[];
|
chat: { player: PlayerId; text: string; at: string }[];
|
||||||
/** Seats the server itself plays: temperament, tier, and secrecy. */
|
/** Seats the server itself plays: temperament, tier, and secrecy. */
|
||||||
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
bots: Map<PlayerId, { style: AutomatonStyle; secret: boolean; tier: AutomatonTier }>;
|
||||||
|
/** Last time anything looked at this room (memory eviction clock). */
|
||||||
|
touchedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rooms = new Map<string, Room>();
|
const rooms = new Map<string, Room>();
|
||||||
@@ -86,7 +88,7 @@ function makeRoomCode(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function roomCount(): number {
|
export function roomCount(): number {
|
||||||
return rooms.size;
|
return countRoomFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every restored, still-running room — so boot can wake their bot pumps. */
|
/** Every restored, still-running room — so boot can wake their bot pumps. */
|
||||||
@@ -110,6 +112,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
|||||||
events: [],
|
events: [],
|
||||||
chat: [],
|
chat: [],
|
||||||
bots: new Map(),
|
bots: new Map(),
|
||||||
|
touchedAt: Date.now(),
|
||||||
};
|
};
|
||||||
rooms.set(room.id, room);
|
rooms.set(room.id, room);
|
||||||
recordRoom(room);
|
recordRoom(room);
|
||||||
@@ -125,7 +128,45 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getRoom(id: string): Room | undefined {
|
export function getRoom(id: string): Room | undefined {
|
||||||
return rooms.get(id.toUpperCase());
|
const code = id.toUpperCase();
|
||||||
|
const live = rooms.get(code);
|
||||||
|
if (live) {
|
||||||
|
live.touchedAt = Date.now();
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
// A room out of memory sleeps in its ledger; wake it on demand.
|
||||||
|
const lines = readRoom(code);
|
||||||
|
if (!lines) return undefined;
|
||||||
|
try {
|
||||||
|
const room = rebuildRoom(code, lines);
|
||||||
|
if (!room) return undefined;
|
||||||
|
room.touchedAt = Date.now();
|
||||||
|
rooms.set(code, room);
|
||||||
|
return room;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`could not wake room ${code}:`, e);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put idle rooms back to sleep: their ledger holds everything, so memory
|
||||||
|
* only carries rooms someone is actually at. A room leaves when no socket
|
||||||
|
* is attached and it has sat untouched past its allowance — a finished
|
||||||
|
* game soon, anything else after a day. */
|
||||||
|
export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number {
|
||||||
|
const now = Date.now();
|
||||||
|
const FINISHED_MS = Number(process.env.WIZWAR_EVICT_FINISHED_MS ?? 30 * 60_000);
|
||||||
|
const IDLE_MS = Number(process.env.WIZWAR_EVICT_IDLE_MS ?? 24 * 60 * 60_000);
|
||||||
|
let evicted = 0;
|
||||||
|
for (const [id, room] of rooms) {
|
||||||
|
if (hasSockets(id)) continue;
|
||||||
|
const idle = now - (room.touchedAt ?? now);
|
||||||
|
const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS;
|
||||||
|
if (idle < allowance) continue;
|
||||||
|
rooms.delete(id);
|
||||||
|
evicted++;
|
||||||
|
}
|
||||||
|
return evicted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinRoom(
|
export function joinRoom(
|
||||||
@@ -611,31 +652,30 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild every persisted room by replaying its file. */
|
/** Rebuild every persisted room by replaying its file. */
|
||||||
export function loadPersistedRooms(): void {
|
/** Rebuild one room from its ledger lines — the seed plus the log IS the
|
||||||
ensureDataDir();
|
* game. Returns null for an abandoned room; throws on a corrupt ledger. */
|
||||||
let restored = 0;
|
function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
|
||||||
for (const [id, lines] of readAllRooms()) {
|
const meta = lines[0] as Extract<RoomLine, { kind: "meta" }>;
|
||||||
try {
|
const hostHash = meta.hostTokenHash ?? (meta.hostToken ? hashToken(meta.hostToken) : null);
|
||||||
const meta = lines[0] as Extract<RoomLine, { kind: "meta" }>;
|
if (!hostHash) throw new Error("meta line has no host token");
|
||||||
const hostHash = meta.hostTokenHash ?? (meta.hostToken ? hashToken(meta.hostToken) : null);
|
const room: Room = {
|
||||||
if (!hostHash) throw new Error("meta line has no host token");
|
id,
|
||||||
const room: Room = {
|
hostId: meta.hostId,
|
||||||
id,
|
players: [meta.hostId],
|
||||||
hostId: meta.hostId,
|
tokens: new Map([[meta.hostId, hostHash]]),
|
||||||
players: [meta.hostId],
|
seed: meta.seed,
|
||||||
tokens: new Map([[meta.hostId, hostHash]]),
|
expansion: false,
|
||||||
seed: meta.seed,
|
colorChoices: new Map(),
|
||||||
expansion: false,
|
createdAt: meta.createdAt,
|
||||||
colorChoices: new Map(),
|
state: null,
|
||||||
createdAt: meta.createdAt,
|
log: [],
|
||||||
state: null,
|
events: [],
|
||||||
log: [],
|
chat: [],
|
||||||
events: [],
|
bots: new Map(),
|
||||||
chat: [],
|
touchedAt: Date.now(),
|
||||||
bots: new Map(),
|
};
|
||||||
};
|
if (lines.some((l) => l.kind === "abandon")) return null;
|
||||||
if (lines.some((l) => l.kind === "abandon")) continue;
|
for (const line of lines.slice(1)) {
|
||||||
for (const line of lines.slice(1)) {
|
|
||||||
if (line.kind === "kick") {
|
if (line.kind === "kick") {
|
||||||
room.players = room.players.filter((p) => p !== line.name);
|
room.players = room.players.filter((p) => p !== line.name);
|
||||||
room.tokens.delete(line.name);
|
room.tokens.delete(line.name);
|
||||||
@@ -672,7 +712,17 @@ export function loadPersistedRooms(): void {
|
|||||||
room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at });
|
room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at });
|
||||||
room.events.push(...result.events);
|
room.events.push(...result.events);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPersistedRooms(): void {
|
||||||
|
ensureDataDir();
|
||||||
|
let restored = 0;
|
||||||
|
for (const [id, lines] of readAllRooms()) {
|
||||||
|
try {
|
||||||
|
const room = rebuildRoom(id, lines);
|
||||||
|
if (!room) continue;
|
||||||
rooms.set(id, room);
|
rooms.set(id, room);
|
||||||
recordRoom(room);
|
recordRoom(room);
|
||||||
restored++;
|
restored++;
|
||||||
|
|||||||
@@ -97,6 +97,29 @@ export function appendLine(roomId: string, line: RoomLine): void {
|
|||||||
appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8");
|
appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read one persisted room's lines, or null if it has no ledger. */
|
||||||
|
export function readRoom(roomId: string): RoomLine[] | null {
|
||||||
|
const file = fileFor(roomId);
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
try {
|
||||||
|
const lines = readFileSync(file, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l.trim().length > 0)
|
||||||
|
.map((l) => JSON.parse(l) as RoomLine);
|
||||||
|
return lines.length > 0 && lines[0]!.kind === "meta" ? lines : null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`unreadable room file ${roomId}:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many rooms have ledgers on disk — the server-wide cap counts these,
|
||||||
|
* not just the rooms currently awake in memory. */
|
||||||
|
export function countRoomFiles(): number {
|
||||||
|
ensureDataDir();
|
||||||
|
return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).length;
|
||||||
|
}
|
||||||
|
|
||||||
/** Read every persisted room's lines, keyed by room id. */
|
/** Read every persisted room's lines, keyed by room id. */
|
||||||
export function readAllRooms(): Map<string, RoomLine[]> {
|
export function readAllRooms(): Map<string, RoomLine[]> {
|
||||||
ensureDataDir();
|
ensureDataDir();
|
||||||
|
|||||||
Reference in New Issue
Block a user