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
376 lines
16 KiB
TypeScript
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');
|
|
});
|
|
});
|