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
79 lines
2.8 KiB
TypeScript
79 lines
2.8 KiB
TypeScript
// Replays every ledger in a directory with THIS checkout's engine and, when
|
|
// a server address is given, compares the result with what that server is
|
|
// showing for the room: turn count, each wizard's health, and the outcome.
|
|
// A ledger the engine cannot replay, or replays differently, is reported
|
|
// and fails the run. Used by deploy/verify-ledgers.sh.
|
|
// tsx deploy/replay-ledgers.ts <ledger-dir> [https://host]
|
|
|
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { resolveTurn } from '../src/lib/game/resolve';
|
|
import { createGame, type GameState, type TurnInput, type WizardId } from '../src/lib/game/state';
|
|
|
|
interface Seat {
|
|
id: WizardId;
|
|
name: string;
|
|
token: string;
|
|
bot: boolean;
|
|
}
|
|
|
|
const [dir, host] = process.argv.slice(2);
|
|
if (!dir) {
|
|
console.error('usage: replay-ledgers.ts <ledger-dir> [https://host]');
|
|
process.exit(2);
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const files = readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
|
let failures = 0;
|
|
for (const file of files) {
|
|
const code = file.slice(0, -'.jsonl'.length);
|
|
const lines = readFileSync(join(dir, file), 'utf8')
|
|
.split('\n')
|
|
.filter((l) => l.trim())
|
|
.map((l) => JSON.parse(l));
|
|
const seats: Seat[] = [];
|
|
let state: GameState | null = null;
|
|
let turns = 0;
|
|
try {
|
|
for (const line of lines) {
|
|
if (line.t === 'seat') seats.push(line);
|
|
else if (line.t === 'start') state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed);
|
|
else if (line.t === 'turn' && state) {
|
|
state = resolveTurn(state, line.inputs as Record<WizardId, TurnInput>);
|
|
turns += 1;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log(`${code}: REFUSED at turn ${turns + 1}: ${e instanceof Error ? e.message : String(e)}`);
|
|
failures += 1;
|
|
continue;
|
|
}
|
|
let verdict = state ? `${turns} turns, ${state.over ? 'over' : 'in play'}` : 'not started';
|
|
const human = seats.find((s) => !s.bot);
|
|
if (host && state && human) {
|
|
const res = await fetch(`${host}/api/rooms/${code}?token=${encodeURIComponent(human.token)}`);
|
|
if (!res.ok) {
|
|
verdict += `, server ${res.status}`;
|
|
} else {
|
|
const view = (await res.json()) as { state: GameState | null };
|
|
const theirs = view.state;
|
|
const same =
|
|
!!theirs &&
|
|
theirs.turn === state.turn &&
|
|
theirs.seats.every((id) => theirs.wizards[id].hp === state!.wizards[id].hp) &&
|
|
(theirs.over?.winner ?? null) === (state.over?.winner ?? null);
|
|
if (!same) {
|
|
failures += 1;
|
|
verdict += `, DIFFERS from the server (server turn ${theirs?.turn}, local turn ${state.turn})`;
|
|
} else verdict += ', matches the server';
|
|
}
|
|
}
|
|
console.log(`${code}: ${verdict}`);
|
|
}
|
|
console.log(`${files.length} ledger${files.length === 1 ? '' : 's'}, ${failures} problem${failures === 1 ? '' : 's'}`);
|
|
process.exit(failures ? 1 : 0);
|
|
}
|
|
|
|
void main();
|