Files
station-master/test/route-cache.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

89 lines
3.9 KiB
TypeScript

/**
* The engine's speed-ups must not change a single game (2026-09-15).
*
* `applyIntent` became `prepareIntent` (check and execute, sharing one walk of the position's routes)
* followed by `commitEvents` (reduce and tally), so the switching planner can decide every candidate
* against one position and apply each to a copy. Two properties hold that together:
*
* 1. `prepareIntent` never writes the state it reads — including through the route cache it opens.
* 2. Preparing on one state and committing to an EQUAL copy lands on exactly what `applyIntent` does.
*
* Checked at every decision of seeded bot games rather than on hand-built positions, so the intents
* exercised are the ones real play submits.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { applyIntent, commitEvents, prepareIntent } from '../src/engine/apply.ts';
import {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
collectiveRevenueFloor,
lengthProfile,
} from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.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('applyIntent split into prepareIntent and commitEvents', () => {
it('prepares without writing, and committing to a copy matches applying in place', () => {
let decisions = 0;
let rejectedSeen = 0;
const s = createGame({ id: 'split-8919', seed: 8919, config: config(), playerNames: ['bot'] });
const policy = {
name: 'split-probe',
choose(st: GameState, player: number, options: ReturnType<typeof legalActions>) {
const chosen = developerBot.choose(st, player, options);
if (decisions < 400) {
decisions++;
const before = serialise(st);
const prepared = prepareIntent(st, player, chosen);
assert.equal(serialise(st), before, `decision ${decisions}: prepareIntent wrote into the state it read`);
assert.ok(prepared.ok, `decision ${decisions}: a legal choice was refused by prepareIntent`);
const viaCommit = structuredClone(st);
const viaApply = structuredClone(st);
commitEvents(viaCommit, prepared.events);
const applied = applyIntent(viaApply, player, chosen);
assert.ok(applied.ok);
assert.deepEqual(applied.events, prepared.events, `decision ${decisions}: the two paths produced different events`);
assert.equal(serialise(viaCommit), serialise(viaApply), `decision ${decisions}: committing to a copy diverged from applying`);
// A refused intent must come back refused from both paths, with nothing written.
const refused = { type: 'switch.end' } as const;
const r = prepareIntent(st, player, refused);
if (!r.ok) {
rejectedSeen++;
assert.equal(serialise(st), before);
assert.equal(applyIntent(structuredClone(st), player, refused).ok, false);
}
}
return chosen;
},
};
const r = playGame(s, policy, pump);
assert.ok(r.finished, 'the probed game did not finish');
assert.ok(decisions > 0, 'no decision was probed');
assert.ok(rejectedSeen > 0, 'no refused intent was exercised');
});
});