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.
1113 lines
47 KiB
TypeScript
1113 lines
47 KiB
TypeScript
/**
|
|
* Build order step 2 — "Move legality provable against Appendix A's worked switching examples."
|
|
* See architecture/components.md §4.
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import type {
|
|
GameState,
|
|
GridCoord,
|
|
OfficeArea,
|
|
RollingStock,
|
|
TrackArc,
|
|
TrackCard,
|
|
TurnoutOrientation,
|
|
} from '../src/engine/state.ts';
|
|
import { coordKey, isOperationalRail, turnOf } from '../src/engine/state.ts';
|
|
import { createGame } from '../src/engine/setup.ts';
|
|
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
|
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
|
|
import {
|
|
allReachable,
|
|
canDropCarsAt,
|
|
exploreMoves,
|
|
canPlaceAt,
|
|
carriesThroughTrack,
|
|
exitsFrom,
|
|
hasPort,
|
|
neighbour,
|
|
opposite,
|
|
reachableDestinations,
|
|
connectionsFor,
|
|
joins,
|
|
slopeAt,
|
|
slopeOfPair,
|
|
variantsFor,
|
|
} from '../src/engine/track.ts';
|
|
import { TRACK_CARDS } from '../src/engine/content.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const straight = (standing: RollingStock[] = []): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true,
|
|
standing,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
/**
|
|
* A curve: along the centre line from the east or west edge to a frog, then out at 45° through the
|
|
* middle of the north or south edge. `ne`/`sw` lie on one diagonal, `nw`/`se` on the other, and only
|
|
* cards on the SAME diagonal meet across a horizontal edge.
|
|
*/
|
|
const curve = (arc: TrackArc, standing: RollingStock[] = []): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'curved', arc },
|
|
baseOperationalRail: true,
|
|
standing,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
|
geometry: o
|
|
? { kind: 'track', geometry: 'turnout', turnout: o }
|
|
: { kind: 'track', geometry: 'turnout' },
|
|
baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const officeCard = (): TrackCard => ({
|
|
geometry: { kind: 'office' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const lockedFacility = (): TrackCard => ({
|
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
// A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
|
|
facility: {
|
|
kind: 'freight',
|
|
subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [],
|
|
inboundBox: [],
|
|
capacity: { outbound: 3, inbound: 0 },
|
|
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
|
|
industryTrack: { cars: [] },
|
|
laborers: 3,
|
|
porters: 0,
|
|
usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
|
|
|
function straightCard(): TrackCard {
|
|
return {
|
|
geometry: { kind: 'track', geometry: 'straight' },
|
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
|
};
|
|
}
|
|
|
|
/** A minimal game wrapped around a prepared Office Area, for engine-level switching tests. */
|
|
function gameWith(area: OfficeArea): GameState {
|
|
const s = createGame({
|
|
id: 'g', seed: 5,
|
|
config: {
|
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
|
},
|
|
playerNames: ['p'],
|
|
});
|
|
s.officeAreas.set(0, area);
|
|
return s;
|
|
}
|
|
|
|
function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea {
|
|
const grid = new Map<string, TrackCard>();
|
|
for (const [k, v] of Object.entries(cards)) grid.set(k, v);
|
|
return {
|
|
seat: 0,
|
|
tier: 'whistlePost',
|
|
grid,
|
|
officeCoord,
|
|
runningRow: officeCoord.row,
|
|
limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
|
|
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
|
|
adOccupancy: [],
|
|
heldAtLimits: [],
|
|
dispatchUsedToday: [],
|
|
};
|
|
}
|
|
|
|
const emptyOccupancy: Occupancy = { trayAt: () => null, freeAdTracks: () => 1 };
|
|
|
|
function ctxFor(area: OfficeArea, over: Partial<MoveContext> = {}): MoveContext {
|
|
return { area, occupancy: emptyOccupancy, consistSize: 0, self: 'tray0', ...over };
|
|
}
|
|
|
|
const at = (r: number, c: number): GridCoord => ({ row: r, col: c });
|
|
const has = (dests: { coord: GridCoord }[], r: number, c: number): boolean =>
|
|
dests.some((d) => d.coord.row === r && d.coord.col === c);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ports and geometry', () => {
|
|
it('pairs opposite ports and grid neighbours consistently', () => {
|
|
for (const p of ['n', 's', 'e', 'w'] as Port[]) {
|
|
assert.equal(opposite(opposite(p)), p);
|
|
const there = neighbour(at(0, 0), p);
|
|
const back = neighbour(there, opposite(p));
|
|
assert.deepEqual(back, at(0, 0));
|
|
}
|
|
});
|
|
|
|
it('joins a turnout stem to both legs but never the legs to each other', () => {
|
|
// §A.1 — the whole point. Entering from the stem reaches either leg; entering from a leg
|
|
// reaches only the stem. This is an ABSENT edge, not a one-way edge.
|
|
const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
|
|
assert.deepEqual(exitsFrom(t, 'e').sort(), ['s', 'w']);
|
|
assert.deepEqual(exitsFrom(t, 'w'), ['e']);
|
|
assert.deepEqual(exitsFrom(t, 's'), ['e']);
|
|
});
|
|
|
|
it('lets a train traverse a turnout in both directions', () => {
|
|
const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
|
|
assert.ok(exitsFrom(t, 'e').includes('s'), 'stem to diverging leg');
|
|
assert.ok(exitsFrom(t, 's').includes('e'), 'diverging leg back to stem');
|
|
});
|
|
|
|
it('gives the Office card a plain east-west track and no stub at all', () => {
|
|
// Every office and industry card on the printed sheet — Depot, Station, Terminal, Refinery,
|
|
// Freighthouse, Coal Tipple, Manufacturing, Town — is a straight east-west rail with no
|
|
// diverging leg. A district hangs off a TURNOUT laid on the Running Track instead.
|
|
const o = officeCard();
|
|
for (const p of ['e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
|
|
for (const p of ['n', 's'] as Port[]) assert.ok(!hasPort(o, p), `office must not have a ${p} stub`);
|
|
assert.deepEqual(exitsFrom(o, 'e'), ['w']);
|
|
});
|
|
|
|
it('runs every straight east-west, with no north-south rotation to choose', () => {
|
|
// There is no north-south track anywhere on the printed sheet, and there cannot be: a vertical
|
|
// rail meets a card edge at 90°, and everything crossing a horizontal edge does so at 45°.
|
|
assert.deepEqual(connectionsFor(straight()), [['e', 'w']]);
|
|
assert.equal(variantsFor('straight', 'none').length, 1, 'a straight has ONE orientation');
|
|
});
|
|
|
|
it('joins a curve from one end of the through track to the middle of an edge', () => {
|
|
// A curve runs along the centre line to a frog and leaves at 45°. Handedness decides which
|
|
// diagonal; turning the card 180° decides whether the leg goes north or south.
|
|
const r: TrackCard = {
|
|
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
};
|
|
assert.deepEqual(exitsFrom(r, 's'), ['e']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('the 45° matching rule', () => {
|
|
const arcCard = (arc: TrackArc): TrackCard => curve(arc);
|
|
const turnoutOf = (o: TurnoutOrientation): TrackCard => turnout(o);
|
|
|
|
it('puts a pair of arcs on each diagonal, named after the pair', () => {
|
|
// The name IS the rule: `ne` and `sw` lie on one line, `nw` and `se` on the other.
|
|
assert.equal(slopeOfPair('n', 'e'), 'ne_sw');
|
|
assert.equal(slopeOfPair('s', 'w'), 'ne_sw');
|
|
assert.equal(slopeOfPair('n', 'w'), 'nw_se');
|
|
assert.equal(slopeOfPair('s', 'e'), 'nw_se');
|
|
assert.equal(slopeOfPair('e', 'w'), null, 'the through track has no slope');
|
|
});
|
|
|
|
it('joins two cards stacked vertically only when their legs share a diagonal', () => {
|
|
// `sw` above `ne` is one unbroken 45° rail across the card edge. `sw` above `nw` is a V: both
|
|
// cards have the port, the rails meet at the same point, and they still do not connect.
|
|
assert.ok(joins(arcCard('sw'), 's', arcCard('ne')), 'sw over ne is continuous rail');
|
|
assert.ok(joins(arcCard('se'), 's', arcCard('nw')), 'se over nw is continuous rail');
|
|
assert.ok(!joins(arcCard('sw'), 's', arcCard('nw')), 'sw over nw is a V');
|
|
assert.ok(!joins(arcCard('se'), 's', arcCard('ne')), 'se over ne is a V');
|
|
});
|
|
|
|
it('matches a turnout to the curve of the same hand, and to no other', () => {
|
|
// A siding needs one card of each hand: a right turnout to drop off the main, a left curve to
|
|
// climb back. This is the pairing that makes that true.
|
|
const rightTurnout = turnoutOf({ stem: 'w', through: 'e', diverge: 's' });
|
|
const leftTurnout = turnoutOf({ stem: 'e', through: 'w', diverge: 's' });
|
|
assert.ok(joins(rightTurnout, 's', arcCard('ne')));
|
|
assert.ok(!joins(rightTurnout, 's', arcCard('nw')));
|
|
assert.ok(joins(leftTurnout, 's', arcCard('nw')));
|
|
assert.ok(!joins(leftTurnout, 's', arcCard('ne')));
|
|
});
|
|
|
|
it('ignores slope on an east-west edge, where every card meets at the same height', () => {
|
|
assert.ok(joins(straight(), 'e', straight()));
|
|
assert.ok(joins(arcCard('ne'), 'e', straight()), 'a curve reaching east meets the through line');
|
|
});
|
|
|
|
it('never connects north to south on any card the supply can produce', () => {
|
|
// Descending a row therefore costs at least two cards — leg down, then a curve turning the run
|
|
// back east-west. A district can never be a vertical column, which is what the sheet shows.
|
|
for (const { geometry, hand } of TRACK_CARDS) {
|
|
for (const v of variantsFor(geometry, hand)) {
|
|
const card = { ...straight(), geometry: { kind: 'track' as const, geometry, ...v, ...(hand !== 'none' ? { hand } : {}) } };
|
|
for (const [a, b] of connectionsFor(card)) {
|
|
const pair = new Set([a, b]);
|
|
assert.ok(!(pair.has('n') && pair.has('s')), `${geometry}/${hand} joins north to south`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('gives every north or south port exactly one slope', () => {
|
|
// `slopeAt` returns the first matching pair, so a card carrying two differently-sloped legs at
|
|
// one edge would silently answer with whichever came first. Nothing may.
|
|
for (const { geometry, hand } of TRACK_CARDS) {
|
|
for (const v of variantsFor(geometry, hand)) {
|
|
const card = { ...straight(), geometry: { kind: 'track' as const, geometry, ...v, ...(hand !== 'none' ? { hand } : {}) } };
|
|
for (const p of ['n', 's'] as Port[]) {
|
|
const slopes = new Set(
|
|
connectionsFor(card).filter(([a, b]) => a === p || b === p).map(([a, b]) => slopeOfPair(a, b)),
|
|
);
|
|
assert.ok(slopes.size <= 1, `${geometry}/${hand} has two slopes at its ${p} edge`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('ties the slope to the printed hand, so turning a card never changes diagonal', () => {
|
|
// A printed card has a back: it turns 180° but never flips. That is the whole reason handedness
|
|
// is not decoration.
|
|
for (const geometry of ['curved', 'sharpCurved', 'turnout'] as const) {
|
|
for (const [hand, expected] of [['left', 'nw_se'], ['right', 'ne_sw']] as const) {
|
|
const variants = variantsFor(geometry, hand);
|
|
assert.equal(variants.length, 2, `${geometry}/${hand} should offer 0° and 180°, and nothing else`);
|
|
const legs = variants.map((v) => {
|
|
const card = { ...straight(), geometry: { kind: 'track' as const, geometry, ...v, hand } };
|
|
return (slopeAt(card, 'n') ?? slopeAt(card, 's'));
|
|
});
|
|
assert.deepEqual(legs, [expected, expected], `${geometry}/${hand} changes diagonal when turned`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Move legality', () => {
|
|
// row 0: [straight] [office] [turnout, leg south] [straight] <- Running Track
|
|
// row -1: [curve ne ] <- Secondary Track
|
|
//
|
|
// The turnout's leg is on the `ne_sw` diagonal ({w,s}), so the card beneath it must be the arc on
|
|
// the SAME diagonal that reaches north — `ne`. `nw` would meet the same point and still not join.
|
|
const basicArea = (): OfficeArea =>
|
|
areaFrom(
|
|
{
|
|
[coordKey(at(0, -1))]: straight(),
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
[coordKey(at(-1, 1))]: curve('ne'),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
|
|
it('travels any distance in a straight line', () => {
|
|
// §2.4 — "regardless of distance".
|
|
const area = basicArea();
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 2), 'w');
|
|
assert.ok(has(dests, 0, 0), 'reaches the office');
|
|
assert.ok(has(dests, 0, -1), 'reaches past it');
|
|
});
|
|
|
|
it('branches onto Secondary Track through a turnout on the Running Track', () => {
|
|
// The crew must enter the turnout through its STEM (west) to take the diverging leg (§A.1).
|
|
const area = basicArea();
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(has(dests, -1, 1), 'reaches the curve below the turnout');
|
|
});
|
|
|
|
it('will not take a diverging leg entered from the through end', () => {
|
|
// §A.1 — through and diverge are not joined to each other. Approaching from the east reaches
|
|
// the stem and nothing else, so the leg below is unreachable from that side.
|
|
const area = basicArea();
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 2), 'w');
|
|
assert.ok(!has(dests, -1, 1), 'no route from the through end onto the diverging leg');
|
|
});
|
|
|
|
it('never reverses within a single Move', () => {
|
|
// Leaving east from the west end cannot double back to the west end.
|
|
const area = basicArea();
|
|
const dests = reachableDestinations(ctxFor(area), at(0, -1), 'e');
|
|
assert.ok(!has(dests, 0, -1), 'a Move may not return to its own start');
|
|
});
|
|
|
|
it('separates forward and reverse into different Moves', () => {
|
|
const area = basicArea();
|
|
const both = allReachable(ctxFor(area), at(0, 0), 'e');
|
|
// Col 1 is the turnout, which carries no wheel icon, so east means running through it to col 2.
|
|
assert.ok(has(both.forward, 0, 2));
|
|
assert.ok(has(both.reverse, 0, -1));
|
|
assert.ok(!has(both.forward, 0, -1), 'reverse destinations must cost their own Move');
|
|
});
|
|
|
|
it('will not stop on a turnout', () => {
|
|
// row 0: [start] [turnout, leg south]
|
|
// row -1: [curve ne ]
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
|
[coordKey(at(-1, 1))]: curve('ne'),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 1), 'a turnout carries no wheel icon (§A.1)');
|
|
assert.ok(has(dests, -1, 1), 'but a train may pass through it');
|
|
});
|
|
|
|
it('will not stop on a facility whose track is locked by a load', () => {
|
|
// §9.3 — while any load sits on MEN|AT|WORK the industry track is not Operational Rail.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: lockedFacility(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 1), 'locked industry track must not accept a train');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('coupling', () => {
|
|
const withCars = (n: number): OfficeArea =>
|
|
areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: straight(Array.from({ length: n }, car)),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
|
|
it('couples cars met along the way, mandatorily', () => {
|
|
// §A.4 — "you MUST pick them up. You may not go around them."
|
|
const dests = reachableDestinations(ctxFor(withCars(2)), at(0, 0), 'e');
|
|
const beyond = dests.find((d) => d.coord.col === 2);
|
|
assert.ok(beyond, 'should be able to pass the occupied card');
|
|
assert.equal(beyond.couples.length, 2, 'cars must be collected, not stepped over');
|
|
});
|
|
|
|
it('forbids a Move that would overfill the tray', () => {
|
|
// §A.4 — max four Rolling Stock, cabooses included. Overfilling makes the move illegal
|
|
// rather than picking up fewer cars.
|
|
const ctx = ctxFor(withCars(3), { consistSize: 3 });
|
|
const dests = reachableDestinations(ctx, at(0, 0), 'e');
|
|
assert.equal(dests.length, 0, '3 in tray + 3 standing exceeds 4');
|
|
});
|
|
|
|
it('permits a Move that exactly fills the tray', () => {
|
|
const ctx = ctxFor(withCars(2), { consistSize: 2 });
|
|
const dests = reachableDestinations(ctx, at(0, 0), 'e');
|
|
assert.ok(dests.length > 0, '2 + 2 = 4 is legal');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('occupancy', () => {
|
|
const twoTrainArea = (): OfficeArea =>
|
|
areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 5), // office elsewhere, so col 1 is ordinary track
|
|
);
|
|
|
|
it('blocks a card another train occupies', () => {
|
|
// §A.4 — two trains may not share a card or move through each other.
|
|
const occupancy: Occupancy = {
|
|
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
|
|
freeAdTracks: () => 1,
|
|
};
|
|
const dests = reachableDestinations(ctxFor(twoTrainArea(), { occupancy }), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 1), 'occupied card');
|
|
assert.ok(!has(dests, 0, 2), 'and no passing through it');
|
|
});
|
|
|
|
it('allows passing through the Office while A/D tracks remain free', () => {
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: officeCard(),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 1),
|
|
);
|
|
const occupancy: Occupancy = {
|
|
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
|
|
freeAdTracks: () => 1,
|
|
};
|
|
const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
|
|
assert.ok(has(dests, 0, 2), 'the Office is the exception to the blocking rule (§A.4)');
|
|
});
|
|
|
|
it('blocks the Office once every A/D track is taken', () => {
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: officeCard(),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 1),
|
|
);
|
|
const occupancy: Occupancy = {
|
|
trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
|
|
freeAdTracks: () => 0,
|
|
};
|
|
const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 2));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('placement and drop-off', () => {
|
|
it('grows a district on BOTH sides of the Running Track', () => {
|
|
// A turnout turned 180° sends its 45° leg north instead of south, so there is nothing special
|
|
// about the rows below. Q7 used to reject everything above the Running Track outright.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, -1))]: straight(),
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
|
[coordKey(at(0, 2))]: turnout({ stem: 'e', through: 'w', diverge: 'n' }),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(canPlaceAt(area, at(-1, 1), curve('ne')), 'below a south-diverging turnout');
|
|
assert.ok(canPlaceAt(area, at(1, 2), curve('sw')), 'above a north-diverging turnout');
|
|
});
|
|
|
|
it('refuses a card whose 45° leg lies on the wrong diagonal', () => {
|
|
// Both cards have the port and both legs meet the same point on the shared edge. They still do
|
|
// not join: one descends to the right and the other to the left, so the rails form a V.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(canPlaceAt(area, at(-1, 1), curve('ne')), 'the matching diagonal joins');
|
|
assert.ok(!canPlaceAt(area, at(-1, 1), curve('nw')), 'the opposite diagonal must not');
|
|
});
|
|
|
|
it('refuses a card whose ports do not meet the neighbour it touches', () => {
|
|
// An east-west straight below a turnout has no north port at all, so nothing can reach it.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(!canPlaceAt(area, at(-1, 1), straight()), 'an east-west straight cannot meet a 45° leg');
|
|
});
|
|
|
|
it('refuses a card that connects to nothing', () => {
|
|
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
|
assert.ok(!canPlaceAt(area, at(3, 3), straight()), 'orphaned track is never legal');
|
|
});
|
|
|
|
it('refuses to place on an occupied cell', () => {
|
|
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
|
assert.ok(!canPlaceAt(area, at(0, 0), straight()));
|
|
});
|
|
|
|
it('forbids dropping cars at the Office', () => {
|
|
// §A.4 — a passenger platform is no place to switch Rolling Stock.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(!canDropCarsAt(area, at(0, 0)), 'Office track');
|
|
assert.ok(canDropCarsAt(area, at(0, 1)), 'ordinary Operational Rail');
|
|
});
|
|
|
|
it('forbids dropping cars on a locked industry track', () => {
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: lockedFacility(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(!canDropCarsAt(area, at(0, 1)));
|
|
});
|
|
|
|
it('lets a crew set out as many cars at an industry as anywhere else (regression)', () => {
|
|
/**
|
|
* REPORTED from a playtest, at undo 188: "we wanted to drop two cars, but were only allowed to
|
|
* drop one."
|
|
*
|
|
* An industry track used to be `length: baseOut + baseIn` cars long, so a Mine Tipple — one
|
|
* green box, no red one — had room for exactly ONE car and refused the second. The box count is
|
|
* how much WORK an industry can hold; it has nothing to do with how much rail is in front of it.
|
|
* No industry card prints a siding of a fixed length, and an industry track is ordinary
|
|
* Operating Rail: four cars fit, the same as on the plain straight beside it.
|
|
*/
|
|
const oneBoxIndustry = (): TrackCard => {
|
|
const c = lockedFacility();
|
|
// Unlocked — §9.3's lock is tested above and is not what this is about.
|
|
c.facility!.menAtWork = [null, null, null];
|
|
c.facility!.capacity = { outbound: 1, inbound: 0 };
|
|
return c;
|
|
};
|
|
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: oneBoxIndustry(),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
|
|
for (const count of [1, 2, 3, 4]) {
|
|
assert.ok(
|
|
canDropCarsAt(area, at(0, 1), count),
|
|
`a one-box industry refused a cut of ${count} — the box count is not the siding length`,
|
|
);
|
|
assert.equal(
|
|
canDropCarsAt(area, at(0, 1), count),
|
|
canDropCarsAt(area, at(0, 2), count),
|
|
`an industry and a plain straight disagreed about a cut of ${count}`,
|
|
);
|
|
}
|
|
|
|
// Four is the ceiling everywhere, because a consist may not exceed four.
|
|
assert.ok(!canDropCarsAt(area, at(0, 1), 5), 'a fifth car fitted on an industry track');
|
|
assert.ok(!canDropCarsAt(area, at(0, 2), 5), 'a fifth car fitted on a plain straight');
|
|
});
|
|
|
|
it('counts the cars already spotted at an industry against the room left', () => {
|
|
const busy = lockedFacility();
|
|
busy.facility!.menAtWork = [null, null, null];
|
|
busy.facility!.industryTrack.cars = [car(), car(), car()];
|
|
const area = areaFrom(
|
|
{ [coordKey(at(0, 0))]: officeCard(), [coordKey(at(0, 1))]: busy },
|
|
at(0, 0),
|
|
);
|
|
assert.ok(canDropCarsAt(area, at(0, 1), 1), 'the fourth car should still fit');
|
|
assert.ok(!canDropCarsAt(area, at(0, 1), 2), 'a fifth car fitted');
|
|
});
|
|
});
|
|
|
|
describe('curves are arcs, and arcs make sidings possible', () => {
|
|
const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({
|
|
geometry: { kind: 'track', geometry, arc },
|
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
|
});
|
|
|
|
it('joins exactly two adjacent edges, with no through track', () => {
|
|
// The printed cards (docs/tracks.png, rows 3-4) are a single sweep from edge to edge. Modelling
|
|
// a curve as through-track PLUS a diverging leg made it a turnout, and an exact duplicate of one.
|
|
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
|
const links = connectionsFor(arcCard(arc));
|
|
assert.equal(links.length, 1, `${arc} should be ONE connection, not a through track plus a leg`);
|
|
assert.deepEqual([...links[0]!].sort(), [...arc].sort(), `${arc} joins the wrong edges`);
|
|
}
|
|
});
|
|
|
|
it('is no longer a duplicate of a turnout', () => {
|
|
const norm = (c: readonly (readonly string[])[]): string =>
|
|
c.map((p) => [...p].sort().join('')).sort().join(' ');
|
|
const turnout: TrackCard = {
|
|
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } },
|
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
|
};
|
|
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
|
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
|
|
`a ${arc} curve still has the same connections as a turnout`);
|
|
}
|
|
});
|
|
|
|
it('can reach NORTH, which is the only way back up to the Running Track', () => {
|
|
// Without it a district could only ever hang below the main — no siding, no run-around.
|
|
const reachesNorth = (['ne', 'nw'] as const).every((arc) =>
|
|
connectionsFor(arcCard(arc)).some((p) => p.includes('n')),
|
|
);
|
|
assert.ok(reachesNorth, 'a curve still cannot reach north');
|
|
});
|
|
|
|
it('offers two rotations, and which two depends on the printed hand', () => {
|
|
// A card turns 180° but never flips, so a left-hand curve reaches `se` and `nw` and can never
|
|
// become `sw` or `ne` — those are the right-hand card.
|
|
for (const g of ['curved', 'sharpCurved'] as const) {
|
|
assert.deepEqual(variantsFor(g, 'left').map((v) => v.arc).sort(), ['nw', 'se'], `${g} left`);
|
|
assert.deepEqual(variantsFor(g, 'right').map((v) => v.arc).sort(), ['ne', 'sw'], `${g} right`);
|
|
}
|
|
});
|
|
|
|
it('a sharp curve differs from a curve only in the Moves it costs', () => {
|
|
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
|
|
assert.deepEqual(
|
|
connectionsFor(arcCard(arc, 'sharpCurved')),
|
|
connectionsFor(arcCard(arc, 'curved')),
|
|
'a sharp curve should be geometrically identical',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('lets a crew run a siding and rejoin the main', () => {
|
|
// THE CANONICAL SIDING, and the reason handedness matters: leave the Running Track on a RIGHT
|
|
// turnout, drop onto its matching right curve (`ne`), run parallel, and climb back on a LEFT
|
|
// curve (`nw`) into a LEFT turnout. One card of each hand, and the slopes agree at both ends.
|
|
// A crew must ENTER a turnout through its stem to take the diverging leg (§A.1).
|
|
const ew = (): TrackCard => straightCard();
|
|
const grid: Record<string, TrackCard> = {
|
|
'0,-2': ew(),
|
|
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
|
'-1,-1': arcCard('ne'),
|
|
'-1,0': ew(),
|
|
'-1,1': arcCard('nw'),
|
|
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
|
|
'0,0': ew(),
|
|
};
|
|
const area = areaFrom(grid, { row: 0, col: 0 });
|
|
const ctx = { area, occupancy: { trayAt: () => null, freeAdTracks: () => 2 }, consistSize: 0, self: 'crew' as const };
|
|
|
|
const seen = new Set<string>(['0,-2']);
|
|
const queue = [{ row: 0, col: -2 }];
|
|
for (let i = 0; i < 40 && queue.length > 0; i++) {
|
|
const at = queue.shift()!;
|
|
for (const facing of ['e', 'w', 'n', 's'] as const) {
|
|
for (const d of reachableDestinations(ctx, at, facing)) {
|
|
const key = `${d.coord.row},${d.coord.col}`;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
queue.push(d.coord);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const cell of ['-1,-1', '-1,0', '-1,1']) {
|
|
assert.ok(seen.has(cell), `the siding cell ${cell} is unreachable — no run-around is possible`);
|
|
}
|
|
assert.ok(seen.has('0,1'), 'the siding does not rejoin the Running Track');
|
|
});
|
|
});
|
|
|
|
describe('coupling lifts only the cars the crew ran over', () => {
|
|
it('leaves cars standing elsewhere in the district alone', () => {
|
|
// The reducer cleared EVERY card in the Office Area on any coupling, so picking up one boxcar
|
|
// deleted every car standing anywhere — including loads worked over several Stages onto an
|
|
// industry track the crew never went near.
|
|
const grid: Record<string, TrackCard> = {
|
|
'0,-1': straightCard(),
|
|
'0,0': straightCard(),
|
|
'0,1': straightCard(),
|
|
'0,2': straightCard(),
|
|
};
|
|
grid['0,-1']!.standing.push({ type: 'boxcar', loaded: false }); // on the path
|
|
grid['0,2']!.standing.push({ type: 'hopper', loaded: true }); // nowhere near it
|
|
|
|
const area = areaFrom(grid, { row: 0, col: 0 });
|
|
const s = gameWith(area);
|
|
s.trays.set('crew', {
|
|
// A local CREW, which is what this test is about — `trainNumber: null`. It used to say train 1,
|
|
// the Crack Limited, which prints "no switching" (§7) and may not make this move at all.
|
|
id: 'crew', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
|
|
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
turnOf(s, 0).option = 'switch';
|
|
turnOf(s, 0).movesRemaining = 6;
|
|
|
|
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: { row: 0, col: -1 }, reverse: false });
|
|
assert.ok(r.ok, 'the move should be legal');
|
|
assert.deepEqual(
|
|
s.trays.get('crew')!.consist.map((c: RollingStock) => c.type),
|
|
['boxcar'],
|
|
'the crew should have picked up the car it ran over',
|
|
);
|
|
assert.equal(
|
|
areaOf(s, 0).grid.get('0,2')!.standing.length,
|
|
1,
|
|
'a car standing off the path was deleted by an unrelated coupling',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Operational Rail — where a train may stop and leave cars (Appendix A)', () => {
|
|
/**
|
|
* "Operational Rail is any track card that a train can stop and leave Rolling Stock (uncouple) on.
|
|
* Operational Rail is any track card with a train wheel icon on it."
|
|
*
|
|
* The wheel icons in the rules diagrams settle which is which: straights, curves, industries, the
|
|
* Limits cards and the DEPOT all carry one; the turnout does not. The turnout page says so in
|
|
* words too — "Since there is no Operational Rail wheel icon, the train may not stop on this card."
|
|
*/
|
|
const facilityCard = (menAtWork: (object | null)[]): TrackCard => ({
|
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: {
|
|
kind: 'freight', subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
|
menAtWork: menAtWork as never,
|
|
industryTrack: { cars: [] },
|
|
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
it('lets a train stop on a straight, a curve and an industry', () => {
|
|
assert.ok(isOperationalRail(straight()));
|
|
assert.ok(isOperationalRail(curve('ne')));
|
|
assert.ok(isOperationalRail(facilityCard([null, null, null])));
|
|
});
|
|
|
|
it('lets a train stop at the Office, but never leave cars there', () => {
|
|
// "While your Office Track is considered Operational Rail, Rolling Stock may not be dropped off
|
|
// here. As there is a passenger platform here, it would be dangerous..." The Depot card in the
|
|
// rules diagram carries a wheel icon AND is drawn as one of the green squares a Crew Tray may
|
|
// move to, so stopping is legal and only uncoupling is not.
|
|
const area = areaFrom(
|
|
{ [coordKey(at(0, 0))]: officeCard(), [coordKey(at(0, 1))]: straight() },
|
|
at(0, 0),
|
|
);
|
|
assert.ok(isOperationalRail(officeCard()), 'the Office IS Operational Rail');
|
|
assert.ok(!canDropCarsAt(area, at(0, 0)), 'but Rolling Stock may not be left there');
|
|
assert.ok(canDropCarsAt(area, at(0, 1)), 'ordinary track still accepts cars');
|
|
});
|
|
|
|
it('will not let a train stop on a turnout, or leave cars on one', () => {
|
|
// The one card in the switching diagrams with no wheel icon.
|
|
const t = turnout({ stem: 'w', through: 'e', diverge: 's' });
|
|
assert.ok(!isOperationalRail(t), 'a turnout carries no wheel icon');
|
|
const area = areaFrom(
|
|
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: t, [coordKey(at(0, 2))]: straight() },
|
|
at(0, 5),
|
|
);
|
|
assert.ok(!canDropCarsAt(area, at(0, 1)), 'cars may not be left on a turnout');
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 1), 'a train may not stop on a turnout');
|
|
assert.ok(has(dests, 0, 2), 'but it runs straight through one');
|
|
});
|
|
|
|
it('locks an industry down while a load is on MEN | AT | WORK', () => {
|
|
// §9.3 — "It loses its status as Operational Rail. No cars can be picked up or dropped off, and
|
|
// no trains may occupy OR MOVE ON it." That last clause is stronger than losing Operational Rail
|
|
// status, and was missing: a crew rolled straight over a locked industry and coupled the cars
|
|
// spotted on it on the way past, which is the pair of things the safety lockout exists to stop.
|
|
const locked = facilityCard([{ type: 'hopper', dir: 'out' }, null, null]);
|
|
assert.ok(!isOperationalRail(locked));
|
|
const area = areaFrom(
|
|
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: locked, [coordKey(at(0, 2))]: straight() },
|
|
at(0, 5),
|
|
);
|
|
assert.ok(!canDropCarsAt(area, at(0, 1)), 'no cars may be dropped off');
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(!has(dests, 0, 1), 'no train may occupy it');
|
|
assert.ok(!has(dests, 0, 2), 'and none may move THROUGH it either');
|
|
});
|
|
|
|
it('reopens the industry once the work is cleared', () => {
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: facilityCard([null, null, null]),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 5),
|
|
);
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(has(dests, 0, 1), 'an idle industry is Operational Rail again');
|
|
assert.ok(has(dests, 0, 2));
|
|
});
|
|
|
|
it('agrees with the track supply about which pieces carry a wheel', () => {
|
|
// The catalogue column and the rules diagrams must not drift apart.
|
|
for (const t of TRACK_CARDS) {
|
|
assert.equal(
|
|
t.isOperationalRail,
|
|
t.geometry !== 'turnout',
|
|
`${t.name}: only a turnout lacks the wheel icon`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('the Running Track must stay a through route', () => {
|
|
const runningArea = (): OfficeArea =>
|
|
areaFrom(
|
|
{
|
|
[coordKey(at(0, -1))]: straight(),
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
|
|
it('refuses a curve in the running row, which would dead-end the main', () => {
|
|
// A curve has ONE road — from an east or west edge round to a 45° leg — so a card of it standing
|
|
// in the Running Track stops the main dead at that square, cutting the Office off from its own
|
|
// Limits. Every other card that may stand there carries an east-west road across it.
|
|
const area = runningArea();
|
|
for (const arc of ['ne', 'nw', 'se', 'sw'] as TrackArc[]) {
|
|
assert.ok(!carriesThroughTrack(curve(arc)), `a ${arc} curve has no through road`);
|
|
assert.ok(!canPlaceAt(area, at(0, 2), curve(arc)), `a ${arc} curve must not go in the Running Track`);
|
|
}
|
|
});
|
|
|
|
it('still accepts everything that does carry the road through', () => {
|
|
const area = runningArea();
|
|
assert.ok(carriesThroughTrack(straight()));
|
|
assert.ok(carriesThroughTrack(turnout({ stem: 'w', through: 'e', diverge: 's' })));
|
|
assert.ok(carriesThroughTrack(officeCard()));
|
|
assert.ok(canPlaceAt(area, at(0, 2), straight()), 'a straight extends the main');
|
|
assert.ok(
|
|
canPlaceAt(area, at(0, 2), turnout({ stem: 'w', through: 'e', diverge: 's' })),
|
|
'a turnout keeps the road AND opens a leg — which is why a district hangs off one',
|
|
);
|
|
});
|
|
|
|
it('leaves a curve free to go anywhere off the main', () => {
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
assert.ok(canPlaceAt(area, at(-1, 1), curve('ne')), 'the siding is exactly where a curve belongs');
|
|
});
|
|
});
|
|
|
|
describe('a turnout may be laid to face another, making a crossover', () => {
|
|
/** A turnout stated the way the cards read: who may come in, and where they may go. */
|
|
const facing = (stem: Port, through: Port, diverge: Port, hand: 'left' | 'right'): TrackCard => ({
|
|
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem, through, diverge }, hand },
|
|
baseOperationalRail: false, standing: [], facility: null, modifiers: [], enhancements: [],
|
|
});
|
|
|
|
it('joins a south-diverging turnout to the north-diverging one beneath it', () => {
|
|
// "Traffic from the east to travel west or turn to the south", with directly beneath it "traffic
|
|
// from the west to travel east or turn to the north". The two 45° legs are one continuous rail
|
|
// across the card edge — a crossover, and the way a siding gets a track parallel to the main.
|
|
const above = facing('e', 'w', 's', 'right');
|
|
const below = facing('w', 'e', 'n', 'right');
|
|
assert.ok(joins(above, 's', below), 'the crossover does not join');
|
|
|
|
// And its mirror, which is the same move built from the other hand.
|
|
const aboveL = facing('w', 'e', 's', 'left');
|
|
const belowL = facing('e', 'w', 'n', 'left');
|
|
assert.ok(joins(aboveL, 's', belowL), 'the mirrored crossover does not join');
|
|
});
|
|
|
|
it('refuses the pair whose legs lie on opposite diagonals', () => {
|
|
// Both cards have the port and both legs meet the same point on the shared edge. They still form
|
|
// a V: one descends to the right and the other to the left.
|
|
assert.ok(!joins(facing('e', 'w', 's', 'right'), 's', facing('e', 'w', 'n', 'left')));
|
|
assert.ok(!joins(facing('w', 'e', 's', 'left'), 's', facing('w', 'e', 'n', 'right')));
|
|
});
|
|
|
|
it('lets a crew run down the crossover onto the parallel track', () => {
|
|
// The point of building one: the lower turnout carries its own east-west road, so the siding
|
|
// runs alongside the main rather than dead-ending under it.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, -1))]: straight(),
|
|
[coordKey(at(0, 0))]: facing('w', 'e', 's', 'left'),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
// Coming down the leg, the lower turnout is entered through its diverging north end, so the
|
|
// only way on is its stem — east, along the parallel track.
|
|
[coordKey(at(-1, 0))]: facing('e', 'w', 'n', 'left'),
|
|
[coordKey(at(-1, 1))]: straight(),
|
|
},
|
|
at(0, 5),
|
|
);
|
|
const dests = reachableDestinations(ctxFor(area), at(0, -1), 'e');
|
|
assert.ok(has(dests, -1, 1), 'a crew cannot reach the parallel track through the crossover');
|
|
assert.ok(!has(dests, -1, 0), 'and may not stop on the turnout itself');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('backing up does not turn the train around', () => {
|
|
it('keeps the engine pointing the same way through a reverse move', () => {
|
|
// REPORTED as forward/reverse being scrambled. `facing` is which way the ENGINE points, and it
|
|
// was reset to the direction of travel on EVERY move — so one reverse move silently spun the
|
|
// train about, everything read "forward" again, and a run-around became pointless: you could
|
|
// change ends for free by backing up twice.
|
|
const s = gameWith(
|
|
areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 5),
|
|
),
|
|
);
|
|
const id = 'crew';
|
|
s.trays.set(id, {
|
|
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', facing: 'e',
|
|
position: { at: 'grid', seat: 0, coord: at(0, 1) }, movesUsed: 0,
|
|
} as never);
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
turnOf(s, 0).option = 'switch';
|
|
turnOf(s, 0).movesRemaining = 6;
|
|
|
|
// Running forward, east: the engine leads, so it still points east.
|
|
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 2), reverse: false }).ok);
|
|
assert.equal(s.trays.get(id)!.facing, 'e', 'a forward move must leave the engine leading');
|
|
|
|
// Backing up, west: the engine trails. It is still pointing east.
|
|
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
|
|
assert.equal(
|
|
s.trays.get(id)!.facing,
|
|
'e',
|
|
'backing up turned the train around — a run-around would then be pointless',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('why a square is NOT offered (the same walk, keeping its rejections)', () => {
|
|
/**
|
|
* REPORTED: "during switching, it seems like trains are blocked from moving onto industry in
|
|
* certain conditions. Can you make it clear what those conditions are?" They were nowhere on
|
|
* screen — a square that is not a destination simply had no button.
|
|
*
|
|
* `exploreMoves` returns the reasons out of the SAME traversal that produces the destinations, so
|
|
* a reason shown to a player is the rule that actually refused the square. A second function
|
|
* working out "why is this missing" would be a second implementation of movement, and its failure
|
|
* mode is a confident explanation that does not match the refusal.
|
|
*/
|
|
const cars = (n: number): RollingStock[] =>
|
|
Array.from({ length: n }, () => ({ type: 'boxcar', loaded: false }) as RollingStock);
|
|
|
|
const workingIndustry = (): TrackCard => ({
|
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
facility: {
|
|
kind: 'freight', subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
|
|
// A load on MEN locks the track (§9.3).
|
|
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null] as never,
|
|
industryTrack: { cars: [] },
|
|
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const reasonFor = (area: OfficeArea, over: Partial<MoveContext>, r: number, c: number): string | null => {
|
|
const { blocked } = exploreMoves(ctxFor(area, over), at(0, 0), 'e');
|
|
return blocked.find((b) => b.coord.row === r && b.coord.col === c)?.why ?? null;
|
|
};
|
|
|
|
it('says MEN AT WORK when an industry is locked, and refuses the square', () => {
|
|
const area = areaFrom(
|
|
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: workingIndustry() },
|
|
at(0, 5),
|
|
);
|
|
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(!has(destinations, 0, 1), 'a locked industry must not be a destination');
|
|
const why = blocked.find((b) => b.coord.col === 1);
|
|
assert.equal(why?.kind, 'locked');
|
|
assert.match(why!.why, /MEN AT WORK/);
|
|
});
|
|
|
|
it('says which card is occupied, and by what rule', () => {
|
|
const area = areaFrom(
|
|
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: straight() },
|
|
at(0, 5),
|
|
);
|
|
const occupancy: Occupancy = { trayAt: (c) => (c.col === 1 ? 'tray9' : null), freeAdTracks: () => 0 };
|
|
assert.match(reasonFor(area, { occupancy }, 0, 1) ?? '', /another train is standing here/);
|
|
});
|
|
|
|
it('says when a move would overfill the train, and counts the cars it would couple', () => {
|
|
const area = areaFrom(
|
|
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: straight(cars(3)) },
|
|
at(0, 5),
|
|
);
|
|
const why = reasonFor(area, { consistSize: 3 }, 0, 1) ?? '';
|
|
assert.match(why, /too many cars/);
|
|
assert.match(why, /couples 3 standing car/, 'the count must be the cars actually there');
|
|
});
|
|
|
|
it('tells a pass-through apart from an obstruction', () => {
|
|
// A turnout is not in the way: a train runs through one all day and simply may not finish a
|
|
// Move on it. Reporting that as "blocked" would bury the reasons that ARE obstructions.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 5),
|
|
);
|
|
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
|
|
assert.ok(has(destinations, 0, 2), 'the train must still be able to run THROUGH the turnout');
|
|
assert.equal(blocked.find((b) => b.coord.col === 1)?.kind, 'noStopping');
|
|
});
|
|
|
|
it('never explains away a square that is actually reachable', () => {
|
|
// The walk can meet a card from a bad angle first and a good one later. A reason attached to a
|
|
// square the player CAN reach is worse than none: it argues with the button beside it.
|
|
const area = areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: straight(),
|
|
[coordKey(at(0, 2))]: straight(),
|
|
},
|
|
at(0, 5),
|
|
);
|
|
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
|
|
for (const b of blocked) {
|
|
assert.ok(
|
|
!has(destinations, b.coord.row, b.coord.col),
|
|
`(${b.coord.row},${b.coord.col}) is both reachable and explained away`,
|
|
);
|
|
}
|
|
});
|
|
});
|