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
89 lines
3.9 KiB
TypeScript
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');
|
|
});
|
|
});
|