/** * 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, // These fixtures were written against a Whistle Post opening — one A/D track and no // Control Point — and several of them test exactly that. Named explicitly since the // default became a Depot. houseRules: { startingOffice: 'whistlePost' }, 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; /** * EIGHT SEEDS, NOT THREE. Adding the Second Section card to the deck (Q9) reshuffles every * seeded deal, and none of the first three produced a non-empty plan any more — so `withSteps` * below, which is what proves the replay path is exercised at all, fell to zero. Widened on the * same stride rather than weakening the assertion; TODO #84 is about exactly this fixture shape. */ for (const seed of [1000, 8919, 16838, 24757, 32676, 40595, 48514, 56433]) { 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'); }); });