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:
Eric Wagoner
2026-08-30 13:43:23 -04:00
co-authored by Claude Fable 5
parent 9cc0430ee2
commit 002698085f
3 changed files with 128 additions and 32 deletions
+79 -29
View File
@@ -22,7 +22,7 @@ import {
type PlayerId,
CURRENT_RULES_REV,
} 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";
export interface LoggedCommand {
@@ -50,6 +50,8 @@ export interface Room {
chat: { player: PlayerId; text: string; at: string }[];
/** Seats the server itself plays: temperament, tier, and secrecy. */
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>();
@@ -86,7 +88,7 @@ function makeRoomCode(): string {
}
export function roomCount(): number {
return rooms.size;
return countRoomFiles();
}
/** 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: [],
chat: [],
bots: new Map(),
touchedAt: Date.now(),
};
rooms.set(room.id, room);
recordRoom(room);
@@ -125,7 +128,45 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } {
}
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(
@@ -611,31 +652,30 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI
}
/** Rebuild every persisted room by replaying its file. */
export function loadPersistedRooms(): void {
ensureDataDir();
let restored = 0;
for (const [id, lines] of readAllRooms()) {
try {
const meta = lines[0] as Extract<RoomLine, { kind: "meta" }>;
const hostHash = meta.hostTokenHash ?? (meta.hostToken ? hashToken(meta.hostToken) : null);
if (!hostHash) throw new Error("meta line has no host token");
const room: Room = {
id,
hostId: meta.hostId,
players: [meta.hostId],
tokens: new Map([[meta.hostId, hostHash]]),
seed: meta.seed,
expansion: false,
colorChoices: new Map(),
createdAt: meta.createdAt,
state: null,
log: [],
events: [],
chat: [],
bots: new Map(),
};
if (lines.some((l) => l.kind === "abandon")) continue;
for (const line of lines.slice(1)) {
/** Rebuild one room from its ledger lines — the seed plus the log IS the
* game. Returns null for an abandoned room; throws on a corrupt ledger. */
function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
const meta = lines[0] as Extract<RoomLine, { kind: "meta" }>;
const hostHash = meta.hostTokenHash ?? (meta.hostToken ? hashToken(meta.hostToken) : null);
if (!hostHash) throw new Error("meta line has no host token");
const room: Room = {
id,
hostId: meta.hostId,
players: [meta.hostId],
tokens: new Map([[meta.hostId, hostHash]]),
seed: meta.seed,
expansion: false,
colorChoices: new Map(),
createdAt: meta.createdAt,
state: null,
log: [],
events: [],
chat: [],
bots: new Map(),
touchedAt: Date.now(),
};
if (lines.some((l) => l.kind === "abandon")) return null;
for (const line of lines.slice(1)) {
if (line.kind === "kick") {
room.players = room.players.filter((p) => p !== 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.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);
recordRoom(room);
restored++;