Files
game-kit/template/deploy/replay-ledgers.ts
T
Eric WagonerandClaude Fable 5.1 33ac0ec808 The credibility pass on Hnefatafl, ported to the template: the ops scripts count traffic through one parser, the reports digest is one program, the plaintext-token fallback and its migration are gone, and the stylesheet holds each shared rule once
From the hnefatafl repo's pass of the same day, everything that touched a
kit-shared file. The visitors digest and the nightly rollup share
deploy/traffic.py, installed to /usr/local/lib/<slug>; the rollup writes
the finished-games count it computed behind "and False". pull-reports.sh
and the reports skill both use deploy/report-digest.ts. The seat line
requires its token hash and the start line its rules revision; the
migration for ledgers written before hashing goes with them. The route
table in server/src/index.ts lists every route; Report, ReportLine and
Tally are declared once in view.ts for both sides; exports nobody
imported are exports no more.

In the client: .small, the × that dismisses, and the frame of the
reading pages are in app.css once; the preferences panel shares the
report slip's modal shape; the room store gains seatEmpty and
seatUnheld, and the lobby and the join page read those instead of
three spellings of their own. The room's moved and awaiting fields
stay: the demo board reads them, and simultaneous rounds are the
contract.

The deploy README no longer describes a browser-only game; the visitors
skill no longer names a /play route; the reports skill's replay call
carries the room's options. The Slack channel id is passed in the
environment rather than filled in as a placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-09-23 20:12:37 -04:00

77 lines
3.0 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;
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;
// The host's table options ride the room line; a game created without them is a different game.
const options = (lines[0]?.t === 'room' ? lines[0].options : undefined) as Record<string, string> | undefined;
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, options);
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';
if (host && state) {
// The gallery's view carries the round and the outcome, which is all the comparison needs.
const res = await fetch(`${host}/api/rooms/${code}`);
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();