created harness to better test bots. improved bot play

This commit is contained in:
Jesse
2026-08-09 19:24:54 -04:00
parent 35575147cd
commit 4a800a053a
13 changed files with 6263 additions and 5503 deletions
+160
View File
@@ -0,0 +1,160 @@
/**
* 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 trainCapSlack=0
*/
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
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++) {
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 · ` +
`${game.state.officeAreas.get(0)?.grid.size ?? 0} 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)]}`,
);
let written = 0;
for (const p of best) {
const check = verifyReplays(p);
if (!check.ok) {
console.warn(` REFUSED seed ${p.seed} — ${check.why}`);
continue;
}
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}`,
history: p.save.history,
},
null,
1,
),
);
console.log(` wrote ${file} (${p.note}, ${check.why})`);
written += 1;
}
console.log(`${written} replay(s) saved — run \`npm run build:web\` to publish them`);
}