/** * Build order steps 5-6 — bots play legal games, and the harness produces stable numbers. * See architecture/components.md §4. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { pump } from '../src/engine/advance.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts'; import { exitsFrom, hasPort, neighbour, opposite } from '../src/engine/track.ts'; import type { Port } from '../src/engine/track.ts'; import { developerBot, playGame, randomBot } from '../src/sim/bot.ts'; import { simulate } from '../src/sim/harness.ts'; import { record } from '../src/sim/replay.ts'; import { anomalies, strategyBuckets } from '../src/sim/stats.ts'; const config: GameConfig = { mode: 'solitaire', victory: 'highestAfterDays', length: 'short', optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, }; const game = (seed: number): GameState => createGame({ id: `g${seed}`, seed, config, playerNames: ['bot'] }); describe('bots (component 17)', () => { it('plays only legal actions — playGame throws otherwise', () => { for (const seed of [1, 2, 3, 5, 8, 13]) { const r = playGame(game(seed), developerBot, pump); assert.ok(r.finished, `seed ${seed} did not finish`); } }); it('the random control also plays only legal actions', () => { for (const seed of [21, 34, 55]) { const r = playGame(game(seed), randomBot(seed), pump); assert.ok(r.finished, `seed ${seed} did not finish`); } }); it('develops the railroad rather than idling', () => { // Guards the class of bug where every game "completes" while nothing happens. const r = playGame(game(42), developerBot, pump); assert.ok(r.cardsPlayed > 0, 'no cards played'); assert.ok(r.days > 1, 'game did not span a Day'); }); it('is reproducible from a seed', () => { const a = playGame(game(99), developerBot, pump); const b = playGame(game(99), developerBot, pump); assert.deepEqual(a.revenue, b.revenue); assert.deepEqual(a.outcome, b.outcome); assert.equal(a.turns, b.turns); }); }); describe('trains complete their runs (regression)', () => { it('does not leave trains parked at an Office forever', () => { // REGRESSION. `moveTrain` originally handled only Division Point and Mainline positions, so a // train that arrived at an Office never departed. It held its A/D track permanently and every // train behind it collided — 3.7 collisions per game, mean revenue −17, zero wins. // // A run of games should now show collisions as the exception, not the rule. const report = simulate({ games: 40, length: 'short', mode: 'solitaire', players: ['bot'], policy: developerBot, }); assert.ok( report.collisionsPerGame.median <= 1, `median ${report.collisionsPerGame.median} collisions/game — trains are piling up again`, ); }); it('keeps A/D occupancy in step with where trains actually are', () => { // The other half of the same bug: adOccupancy was only ever appended to, so an Office looked // permanently full once any train had visited. const s = game(7); playGame(s, developerBot, pump); for (const [owner, area] of s.officeAreas) { for (const id of area.adOccupancy) { const tray = s.trays.get(id); assert.ok(tray, `A/D track holds ${id}, which no longer exists`); assert.equal(tray.position.at, 'grid', `${id} is recorded at an Office but is elsewhere`); if (tray.position.at === 'grid') { assert.equal(tray.position.owner, owner); } } } }); }); describe('balance harness (component 18)', () => { it('produces a stable report over many games', () => { const report = simulate({ games: 25, length: 'short', mode: 'solitaire', players: ['bot'], policy: developerBot, }); assert.equal(report.games, 25); assert.equal(report.finished, 25, 'every game must reach an outcome'); assert.ok(report.daysPlayed.mean > 0); }); it('is deterministic — the same options give the same report', () => { const opts = { games: 10, length: 'short' as const, mode: 'solitaire' as const, players: ['bot'], policy: developerBot, }; assert.deepEqual(simulate(opts), simulate(opts)); }); it('shows the heuristic bot outperforming the random control', () => { // If the policy is worth anything it must beat picking uniformly at random. const base = { games: 60, length: 'short' as const, mode: 'solitaire' as const, players: ['bot'] }; const smart = simulate({ ...base, policy: developerBot }); const dumb = simulate({ ...base, policy: randomBot(4242) }); assert.ok( smart.revenuePerPlayer.mean > dumb.revenuePerPlayer.mean, `heuristic ${smart.revenuePerPlayer.mean.toFixed(1)} did not beat random ${dumb.revenuePerPlayer.mean.toFixed(1)}`, ); }); }); describe('the revenue chain works end to end (regression)', () => { it('spots cars on industry tracks', () => { // REGRESSION. Cars dropped at a facility went into the card's `standing` list, while §9.3's // loading rule looked at `industryTrack`. Across 50 games ZERO cars were ever spotted and // freight revenue was structurally zero. Win rate went 0% -> ~35% when these were unified. let spotted = 0; for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) { const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] }); playGame(s, developerBot, pump); for (const card of s.officeAreas.get(0)!.grid.values()) { if (card.facility?.kind === 'freight') spotted += card.facility.industryTrack.cars.length; } } assert.ok(spotted > 0, 'no car ever reached an industry track across eight games'); }); it('instantiates a real Facility when a freight card is placed', () => { // REGRESSION. Placed facility cards were built with `facility: null` — inert, unworkable. // // Checked across several seeds: the design's deck holds only 9 industries in 115 cards, so a // single game may legitimately never draw one. That dilution is itself worth knowing — under // the old 52-card placeholder it was 10 in 52. let placed = 0; for (const seed of [11, 23, 47, 91, 137, 211]) { const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] }); playGame(s, developerBot, pump); for (const card of s.officeAreas.get(0)!.grid.values()) { if (card.geometry.kind !== 'facility') continue; placed++; assert.ok(card.facility, 'a placed facility card has no Facility record'); assert.ok(card.facility.laborers > 0, 'facility has no Laborers'); } } assert.ok(placed > 0, 'no facility was placed across six campaign games'); }); it('earns freight revenue, not just passenger revenue', () => { // Asserts the FREIGHT chain specifically. An earlier version asserted `wins > 0`, which passed // only because unloads were mis-scored as completed loads after one Laborer action instead of // four. Correcting that dropped mean revenue from 24.8 to ~4.6 and the win rate to zero, so // "did anyone win" is no longer a safe proxy for "does freight work". const report = simulate({ games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, }); const freight = report.perGame.reduce((n, g) => n + g.revenue.freightLoad, 0); assert.ok(freight > 0, 'no freight load completed across 40 games'); }); it('grows the Office Area downward from the Running Track (Q7)', () => { // The card art puts the through-track at the top edge, so Secondary Track hangs beneath it. // Nothing should ever be built above row 0. const rows = new Set(); for (const seed of [3, 9, 27, 81]) { const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] }); playGame(s, developerBot, pump); for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0])); } assert.ok([...rows].some((r) => r < 0), 'nothing was ever built below the Running Track'); assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE the Running Track'); }); }); describe('end-of-game statistics', () => { it('accounts for revenue by source', () => { const report = simulate({ games: 20, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, }); for (const g of report.perGame) { const gross = g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain; assert.ok(gross >= 0, 'gross revenue cannot be negative'); assert.ok(g.freightShare >= 0 && g.freightShare <= 1, 'freight share out of range'); } }); it('reports no anomalies — every rule in the engine is reachable', () => { // THE POINT OF THIS MODULE. Every serious bug so far was an unreachable rule: a pipeline with // no entry point, a facility that could not be worked, a car that could not be spotted. If an // expected event stops firing, something has become unreachable again. const report = simulate({ games: 60, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, }); const found = anomalies(report.perGame); const never = found.filter((a) => a.severity === 'never'); assert.deepEqual( never.map((a) => a.what), [], `unreachable rules: ${never.map((a) => `${a.what} (${a.detail})`).join('; ')}`, ); }); it('buckets games by strategy without losing any', () => { const report = simulate({ games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, }); const scored = report.perGame.filter( (g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0, ); const bucketed = strategyBuckets(report.perGame).reduce((n, b) => n + b.games, 0); assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket'); }); }); describe('switching accomplishes something (regression)', () => { it('does not shuttle the crew back and forth to no purpose', () => { // REGRESSION. The bot chose to SWITCH whenever a train stood at the Office, whether or not // there was anything to switch — then, having nothing useful to do, took the first legal move // repeatedly and burned all six Moves oscillating between two cells. A passenger train needs // no switching at all (§9.2 works coaches straight off the A/D track), so an entire Local // Operations action was wasted. const rec = record(1234, 'standard'); let worstRun = 0; let run = 0; let lastFrom: string | null = null; for (const f of rec.frames) { const move = f.lines.find((l) => /^CREW moved/.test(l.text)); const didWork = f.lines.some((l) => /Dropped|Coupled/.test(l.text)); if (!move || didWork) { run = 0; lastFrom = null; continue; } const m = /\((-?\d+),(-?\d+)\) → \((-?\d+),(-?\d+)\)/.exec(move.text); if (!m) continue; const from = `${m[1]},${m[2]}`; const to = `${m[3]},${m[4]}`; // An oscillation is a move that lands exactly where the previous move started. run = lastFrom === to ? run + 1 : 0; worstRun = Math.max(worstRun, run); lastFrom = from; } assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`); }); it('does not shuttle aimlessly — Moves have to buy something', () => { // Counting Moves alone stopped being a fair test once the crew could do real work. Run-arounds // and sidings are SUPPOSED to cost several Moves each: the crew leaves the main, runs the loop, // and comes back with a different car droppable. Measured before sidings existed the crew // coupled ~0.4 cars a game; it now couples ~8 and drops ~11. // // So test the thing the old threshold was a proxy for: motion must convert into work. A crew // that shuttles for its own sake shows a high ratio here, however few or many Moves it makes. const report = simulate({ games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot, }); const per = (f: (g: (typeof report.perGame)[number]) => number): number => report.perGame.reduce((n, g) => n + f(g), 0) / report.perGame.length; const moves = per((g) => g.actions.moves); const work = per((g) => g.actions.drops + g.actions.couples); assert.ok(work > 2, `only ${work.toFixed(1)} productive acts a game — the crew is doing nothing`); assert.ok( moves / work < 8, `${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` + `(${moves.toFixed(0)} Moves, ${work.toFixed(1)} productive acts)`, ); }); }); describe('the bot builds sidings that are actually sidings (regression)', () => { /** * Walk south off one Running Track column and report which OTHER main-line columns the walk can * climb back up to. Uses the engine's own `exitsFrom`, so the test cannot credit a connection the * rules do not have — a turnout's two legs never join each other (§A.1), and a re-implementation * of the traversal would quietly invent that join. */ const rejoinsFrom = (area: OfficeArea, col: number): { rejoins: Set; seen: Set } => { const main = area.runningRow; const rejoins = new Set(); const seen = new Set(); const start = area.grid.get(`${main - 1},${col}`); if (!start || !hasPort(start, 'n')) return { rejoins, seen }; const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }]; const visited = new Set(); while (stack.length > 0) { const at = stack.pop()!; const id = `${at.row},${at.col}:${at.entry}`; if (visited.has(id)) continue; visited.add(id); const card = area.grid.get(`${at.row},${at.col}`); if (!card) continue; seen.add(`${at.row},${at.col}`); for (const exit of exitsFrom(card, at.entry)) { const n = neighbour({ row: at.row, col: at.col }, exit); const next = area.grid.get(`${n.row},${n.col}`); if (!next || !hasPort(next, opposite(exit))) continue; if (n.row >= main) { if (n.col !== col) rejoins.add(n.col); continue; } stack.push({ row: n.row, col: n.col, entry: opposite(exit) }); } } return { rejoins, seen }; }; const districts = (games: number): OfficeArea[] => { const out: OfficeArea[] = []; for (let i = 0; i < games; i++) { const s = createGame({ id: `sd-${i}`, seed: 1000 + i * 7919, config: { ...config, length: 'standard' }, playerNames: ['bot'], }); playGame(s, developerBot, pump); const area = s.officeAreas.get(0); if (area) out.push(area); } return out; }; it('closes run-arounds instead of building only dead-end stubs', () => { // REGRESSION. `bestTrackLay` scored an arc +12 with the comment "close the loop back up to the // main: the run-around is complete", but only checked that a neighbour ran east-west — never // that anything above it had a south port to join. Measured: 0 run-arounds in 100 games. Every // district terminated its siding in an arc pointing north into empty space. const areas = districts(60); const closed = areas.filter((area) => { for (const [k, card] of area.grid) { const [r, c] = k.split(',').map(Number); if (r !== area.runningRow || !hasPort(card, 's')) continue; if (rejoinsFrom(area, c!).rejoins.size > 0) return true; } return false; }).length; // Measured at 91/100 after the fix, against 0/100 before it. Half is a floor with room for the // deal to be unkind, not a restatement of the current number. assert.ok( closed >= areas.length / 2, `only ${closed}/${areas.length} districts contain a run-around — sidings are dead-end stubs, ` + 'so a crew cannot run around its own train (§A.5)', ); }); it('puts industries ON the run-around, not merely near one', () => { // REGRESSION. Facility placement was `options.find(i => i.placement !== undefined)` — the first // legal square the generator listed, unscored, while track laying had sixty lines of scoring. // Once the bot started closing run-arounds in 91 games of 100, facilities sitting on one stayed // at 0.00 a game: the loops were built and the industries put somewhere else. // // A facility on a run-around can be reached from either end and its standing cars passed // (§A.5); on a stub the crew must shove in and back out. const areas = districts(60); let onLoop = 0; for (const area of areas) { const loop = new Set(); for (const [k, card] of area.grid) { const [r, c] = k.split(',').map(Number); if (r !== area.runningRow || !hasPort(card, 's')) continue; const walk = rejoinsFrom(area, c!); if (walk.rejoins.size === 0) continue; for (const cell of walk.seen) { if (area.grid.get(cell)?.geometry.kind === 'facility') loop.add(cell); } } onLoop += loop.size; } // Measured at 1.08 facilities a game on a run-around, against 0.00 before. A third of the games // managing it at all is a floor, not a restatement. assert.ok( onLoop >= areas.length / 3, `only ${onLoop} facilities across ${areas.length} districts sit on a run-around — the loops ` + 'are built and the industries are somewhere else, so the run-around buys no freight', ); }); it('moves the commodities the district needs, not just boxcars', () => { // REGRESSION. `wantedCars` decided what to put on a train from a hand-written industry -> car // map that had drifted from the sheet, so three of the six industries never asked for anything. // Measured across 100 games: tank cars boarded a train 0.07 times a game and were dropped by a // crew ZERO times, while boxcars were 67% of every drop — and 23 of 79 waiting loads were // sitting at an industry that wanted a tank. const dropped = new Set(); for (let i = 0; i < 30; i++) { const s = createGame({ id: `cs-${i}`, seed: 1000 + i * 7919, config: { ...config, length: 'standard' }, playerNames: ['bot'], }); const r = playGame(s, developerBot, pump); for (const e of r.events) { if (e.type !== 'carsDropped') continue; for (const c of e.stock) dropped.add(c.type); } } // Tank is the strict test: it is the one commodity no industry shares, so it can only appear if // the Refinery is asking for it. assert.ok(dropped.has('tank'), `no tank car was ever dropped — types seen: ${[...dropped].join(', ')}`); assert.ok(dropped.has('reefer'), `no reefer was ever dropped — types seen: ${[...dropped].join(', ')}`); }); it('spends no TRACK east of the last column it could rejoin at', () => { // REGRESSION, the other half of the same bug. Nothing capped the east-west extension and // `bestTrackLay` never declined a piece, so once the useful squares were taken it kept laying // whatever was legal — the siding ran past the end of the Running Track and its closing arc had // nothing above it. The loop missed by one card in 96 of 100 games. // // TRACK specifically: a Modifier out there burns tablespace by design and a Facility is placed // for its own reasons. The defect is spending one of 26 track pieces on a loop that cannot // close, so that is what this counts. const areas = districts(60); let overshot = 0; for (const area of areas) { const main = area.runningRow; let lastWayUp = -Infinity; let lastTrack = -Infinity; for (const [k, card] of area.grid) { const [r, c] = k.split(',').map(Number); if (r === main && hasPort(card, 's')) lastWayUp = Math.max(lastWayUp, c!); if (r === main - 1 && card.geometry.kind === 'track') lastTrack = Math.max(lastTrack, c!); } if (lastTrack > lastWayUp) overshot++; } assert.equal( overshot, 0, `${overshot}/${areas.length} districts lay track east of the last column with a way up, ` + 'which spends the track supply on a loop that cannot close', ); }); }); describe('the bot does not throw away its own freight (regression)', () => { it('never unjams a green box that is merely waiting', () => { // REGRESSION. `facilityUnjammed` from `outbound` splices the load out of the green box and // pushes it to the classification yard — that load cost a Local Operations action to stock, so // discarding it is strictly negative. The bot reached it through two fallbacks meeting: // // 1. `canStockProductively` did not check the Division Yard, while the engine's own // `stockOutbound` requires a LOADED car of that commodity to be sitting there. Two rules // for one act, and only one was true — so the Freight Agent option was chosen believing a // box could be stocked when none could. // 2. Having chosen it, the follow-through found nothing stuck, nothing to clear and nothing // stockable, and fell through to "clear whatever is stuck" with nothing stuck. // // Measured at 3.10 discarded loads a game against 2.71 STARTED: the bot destroyed more freight // than it worked. §6.3's unjam is for a load stranded on MEN|AT|WORK; a green box holding a // load waiting for a car is not jammed, it is waiting. let outbound = 0; let menAtWork = 0; let started = 0; const games = 40; for (let i = 0; i < games; i++) { const s = createGame({ id: `uj-${i}`, seed: 1000 + i * 7919, config: { ...config, length: 'standard' }, playerNames: ['bot'], }); const r = playGame(s, developerBot, pump); for (const e of r.events) { if (e.type === 'loadStarted') started++; if (e.type !== 'facilityUnjammed') continue; if (e.from === 'outbound') outbound++; if (e.from === 'menAtWork') menAtWork++; } } assert.equal( outbound, 0, `${outbound} loads were unjammed out of a green box across ${games} games ` + `(${started} loads were started) — the bot is discarding freight it paid to stock`, ); // The intended use must survive: gutting the fallback entirely would also pass the assertion // above while leaving genuine jams to block the pipeline forever. assert.ok(menAtWork > 0, 'no MEN|AT|WORK jam was ever cleared — §6.3 unjam has become dead code'); }); }); describe('Enhancements can reach the board at all (regression)', () => { it('keeps a straight on the Running Track for an Enhancement to attach to', () => { // REGRESSION, structural. Interlocking, Water Column and Telegraph all print "any Running Track // Straight"; Yard Office and Small Yard want a Secondary Track Straight; Telephone and Radio // chain off Telegraph. The bot builds minimal two-arc run-arounds (`ne` meets `nw` directly), // so it never needed a straight and laid NONE — 0.00 on the Running Track across 100 games. // Thirteen of the eighteen Enhancement cards that go on the board were therefore unplayable: // drawn 3.78 a game, placed 0.64. let withStraight = 0; const games = 40; for (let i = 0; i < games; i++) { const s = createGame({ id: `st-${i}`, seed: 1000 + i * 7919, config: { ...config, length: 'standard' }, playerNames: ['bot'], }); playGame(s, developerBot, pump); const area = s.officeAreas.get(0); if (!area) continue; for (const [k, card] of area.grid) { const row = Number(k.split(',')[0]); if (row !== area.runningRow) continue; if (card.geometry.kind === 'track' && card.geometry.geometry === 'straight') { withStraight++; break; } } } assert.ok( withStraight >= games / 2, `only ${withStraight}/${games} districts have a straight on the Running Track — an ` + 'Enhancement that prints "any Running Track Straight" has nowhere to go', ); }); it('gets Interlocking down, which is the only cure for the only penalty', () => { // "No free A/D track" is the sole cause of every penalty in the game — 2.70 revenue a game, // 27% of gross. Interlocking is the designed answer (advance.ts:621): it holds the train at the // Limits instead of colliding. It had never once been placed. // // Measured at 15 games in 60 after the fix, against 0 in 100 before. A quarter of that is a // floor that tolerates an unkind deal without restating the current number. let placed = 0; const games = 60; for (let i = 0; i < games; i++) { const s = createGame({ id: `il-${i}`, seed: 1000 + i * 7919, config: { ...config, length: 'standard' }, playerNames: ['bot'], }); playGame(s, developerBot, pump); const area = s.officeAreas.get(0); if (!area) continue; for (const card of area.grid.values()) { if (card.enhancements.includes('interlocking')) { placed++; break; } } } assert.ok( placed >= games / 8, `Interlocking reached the board in only ${placed}/${games} games — the district has no ` + 'answer to a full Office but a collision', ); }); }); describe('rolling stock stays in circulation (regression)', () => { it('returns the Classification Yard to service overnight', () => { // REGRESSION, and the largest single revenue change measured on this bot: +2.32 ± 0.52 paired // over 400 seeds (t = 8.79). // // The Classification Yard was WRITE-ONLY. Seven places pushed cars into it — retired trains, // collisions, unjams, set-outs — and nothing in the engine ever read it. Rolling stock drained // one way out of the game: 30 cars a game dead by the end, 37% of the 80 dealt at setup. // // It was not obvious this mattered. The Division Yard does not run DRY in five Days (16.6 // loaded freight cars left, empty in 2 games of 100), which is why this was ranked third and // predicted not to affect revenue. The aggregate was never the point: what starves freight is // not having the RIGHT commodity when a green box needs stocking, and returning classified // cars keeps the mix alive. const games = 30; let deadAtEnd = 0; let everReturned = 0; for (let i = 0; i < games; i++) { const s = createGame({ id: `cy-${i}`, seed: 1000 + i * 7919, config: { ...config, length: 'standard' }, playerNames: ['bot'], }); const r = playGame(s, developerBot, pump); deadAtEnd += s.yards.classificationYard.length; // Cars must have passed THROUGH it, or the test proves nothing about the return path. if (r.events.some((e) => e.type === 'trainCompleted')) everReturned++; } assert.ok(everReturned > 0, 'no train ever completed — nothing reached the Classification Yard'); // A Day's worth of arrivals may still be sitting there when the game ends; a whole game's worth // may not. Before the fix this was ~30 a game. assert.ok( deadAtEnd / games < 15, `${(deadAtEnd / games).toFixed(1)} cars a game are still stranded in the Classification ` + 'Yard at the end — rolling stock is leaving the game and never coming back', ); }); }); describe('measurement discipline', () => { it('the observer sees exactly the events the log records', () => { // REGRESSION, against a measurement bug rather than a game bug. Events reach the log from TWO // sources — the phase driver (`pump`) and player intents (`applyIntent`). An ad-hoc probe that // watched only the first reported "60 of 60 games never upgraded past Whistle Post" while the // Office tier histogram from the same run plainly showed Depots, Stations and Terminals. // // A wrong measurement is worse than a missing one: it gets believed and acted on. This asserts // the observer hook cannot drift from the log, so probes have one trustworthy way in and no // reason to hand-roll the drive loop again. const seen: string[] = []; const s = createGame({ id: 'obs', seed: 4242, config: { ...config, length: 'standard' }, playerNames: ['b'] }); const out = playGame(s, developerBot, pump, 50_000, (e) => seen.push(e.type)); assert.ok(out.events.length > 0, 'a finished game must produce events'); assert.deepEqual(seen, out.events.map((e) => e.type)); }); it('records office upgrades where a probe can actually find them', () => { // The upgrade arrives via applyIntent, not pump — the exact branch the broken probe missed. // Asserted across several seeds because a single game may never draw a Depot card. let upgrades = 0; for (let seed = 0; seed < 25; seed++) { const s = createGame({ id: `u${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['b'] }); const out = playGame(s, developerBot, pump); upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length; } assert.ok(upgrades > 0, 'no office upgrade was visible in the event log across 25 games'); }); });