Files
station-master/test/switch-planner.test.ts
T
Jesse.MarkowitzandClaude Opus 5 76c6e103b3 v0.8.0.9 — the bot plans its switching turn, stops wasting its draws, and the engine walks each route once
The developer bot, re-measured decision by decision against the bot before it,
goes from about -0.3 revenue a game to about 4.8:

- plans the whole switching turn before its first Move (sim/switch-planner.ts),
  +2.89 over 1600 paired seeds; closes TODO #53
- takes a face-up card only if it could play it, +1.52 over 1600 seeds
- stops running Second Sections by accident in the New Train phase, +0.32
- lays track by what the district can do afterwards, +0.12 over 6400 seeds,
  run-arounds in 22 of 60 districts against 9

The engine is 2.8x faster with play proven identical: a route cache scoped to
one unchanged position, applyIntent split into prepareIntent + commitEvents,
and less allocation in exploreMoves. npm test now leaves out the bot
simulations, which run as npm run test:sim.

No rule changed; games in progress resume. Rejected candidates and the
Second Section card question are in CHANGELOG.md and TODO.md (#104-#106).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 15:30:42 -04:00

101 lines
4.8 KiB
TypeScript

/**
* The switching planner (`sim/switch-planner.ts`) — the two properties it cannot be allowed to lose.
*
* 1. PLANNING TOUCHES NOTHING. The planner applies intents to a partial copy of the game
* (`forkForSwitching`) that shares everything a switching intent is not supposed to write. If a
* reducer ever starts writing somewhere new, the copy leaks into the real game, and this is where
* that shows: the game is serialised before and after planning and must not have changed.
* 2. A PLAN IS WHAT THE ENGINE WILL DO. Every step replays through `applyIntent` on a FULL copy, and
* lands on the fingerprint the planner promised for it. That is what makes the partial copy
* trustworthy, and it is what the bot relies on to know it is still on plan.
*
* Taken from real seeded bot games rather than hand-built positions, because a district that
* satisfies the track geometry by hand tests the builder as much as the planner.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { applyIntent } from '../src/engine/apply.ts';
import { legalActions, legalSwitchingActions } from '../src/engine/legal.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
MOVES_PER_LOCAL_OPS,
collectiveRevenueFloor,
lengthProfile,
} from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { actingPlayer, turnOf } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { planSwitchingTurn, switchFingerprint } from '../src/sim/switch-planner.ts';
const config = (): GameConfig => {
const days = lengthProfile('short').days;
return {
mode: 'solitaire',
days,
minCombinedRevenue: collectiveRevenueFloor(1, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
};
const serialise = (s: GameState): string =>
JSON.stringify(s, (_k, v) => (v instanceof Map ? [...v] : v instanceof Set ? [...v] : v));
describe('switching planner', () => {
it('asks for switching intents that are exactly the switching subset of legalActions, in order', () => {
const SWITCHING = new Set(['switch.move', 'switch.dropCars', 'switch.sortConsist', 'maneuver.flyingSwitch', 'switch.end']);
let compared = 0;
for (const seed of [1000, 8919]) {
const s = createGame({ id: `legal-${seed}`, seed, config: config(), playerNames: ['bot'] });
playGame(s, developerBot, pump, 50_000, undefined, (st) => {
const p = actingPlayer(st);
if (p === null || st.clock.phase !== 'localOps') return;
const all = legalActions(st, p).filter((i) => SWITCHING.has(i.type));
assert.deepEqual(legalSwitchingActions(st, p), all);
compared++;
});
}
assert.ok(compared > 0, 'no Local Operations decision was reached');
});
it('never changes the game it plans for, and every plan replays to the position it promised', () => {
let checked = 0;
let withSteps = 0;
for (const seed of [1000, 8919, 16838]) {
const s = createGame({ id: `plan-${seed}`, seed, config: config(), playerNames: ['bot'] });
const r = playGame(s, developerBot, pump, 50_000, undefined, (st) => {
const p = actingPlayer(st);
if (p === null || st.clock.phase !== 'localOps') return;
const turn = turnOf(st, p);
if (turn.option !== 'switch' || turn.movesRemaining !== MOVES_PER_LOCAL_OPS) return;
const before = serialise(st);
const plan = planSwitchingTurn(st, p, { budget: 400, beam: 16 });
assert.equal(serialise(st), before, `seed ${seed}: planning wrote into the real game`);
assert.ok(plan.score >= plan.rootScore, 'a plan is never worse than stopping where the crew stands');
const copy = structuredClone(st);
plan.steps.forEach((step, n) => {
assert.equal(switchFingerprint(copy, p), plan.keys[n], `seed ${seed}: step ${n} started off plan`);
const applied = applyIntent(copy, p, step);
assert.ok(applied.ok, `seed ${seed}: step ${n} (${step.type}) was refused by the engine`);
});
assert.equal(switchFingerprint(copy, p), plan.keys.at(-1), `seed ${seed}: the plan did not end where it said`);
checked++;
if (plan.steps.length > 0) withSteps++;
});
assert.ok(r.finished, `seed ${seed}: a game with the planner switched on did not finish`);
}
assert.ok(checked > 0, 'no switching turn was reached, so nothing was tested');
assert.ok(withSteps > 0, 'every plan was empty, so replay was never exercised');
});
});