/** * §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, movesFor } from '../src/engine/apply.ts'; import { legalActions } from '../src/engine/legal.ts'; import { describeIntent } from '../src/sim/view.ts'; import { badlyMadeUp } from '../src/engine/advance.ts'; import { createGame } from '../src/engine/setup.ts'; import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts'; import { carsOn, coordKey, turnOf } from '../src/engine/state.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 = 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, standingWest: 0, 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()); } /** * `facing` is the PORT the engine points out through, which is not always an east-west one: a train * standing on a curve points along its 45° leg. `railFacing` carries the east-west sense the train * arrived with, so it keeps a straight answer whatever port the nose is on (`railFacingOf`). */ function placeTray( s: GameState, coord: GridCoord, consist: RollingStock[], facing: 'n' | 's' | 'e' | 'w', engineAt = 0, railFacing: 'e' | 'w' = facing === 'w' ? 'w' : 'e', ): string { const id = s.freeTrays.pop()!; s.trays.set(id, { id, trainNumber: null, trainIsExtra: false, engineAt, consist, direction: railFacing === 'w' ? 'west' : 'east', facing, railFacing, 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']); // `standingWest` lives on the CARD now, not the tray (docs/plans/switching-paths.md — "The // Roster Pass"): the Office is the one square more than one train may share, and a cut lies // west or east of the block of A/D tracks rather than of any one engine. assert.equal( areaOf(s, 0).grid.get(coordKey(at(1, 0)))!.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', ); }); }); // --------------------------------------------------------------------------- describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)', () => { /** * Reported: "Cars were West to East Caboose, Loaded boxcar, Loaded boxcar, Loaded boxcar. After * backing into that square cars were attached to the train Loaded boxcar, Loaded boxcar, Loaded * boxcar, Caboose, Engine." The caboose came back next to the engine instead of at the far end, * which also leaves the train badly made up under §8.2. * * The square was a `sw` CURVE and the train backed in through its SOUTH leg. `standing` runs west * to east, and the two places that walk it both asked the PORT which end of the row they were at: * `exploreMoves` reversed the row for an 'e' entry and for nothing else, and `cutTowards` answered * "you meet nothing" for a north or south exit. Neither is a property of the port. * * A 45° leg leaves through the MIDDLE of its edge, so its end of the run is whichever end the arc * does not reach: the south leg of a `sw` curve is the row's EAST end, and the south leg of an * `se` curve is its WEST end. Same port, opposite answers — which is why `rowEndAt` has to ask the * card. */ const curve = (arc: TrackArc, standing: RollingStock[] = [], standingWest = 0): TrackCard => ({ geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' }, baseOperationalRail: true, standing, standingWest, facility: null, modifiers: [], enhancements: [], }); /** * The reported board, minimally: a `sw` curve holding the cut, and an `ne` curve below it for the * train to run from. Both legs lie on the `ne_sw` diagonal, so the two cards actually join. */ function board(standing: RollingStock[], standingWest = standing.length): GameState { const s = game(); addCard(s, at(1, 0), curve('sw', standing, standingWest)); addCard(s, at(0, 0), curve('ne')); switching(s); return s; } it('backs into a cut through the south leg and meets the EAST end of the row first', () => { const s = board([car('caboose', true), car('boxcar', true), car('boxcar', true), car('boxcar', true)]); // Facing east on the `ne` curve, so reversing pulls out through its north leg and into the // curve above through that card's south leg — the move in the reported save. const id = placeTray(s, at(0, 0), [], 'e'); 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}`); // Coupled behind the engine nearest-car-first, and the nearest car is the one at the south end // — the LAST of a west-to-east row on a `sw` curve. The caboose was westmost, so it ends up // furthest from the engine, which is where §8.2 needs it. assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar', 'boxcar', 'boxcar', 'caboose']); }); it('meets the WEST end of the row first where the same leg belongs to an `se` curve', () => { // The mirror, and the reason the port alone cannot answer: an `se` curve's south leg is the // west end of its row, so the same reverse move meets the caboose first. const s = game(); addCard(s, at(1, 0), curve('se', [car('caboose', true), car('boxcar', true)], 2)); addCard(s, at(0, 0), curve('nw')); switching(s); const id = placeTray(s, at(0, 0), [], 'w'); 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', 'boxcar']); }); it('takes its own cut back with it when it pulls out through the south leg (§A.4)', () => { // The other half of the same assumption: `cutTowards` said a train leaving north or south meets // nothing, so a crew standing on a curve drove away and left the cars beside it standing — // exactly what mandatory coupling forbids. const s = board([car('boxcar', true)], 0); // `standingWest` 0 puts the boxcar EAST of the train, which on a `sw` curve is between it and // the south leg it is about to leave by. const id = placeTray(s, at(1, 0), [], 's'); const r = applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: false }); assert.ok(r.ok, `the move off the curve was refused: ${r.ok ? '' : r.code}`); assert.deepEqual(types(s.trays.get(id)!.consist), ['boxcar'], 'the cut beside the train was left standing'); assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card'); }); }); // --------------------------------------------------------------------------- /** * The Small Yard's re-order menu, reported from Day 3 of the 2026-09-17 playtest. * * Jesse had train 10 on his Small Yard — nose first, `[loaded boxcar, loaded hopper, empty tank, * caboose]` — wanted the boxcar on the tail, and could not tell which button did it: every option * read `re-order consist [1,2,3,0]`, the engine's own array indices offered to a person. The move he * wanted was the FIRST of the five. One of the other four re-ordered nothing at all and would still * have spent one of his six Moves. */ describe('the Small Yard says what each re-order would build', () => { const smallYard = (standing: RollingStock[] = []): TrackCard => ({ ...straight(standing), enhancements: ['smallYard'], }); /** Train 10's consist as it actually stood, on a card carrying a Small Yard. */ const onTheYard = (): { s: GameState; trayId: string } => { const s = game(); row(s, 3); addCard(s, at(1, 1), smallYard()); const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true), car('tank'), car('caboose', true)], 'e'); switching(s); return { s, trayId }; }; it('never offers a sort that changes nothing', () => { /** * THE PAIR IS WHAT COUNTS, since the engine position became part of a sort (2026-09-17). The * identity car order is now a perfectly good option when it moves the ENGINE — that is the whole * of the separate engine control — so what must never be offered is the pair that reproduces the * train already standing there, and no pair may appear twice. */ const { s, trayId } = onTheYard(); const tray = s.trays.get(trayId)!; const offered = legalActions(s, 0) .filter((i) => i.type === 'switch.sortConsist') .map((i) => { const sort = i as { order: number[]; engineAt?: number }; return `${sort.order.join(',')}|${sort.engineAt ?? 0}`; }); assert.ok(offered.length > 0, 'no sort was offered at all, so this proved nothing'); const unchanged = `${[...tray.consist.keys()].join(',')}|${tray.engineAt}`; assert.ok( !offered.includes(unchanged), 'the menu offered the train as it already stands — a Move spent to change nothing', ); assert.equal(new Set(offered).size, offered.length, 'the menu offered the same sort twice'); }); it('offers the engine every position, including ahead of its own cars', () => { // Jesse's ruling, 2026-09-17, following `implications.md` against the v0.4.5 card text: a train // in a Small Yard "may sort itself into any order, INCLUDING cars ahead of the engine". const { s, trayId } = onTheYard(); const n = s.trays.get(trayId)!.consist.length; const positions = legalActions(s, 0) .filter((i) => i.type === 'switch.sortConsist') .map((i) => (i as { engineAt?: number }).engineAt ?? 0); for (let k = 1; k <= n; k++) { assert.ok(positions.includes(k), `the engine was never offered position ${k} of ${n}`); } }); it('always offers a made-up order to a train that is not in one', () => { /** * Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the back… sorting all * the cars back in, ready to leave the station." * * A train with its caboose mid-consist is one §8.2 will not let out of the Office, so at least * one offer has to put it right. It comes out of the ordinary curated set — "bring car k to the * tail" is enumerated for every car, and the caboose is one of them. */ const s = game(); row(s, 3); addCard(s, at(1, 1), smallYard()); const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('caboose', true), car('hopper', true)], 'e'); const tray = s.trays.get(trayId)!; tray.trainNumber = 10; switching(s); const fit = legalActions(s, 0) .filter((i) => i.type === 'switch.sortConsist') .filter((i) => { const sort = i as { order: number[]; engineAt?: number }; const after = sort.order.map((n) => tray.consist[n]!); return badlyMadeUp({ ...tray, consist: after, engineAt: sort.engineAt ?? 0 }) === null; }); assert.ok( fit.length > 0, 'a train that cannot leave the Office was offered no sort that would make it up', ); // And the one that does it says so on the button, rather than leaving it to be discovered. assert.ok( fit.some((i) => describeIntent(s, i).includes('MADE UP, ready to leave')), 'the sort that makes the train up does not say so', ); }); it('lays the train out west to east, with the engine where it will be', () => { /** * "You specify above that the order is front to back, but on the screen, if it's eastbound or * westbound, it may look different" — Jesse, 2026-09-17. * * `board-svg.ts` draws the crew strip west on the left, reversing a consist for an east-facing * train so its nose lands at the east end. The button has to read the same way or it describes a * different train from the one on the board. */ const s = game(); row(s, 3); addCard(s, at(1, 1), smallYard()); // Engine points WEST: the consist is stored nose first, so west to east reads engine first. const west = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true)], 'w'); switching(s); const westLabel = legalActions(s, 0) .filter((i) => i.type === 'switch.sortConsist') .map((i) => describeIntent(s, i)) .find((l) => l.startsWith('re-order')); assert.ok(westLabel, 'no re-order was offered for the west-facing train'); assert.match(westLabel, /west to east: ◀ ENGINE · /, `a west-facing engine was not drawn leading: ${westLabel}`); // The same train pointing EAST puts the engine at the far end of the same sentence. s.trays.get(west)!.railFacing = 'e'; s.trays.get(west)!.facing = 'e'; const eastLabel = legalActions(s, 0) .filter((i) => i.type === 'switch.sortConsist') .map((i) => describeIntent(s, i)) .find((l) => l.startsWith('re-order')); assert.ok(eastLabel, 'no re-order was offered for the east-facing train'); assert.match(eastLabel, / · ENGINE ▶($| ·)/, `an east-facing engine was not drawn at the east end: ${eastLabel}`); }); it('labels each option with the train it would make, not with array indices', () => { const { s } = onTheYard(); const labels = legalActions(s, 0) .filter((i) => i.type === 'switch.sortConsist') .map((i) => describeIntent(s, i)); assert.ok( labels.every((l) => !/\[\d(,\d)*\]/.test(l)), `a re-order option still reads as a permutation: ${labels.find((l) => /\[\d(,\d)*\]/.test(l))}`, ); /** * The one Jesse wanted, written the way the board draws it: this crew faces EAST, so the strip * runs west to east and the engine sits at the east end with the boxcar now furthest west. */ assert.ok( labels.includes('re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶'), `the move that puts the boxcar on the tail was not offered in words — got: ${labels.join(' | ')}`, ); // And the engine's own positions read as what they do, not as an index. assert.ok( labels.some((l) => l.startsWith('put the whole consist ahead of the engine —')), `the shoving sort was not offered in words — got: ${labels.join(' | ')}`, ); }); }); /** * WHY A SQUARE IS REFUSED, AND THE ONE PICK-UP EVERY TRAIN MAY MAKE. * * Asked from a table, 2026-09-23: "where does it show that you can't make a particular move because * of a rule that's violated… how does a user know what rule is violated and why you can't go * there?" `exploreMoves` decides where the rails go; the train's own card is enforced afterwards in * `check` — so a square the rails reach and the card forbids was reachable, un-offered, and absent * from the block list with no reason given. */ describe("a train's own card explains the squares it may not enter", () => { /** A drop-only Extra (X13 "may drop MTs but not pick up anything") on row 1, with track beside it. */ const dropOnlyAt = (s: GameState, cars: RollingStock[]): { trayId: string; there: GridCoord } => { row(s, 3); switching(s); const trayId = placeTray(s, at(1, 0), [], 'e'); const tray = s.trays.get(trayId)!; tray.trainNumber = 13; tray.trainIsExtra = true; const there = at(1, 1); areaOf(s, 0).grid.get(coordKey(there))!.standing = cars; return { trayId, there }; }; it('reports a drop-only train as blocked, in words, rather than silently', () => { const s = game(); const { trayId, there } = dropOnlyAt(s, [{ type: 'boxcar', loaded: false }]); const { to, blocked } = movesFor(s, 0, trayId); const k = (c: GridCoord): string => `${c.row},${c.col}`; assert.ok(!to.some((c) => k(c) === k(there)), 'a square the card forbids is still offered'); const b = blocked.find((x) => k(x.coord) === k(there)); assert.ok(b, 'the forbidden square is missing from the block list entirely — no reason is shown'); assert.equal(b!.kind, 'cardRule', "the block is not attributed to the train's card"); assert.match(b!.why, /forbids picking cars up/, `the reason does not name the rule: ${b!.why}`); assert.match(b!.why, /coupling is mandatory/, `the reason does not say why it bites: ${b!.why}`); }); it('lets a drop-only train recover its OWN caboose', () => { /** * JESSE'S RULING, 2026-09-23. X13 prints "may drop MTs but not pick up anything", and a train * needs its caboose at the far end to be made up (§8.2) — so a train that parted with its * caboose could never legally leave again. It stranded itself, permanently and silently. * * The caboose only, not "your own cars" generally: it is the one car whose absence stops the * train departing, so recovering it repairs a consist rather than doing fresh work. */ const s = game(); const { trayId, there } = dropOnlyAt(s, [{ type: 'caboose', loaded: false }]); assert.equal( check(s, 0, { type: 'switch.move', trayId, to: there, reverse: false }), null, 'a drop-only train may not recover its own caboose, so it can never be made up again', ); // A boxcar in the same place is still a pick-up and still refused. areaOf(s, 0).grid.get(coordKey(there))!.standing = [{ type: 'boxcar', loaded: false }]; assert.equal( check(s, 0, { type: 'switch.move', trayId, to: there, reverse: false }), 'PICKUP_NOT_ALLOWED', 'the caboose exemption leaked into ordinary cars', ); }); });