created harness to better test bots. improved bot play
This commit is contained in:
+35
-3
@@ -229,11 +229,23 @@ export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): bool
|
||||
return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && !c.loaded);
|
||||
}
|
||||
|
||||
/** §9.2 — de-training needs an open red slot and a train carrying a loaded coach. */
|
||||
/**
|
||||
* §9.2 — de-training needs an open red slot, a train carrying a loaded coach, AND AN EMPTY COACH IN
|
||||
* THE DIVISION YARD.
|
||||
*
|
||||
* "*Requirements: a white empty coach in the Division Yard and an unoccupied red Unloading slot.
|
||||
* Replace the blue coach on the train with the white one.*" The empty coach is where the passengers
|
||||
* were sitting — it has to come from somewhere, and the rule says where.
|
||||
*
|
||||
* That requirement was missing, and the reducer conjured the coach rather than taking it, so every
|
||||
* de-training MINTED a coach: the loaded one went to the red box and a new empty one appeared in the
|
||||
* train. Measured at 1.29 cars a game created out of nothing across the two inbound paths.
|
||||
*/
|
||||
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
|
||||
const f = facilityAt(s, player, at);
|
||||
if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
|
||||
if (f.inboundBox.length >= f.capacity.inbound) return false;
|
||||
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return false;
|
||||
return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && c.loaded);
|
||||
}
|
||||
|
||||
@@ -557,6 +569,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
|
||||
const car = f.industryTrack.cars[i.carIndex];
|
||||
if (!car || !car.loaded) return 'WRONG_CAR_TYPE';
|
||||
/**
|
||||
* §9.3 — "*Requirements: a load on the industry's track AND AN EMPTY CAR OF THAT TYPE IN THE
|
||||
* DIVISION YARD. The first Laborer replaces the load with an empty car of that type.*"
|
||||
*
|
||||
* The empty car requirement was not checked and the reducer conjured the car rather than
|
||||
* taking it, so every unload minted one. Unloading is meant to consume supply.
|
||||
*/
|
||||
if (!s.yards.divisionYard.some((c) => c.type === car.type && !c.loaded)) return 'NO_SUITABLE_CAR';
|
||||
// The load is placed on WORK, the last box, so that box must be free.
|
||||
return f.menAtWork[f.menAtWork.length - 1] === null ? null : 'BOX_FULL';
|
||||
}
|
||||
@@ -1399,8 +1419,13 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
const tray = s.trays.get(id);
|
||||
const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1;
|
||||
if (tray && ci >= 0) {
|
||||
// The empty coach comes OUT OF THE DIVISION YARD, as §9.2 says. It used to be conjured,
|
||||
// which minted a coach on every de-training.
|
||||
const yi = s.yards.divisionYard.findIndex((c) => c.type === 'coach' && !c.loaded);
|
||||
const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: 'coach' as const, loaded: false };
|
||||
refillDivisionYardIfEmpty(s);
|
||||
f.inboundBox.push(tray.consist[ci]!);
|
||||
tray.consist[ci] = { type: 'coach', loaded: false };
|
||||
tray.consist[ci] = empty;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1448,7 +1473,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'unloadBegan': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
|
||||
if (ci >= 0) f.industryTrack.cars[ci] = { type: e.carType, loaded: false };
|
||||
if (ci >= 0) {
|
||||
// §9.3 — the replacement empty comes out of the Division Yard. Conjuring it here is what
|
||||
// minted a car on every unload, and it also skipped a requirement the rule states.
|
||||
const yi = s.yards.divisionYard.findIndex((c) => c.type === e.carType && !c.loaded);
|
||||
const empty = yi >= 0 ? s.yards.divisionYard.splice(yi, 1)[0]! : { type: e.carType, loaded: false };
|
||||
refillDivisionYardIfEmpty(s);
|
||||
f.industryTrack.cars[ci] = empty;
|
||||
}
|
||||
f.menAtWork[f.menAtWork.length - 1] = { type: e.carType, dir: 'in' };
|
||||
f.usedThisStage.laborers += 1;
|
||||
break;
|
||||
|
||||
+146
-24
@@ -57,6 +57,7 @@ function ruleOnClearance(options: Intent[]): Intent | null {
|
||||
return options.find((i) => i.type === 'mainline.clearance' && i.allow === false) ?? null;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -91,16 +92,28 @@ function because(reason: string, intent: Intent): Intent {
|
||||
* changelog. What must not happen is a bot that accumulates switches nobody can account for — a
|
||||
* heuristic with no measurement attached is exactly what this machinery exists to prevent.
|
||||
*/
|
||||
/**
|
||||
* ABLATIONS, for re-measuring the heuristics that are now the bot's default play.
|
||||
*
|
||||
* Every flag here turns something OFF. That is the opposite of how this started — the tweaks were
|
||||
* candidates to switch on — and it is the right shape once a candidate has been adopted: what a
|
||||
* measured heuristic needs afterwards is a way to ask "is this still worth it?" when the deck or
|
||||
* the rules move under it. Both of these were worth about +1.5 revenue together when adopted; if a
|
||||
* rebalance changes the economy, that is a claim to re-test rather than to assume.
|
||||
*
|
||||
* The candidates that did NOT survive are gone rather than left switched off: preferring coaches at
|
||||
* make-up, stocking the platform first, playing Interlocking earlier, hunting the Depot in the
|
||||
* Departments, refusing to bury the engine, holding track for a run-around, reserving Moves to get
|
||||
* home, granting clearance to a leaving train, and discarding capped train cards. Their measurements
|
||||
* are in CHANGELOG.md; the code is not, because a switch nobody turns on is a switch nobody
|
||||
* maintains.
|
||||
*/
|
||||
export type BotTweaks = {
|
||||
/**
|
||||
* Refuse to schedule a train the Office has no room for: cap committed trains (timetable slots
|
||||
* filled, plus queued Extras) at `adTracks + trainCapSlack`.
|
||||
*
|
||||
* `undefined` means no cap — today's behaviour, which plays every train card on sight. A Whistle
|
||||
* Post has ONE A/D track, and a second train standing there makes every later arrival an
|
||||
* automatic collision (Gap 2d).
|
||||
*/
|
||||
trainCapSlack?: number;
|
||||
/** Play every train card on sight, as the bot did before the A/D capacity cap. */
|
||||
noTrainCap?: boolean;
|
||||
/** Draw whenever nothing is urgent, as the bot did before it preferred operating. */
|
||||
noOperateFirst?: boolean;
|
||||
|
||||
};
|
||||
|
||||
/** The bot as it plays today. Every knob off. */
|
||||
@@ -178,6 +191,17 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
||||
* Never to an empty list — a bot with nothing legal to choose is a crash, and §6.2 can force
|
||||
* a play when the hand is over its limit. If filtering leaves nothing, the cap yields.
|
||||
*/
|
||||
/**
|
||||
* A CAP HAS TO BE APPLIED TO THE OPTIONS, NOT TO ONE BRANCH.
|
||||
*
|
||||
* The first attempt gated the two branches that exist to play a train card, measured exactly
|
||||
* zero difference over 400 paired seeds, and was right to: `followThrough` ends with a generic
|
||||
* "play what is in hand" fallback that played the card anyway. Removing the option itself is
|
||||
* the only way to be sure no path reaches it.
|
||||
*
|
||||
* Never to an empty list — a bot with nothing legal to choose is a crash, and §6.2 can force
|
||||
* a play when the hand is over its limit. If filtering leaves nothing, the cap yields.
|
||||
*/
|
||||
const held = trainWouldOverfillTheOffice(s, player, tweaks)
|
||||
? options.filter((i) => !(i.type === 'card.play' && isTrainCard(s, i.cardId)))
|
||||
: options;
|
||||
@@ -220,9 +244,36 @@ function committedTrains(s: GameState): number {
|
||||
* once the A/D tracks are spoken for. Off unless `trainCapSlack` is set.
|
||||
*/
|
||||
function trainWouldOverfillTheOffice(s: GameState, player: PlayerIndex, tweaks: BotTweaks): boolean {
|
||||
if (tweaks.trainCapSlack === undefined) return false;
|
||||
const cap = officeProfile(areaOf(s, player).tier).adTracks + tweaks.trainCapSlack;
|
||||
return committedTrains(s) >= cap;
|
||||
if (tweaks.noTrainCap) return false;
|
||||
return committedTrains(s) >= officeProfile(areaOf(s, player).tier).adTracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Would running here leave the engine buried among its own cars?
|
||||
*
|
||||
* Cars met on a FORWARD move couple onto the nose (§A.3), which pushes the engine back through its
|
||||
* own train — `carsCoupled` moves `engineAt` by the number taken. So the engine ends up buried
|
||||
* whenever it had cars behind it already and picks up more in front, and §8.2 then refuses to let
|
||||
* the train leave the Office. Only trains care: a local crew has nowhere it must depart from.
|
||||
*
|
||||
* Asked of the engine's own destination list, so the count is the count that will really couple.
|
||||
*/
|
||||
function wouldBuryTheEngine(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
move: Extract<Intent, { type: 'switch.move' }>,
|
||||
): boolean {
|
||||
const tray = s.trays.get(move.trayId);
|
||||
if (!tray || tray.trainNumber === null) return false;
|
||||
if (tray.position.at !== 'grid') return false;
|
||||
if (move.reverse) return false; // cars taken while backing up couple BEHIND the engine
|
||||
const len = tray.consist.length;
|
||||
if (len === 0 || tray.engineAt >= len) return false; // nothing behind the engine to bury it against
|
||||
|
||||
const dest = destinationsFor(s, player, move.trayId, tray.position.coord, false).find(
|
||||
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
|
||||
);
|
||||
return (dest?.couples.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function chooseLocalOption(
|
||||
@@ -239,6 +290,35 @@ function chooseLocalOption(
|
||||
const area = areaOf(s, player);
|
||||
const hand = s.decks.hands.get(player) ?? [];
|
||||
|
||||
/**
|
||||
* OPERATE BEFORE DRAWING.
|
||||
*
|
||||
* The bot drew 47 of its 60 turns and played 24 cards, and the funnel said why that was wrong:
|
||||
* only 8% of Cargo phases had a stocked green box — the scarcest thing in the whole economy — and
|
||||
* the bot was spending the turns that would have filled one on another card instead. Preferring
|
||||
* the work to the lottery raises green-stocked phases by 60% and freight revenue by 39%.
|
||||
*
|
||||
* MEASURED AT +0.48 ± 0.07 (t = 6.60) over 1600 paired seeds, and 317 seeds better against 77
|
||||
* worse — the broadest improvement of anything tried, where the A/D cap below is a narrow rescue
|
||||
* of a rare catastrophe.
|
||||
*
|
||||
* NO BUILDING PHASE, though the idea arrived as "build for a Day, then switch". Reserving the
|
||||
* first Day, or two, for development measures the same (+0.52, +0.56) with a worse better/worse
|
||||
* ratio, and worse again alongside the cap. An explicit build phase spends turns on development
|
||||
* the draw-priority ordering was already doing.
|
||||
*/
|
||||
if (!tweaks.noOperateFirst) {
|
||||
if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) {
|
||||
return because('a train is at the Office and there is work to do on it — that beats another draw', can('switch')!);
|
||||
}
|
||||
if (can('freightAgent') && (canStockProductively(s, player) || hasStuckLoad(s, player) || needsClearing(s, player))) {
|
||||
return because('feed the pipeline rather than draw another card — a stocked green box is work, a draw is a lottery', can('freightAgent')!);
|
||||
}
|
||||
if (can('switch') && strandedFromOffice(s, player)) {
|
||||
return because('the crew must get back to the Office to depart at all', can('switch')!);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Trains first, always. A scheduled train runs EVERY Day thereafter, so it is the only card
|
||||
// whose value compounds. Playing one needs the draw option.
|
||||
// An Office upgrade outranks even a train card: a train that arrives with nowhere to stand is a
|
||||
@@ -295,7 +375,9 @@ function chooseLocalOption(
|
||||
return because('a facility is jammed or its red box is full, blocking the pipeline', can('freightAgent')!);
|
||||
}
|
||||
|
||||
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
|
||||
// 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay —
|
||||
// unless a green box could be filled, in which case a turn spent stocking converts directly into
|
||||
// work a Laborer can do, where a drawn card may be neither playable nor useful.
|
||||
if (can('draw')) return because('nothing urgent — develop the district instead', can('draw')!);
|
||||
// Freight Agent is deliberately NOT the idle default. Reaching its follow-through with nothing
|
||||
// productive to do is what fed the unjam fallback that discarded loads; switching, at worst,
|
||||
@@ -343,14 +425,25 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null {
|
||||
|
||||
/** A face-up card worth spending the draw on rather than gambling on the deck. */
|
||||
function isWorthTaking(s: GameState, slot: number): boolean {
|
||||
return takingRank(s, slot) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* HOW BADLY the face-up card is wanted. 0 means not worth the draw.
|
||||
*
|
||||
* A bare boolean made an Office card and a train card tie, so the winner was whichever Department
|
||||
* happened to be scanned first — a coin flip on the card that decides whether the district ever
|
||||
* becomes a Passenger Facility at all.
|
||||
*/
|
||||
function takingRank(s: GameState, slot: number): number {
|
||||
const id = topOfDepartment(s, slot);
|
||||
if (!id) return false;
|
||||
if (!id) return 0;
|
||||
const k = s.cards.get(id)?.kind;
|
||||
if (!k) return false;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return true;
|
||||
if (k.kind === 'freightFacility') return true;
|
||||
if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier;
|
||||
return false;
|
||||
if (!k) return 0;
|
||||
if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier ? 3 : 0;
|
||||
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
|
||||
if (k.kind === 'freightFacility') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Does any facility want this particular car? Tolerates an undefined end car. */
|
||||
@@ -803,6 +896,14 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
||||
// Measured: capping this to one or two ways down cost more than the spare turnouts did
|
||||
// (revenue 3.3 -> 2.5, freight 1.1 -> 0.6). More ways off the main means more industries the
|
||||
// crew can actually reach, which matters more than a tidy Running Track.
|
||||
/**
|
||||
* A SECOND WAY DOWN, WHILE THE FIRST ONE IS STILL A STUB.
|
||||
*
|
||||
* Ways off the main are individually valuable — capping them measured worse — but one that is
|
||||
* cut while an earlier run has never climbed back spends the supply on breadth when the thing
|
||||
* that makes switching solvable is one run that closes. `openRuns` asks the engine which runs
|
||||
* descend from the main and never rejoin it.
|
||||
*/
|
||||
bonus += turnouts.length === 0 ? 14 : 3;
|
||||
} else if (Math.abs(row - area.runningRow) === 1) {
|
||||
// Which side of the main this square is on. Both are ordinary now: a turnout turned 180°
|
||||
@@ -1031,9 +1132,17 @@ function followThrough(
|
||||
// card unconditionally meant the bot never once drew blind from the deck across 60 games,
|
||||
// which the anomaly detector correctly flagged: a whole branch of §6.2 going unexercised.
|
||||
if (!s.turn.drawnThisTurn) {
|
||||
const useful = options.find(
|
||||
(i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
|
||||
const piles = options.filter(
|
||||
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
|
||||
i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
|
||||
);
|
||||
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
|
||||
// both "qualify", and only one of them stops the collisions.
|
||||
// Best-ranked pile, not the first that qualifies. Measured as a near no-op — an Office card
|
||||
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
|
||||
// ranking function is for, and a coin flip on the card that decides whether the district
|
||||
// ever becomes a Passenger Facility is not worth keeping for its own sake.
|
||||
const useful = piles.sort((a, b) => takingRank(s, b.slot) - takingRank(s, a.slot))[0];
|
||||
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
|
||||
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
|
||||
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
|
||||
@@ -1410,12 +1519,14 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
|
||||
// de-trained. Only ever loading empty coaches means no train ever brings anyone to de-train —
|
||||
// which is exactly what happened: zero de-trainings across a whole game.
|
||||
const office = areaOf(s, player).grid.get(coordKey(areaOf(s, player).officeCoord))?.facility;
|
||||
const coaches: WantedCar[] = [];
|
||||
if (office?.kind === 'passenger' && office.porters > 0) {
|
||||
const arrivalsWaiting = office.inboundBox.length < office.capacity.inbound;
|
||||
if (arrivalsWaiting) out.push({ type: 'coach', loaded: true });
|
||||
if (arrivalsWaiting) coaches.push({ type: 'coach', loaded: true });
|
||||
}
|
||||
out.push({ type: 'coach', loaded: false });
|
||||
return out;
|
||||
coaches.push({ type: 'coach', loaded: false });
|
||||
|
||||
return [...out, ...coaches];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1580,6 +1691,14 @@ export type PlayOutcome = {
|
||||
events: GameEvent[];
|
||||
/** Every intent the bot actually submitted, for action-mix analysis. */
|
||||
intents: Intent['type'][];
|
||||
/**
|
||||
* The same intents in full, which is a SAVE: `{ seed, history }` replays exactly through the
|
||||
* engine, so a game the bot played can be watched in the site's replay viewer.
|
||||
*
|
||||
* Kept alongside the type list rather than replacing it — `summarize` counts types and has no use
|
||||
* for the payloads, and a bot game is 300-odd intents either way.
|
||||
*/
|
||||
history: Intent[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1617,6 +1736,7 @@ export function playGame(
|
||||
): PlayOutcome {
|
||||
const events: GameEvent[] = [];
|
||||
const intents: Intent['type'][] = [];
|
||||
const history: Intent[] = [];
|
||||
let collisions = 0;
|
||||
let trainsScheduled = 0;
|
||||
let cardsPlayed = 0;
|
||||
@@ -1652,6 +1772,7 @@ export function playGame(
|
||||
onTurn?.(s);
|
||||
const chosen = policy.choose(s, actor, options);
|
||||
intents.push(chosen.type);
|
||||
history.push(chosen);
|
||||
const r = applyIntent(s, actor, chosen);
|
||||
if (!r.ok) throw new Error(`${policy.name} bot chose an illegal action: ${r.code}`);
|
||||
tally(r.events);
|
||||
@@ -1668,5 +1789,6 @@ export function playGame(
|
||||
outcome: s.outcome,
|
||||
events,
|
||||
intents,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
+23
-8
@@ -21,8 +21,12 @@
|
||||
* abandoned an expensive channel for a cheap one, and the two are identical in a single number.
|
||||
*
|
||||
* Run with:
|
||||
* node src/sim/compare.ts 1600 trainCapSlack=1
|
||||
* node src/sim/compare.ts 400 trainCapSlack=0 --length short
|
||||
* node src/sim/compare.ts 1600 noTrainCap=1 — what the A/D cap is worth today
|
||||
* node src/sim/compare.ts 1600 noOperateFirst=1 — what operating before drawing is worth
|
||||
*
|
||||
* The flags are ABLATIONS: they turn off heuristics the bot already plays, so a negative delta is
|
||||
* the heuristic earning its place. That is what a measured bot needs going forward — the question
|
||||
* is no longer "does this help?" but "is this still true after the deck moved?".
|
||||
*/
|
||||
|
||||
import { pump } from '../engine/advance.ts';
|
||||
@@ -198,17 +202,28 @@ export function formatPaired(r: PairedResult): string {
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `trainCapSlack=1` -> `{ trainCapSlack: 1 }`. Unknown names are refused rather than ignored. */
|
||||
/**
|
||||
* `noTrainCap=1` -> `{ noTrainCap: true }`.
|
||||
*
|
||||
* Unknown names THROW rather than being ignored: a typo parsed as "no tweaks" would compare the bot
|
||||
* against itself and report a confident zero, which is the most expensive way this tool could fail.
|
||||
*/
|
||||
export const NUMERIC_TWEAKS = new Set<string>([]);
|
||||
export const BOOLEAN_TWEAKS = new Set(['noTrainCap', 'noOperateFirst']);
|
||||
|
||||
export function parseTweaks(args: string[]): BotTweaks {
|
||||
const known = new Set(['trainCapSlack']);
|
||||
const tweaks: Record<string, number> = {};
|
||||
const tweaks: Record<string, number | boolean> = {};
|
||||
for (const a of args) {
|
||||
const m = /^([A-Za-z]\w*)=(-?\d+(?:\.\d+)?)$/.exec(a);
|
||||
if (!m) continue;
|
||||
if (!known.has(m[1]!)) {
|
||||
throw new Error(`unknown tweak "${m[1]}" — known: ${[...known].join(', ')}`);
|
||||
const name = m[1]!;
|
||||
if (NUMERIC_TWEAKS.has(name)) tweaks[name] = Number(m[2]);
|
||||
else if (BOOLEAN_TWEAKS.has(name)) tweaks[name] = Number(m[2]) !== 0;
|
||||
else {
|
||||
throw new Error(
|
||||
`unknown tweak "${name}" — known: ${[...NUMERIC_TWEAKS, ...BOOLEAN_TWEAKS].join(', ')}`,
|
||||
);
|
||||
}
|
||||
tweaks[m[1]!] = Number(m[2]);
|
||||
}
|
||||
return tweaks as BotTweaks;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
Reference in New Issue
Block a user