/** * §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', ); }); });