Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.
TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD
Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.
- Setting out is batch-invariant. Four cars at once, four singles and two pairs
parked three different orders, one of them physically impossible. Successive
cuts off the same end stack up towards the engine, so the insertion point is the
train's own place in the row.
- Approaching a cut from either end now mirrors. `couples` is built nearest-first
along the direction of travel and reverses onto the nose, so the farthest car met
ends up nose-most — which is what makes a run-around worth its Move.
- A train no longer drives through its own cut. The walk began at the neighbour of
the start square and never read the start card, so a crew could set cars out and
pull straight away from them. Coupling is mandatory (§A.4) and your own square is
no exception; the cut counts against the four-car limit. Setting out off the end
you are not leaving by still works.
`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.
On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.
Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.
Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.
THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT
Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.
AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD
Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.
THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE
§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.
THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY
+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.
Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.
570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
366 lines
16 KiB
TypeScript
366 lines
16 KiB
TypeScript
/**
|
||
* §A.3 — cars occupy the track "in the same order they originally held, left-to-right".
|
||
*
|
||
* Left-to-right is west-to-east, and `TrackCard.standing` had no defined orientation at all until
|
||
* this release. Three reports came out of that one omission and each gets its own battery here:
|
||
* a cut parked in a different order depending on how many cars were set out at once; a train that
|
||
* got the identical consist whichever end it approached a cut from; and a train that drove straight
|
||
* through the cars it had just set out.
|
||
*/
|
||
|
||
import { describe, it } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||
import { createGame } from '../src/engine/setup.ts';
|
||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackCard } from '../src/engine/state.ts';
|
||
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||
|
||
const config: GameConfig = {
|
||
mode: 'solitaire',
|
||
victory: 'highestAfterDays',
|
||
length: 'standard',
|
||
optionalRules: {
|
||
reducedVisibility: false,
|
||
sisterTrains: false,
|
||
employeeRotation: false,
|
||
emergencyToolbox: false,
|
||
},
|
||
};
|
||
|
||
const game = (seed = 77): GameState => createGame({ id: 'g', seed, config, playerNames: ['Jesse'] });
|
||
const at = (row: number, col: number): GridCoord => ({ row, col });
|
||
const car = (type: string, loaded = false): RollingStock => ({ type, loaded }) as RollingStock;
|
||
|
||
const straight = (standing: RollingStock[] = []): TrackCard => ({
|
||
geometry: { kind: 'track', geometry: 'straight' },
|
||
baseOperationalRail: true,
|
||
standing,
|
||
facility: null,
|
||
modifiers: [],
|
||
enhancements: [],
|
||
});
|
||
|
||
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
|
||
areaOf(s, 0).grid.set(coordKey(coord), card);
|
||
}
|
||
|
||
/** A row of plain straights, on row 1 — row 0 is the Running Track and holds the Office. */
|
||
function row(s: GameState, n: number): void {
|
||
for (let c = 0; c < n; c++) addCard(s, at(1, c), straight());
|
||
}
|
||
|
||
function placeTray(
|
||
s: GameState,
|
||
coord: GridCoord,
|
||
consist: RollingStock[],
|
||
facing: 'e' | 'w',
|
||
engineAt = 0,
|
||
): string {
|
||
const id = s.freeTrays.pop()!;
|
||
s.trays.set(id, {
|
||
id,
|
||
trainNumber: null,
|
||
trainIsExtra: false,
|
||
engineAt,
|
||
consist,
|
||
direction: facing === 'w' ? 'west' : 'east',
|
||
facing,
|
||
railFacing: facing,
|
||
position: { at: 'grid', seat: 0, coord },
|
||
movesUsed: 0,
|
||
} as CrewTray);
|
||
return id;
|
||
}
|
||
|
||
/** Puts the game in the one phase switching intents are legal in. */
|
||
function switching(s: GameState): void {
|
||
s.clock.phase = 'localOps';
|
||
s.clock.currentActor = 0;
|
||
turnOf(s, 0).option = 'switch';
|
||
}
|
||
|
||
const types = (cars: readonly RollingStock[]): string[] => cars.map((c) => c.type);
|
||
|
||
const FOUR = (): RollingStock[] => [car('boxcar'), car('hopper'), car('reefer'), car('tank')];
|
||
|
||
const standingAt = (s: GameState, coord: GridCoord): string[] =>
|
||
types(carsOn(areaOf(s, 0).grid.get(coordKey(coord))!));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('a cut parks in the same order however many cars come off at once', () => {
|
||
/**
|
||
* Reported: "when dropping all 4 cars, order was reversed. It worked properly if we dropped cars
|
||
* individually." Both halves are the same bug — `carsDropped` pushed onto the end of the array
|
||
* whatever end the cut came off, so the answer depended on how the player chose to spend the drop.
|
||
*
|
||
* A drop costs no Moves, so one drop of four and four drops of one are the SAME turn played two
|
||
* ways. They must park the same cars in the same order, and that is the whole property.
|
||
*/
|
||
function park(facing: 'e' | 'w', fromNose: boolean, batches: number[]): string[] {
|
||
const s = game();
|
||
row(s, 1);
|
||
// The engine goes at whichever end lets the cut come off the one being tested: cars ahead of it
|
||
// for a nose drop, behind it for a tail drop.
|
||
const id = placeTray(s, at(1, 0), FOUR(), facing, fromNose ? 4 : 0);
|
||
switching(s);
|
||
for (const count of batches) {
|
||
const r = applyIntent(s, 0, {
|
||
type: 'switch.dropCars',
|
||
trayId: id,
|
||
count,
|
||
...(fromNose ? { fromNose: true } : {}),
|
||
});
|
||
assert.ok(r.ok, `drop of ${count} refused: ${r.ok ? '' : r.code}`);
|
||
}
|
||
return standingAt(s, at(1, 0));
|
||
}
|
||
|
||
for (const facing of ['e', 'w'] as const) {
|
||
for (const fromNose of [false, true]) {
|
||
const end = fromNose ? 'nose' : 'tail';
|
||
it(`is batch-invariant off the ${end}, facing ${facing}`, () => {
|
||
const all = park(facing, fromNose, [4]);
|
||
assert.equal(all.length, 4, 'all four cars should be standing on the card');
|
||
assert.deepEqual(park(facing, fromNose, [1, 1, 1, 1]), all, 'four singles parked a different order than one four');
|
||
assert.deepEqual(park(facing, fromNose, [2, 2]), all, 'two pairs parked a different order again');
|
||
});
|
||
}
|
||
}
|
||
|
||
it('parks west-to-east, so an east-facing train sets its tail cut out behind itself', () => {
|
||
// Consist is nose first and the nose points east, so `[boxcar hopper reefer tank]` stands on the
|
||
// ground as tank, reefer, hopper, boxcar reading west to east. The tail car is the westmost.
|
||
assert.deepEqual(park('e', false, [4]), ['tank', 'reefer', 'hopper', 'boxcar']);
|
||
// Facing west the tray already reads west-to-east, so it goes down as it stands.
|
||
assert.deepEqual(park('w', false, [4]), ['boxcar', 'hopper', 'reefer', 'tank']);
|
||
});
|
||
|
||
it('keeps the two cuts apart when a train sets out off both ends on one square', () => {
|
||
// §A.3 lets a cut come off either outer end, so both can happen on one square. The engine ends
|
||
// up standing between them, which is the thing `standingWest` exists to record.
|
||
const s = game();
|
||
row(s, 1);
|
||
const id = placeTray(s, at(1, 0), FOUR(), 'e', 2);
|
||
switching(s);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1 }).ok);
|
||
|
||
// Facing east: the nose cut (boxcar) is set out east of the engine, the tail cut (tank) west.
|
||
assert.deepEqual(standingAt(s, at(1, 0)), ['tank', 'boxcar']);
|
||
assert.equal(s.trays.get(id)!.standingWest, 1, 'the engine should be standing between the two cuts');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('a parked cut mirrors depending on which end the train comes from', () => {
|
||
/**
|
||
* The consist a train ends up with must depend on which way it ran, or a run-around buys nothing.
|
||
* `couples` was accumulated in path order with no reference to the direction of travel, so both
|
||
* approaches produced the identical tray.
|
||
*/
|
||
function collect(from: 'west' | 'east'): string[] {
|
||
const s = game();
|
||
row(s, 3);
|
||
// The cut stands on the middle square, boxcar west of hopper.
|
||
addCard(s, at(1, 1), straight([car('boxcar'), car('hopper')]));
|
||
const start = from === 'west' ? at(1, 0) : at(1, 2);
|
||
const to = from === 'west' ? at(1, 2) : at(1, 0);
|
||
const id = placeTray(s, start, [], from === 'west' ? 'e' : 'w');
|
||
switching(s);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse: false });
|
||
assert.ok(r.ok, `the run was refused: ${r.ok ? '' : r.code}`);
|
||
return types(s.trays.get(id)!.consist);
|
||
}
|
||
|
||
it('gives mirrored consists, never the same one twice', () => {
|
||
const eastbound = collect('west');
|
||
const westbound = collect('east');
|
||
assert.notDeepEqual(eastbound, westbound, 'approaching from either end gave the identical consist');
|
||
assert.deepEqual(eastbound, [...westbound].reverse(), 'the two approaches are not mirror images');
|
||
});
|
||
|
||
it('puts the car the engine met LAST on the nose, running forward', () => {
|
||
// Running east onto boxcar-then-hopper, the engine pushes the boxcar ahead of it and picks the
|
||
// hopper up in front of that. Nose first, the tray reads hopper, boxcar.
|
||
assert.deepEqual(collect('west'), ['hopper', 'boxcar']);
|
||
assert.deepEqual(collect('east'), ['boxcar', 'hopper']);
|
||
});
|
||
|
||
it('couples behind the train nearest-car-first when backing up', () => {
|
||
const s = game();
|
||
row(s, 3);
|
||
addCard(s, at(1, 1), straight([car('boxcar'), car('hopper')]));
|
||
// Facing east at the east end, so backing up runs WEST into the cut: it meets the hopper first.
|
||
const id = placeTray(s, at(1, 2), [car('caboose')], 'e');
|
||
switching(s);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true });
|
||
assert.ok(r.ok, `the reverse move was refused: ${r.ok ? '' : r.code}`);
|
||
assert.deepEqual(types(s.trays.get(id)!.consist), ['caboose', 'hopper', 'boxcar']);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('a train may not drive through the cut on its own card (§A.4)', () => {
|
||
/**
|
||
* Reported: "if I put cars off the nose on a given track, and my next move is go forward, I need
|
||
* to couple those cars right back on." The walk started at the NEIGHBOUR of the start square and
|
||
* never read the start card at all, so the cut was simply left behind.
|
||
*
|
||
* The rule is direction-sensitive, which is the point: only the cut at the end the train pulls out
|
||
* through is in the way. That is what makes setting out off one particular end a decision.
|
||
*/
|
||
function runOut(fromNose: boolean, reverse: boolean): { consist: string[]; left: string[] } {
|
||
const s = game();
|
||
row(s, 3);
|
||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', fromNose ? 2 : 0);
|
||
switching(s);
|
||
assert.ok(
|
||
applyIntent(s, 0, {
|
||
type: 'switch.dropCars',
|
||
trayId: id,
|
||
count: 1,
|
||
...(fromNose ? { fromNose: true } : {}),
|
||
}).ok,
|
||
);
|
||
const to = reverse ? at(1, 0) : at(1, 2);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to, reverse });
|
||
assert.ok(r.ok, `the move was refused: ${r.ok ? '' : r.code}`);
|
||
return { consist: types(s.trays.get(id)!.consist), left: standingAt(s, at(1, 1)) };
|
||
}
|
||
|
||
it('picks a nose cut back up when it pulls forward through it', () => {
|
||
const out = runOut(true, false);
|
||
assert.deepEqual(out.left, [], 'the cut was left standing on the card the train pulled out of');
|
||
assert.equal(out.consist.length, 2, 'the train should have both cars back');
|
||
});
|
||
|
||
it('leaves a nose cut standing when it backs away from it instead', () => {
|
||
const out = runOut(true, true);
|
||
assert.deepEqual(out.left, ['boxcar'], 'backing away from a nose cut should leave it where it is');
|
||
assert.deepEqual(out.consist, ['hopper']);
|
||
});
|
||
|
||
it('encodes §A.5’s trailing-point step 1 — drop the back cars, then pull away from them', () => {
|
||
// "Drop the back two cars (blue and red) on Card F. Move train to Card B." The whole example
|
||
// collapses if that first move drags them along again.
|
||
const s = game();
|
||
row(s, 3);
|
||
const id = placeTray(s, at(1, 2), FOUR(), 'w');
|
||
switching(s);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 2 }).ok);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: false });
|
||
assert.ok(r.ok, `pulling away from the set-out cars was refused: ${r.ok ? '' : r.code}`);
|
||
assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar', 'hopper'], 'the train kept cars it had set out');
|
||
assert.deepEqual(
|
||
standingAt(s, at(1, 2)),
|
||
['reefer', 'tank'],
|
||
'the two cars set out should still be standing on the square the train left',
|
||
);
|
||
});
|
||
|
||
it('counts the own cut against the four-car limit', () => {
|
||
// A full tray that sets out one car cannot simply pull forward through it again — that would be
|
||
// five cars, and coupling is mandatory, so the MOVE is what has to be refused.
|
||
const s = game();
|
||
row(s, 3);
|
||
const id = placeTray(s, at(1, 1), FOUR(), 'e', 4);
|
||
switching(s);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||
// Refill the tray so the recoupled car would be a fifth.
|
||
s.trays.get(id)!.consist = FOUR();
|
||
assert.equal(check(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false }), 'ILLEGAL_MOVE');
|
||
// Backing away from it is still legal — the cut is not at that end.
|
||
assert.equal(check(s, 0, { type: 'switch.move', trayId: id, to: at(1, 0), reverse: true }), null);
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('the round trip comes back with the train it started with', () => {
|
||
/**
|
||
* Drop, run clear, back up, collect. This was already self-consistent before the fix — whatever
|
||
* order got parked came back — and it has to STAY that way now that both halves have changed, at
|
||
* both facings and every batch size.
|
||
*/
|
||
for (const facing of ['e', 'w'] as const) {
|
||
for (const batches of [[4], [1, 1, 1, 1], [2, 2]]) {
|
||
it(`survives ${batches.join('+')} cars set out, facing ${facing}`, () => {
|
||
const s = game();
|
||
row(s, 3);
|
||
const start = facing === 'e' ? at(1, 0) : at(1, 2);
|
||
const away = facing === 'e' ? at(1, 2) : at(1, 0);
|
||
const before = types(FOUR());
|
||
const id = placeTray(s, start, FOUR(), facing);
|
||
switching(s);
|
||
turnOf(s, 0).movesRemaining = 6;
|
||
|
||
for (const count of batches) {
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count }).ok);
|
||
}
|
||
assert.equal(s.trays.get(id)!.consist.length, 0, 'the whole train should be on the ground');
|
||
|
||
// Pull forward clear of the cut, then back up onto it again.
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: away, reverse: false }).ok);
|
||
const back = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: start, reverse: true });
|
||
assert.ok(back.ok, `backing onto the cut was refused: ${back.ok ? '' : back.code}`);
|
||
|
||
assert.deepEqual(types(s.trays.get(id)!.consist), before, 'the train came back in a different order');
|
||
assert.deepEqual(standingAt(s, start), [], 'cars were left behind');
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('taking your own cut back is undoing the drop, not a fresh pick-up', () => {
|
||
/**
|
||
* Jesse's call, of the two answers the plan put up. Trains 3/4 print "may drop OR pick up one
|
||
* freight car at every location" and the two share one budget, so a drop off the nose would
|
||
* otherwise leave the train forbidden to pull forward past its own cars — a legal-looking move
|
||
* that is silently one-way. Same shape for X13's "drop but not pick up".
|
||
*/
|
||
it('refunds the 3/4 Express its per-location freight budget', () => {
|
||
const s = game();
|
||
row(s, 3);
|
||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', 2);
|
||
s.trays.get(id)!.trainNumber = 3;
|
||
switching(s);
|
||
turnOf(s, 0).movesRemaining = 6;
|
||
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||
const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false });
|
||
assert.ok(r.ok, `the Express could not take its own car back: ${r.ok ? '' : r.code}`);
|
||
assert.equal(s.trays.get(id)!.consist.length, 2, 'the car it set out should be back on the train');
|
||
assert.deepEqual(
|
||
Object.values(turnOf(s, 0).freightWorked).filter((n) => n > 0),
|
||
[],
|
||
'the drop it undid is still charged against the square it was made on',
|
||
);
|
||
});
|
||
|
||
it('still charges a genuine pick-up', () => {
|
||
// The exemption is for YOUR OWN cut and nothing else: a car that was already standing there when
|
||
// the crew arrived is an ordinary pick-up and the budget still bites.
|
||
const s = game();
|
||
row(s, 3);
|
||
addCard(s, at(1, 2), straight([car('reefer')]));
|
||
const id = placeTray(s, at(1, 1), [car('boxcar'), car('hopper')], 'e', 2);
|
||
s.trays.get(id)!.trainNumber = 3;
|
||
switching(s);
|
||
turnOf(s, 0).movesRemaining = 6;
|
||
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true }).ok);
|
||
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(1, 2), reverse: false }).ok);
|
||
assert.equal(
|
||
turnOf(s, 0).freightWorked[`${id}@${coordKey(at(1, 2))}`],
|
||
1,
|
||
'only the car that was already standing there should be charged',
|
||
);
|
||
});
|
||
});
|