The game kit: the shared infrastructure of Wiz-War and Waving Hands as a template

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 11:00:05 -04:00
co-authored by Claude Fable 5.1
commit 1cd24e3ddd
59 changed files with 7420 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
// 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 === '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();