Draw freight on the card, and teach the bot to build a railroad

This commit is contained in:
Jesse
2026-08-04 04:59:23 -04:00
parent e23d81eade
commit 982952fd6c
11 changed files with 1288 additions and 89 deletions
+42 -2
View File
@@ -6,8 +6,8 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, check, areaOf } from '../src/engine/apply.ts';
import { HAND_LIMIT, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import { applyIntent, check, areaOf, facilityCarTypes } from '../src/engine/apply.ts';
import { HAND_LIMIT, INDUSTRY_PROFILES, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
@@ -595,3 +595,43 @@ describe('legalActions shares its rules with apply (component 6)', () => {
assert.equal(legalActions(s, 0).length, 0);
});
});
describe('an industry handles every commodity its profile names (regression)', () => {
it('derives car types from the sheet rather than a hand-written map', () => {
// REGRESSION, and the reason tank cars were dropped ZERO times in 100 games.
//
// Two maps of industry -> car type existed. The engine's returned `carTypes[0]`, so the second
// commodity of a two-commodity industry was unreachable — a Power Plant burns coal OR oil and
// a Grocer's Warehouse receives dry goods OR perishables. The bot kept its own switch beside
// it, which had drifted further: it named `produceShed` and `oilRefinery`, neither of which is
// an industry, and omitted `freightHouse`, `refinery` and `packingSheds`, which are. Half the
// industries in the deck therefore never asked for a car at all.
//
// Asserting equality with the profile is the point: it fails if anyone writes a third map.
for (const p of INDUSTRY_PROFILES) {
const f = {
kind: 'freight',
subtype: p.kind,
allows: { outbound: p.baseOut > 0, inbound: p.baseIn > 0 },
outboundBox: [], inboundBox: [],
capacity: { outbound: p.baseOut, inbound: p.baseIn },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [] },
laborers: p.baseLoaders, porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
} as never;
assert.deepEqual(
[...facilityCarTypes(f)],
[...p.carTypes],
`${p.kind} handles ${p.carTypes.join('/')} on the sheet`,
);
}
});
it('names both commodities of the two-commodity industries', () => {
// Guards the specific collapse: `carTypes[0]` alone passes the test above only if someone also
// rewrites the profiles, but silently fails this one.
const twoCommodity = INDUSTRY_PROFILES.filter((p) => p.carTypes.length > 1).map((p) => p.kind);
assert.deepEqual(twoCommodity, ['powerPlant', 'grocersWarehouse']);
});
});
+332 -1
View File
@@ -8,7 +8,9 @@ import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.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';
@@ -301,6 +303,335 @@ describe('switching accomplishes something (regression)', () => {
});
});
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 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
+95
View File
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.ts';
import { officeSvg } from '../src/sim/board-svg.ts';
import {
actionGroups,
actionMenu,
@@ -188,6 +189,100 @@ describe('the action menu presents choices the way they are made', () => {
});
});
describe('the board shows freight work happening', () => {
it('moves the load across MEN | AT | WORK on the card itself', () => {
// Advancing a load changed only the side panel, so the card being worked showed nothing. The
// load crossing green -> MEN|AT|WORK -> a spotted car IS freight; the printed cards put these
// squares under the track for exactly this reason.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [{ type: 'hopper', loaded: true }], inboundBox: [],
capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [{ type: 'hopper', loaded: false }] },
laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const filled = (): string[] => {
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
const svg = officeSvg([cell], 0);
return [...svg.matchAll(/class="bs-wb (bs-\w+)( bs-occ)?"/g)].map(
(m) => m[1]!.replace('bs-', '') + (m[2] ? '=FULL' : ''),
);
};
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw', 'red'], 'the waiting load is not drawn');
game.state.clock.phase = 'loadUnload';
game.state.clock.currentActor = 0;
assert.ok(submit(game, { type: 'laborer.startLoad', at: { row: -1, col: 0 } }));
assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw', 'red'], 'starting a load is invisible');
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 0 }));
assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw', 'red'], 'advancing a load is invisible');
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 1 }));
assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL', 'red'], 'the load did not reach WORK');
});
it('names where the work has got to, in the tooltip', () => {
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [],
capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, { type: 'hopper', dir: 'out' }, null],
industryTrack: { length: 2, cars: [] },
laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
assert.match(cell.what, /a load is on AT/, 'the card does not say where the work has got to');
});
it('keeps the enhancement label clear of the pipeline squares', () => {
// `overpass` and `facingPointLocks` are placed `onCard`, so they can land on a facility. At its
// old baseline the label printed straight through the green/MEN|AT|WORK/red row.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: ['overpass'],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [],
capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [] },
laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
const svg = officeSvg([cell], 0);
const enhY = Number(/class="bs-enh" x="\d+" y="(\d+)"/.exec(svg)?.[1]);
const boxTop = Math.min(
...[...svg.matchAll(/class="bs-wb [^"]*" x="\d+" y="(\d+)"/g)].map((m) => Number(m[1])),
);
assert.ok(Number.isFinite(enhY) && Number.isFinite(boxTop), 'label or squares are not drawn');
// 9px text sits roughly 9px above its baseline, so the baseline itself must clear the squares.
assert.ok(enhY <= boxTop, `enhancement label at y=${enhY} prints over the squares at y=${boxTop}`);
});
});
describe('board highlighting', () => {
it('gives every spot a coordinate to highlight', () => {
const game = newGame(555);