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
101 lines
4.8 KiB
TypeScript
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');
|
|
});
|
|
});
|