/** * 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, // 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('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) { 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'); }); });