1056 lines
44 KiB
TypeScript
1056 lines
44 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 } 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: { length: 4, 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 {
|
|
owner: 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)));
|
|
});
|
|
});
|
|
|
|
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', owner: 0, coord: { row: 0, col: 0 } }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
s.turn.option = 'switch';
|
|
s.turn.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: { length: 2, 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', owner: 0, coord: at(0, 1) }, movesUsed: 0,
|
|
} as never);
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
s.turn.option = 'switch';
|
|
s.turn.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: { length: 2, 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`,
|
|
);
|
|
}
|
|
});
|
|
});
|