Two issues off the tracker, and they are halves of one thing: the end of a game. Neither ships on the 0.4.9 line — Jesse's call, that line may be complete and these are not fixes people mid-playtest need. EXTENDED PLAY (#11). The official result is settled at the original game length and never changes: in a five-Day game extended to eight, the winner is whoever led at the end of Day 5. Extending grants exactly one Day and the question is put again at the end of it — solitaire the player decides alone, multiplayer it is unanimous and one refusal ends it there. Only days-based endings offer it; a §3.4 collision breach is final, during an extended Day exactly as during the scheduled game. It could not be a client-side change. `check` refused every intent once `status` left `active`; the server never loads a `finished` game back into memory; and a save is `{ seed, config, history }` replayed through the engine, so a "continue" the history does not record did not happen. Hence a fourth status, `awaitingExtension`, and a `game.extend` intent. `config.days` never moves — `extraDays` counts the borrowed Days and `official` freezes the outcome, the standings and the statistics at the first ending. THE RESULTS SCREEN (#16). `GAME OVER — revenueFloor` was `outcome.reason`, an internal enum interpolated into the page at the one moment the game has the player's whole attention. Every reason now has a sentence with the game's own numbers in it. Around it: the result and winner, standings, the rules the game was dealt under, a per-player breakdown, and the railroad — trains through the Division and how many worked en route, loads made up and broken, passengers, cars switched, trains destroyed. It shares the Day-end dialog's blocks rather than reimplementing them, and stays reopenable so continuing does not cost you the results. Statistics are folded, not recorded: `state.tally` counts what the event stream says happened, hooked at `applyIntent` and `advance` because `reduce` never sees the phase driver's events — and those are the interesting ones. Nothing in the rules reads it, and it rides the Frame, so multiplayer gets the same numbers as solitaire from one implementation. THREE BUGS FOUND IN TESTING, all of which would have shipped: - a saved game containing a vote could not be resumed (NO_ACTOR). A history is a flat Intent[] with no seat recorded; the replay derives who acted from the turn order, which cannot work for an intent every seat may send in any order. `game.extend` carries its voter, checked against the authenticated seat. - an all-bot game hung on the question for ever. `driveBots` loops on `currentActor`, null the moment the game stops, so it cannot cast a vote, and the bot-vote driver returned early with no humans to follow. - the balance harness became unbounded — `test/sim.test.ts` went from under a second to never finishing. `randomBot` took another Day about half the time, so every seeded game ran to playGame's 50,000-turn cap. Fixed in the driver, not in a policy, so it holds for bots not yet written. All three have regression tests. 832 tests pass, against 793 before this change. NOT BUILT, and a correction. #16's own comment said `trainStoodStill` "is emitted per Stage, so a run of them is exactly the sat-on-a-siding streak". It is not: reading advance.ts, it fires once per game and only for a train whose profile sets `stopEarnsPoint` — the X18 Circus — with `stopPointClaimed` preventing a second. The streak was built, rendered "1 Stage at (0,0)", and was taken out again. There is no per-Stage "this train did not move" signal in the engine, so "longest an engine sat on a siding" needs one first; TODO.md #36 records what it would take, and the Circus set-up is reported instead. Badges remain the second pass #16 asks for (TODO.md #33), and because the statistics are derived rather than recorded, that pass can add any of them retroactively to games already played and saved. Extended play has not yet been played at a real table (TODO.md #35): the multiplayer vote has only been driven through `session.intent`, never through two browsers. Closes #11 Closes #16 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
200 lines
8.6 KiB
TypeScript
200 lines
8.6 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 trainCapSlack=0
|
|
*/
|
|
|
|
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`);
|
|
}
|