Files
station-master/test/train-rules.test.ts
Jesse.MarkowitzandClaude Fable 5.1 04ca74c365 v0.8.5 — housekeeping from the audit, and the playtest line retired
The third release from the audit; nothing a player sees changes. CHANGELOG has the detail.

The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that
existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every
line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused
declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were
dead bot functions from rejected candidates the round said it had deleted. The documents no
longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2),
model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted);
the README's account of bot flags now matches the bot's. Five playtest saves committed in
`docs/` against the repository's own rule are in the ignored `playtests/`.

What the audit found and did not fix is written down as TODO #112-#117, each with its reason.
#112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 —
`/api/save` hands a seat the seed mid-game — waits on a conversation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
2026-09-29 17:02:33 -04:00

376 lines
16 KiB
TypeScript

/**
* §7 — the operating rules printed on each train's card.
*
* Nine of these were declared on `TrainRules` and read by nothing, so a Military train could be
* worked by Porters and a Crack Limited could shunt an industry. Each is enforced at the point the
* act happens, and a LOCAL CREW (`trainNumber: null`) is exempt from all of them — it has no card.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { ALL_TRAINS, trainProfile } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts';
import { coordKey, turnOf } from '../src/engine/state.ts';
import { trainRules } from '../src/sim/view.ts';
const config: GameConfig = {
mode: 'solitaire',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
const straight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
});
/** A district of plain straight track west of the Office, and a train sitting on the Office square. */
function switching(
s: GameState,
trainNumber: number | null,
isExtra: boolean,
consist: RollingStock[] = [],
westCars: RollingStock[] = [],
): string {
const area = areaOf(s, 0);
const office = area.officeCoord;
// The crew stands one square west of the Office and works westward, away from the Office square's
// own special-cased drop rule (§A.4 forbids everything but a coach there, v0.5.0) — tests that want
// that exception exercise it directly instead.
const here = { row: office.row, col: office.col - 1 };
area.grid.set(coordKey(here), straight());
area.grid.set(coordKey({ row: office.row, col: office.col - 2 }), straight(westCars));
area.grid.set(coordKey({ row: office.row, col: office.col - 3 }), straight());
s.trays.set('t', {
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist,
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: here }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
return 't';
}
/** One square further west than the crew starts, i.e. the square carrying `westCars`. */
const west = (s: GameState, n = 2): GridCoord => {
const o = areaOf(s, 0).officeCoord;
return { row: o.row, col: o.col - n };
};
const boxcar = (loaded = false): RollingStock => ({ type: 'boxcar', loaded });
/** As `ROLLING_STOCK_SUPPLY` mints them: there is no empty caboose in the game. */
const caboose = (): RollingStock => ({ type: 'caboose', loaded: true });
const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
// ---------------------------------------------------------------------------
describe('§7 — no switching', () => {
it('still allows a plain move — "no switching" means no adding or dropping cars', () => {
// Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains.
// A train held at the Office may still need to clear onto Secondary Track ahead of other
// traffic, so a move that couples nothing is legal.
const s = game();
switching(s, 1, false, [boxcar()]);
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
});
it('refuses a move that would couple a car, and refuses dropping and sorting outright', () => {
const s = game();
switching(s, 1, false, [boxcar()], [boxcar()]);
assert.equal(
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
'PICKUP_NOT_ALLOWED',
);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING');
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING');
});
it('leaves a local crew alone — it has no card and therefore no printed rules', () => {
const s = game();
switching(s, null, false, [boxcar()]);
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('leaves an ordinary freight train alone', () => {
// Train 9 Heavy Freight prints no rules at all, so nothing here may touch it.
const s = game();
switching(s, 9, false, [boxcar()]);
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
});
});
describe('§7 — what a train may couple', () => {
it('X13 Appleseed may drop but not pick up', () => {
// Coupling is mandatory (§A.4), so the restriction has to bite on the MOVE that would pick the
// cars up — there is no moving over them and leaving them behind.
const s = game();
switching(s, 13, true, [boxcar()], [boxcar()]);
assert.equal(
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
'PICKUP_NOT_ALLOWED',
);
// Setting out is exactly what it IS allowed to do.
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('X22 Pee-Dee may pick up empties and refuses a loaded car', () => {
const empty = game();
switching(empty, 22, true, [], [boxcar(false)]);
assert.equal(check(empty, 0, { type: 'switch.move', trayId: 't', to: west(empty), reverse: false }), null);
const loaded = game();
switching(loaded, 22, true, [], [boxcar(true)]);
assert.equal(
check(loaded, 0, { type: 'switch.move', trayId: 't', to: west(loaded), reverse: false }),
'EMPTIES_ONLY',
);
});
it('X22 Pee-Dee may still couple a caboose, which is not a load (Gitea#8)', () => {
// Every caboose in the game is minted `loaded: true` because the supply table's loaded/empty
// split doubles as a piece count. Taken literally that left the per-diem train unable to pick
// up ANY caboose, its own included: set it out at the end of a sweep and it was stranded there.
const s = game();
switching(s, 22, true, [], [caboose()]);
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
// The restriction itself is untouched — a loaded car alongside the caboose still refuses.
const withLoad = game();
switching(withLoad, 22, true, [], [caboose(), boxcar(true)]);
assert.equal(
check(withLoad, 0, { type: 'switch.move', trayId: 't', to: west(withLoad), reverse: false }),
'EMPTIES_ONLY',
);
});
});
describe('§7 — one freight car per location (trains 3/4)', () => {
it('spends the budget per SQUARE, not per turn', () => {
// "May drop or pick up one freight car at every location" — so a second car on the same square
// is refused, and the same train may still work another car one square along.
const s = game();
switching(s, 4, false, [boxcar(), boxcar()]);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 2 }), 'FREIGHT_WORKED_HERE');
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
assert.equal(
check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }),
'FREIGHT_WORKED_HERE',
'a second car came off the same square',
);
// One square west, the budget is fresh.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }).ok);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('shares one budget between dropping and picking up, as the card says "drop OR pick up"', () => {
const s = game();
switching(s, 4, false, [boxcar()], [boxcar()]);
// Drop here first, then try to take the car standing one square west… which is a new square, so
// it is allowed; the point is that the drop was charged where it happened.
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'CONSIST_EMPTY');
});
it('does not count a caboose or a coach against it — the card says FREIGHT', () => {
const s = game();
switching(s, 4, false, [coach(), { type: 'caboose', loaded: false }]);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 2 }), null);
});
});
describe('§7 — the Local keeps its coach (trains 7/8)', () => {
it('never sets the coach out while switching, but shunts its freight freely', () => {
/**
* "Coach must remain on station track if switching." The intended reading was "set out only at
* the Office" — but §A.4 refuses the Office square to every drop, so "only there" and "nowhere"
* are the same rule. What is enforced is the effect that matters: the Local may work its freight
* car around the district and may not abandon its coach doing it.
*/
// Consist reads nose-first with the engine at index 0, so both cars trail it and a cut comes off
// the tail: one car is the coach, two cars is the boxcar and the coach together.
const s = game();
switching(s, 7, false, [boxcar(), coach()]);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'COACH_MUST_STAY');
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 2 }), 'COACH_MUST_STAY');
// The same train with only freight behind it shunts exactly as any other train would.
const freight = game();
switching(freight, 7, false, [boxcar()]);
assert.equal(check(freight, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('leaves an ordinary train free to set a coach out', () => {
const s = game();
switching(s, 9, false, [boxcar(), coach()]);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
});
it('may set the coach out at the Office — the one place §A.4 now allows it (v0.5.0)', () => {
const s = game();
const area = areaOf(s, 0);
const office = area.officeCoord;
s.trays.set('t', {
id: 't',
trainNumber: 7,
trainIsExtra: false,
engineAt: 0,
consist: [boxcar(), coach()],
direction: 'west',
facing: 'w',
position: { at: 'grid', seat: 0, coord: office },
movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
turnOf(s, 0).movesRemaining = 6;
assert.equal(
check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }),
null,
'the coach may be cut loose at the Office',
);
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
const officeCard = area.grid.get(coordKey(office))!;
assert.deepEqual(
officeCard.standing.map((c) => c.type),
['coach'],
'the coach is parked on the Office card',
);
assert.equal(s.trays.get('t')!.consist.length, 1, 'the coach left the tray');
});
});
describe('§7 — trains Porters may not work', () => {
/** An Office holding a train, with passengers waiting and an empty coach to put them in. */
function platform(s: GameState, trainNumber: number, isExtra: boolean, tier: 'depot' | 'terminal') {
const area = areaOf(s, 0);
area.tier = tier;
const office = area.grid.get(coordKey(area.officeCoord))!;
office.facility = {
kind: 'passenger', subtype: 'office',
allows: { outbound: true, inbound: true },
outboundBox: [coach(true)], inboundBox: [],
capacity: { outbound: 2, inbound: 2 },
menAtWork: null,
industryTrack: { cars: [] },
laborers: 0, porters: 2, usedThisStage: { laborers: 0, porters: 0 },
};
s.trays.set('t', {
id: 't', trainNumber, trainIsExtra: isExtra, engineAt: 0, consist: [coach(false)],
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push('t');
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
return area.officeCoord;
}
it('refuses the Military train and the Director\'s car outright', () => {
for (const n of [19, 20]) {
const s = game();
const where = platform(s, n, true, 'terminal');
assert.equal(
check(s, 0, { type: 'porter.board', at: where }), 'NO_PASSENGER_WORK',
`train X${n} was worked by a Porter`,
);
}
});
it('works the Crack Limited at a Terminal and nowhere else', () => {
// "Stop at Terminals only" — it still runs into every Office and takes an A/D track, but the
// passengers it is carrying can only be worked where it is booked to stop.
const depot = game();
const atDepot = platform(depot, 2, false, 'depot');
assert.equal(check(depot, 0, { type: 'porter.board', at: atDepot }), 'NOT_A_TERMINAL');
const terminal = game();
const atTerminal = platform(terminal, 2, false, 'terminal');
assert.equal(check(terminal, 0, { type: 'porter.board', at: atTerminal }), null);
});
it('still works an ordinary train at a Depot', () => {
const s = game();
const where = platform(s, 7, false, 'depot');
assert.equal(check(s, 0, { type: 'porter.board', at: where }), null);
});
});
describe('§7 — the Campaign Train stops once, then runs expedited', () => {
it('makes its speech at the first Office and expedites afterwards', () => {
// X17 prints "one turn at station (speeches) then expedite". The first arrival is an ordinary
// stop; from then on it departs in the Stage it arrives, like any expedited train.
const s = game();
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
ml.card = 'plains';
ml.transits.push({ tray: 'x17', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
s.trays.set('x17', {
id: 'x17', trainNumber: 17, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
const tray = s.trays.get('x17');
assert.ok(tray, 'the Campaign Train vanished');
assert.equal(tray!.position.at, 'grid', 'it should stop at the first Office for the speeches');
assert.equal(tray!.speechMade, true, 'the speech was not recorded');
});
});
describe('§7 — the catalogue and what it claims', () => {
it('no longer tells a player a rule is unenforced', () => {
for (const t of ALL_TRAINS) {
const text = trainRules({ trainNumber: t.number, trainIsExtra: t.isExtra });
assert.doesNotMatch(text, /NOT YET ENFORCED/, `${t.name} still apologises for its own card`);
}
});
it('describes every rule a train actually carries', () => {
// Train 1 carries three at once, so the tooltip has to say all three.
const limited = trainRules({ trainNumber: 1, trainIsExtra: false });
assert.match(limited, /NO SWITCHING/);
assert.match(limited, /TERMINALS ONLY/);
assert.match(limited, /EXPEDITED/);
});
it('has dropped copiesNextScheduled, which no train ever carried', () => {
// A Second Section is a Maneuver card with its own intent (`newTrain.secondSection`), not a
// property of a train card. The flag was an unreachable second description of a working mechanic.
for (const t of ALL_TRAINS) {
assert.equal(
(t.rules as Record<string, unknown>).copiesNextScheduled, undefined,
`${t.name} carries a flag that should not exist`,
);
}
assert.ok(trainProfile(1, false), 'the catalogue should still load');
});
});