Files
waving-hands/deploy/replay-ledgers.ts
T
Eric WagonerandClaude Fable 5.1 5c79a65319 A crowded table: keyed threats, a compact roster, a ledger selector, host-only starts
Threats are keyed by seat as well as hand and spell, so two opponents
building the same spell no longer crash the board (regression test).
Past three wizards the rail lists the others on a line each with health,
condition and whether they have moved; a row of names turns the ledger
toward any wizard, as do pressing a warning or choosing a target; each
seat keeps one colour across the ledger, the warnings and the rail. The
result strip folds untouched wizards into one line. A full table no
longer begins by itself: the host begins at every size and may send a
bot away. Target lists carry health and condition, and an intent
sentence says what the reveal will do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-09-23 11:21:13 -04:00

80 lines
2.9 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 === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
else if (line.t === 'start') state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1);
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();