The 0.4.9 line's port of v0.7.2, minus Gitea#18: the Division map redraw is a multiplayer redesign and does not belong in a build people are mid-playtest on. Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut through a curve's south leg no longer couples it back to front, and a crew pulling out through a leg takes its own cut with it. Gitea#14 — every count is docs/Deck cards5.xlsx, including removing the Q12 office doubling and the Gap 12 industry tripling. Gitea#15 — RAR reversed it: the placement is legal; what must hold is that no train crosses the gap. Gitea#3 — the printed speeds are scenery. One Stage per printed region, and where a train starts is what varies.
462 lines
22 KiB
TypeScript
462 lines
22 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, TrackArc, 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,
|
||
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');
|
||
});
|
||
});
|