adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.

This commit is contained in:
Jesse
2026-08-04 21:03:17 -04:00
parent 4bba991f41
commit 821d3cdffa
26 changed files with 6876 additions and 2638 deletions
+197 -36
View File
@@ -8,8 +8,11 @@ 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 { exitsFrom, hasPort, neighbour, opposite } from '../src/engine/track.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';
@@ -236,8 +239,13 @@ describe('end-of-game statistics', () => {
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.passengerBoard + g.revenue.passengerDetrain > 0,
(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');
@@ -591,45 +599,49 @@ describe('Enhancements can reach the board at all (regression)', () => {
});
});
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).
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.
//
// 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.
// 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.
//
// 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++;
}
// 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'],
});
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',
// 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', () => {
@@ -662,3 +674,152 @@ describe('measurement discipline', () => {
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)})`,
);
}
});
});