826 lines
36 KiB
TypeScript
826 lines
36 KiB
TypeScript
/**
|
||
* 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 { refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
|
||
import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
|
||
import type { Intent } from '../src/engine/intents.ts';
|
||
import { connectionsFor, exitsFrom, hasPort, neighbour, opposite, variantsFor } 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<number>();
|
||
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,
|
||
});
|
||
// Both halves of freight, matching `strategyBuckets`. This filter used to omit `freightUnload`
|
||
// — the same omission the summary itself had — so a game that scored only by unloading was
|
||
// bucketed but not counted here.
|
||
const scored = report.perGame.filter(
|
||
(g) =>
|
||
g.revenue.freightLoad + g.revenue.freightUnload +
|
||
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<number>; seen: Set<string> } => {
|
||
const main = area.runningRow;
|
||
const rejoins = new Set<number>();
|
||
const seen = new Set<string>();
|
||
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<string>();
|
||
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<string>();
|
||
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<string>();
|
||
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 returns to service (regression)', () => {
|
||
it('empties the Classification Yard into the Division Yard when the Division Yard runs out', () => {
|
||
// §2 — used Rolling Stock is set out in the Classification Yard, used engines and cabooses go
|
||
// straight back to the Division Yard, and the Classification Yard empties ONLY when the
|
||
// Division Yard is bare. Then all of it returns at once.
|
||
//
|
||
// REGRESSION on two counts. The Classification Yard was write-only to begin with — seven
|
||
// writers, no readers — so 37% of the stock left the game. The first fix then guessed the wrong
|
||
// trigger, returning cars at every DAY boundary, which keeps the yard topped up continuously
|
||
// and is far more generous than the rule: worth +2.42 revenue a game that the game does not
|
||
// actually grant.
|
||
//
|
||
// So this asserts the TRIGGER, not a tidy end state. The Division Yard legitimately still holds
|
||
// cars at the end of a short game, and the Classification Yard legitimately holds a pile.
|
||
const s = createGame({
|
||
id: 'refill',
|
||
seed: 4,
|
||
config: { ...config, length: 'standard' },
|
||
playerNames: ['bot'],
|
||
});
|
||
|
||
// Drain the Division Yard and park a known car in Classification.
|
||
const parked = s.yards.divisionYard.splice(0, s.yards.divisionYard.length);
|
||
s.yards.classificationYard.push(...parked);
|
||
assert.equal(s.yards.divisionYard.length, 0);
|
||
const waiting = s.yards.classificationYard.length;
|
||
assert.ok(waiting > 0, 'nothing was parked to return');
|
||
|
||
const moved = refillDivisionYardIfEmpty(s);
|
||
assert.ok(moved, 'an empty Division Yard did not pull the Classification Yard back');
|
||
assert.equal(moved!.count, waiting);
|
||
assert.equal(s.yards.divisionYard.length, waiting, 'the cars did not come back');
|
||
assert.equal(s.yards.classificationYard.length, 0, 'the Classification Yard was not emptied');
|
||
|
||
// And it must NOT fire while the Division Yard still has anything at all.
|
||
s.yards.classificationYard.push({ type: 'boxcar', loaded: false });
|
||
assert.equal(
|
||
refillDivisionYardIfEmpty(s),
|
||
null,
|
||
'the Classification Yard returned while the Division Yard still had cars',
|
||
);
|
||
});
|
||
|
||
});
|
||
|
||
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');
|
||
});
|
||
});
|
||
|
||
describe('the bot does not lay track that cannot work (regression)', () => {
|
||
/** Drive a game with the developer bot, reporting every track piece it lays. */
|
||
const laysIn = (seed: number, prepare?: (s: GameState) => void): Intent[] => {
|
||
const s = createGame({
|
||
id: `lay-${seed}`,
|
||
seed,
|
||
config: { ...config, length: 'standard' },
|
||
playerNames: ['Solitaire'],
|
||
});
|
||
prepare?.(s);
|
||
const laid: Intent[] = [];
|
||
const spy = {
|
||
name: 'spy',
|
||
choose(st: GameState, p: 0, opts: Intent[]) {
|
||
const pick = developerBot.choose(st, p, opts);
|
||
if (pick.type === 'track.lay') laid.push(pick);
|
||
return pick;
|
||
},
|
||
};
|
||
playGame(s, spy as never, pump);
|
||
return laid;
|
||
};
|
||
|
||
it('will not cut a turnout with no arc left to hang beneath it', () => {
|
||
// A turnout is a hole in the Running Track, worth cutting only for what hangs under it. This
|
||
// does not happen naturally — measured at 0%, because the bot lays the arc immediately after
|
||
// the turnout and never runs the supply dry first — so the situation is constructed. It guards
|
||
// against the supply changing rather than against something happening today.
|
||
const drained = laysIn(4242, (s) => {
|
||
const area = s.officeAreas.get(0)!;
|
||
for (const k of ['curved:left', 'curved:right', 'sharpCurved:left', 'sharpCurved:right']) {
|
||
area.trackSupply.set(k, 0);
|
||
}
|
||
});
|
||
const turnouts = drained.filter((i) => i.type === 'track.lay' && i.geometry === 'turnout');
|
||
assert.equal(
|
||
turnouts.length,
|
||
0,
|
||
`${turnouts.length} turnouts were cut with no arc left to build beneath them`,
|
||
);
|
||
|
||
// And the guard must not be doing this by simply stopping the bot from building.
|
||
const normal = laysIn(4242);
|
||
assert.ok(normal.length > 5, `the bot laid only ${normal.length} pieces with a full supply`);
|
||
});
|
||
|
||
it('rarely lays rail that butts a card which cannot accept it', () => {
|
||
// A port facing an EMPTY square is a promise — something may be built there later. A port
|
||
// butting an OCCUPIED square whose card has no matching port is not: that square is taken, so
|
||
// the rail stops dead and always will. Reported from a replay, where an arc was dropped off a
|
||
// turnout with its far end jammed into a curve.
|
||
//
|
||
// A TIE-BREAKER rather than a veto, so this is a rate and not a zero: forbidding it outright
|
||
// measured WORSE (-0.62 revenue a game), while preferring the cleaner of two equally good
|
||
// placements measured better and cut these from 28% of pieces to 7%.
|
||
let laid = 0;
|
||
let dead = 0;
|
||
for (const seed of [1000, 8919, 16838, 24757, 32676]) {
|
||
const s = createGame({
|
||
id: `dp-${seed}`, seed,
|
||
config: { ...config, length: 'standard' },
|
||
playerNames: ['Solitaire'],
|
||
});
|
||
const spy = {
|
||
name: 'spy',
|
||
choose(st: GameState, p: 0, opts: Intent[]) {
|
||
const pick = developerBot.choose(st, p, opts);
|
||
if (pick.type === 'track.lay') {
|
||
laid++;
|
||
const area = st.officeAreas.get(p)!;
|
||
const v = variantsFor(pick.geometry)[pick.variant ?? 0];
|
||
const probe = {
|
||
geometry: {
|
||
kind: 'track', geometry: pick.geometry,
|
||
...(v?.axis ? { axis: v.axis } : {}),
|
||
...(v?.arc ? { arc: v.arc } : {}),
|
||
...(v?.turnout ? { turnout: v.turnout } : {}),
|
||
},
|
||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||
} as never;
|
||
for (const port of new Set(connectionsFor(probe).flat())) {
|
||
const n = neighbour(pick.placement, port);
|
||
const nb = area.grid.get(`${n.row},${n.col}`);
|
||
if (nb && !hasPort(nb, opposite(port))) dead++;
|
||
}
|
||
}
|
||
return pick;
|
||
},
|
||
};
|
||
playGame(s, spy as never, pump);
|
||
}
|
||
assert.ok(laid > 40, `only ${laid} pieces laid across five games`);
|
||
const rate = dead / laid;
|
||
assert.ok(
|
||
rate < 0.15,
|
||
`${(rate * 100).toFixed(0)}% of pieces were laid butting a card that cannot accept them`,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('the freight figures count both halves (regression)', () => {
|
||
it('scores an unload as Revenue earned, not as an unload started', () => {
|
||
// REGRESSION, against the instrument rather than the game. `rev.freightUnload` was assigned
|
||
// `eventCounts['unloadBegan']` — unloads STARTED, not Revenue EARNED, which differ by every
|
||
// unload that never finished — and `grossFreight` then used `freightLoad` alone, so
|
||
// `freightShare` reported roughly 60% of the freight actually earned. That is the number the
|
||
// "freight is only 13-18% of gross" finding was read from, and it drove a density change.
|
||
//
|
||
// Asserted against the ENGINE's own events, so the summary cannot drift from what happened.
|
||
const report = simulate({
|
||
games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
|
||
});
|
||
|
||
let loads = 0;
|
||
let unloads = 0;
|
||
let begun = 0;
|
||
for (const seed of [1000, 8919, 16838]) {
|
||
const s = createGame({
|
||
id: `fu-${seed}`, seed,
|
||
config: { ...config, length: 'standard' },
|
||
playerNames: ['bot'],
|
||
});
|
||
const r = playGame(s, developerBot, pump);
|
||
for (const e of r.events) {
|
||
if (e.type === 'unloadBegan') begun++;
|
||
if (e.type !== 'revenueChanged') continue;
|
||
if (e.reason === 'freightLoad') loads += e.delta;
|
||
if (e.reason === 'freightUnload') unloads += e.delta;
|
||
}
|
||
}
|
||
assert.ok(unloads > 0, 'no unload ever earned Revenue');
|
||
// `begun` is what the summary used to count. It is an upper bound on what was EARNED, because
|
||
// an unload can be started and never finished — so counting it was wrong whether or not the
|
||
// two happen to agree on a given sample.
|
||
assert.ok(begun >= unloads, 'more unloads earned Revenue than were ever begun');
|
||
|
||
// Every game in the report must count both halves toward its freight share.
|
||
for (const g of report.perGame) {
|
||
const gross = g.revenue.freightLoad + g.revenue.freightUnload + g.revenue.passengerBoard + g.revenue.passengerDetrain;
|
||
if (gross === 0) continue;
|
||
const expected = (g.revenue.freightLoad + g.revenue.freightUnload) / gross;
|
||
assert.ok(
|
||
Math.abs(g.freightShare - expected) < 1e-9,
|
||
`freightShare ${g.freightShare.toFixed(3)} omits the unload half (expected ${expected.toFixed(3)})`,
|
||
);
|
||
}
|
||
});
|
||
});
|