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
+7 -7
View File
@@ -257,7 +257,7 @@ describe('Mainline Phase (§8)', () => {
id: `t${i}`,
trainNumber: spec.n,
trainIsExtra: spec.extra,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
@@ -287,7 +287,7 @@ describe('collisions are automatic (Gap 2)', () => {
id,
trainNumber: 2,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [{ type: 'coach', loaded: true }],
direction: 'east',
position: { at: 'mainline', index: 1 },
@@ -328,7 +328,7 @@ describe('collisions are automatic (Gap 2)', () => {
id: 'x',
trainNumber: 8,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [
{ type: 'hopper', loaded: true },
{ type: 'caboose', loaded: true },
@@ -359,7 +359,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
id: 'ahead',
trainNumber: 4,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'mainline', index: 1 },
@@ -379,7 +379,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
id: 'behind',
trainNumber: 2,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
@@ -402,7 +402,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
id: 'oncoming',
trainNumber: 3,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'west',
position: { at: 'mainline', index: 1 },
@@ -421,7 +421,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
id: 'waiting',
trainNumber: 2,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
+1 -1
View File
@@ -38,7 +38,7 @@ function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist']
id,
trainNumber: null,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist,
direction: 'east',
position: { at: 'grid', owner: 0, coord },
+6 -6
View File
@@ -52,7 +52,7 @@ function handEnhancement(s: GameState, key: string): string {
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0,
});
return id;
@@ -185,7 +185,7 @@ describe('Interlocking and Yard Office relieve the Office', () => {
function inbound(s: GameState, consist: TrackCard['standing']) {
const id = 'inbound';
s.trays.set(id, {
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
id, trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
const ml = s.division.nodes[1];
@@ -264,11 +264,11 @@ describe('ABS Signals amend the collision rule', () => {
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
}
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.trays.set('behind', {
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
s.clock.phase = 'mainline';
@@ -292,7 +292,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
}
// An oncoming senior train — §8.1 makes this an absolute bar without a device.
s.trays.set('oncoming', {
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineFront: true,
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
const ml = s.division.nodes[1];
@@ -304,7 +304,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.trays.set('mine', {
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
s.clock.phase = 'mainline';
+99 -7
View File
@@ -16,12 +16,15 @@ import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
REALIGNMENTS,
crossingStages,
mainlineModifierRule,
mainlineProfile,
} from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameEvent } from '../src/engine/events.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import { snapshot } from '../src/sim/view.ts';
@@ -210,11 +213,11 @@ describe('Red Flags protect a stopped train', () => {
const node = pinned(s, 1, 'plains');
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.trays.set('behind', {
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
const dp = s.division.nodes[0];
@@ -313,7 +316,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
area.grid.set(coordKey(at(-2, 0)), industry);
s.trays.set('crew', {
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
// Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
// track needs its actual port — without it the engine looks for an east exit the card has not
@@ -405,12 +408,17 @@ describe('card coverage', () => {
}
});
it('Poling remains unimplemented, deliberately', () => {
it('Poling stays unimplemented AND out of the deck, deliberately', () => {
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
// source says otherwise, a silent no-op would be worse than a rejection.
//
// It is dealt zero copies rather than deleted: a card that cannot be played is worse in a
// player's hand than absent from the deck, but the entry stays so the gap remains visible and
// the card returns the moment its rule is known.
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
assert.ok(poling, 'Poling should still be in the deck');
assert.ok(poling, 'the Poling entry should stay, so the gap does not vanish');
assert.match(poling!.effect, /TBD/i);
assert.equal(poling!.copies, 0, 'Poling is dealt into the deck but cannot be played');
});
});
@@ -495,7 +503,7 @@ describe("a train is made up to its card's consist (§8.2)", () => {
const s = game();
s.clock.phase = 'newTrain';
s.trays.set('t', {
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
});
@@ -525,7 +533,7 @@ describe("a train is made up to its card's consist (§8.2)", () => {
const s = game();
s.clock.phase = 'newTrain';
s.trays.set('t', {
id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
id: 't', trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'caboose', loaded: true }],
direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
});
@@ -595,3 +603,87 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
}
});
});
describe('Q13 — a train that catches the one ahead runs into it', () => {
/**
* Two trains on one card, same direction, the follower closing. `total 3 / remaining 2` puts the
* leader in region 1 after it moves; `total 2 / remaining 2` puts the follower in region 0 and
* then region 1 — so it catches up whichever order the phase happens to process them in.
*/
const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => {
const s = createGame({
id: 'rear', seed: 3,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['bot'],
});
const index = s.division.nodes.findIndex(
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
);
assert.ok(index >= 0, 'no single-track Mainline card in this Division');
const node = s.division.nodes[index]!;
assert.ok(node.kind === 'mainline');
if (node.kind !== 'mainline') throw new Error('unreachable');
if (opts.absSignals) node.absSignals = true;
const make = (n: number, total: number, left: number): string => {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }],
direction: 'east',
position: { at: 'mainline', index },
} as never);
node.transits.push({ tray: id, stagesRemaining: left, stagesTotal: total, direction: 'east' });
return id;
};
make(9, 3, 2); // the leader
make(11, 2, 2); // the follower, closing
s.clock.phase = 'mainline';
const events: GameEvent[] = [];
for (let i = 0; i < 4; i++) events.push(...advance(s).events);
return { s, events };
};
it('destroys both trains, and it is the Superintendent\'s fault (§10)', () => {
// REGRESSION against a rule that was never implemented. §10 makes a Mainline collision the
// Superintendent's fault and removes both trains, and ABS Signals exists to prevent rear-enders
// — but §8.3's trigger list never named one, so granting clearance was FREE: both trains
// survived, no penalty, and ABS Signals protected against nothing.
const { events } = twoTrains();
const smash = events.find((e) => e.type === 'trainsDestroyed');
assert.ok(smash, 'the follower ran into the leader and nothing happened');
if (smash.type !== 'trainsDestroyed') throw new Error('unreachable');
assert.match(smash.reason, /ran into the train ahead/);
assert.equal(smash.trains.length, 2, 'a rear-ender removes BOTH trains');
assert.ok(
events.some((e) => e.type === 'revenueChanged' && e.delta < 0),
'the collision cost nothing',
);
});
it('ABS Signals holds the follower short instead', () => {
// "Trains on this card will not rear-end each other; they stop short of a collision."
const { events } = twoTrains({ absSignals: true });
assert.ok(
!events.some((e) => e.type === 'trainsDestroyed'),
'ABS Signals did not prevent the collision',
);
assert.ok(
events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)),
'nothing was held short of the train ahead',
);
});
it('leaves trains alone on a card that prints "trains may pass"', () => {
// Double Track and Uncontrolled Siding hold two trains because they HAVE two roads. Catching up
// there means going past, which is what the card is for. Without this the mechanic fired 0.41
// times a game while the bot never once granted clearance — the tell that they were all
// passing cards.
const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind);
assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']);
});
});
+12 -7
View File
@@ -53,7 +53,11 @@ describe('card catalogue (component 1)', () => {
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus 18 extra industry cards
// (Gap 12, industries 9 → 27) and 7 extra office cards (Q12, offices 7 → 14). Both are
// deliberate departures from the sheet and both are flagged provisional in content.ts.
assert.equal(DECK_SIZE, 140);
//
// 139, not 140: Poling is dealt ZERO copies. Its effect is "TBD in the source", so a card that
// cannot be played is worse in a hand than absent from the deck. The entry stays in the
// catalogue so the gap remains visible.
assert.equal(DECK_SIZE, 139);
assert.equal(buildDeck().length, DECK_SIZE);
});
@@ -69,20 +73,21 @@ describe('card catalogue (component 1)', () => {
spaceUse: 12,
enhancement: 18,
mainlineModifier: 7,
maneuver: 7,
// 6, not 7 — Poling is dealt no copies until its rule is known.
maneuver: 6,
action: 10,
});
});
it('removes opponent-directed cards from a solitaire deck', () => {
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
// game they would be 22 of 140 draws (16%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 118);
// game they would be 22 of 139 draws (16%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 117);
const solo = buildDeck('solitaire');
assert.equal(solo.length, 118);
assert.equal(solo.length, SOLITAIRE_DECK_SIZE);
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
// A competitive deck keeps them.
assert.equal(buildDeck('competitive').length, 140);
// A competitive deck keeps them. 139, not 140 — Poling is dealt no copies.
assert.equal(buildDeck('competitive').length, 139);
});
it('keeps track OUT of the deck, as a per-player supply', () => {
+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)})`,
);
}
});
});
+1 -1
View File
@@ -545,7 +545,7 @@ describe('coupling lifts only the cars the crew ran over', () => {
const area = areaFrom(grid, { row: 0, col: 0 });
const s = gameWith(area);
s.trays.set('crew', {
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true, consist: [],
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', facing: 'w', position: { at: 'grid', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
});
s.clock.phase = 'localOps';
+165 -2
View File
@@ -13,6 +13,7 @@ import { dirname, join, resolve } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import { snapshot } from '../src/sim/view.ts';
import { createGame as createEngineGame } from '../src/engine/setup.ts';
import {
@@ -529,11 +530,11 @@ describe('the page explains itself', () => {
const game = newGame(5);
const s = game.state;
s.trays.set('tray2', {
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineFront: true,
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.trays.set('tray3', {
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineFront: true,
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
@@ -1191,3 +1192,165 @@ describe('a train says what its card calls for', () => {
}
});
});
describe('the three places a game is drawn stay in step', () => {
// A game is drawn on the playable page, in the standalone replay file, and in the website's
// replay viewer. They had drifted: the viewer offered three speeds where the standalone offered
// five, and had neither the sound nor the auto-hide the other two grew. Anything a player learns
// on one screen should hold on the others.
it('offers the same playback speeds in both replay viewers', () => {
const site = readFileSync(join(dist, 'replays.html'), 'utf8');
const standalone = renderHtml(record(880009, 'standard', 600));
const speeds = (html: string): string[] =>
[...html.matchAll(/<option value="\d+"[^>]*>([a-z ]+)<\/option>/g)].map((m) => m[1]!.trim());
const a = speeds(site);
const b = speeds(standalone);
assert.ok(a.length >= 5, `the site viewer offers only ${a.length} speeds: ${a.join(', ')}`);
assert.deepEqual(
new Set(a),
new Set(b),
`the two viewers offer different speeds — site: ${a.join(', ')} / standalone: ${b.join(', ')}`,
);
for (const wanted of ['extra slow', 'very fast']) {
assert.ok(a.includes(wanted), `the site viewer has no "${wanted}" speed`);
}
});
it('offers sound and auto-hide everywhere a game is drawn', () => {
const pages: [string, string][] = [
['play.html', readFileSync(join(dist, 'play.html'), 'utf8')],
['replays.html', readFileSync(join(dist, 'replays.html'), 'utf8')],
['the standalone replay', renderHtml(record(880009, 'standard', 600))],
];
for (const [name, html] of pages) {
assert.match(html, /id="v?sound"/, `${name} has no sound control`);
assert.match(html, /id="v?districttoggle"/, `${name} has no auto-hide control`);
assert.match(html, /id="v?districtsummary"/, `${name} shows nothing when the district is folded`);
assert.match(html, /district\.folded/, `${name} has no rule to fold the district away`);
}
});
it('asks the replay viewer for no element its page lacks', () => {
// The same total check the playable page gets: a `$('vfoo')` left behind after removing #vfoo
// throws on load, and the page simply never starts.
const src = readFileSync(join(root, 'src/web/replays.ts'), 'utf8');
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
for (const id of asked) {
assert.ok(present.has(id), `replays.ts asks for #${id}, which replays.html does not contain`);
}
});
});
describe('every file the build needs is actually in the repo (regression)', () => {
it('does not gitignore a source page', () => {
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
// repo root — unanchored, so it also matched `src/web/replays.html`, which is a SOURCE file.
// That page had therefore NEVER been committed: a fresh clone was missing it, and the build
// copies it unconditionally. The pattern is anchored to the root now.
//
// Asked of git rather than read out of .gitignore: the question is whether the file survives a
// clone, and only git can answer that. `check-ignore -q` exits 0 when ignored, 1 when not.
for (const page of ['src/web/index.html', 'src/web/play.html', 'src/web/replays.html']) {
let ignored: boolean;
try {
execFileSync('git', ['check-ignore', '-q', page], { cwd: root, stdio: 'pipe' });
ignored = true;
} catch {
ignored = false;
}
assert.equal(ignored, false, `${page} is gitignored — it would be missing from a fresh clone`);
}
});
});
describe('the tray is an engine plus its Rolling Stock', () => {
it('shows where the engine sits, because it may pull, push, or do both', () => {
// A Crew Tray is an engine and its cars, and the engine may be PULLING (ahead of everything),
// PUSHING (behind everything) or in the middle doing both — which decides which end cars couple
// onto (§A.3) and which way the train can shove.
//
// REGRESSION on a dead field: `engineFront` was a boolean written in three places and read in
// NONE, so the engine had a position the game recorded and never used, and every consist was
// drawn as an anonymous row of cars. It is an index into the consist now.
const s = createEngineGame({
id: 'eng', seed: 1038389,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
// A tray built by hand, so every position can be checked rather than hoping one turns up.
// Trays are created when a train is made up, so a fresh game has none.
const trayId = s.freeTrays.pop()!;
const tray = {
id: trayId,
trainNumber: 9,
trainIsExtra: false,
engineAt: 0,
consist: [
{ type: 'boxcar', loaded: false },
{ type: 'hopper', loaded: true },
],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
} as never;
s.trays.set(trayId, tray);
const west = s.division.nodes.find((n) => n.kind === 'divisionPoint');
if (west && west.kind === 'divisionPoint') west.holding.push(trayId);
const consistFor = (at: number): string[] => {
(tray as unknown as { engineAt: number }).engineAt = at;
const f = snapshot(s, [], null);
for (const node of f.division) {
for (const t of node.trains.flat()) {
if (t.consist.includes('ENG')) return t.consist;
}
}
return [];
};
assert.equal(consistFor(0)[0], 'ENG', 'an engine at 0 is not drawn pulling');
assert.equal(consistFor(2)[2], 'ENG', 'an engine behind the cars is not drawn pushing');
const middle = consistFor(1);
assert.equal(middle[1], 'ENG', 'an engine between the cars is not drawn in the middle');
assert.equal(middle.length, 3, 'the engine displaced a car instead of sitting between them');
});
it('shows both yards, split loaded and empty', () => {
// Rolling stock is finite and the Classification Yard returns to service only when the Division
// Yard is BARE, so watching the Division Yard run down is real information — and neither yard
// was shown anywhere.
const s = createEngineGame({
id: 'yards', seed: 1038389,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const f = snapshot(s, [], null);
assert.ok(f.yards.divisionTotal > 0, 'the Division Yard is dealt nothing');
assert.equal(
f.yards.division.reduce((n, r) => n + r.loaded + r.empty, 0),
f.yards.divisionTotal,
'the Division Yard breakdown does not add up to its total',
);
assert.equal(
f.yards.classification.reduce((n, r) => n + r.loaded + r.empty, 0),
f.yards.classificationTotal,
'the Classification Yard breakdown does not add up to its total',
);
// The page must ask for the elements it fills.
const html = readFileSync(join(dist, 'play.html'), 'utf8');
for (const id of ['divyard', 'clsyard', 'divtot', 'clstot']) {
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
}
});
});