The third release from the audit; nothing a player sees changes. CHANGELOG has the detail. The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were dead bot functions from rejected candidates the round said it had deleted. The documents no longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2), model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted); the README's account of bot flags now matches the bot's. Five playtest saves committed in `docs/` against the repository's own rule are in the ignored `playtests/`. What the audit found and did not fix is written down as TODO #112-#117, each with its reason. #112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 — `/api/save` hands a seat the seed mid-game — waits on a conversation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
200 lines
8.5 KiB
TypeScript
200 lines
8.5 KiB
TypeScript
/**
|
|
* Component 18c — save a bot game as a replay the site can play back.
|
|
*
|
|
* Dev-side. Finds the best games a policy produces and writes them to `public/replays/`, where the
|
|
* build publishes them and the replay viewer lists them.
|
|
*
|
|
* WHY IT PLAYS THROUGH `web/game.ts` RATHER THAN THE HARNESS. A replay is `{ seed, history }` and
|
|
* the viewer reconstructs it by replaying those intents through `fromSave` — so the game that gets
|
|
* saved has to have been played through exactly the setup `newGame(seed)` produces. The harness
|
|
* builds its games with a different id and player name, and while neither feeds the RNG today,
|
|
* "neither of these matters" is precisely the assumption that rots. Playing the real thing costs a
|
|
* few lines and cannot drift.
|
|
*
|
|
* EVERY SAVE IS VERIFIED BEFORE IT IS WRITTEN. `TODO.md` records both published replays going dead
|
|
* without anyone noticing — one got 42 intents into 360 — because a save from an older ruleset stops
|
|
* replaying rather than failing loudly. So each candidate is replayed through `fromSave` here, and
|
|
* refused unless it lands on the same position: same revenue, same Day, same intent count.
|
|
*
|
|
* Run with:
|
|
* node src/sim/save-replay.ts 400 --top 3
|
|
* node src/sim/save-replay.ts 400 --top 3 noValueLays=1
|
|
*/
|
|
|
|
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { areaAtSeat } from '../engine/apply.ts';
|
|
import { legalActions } from '../engine/legal.ts';
|
|
import type { BotPolicy } from './bot.ts';
|
|
import { developerBot, makeDeveloperBot } from './bot.ts';
|
|
import { parseTweaks } from './compare.ts';
|
|
import { currentActor, fromSave, newGame, submit, toSave } from '../web/game.ts';
|
|
import type { Save } from '../web/game.ts';
|
|
|
|
export type PlayedGame = {
|
|
seed: number;
|
|
revenue: number;
|
|
days: number;
|
|
save: Save;
|
|
/** A sentence about how it went, for the replay list. */
|
|
note: string;
|
|
};
|
|
|
|
/**
|
|
* One game, played by `policy` through the same entry points the browser uses.
|
|
*
|
|
* `submit` is the page's own action path — it applies the intent, narrates it and pumps the engine
|
|
* forward — so the history this accumulates is a save in the literal sense: the file the Save
|
|
* button would have written.
|
|
*/
|
|
export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000): PlayedGame {
|
|
const game = newGame(seed);
|
|
for (let t = 0; t < maxTurns; t++) {
|
|
/**
|
|
* §3.3, EXTENDED PLAY (Gitea#11) — a recorded replay is a game played to its end.
|
|
*
|
|
* The timetable running out leaves the game on "play one more Day?", where `currentActor` is
|
|
* null and this loop would otherwise stop — recording a file that replays to a question nobody
|
|
* answered rather than to a finished game. A recording bot plays the timetable it was dealt, the
|
|
* same rule `playGame` follows, so it declines and the file ends where a real game would.
|
|
*/
|
|
if (game.state.status === 'awaitingExtension') {
|
|
const voter = game.state.extensionVotes.findIndex((v) => v === null);
|
|
if (voter < 0) break;
|
|
if (!submit(game, { type: 'game.extend', player: voter, agree: false }, voter)) break;
|
|
continue;
|
|
}
|
|
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
// `submit` is the page's own action path: it applies the intent, narrates it, and pumps the
|
|
// engine on. It returns false only if the engine rejects the move, which a bot must never do.
|
|
if (!submit(game, policy.choose(game.state, actor, options))) break;
|
|
}
|
|
|
|
const revenue = game.state.players[0]?.revenue ?? 0;
|
|
const collisions = game.log.filter((l) => /COLLISION/i.test(l.text)).length;
|
|
const trains = new Set(game.state.timetable.filter((n) => n !== null)).size;
|
|
|
|
return {
|
|
seed,
|
|
revenue,
|
|
days: game.state.clock.day,
|
|
save: toSave(game),
|
|
note:
|
|
`${revenue} Revenue over ${game.state.clock.day - 1} Days · ${trains} train(s) on the timetable · ` +
|
|
`${areaAtSeat(game.state, 0).grid.size} cards down` +
|
|
(collisions > 0 ? ` · ${collisions} collision(s)` : ' · no collisions'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Replay the save and check it lands where the game did.
|
|
*
|
|
* The whole failure mode this guards against is silent: `fromSave` stops at the first intent the
|
|
* rules no longer accept and returns a SHORTER game, which looks like a game that simply ended
|
|
* early. Comparing the intent count is what catches that; comparing the revenue catches the subtler
|
|
* case where the same moves produce a different result.
|
|
*/
|
|
export function verifyReplays(played: PlayedGame): { ok: boolean; why: string } {
|
|
const back = fromSave(played.save);
|
|
const got = back.state.players[0]?.revenue ?? 0;
|
|
if (back.history.length !== played.save.history.length) {
|
|
return {
|
|
ok: false,
|
|
why: `replay stopped after ${back.history.length} of ${played.save.history.length} intents`,
|
|
};
|
|
}
|
|
if (got !== played.revenue) return { ok: false, why: `replay scored ${got}, the game scored ${played.revenue}` };
|
|
if (back.state.clock.day !== played.days) {
|
|
return { ok: false, why: `replay ended on Day ${back.state.clock.day}, the game on Day ${played.days}` };
|
|
}
|
|
return { ok: true, why: 'replays exactly' };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const isMain = process.argv[1]?.endsWith('save-replay.ts') ?? false;
|
|
|
|
if (isMain) {
|
|
const args = process.argv.slice(2);
|
|
const games = Number(args.find((a) => /^\d+$/.test(a)) ?? 400);
|
|
const topIdx = args.indexOf('--top');
|
|
const top = topIdx >= 0 ? Number(args[topIdx + 1]) : 3;
|
|
const tweaks = parseTweaks(args);
|
|
const policy = Object.keys(tweaks).length > 0 ? makeDeveloperBot(tweaks) : developerBot;
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const dest = join(root, 'public/replays');
|
|
|
|
console.log(`playing ${games} games with ${policy.name}, keeping the best ${top}`);
|
|
const played: PlayedGame[] = [];
|
|
for (let i = 0; i < games; i++) {
|
|
played.push(playForReplay(1000 + i * 7919, policy));
|
|
}
|
|
played.sort((a, b) => b.revenue - a.revenue || a.save.history.length - b.save.history.length);
|
|
|
|
const best = played.slice(0, top);
|
|
const revenues = played.map((p) => p.revenue).sort((a, b) => b - a);
|
|
console.log(
|
|
` best ${revenues.slice(0, 5).join(', ')} · median ${revenues[Math.floor(revenues.length / 2)]}`,
|
|
);
|
|
|
|
const verified = best.filter((p) => {
|
|
const check = verifyReplays(p);
|
|
if (!check.ok) console.warn(` REFUSED seed ${p.seed} — ${check.why}`);
|
|
return check.ok;
|
|
});
|
|
|
|
/**
|
|
* THE PUBLISHED SET IS REPLACED, NOT ADDED TO.
|
|
*
|
|
* Every file here is named for its seed, so re-recording used to leave the previous set sitting
|
|
* beside the new one — and the previous set is precisely the one whose rules have just moved. The
|
|
* point of re-recording is that those files are dead; keeping them means the site serves dead
|
|
* replays and `harness.test.ts` fails on them forever, which is how the last two went unnoticed.
|
|
*
|
|
* Only ever run after at least one replacement verifies, so a run that produces nothing publishable
|
|
* leaves what is already published alone.
|
|
*/
|
|
if (verified.length > 0) {
|
|
for (const old of readdirSync(dest).filter((f) => f.endsWith('.json') && f !== 'manifest.json')) {
|
|
if (verified.some((p) => `seed-${p.seed}.json` === old)) continue;
|
|
unlinkSync(join(dest, old));
|
|
console.log(` retired ${old} — recorded under rules that have since moved`);
|
|
}
|
|
}
|
|
|
|
let written = 0;
|
|
for (const p of verified) {
|
|
const file = join(dest, `seed-${p.seed}.json`);
|
|
writeFileSync(
|
|
file,
|
|
JSON.stringify(
|
|
{
|
|
seed: p.seed,
|
|
// Short enough to read in a list. Which variant played it belongs in the note, where
|
|
// there is room — a title carrying seven tweak names is a title nobody reads.
|
|
title: `${p.revenue} Revenue · seed ${p.seed}`,
|
|
note: `${p.note} · played by ${policy.name}`,
|
|
// The house rules it was DEALT under, without which the seed does not name this game and
|
|
// the file replays as something else — see `Save.rules` in `web/game.ts`.
|
|
...(p.save.rules ? { rules: p.save.rules } : {}),
|
|
history: p.save.history,
|
|
},
|
|
null,
|
|
1,
|
|
),
|
|
);
|
|
console.log(` wrote ${file} (${p.note})`);
|
|
written += 1;
|
|
}
|
|
console.log(`${written} replay(s) saved — run \`npm run build:web\` to publish them`);
|
|
}
|