Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
76 lines
2.8 KiB
TypeScript
76 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: the round reached 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 { game } from '../src/lib/game';
|
|
import type { SeatId } from '../src/lib/game/spec';
|
|
|
|
interface Seat {
|
|
id: SeatId;
|
|
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: ReturnType<typeof game.create> | null = null;
|
|
let turns = 0;
|
|
try {
|
|
for (const line of lines) {
|
|
if (line.t === 'seat') seats.push(line);
|
|
else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
|
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
|
|
else if (line.t === 'turn' && state) {
|
|
state = game.resolve(state, line.inputs);
|
|
turns += 1;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log(`${code}: REFUSED at turn ${turns + 1}: ${e instanceof Error ? e.message : String(e)}`);
|
|
failures += 1;
|
|
continue;
|
|
}
|
|
const outcome = state ? game.over(state) : null;
|
|
let verdict = state ? `${turns} turns, ${outcome ? '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 theirs = (await res.json()) as { turn: number | null; over: { winner: SeatId | null } | null };
|
|
const same = theirs.turn === game.turn(state) && (theirs.over?.winner ?? null) === (outcome?.winner ?? null);
|
|
if (!same) {
|
|
failures += 1;
|
|
verdict += `, DIFFERS from the server (server round ${theirs.turn}, local round ${game.turn(state)})`;
|
|
} 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();
|