Sleeping rooms stay asleep across a restart

Idle rooms already went back to their ledgers, but a boot replayed
every ledger before the first sweep could put them to bed again — most
of a restart's fifteen seconds spent rebuilding games nobody was at.
A room put to sleep now leaves a stub beside the ledgers, with the
ledger's size at that moment; at boot a stub whose size still matches
answers for the room and the ledger stays unreplayed. Replayed rooms
take their eviction clock from the ledger's last line, so the first
sweep after a boot writes the stubs for everything long idle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-05 22:05:43 -04:00
co-authored by Claude Fable 5.1
parent c7edfa74ad
commit 6293c36add
2 changed files with 70 additions and 22 deletions
+29 -5
View File
@@ -22,7 +22,7 @@ import {
type PlayerId,
CURRENT_RULES_REV,
} from "@wizwar/engine";
import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, readAllRooms, readRoom, roomFileExists, type RoomLine } from "./store";
import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, ledgerStat, listRoomIds, readRoom, readStubFile, roomFileExists, writeStubFile, type RoomLine } from "./store";
import { recordRoom } from "./stats";
export interface LoggedCommand {
@@ -159,9 +159,16 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number
const idle = now - (room.touchedAt ?? now);
const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS;
if (idle < allowance) continue;
sleepingStubs.set(id, stubOf(room));
const stub = stubOf(room);
sleepingStubs.set(id, stub);
rooms.delete(id);
evicted++;
try {
const stat = ledgerStat(id);
if (stat) writeStubFile(id, stat.bytes, { ...stub, tokens: Object.fromEntries(stub.tokens) });
} catch (e) {
console.error(`could not write stub for ${id}:`, e);
}
}
return evicted;
}
@@ -176,6 +183,8 @@ interface RoomStub {
base: Omit<GameSummary, "name" | "yourTurn" | "attention">;
}
const sleepingStubs = new Map<string, RoomStub>();
/** A stub as it rests on disk — the token map spelled as an object. */
type StoredStub = Omit<RoomStub, "tokens"> & { tokens: Record<PlayerId, string> };
function stubOf(room: Room): RoomStub {
const { active, turnHolder, waitKind } = turnFacts(room.state);
@@ -775,14 +784,29 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null {
return room;
}
/** Rebuild every persisted room by replaying its file. */
/** Bring the rooms back after a restart. A room whose stub still matches
* its ledger sleeps on, answering the games ledger from the stub; the
* rest are replayed, with the eviction clock set to their last line so
* the first sweep puts the long-idle ones back to bed. */
export function loadPersistedRooms(): void {
ensureDataDir();
let restored = 0;
for (const [id, lines] of readAllRooms()) {
let asleep = 0;
for (const id of listRoomIds()) {
const stat = ledgerStat(id);
if (!stat) continue;
const stored = readStubFile<StoredStub>(id);
if (stored && stored.bytes === stat.bytes) {
sleepingStubs.set(id, { ...stored.stub, tokens: new Map(Object.entries(stored.stub.tokens)) });
asleep++;
continue;
}
const lines = readRoom(id);
if (!lines) continue;
try {
const room = rebuildRoom(id, lines);
if (!room) continue;
room.touchedAt = stat.mtimeMs;
rooms.set(id, room);
recordRoom(room);
restored++;
@@ -790,5 +814,5 @@ export function loadPersistedRooms(): void {
console.error(`could not restore room ${id}:`, e);
}
}
if (restored > 0) console.log(`restored ${restored} room(s) from disk`);
if (restored + asleep > 0) console.log(`restored ${restored} room(s) from disk, ${asleep} left sleeping`);
}