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
77 lines
3.0 KiB
TypeScript
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();
|