/** * 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`); }