Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut through a curve's south leg no longer couples it back to front. The same assumption left a crew's own cut standing when it pulled out through a leg, which is the "cars left behind" report we had failed to reproduce. Gitea#14 — every count is docs/Deck cards5.xlsx. Track halved, and the Q12 office doubling and Gap 12 industry tripling both come out with it: they were measured against a deck with twice the track, and keeping them at the sheet's track count wipes out the reefer chain entirely. 84 rows now match card for card; the ten Safety, Event and Inspection cards it adds are not built and are held out. Cards the sheet no longer lists are dealt zero copies rather than deleted, so their rules stay implemented. Gitea#15 — RAR reversed it: a rail may stop dead against its neighbour and the placement is legal. What must hold is that no train crosses the gap, which was already true and is now pinned against the reported board. Gitea#3 — the printed speeds are scenery. A card costs one Stage per printed region and where a train STARTS is what varies; Fast/Slow is read on Hilly alone. Entering a one-region card behind another is a collision now, which is what ABS exists to prevent, and ABS no longer holds trains silently. Gitea#18 — the Division draws as one row, west to east, with no office-area detail. East is finally always to the right. Closes #3 Closes #14 Closes #15 Closes #17 Closes #18
1362 lines
59 KiB
TypeScript
1362 lines
59 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 { legalActions } from '../src/engine/legal.ts';
|
|
import type { Intent } from '../src/engine/intents.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,
|
|
standingWest: 0,
|
|
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,
|
|
standingWest: 0,
|
|
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: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const officeCard = (): TrackCard => ({
|
|
geometry: { kind: 'office' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const lockedFacility = (): TrackCard => ({
|
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
standingWest: 0,
|
|
// 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: [], standingWest: 0, 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', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
|
optionalRules: { reducedVisibility: 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: [],
|
|
standingWest: 0,
|
|
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');
|
|
});
|
|
|
|
describe('a rail that stops dead against its neighbour (Gitea#15)', () => {
|
|
/**
|
|
* REPORTED, THEN REVERSED. The issue first read "if a card is placed in that space, it MUST
|
|
* connect", against a right-hand curve laid at (1,-1) with an Ice House above it and a turnout
|
|
* with a north-facing leg below. **RAR reviewed it and ruled the other way (2026-08-26): the
|
|
* placement is fine, and a stub like that has a use — a siding to park cars on.**
|
|
*
|
|
* "We need to confirm, however, that trains are not allowed to traverse from the turnout below
|
|
* to that right-hand curve since the tracks do not connect." That is what these tests are: the
|
|
* rule lives in MOVEMENT, not in placement.
|
|
*
|
|
* The save cannot carry this any more — Gitea#14 took the deck from 206 cards to 121, so its
|
|
* card ids no longer exist and the history stops at the first `card.play`. The geometry is what
|
|
* mattered, and it is rebuilt here directly.
|
|
*/
|
|
/** A Modifier card — an Ice House. Not track: no ports on any edge. */
|
|
const modifierCard = (): TrackCard => ({
|
|
geometry: { kind: 'modifier', modifier: 'iceHouse' },
|
|
baseOperationalRail: false,
|
|
standing: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
/** A Grocer's Warehouse — a Facility, so a plain east-west through track. */
|
|
const warehouse = (): TrackCard => ({
|
|
geometry: { kind: 'facility', facility: 'grocersWarehouse' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
/**
|
|
* The reported district. `withCurve` puts the disputed right-hand curve on the square; without
|
|
* it, the square is empty and the placement itself is under test.
|
|
*
|
|
* The turnout's leg goes NORTH on the `nw_se` diagonal; the curve is `ne`, which is `ne_sw` and
|
|
* has no south port at all. Two reasons the two do not join, either of which is enough.
|
|
*/
|
|
const board = (withCurve: boolean): OfficeArea =>
|
|
areaFrom(
|
|
{
|
|
[coordKey(at(0, -1))]: turnout({ stem: 'w', through: 'e', diverge: 'n' }),
|
|
[coordKey(at(0, 0))]: officeCard(),
|
|
[coordKey(at(1, 0))]: warehouse(),
|
|
[coordKey(at(2, -1))]: modifierCard(),
|
|
...(withCurve ? { [coordKey(at(1, -1))]: curve('ne') } : {}),
|
|
},
|
|
at(0, 0),
|
|
);
|
|
|
|
it('allows the reported placement, which connects on one side and nothing else', () => {
|
|
// RAR's ruling. The curve joins the warehouse to its east; its north leg faces an Ice House
|
|
// that carries no rail, and the turnout below faces its portless south edge. All legal.
|
|
assert.ok(canPlaceAt(board(false), at(1, -1), curve('ne')), 'the reported play was refused');
|
|
});
|
|
|
|
it('will not let a train cross from the turnout below onto that curve', () => {
|
|
// The confirmation the issue actually asks for. Running west out of the Office and into the
|
|
// turnout, the 45° leg goes north — and stops at the curve's blank south edge.
|
|
const dests = reachableDestinations(ctxFor(board(true)), at(0, 0), 'w');
|
|
assert.ok(!has(dests, 1, -1), 'a train drove across rails that do not meet');
|
|
});
|
|
|
|
it('still reaches the curve from the side that DOES join', () => {
|
|
// Otherwise the test above would pass on a card that is simply unreachable, which proves
|
|
// nothing. East of the curve is the warehouse, and east-west edges always meet.
|
|
const dests = reachableDestinations(ctxFor(board(true)), at(1, 0), 'w');
|
|
assert.ok(has(dests, 1, -1), 'the curve was unreachable from the side that joins');
|
|
});
|
|
|
|
it('will not let a train cross a north edge onto a card with no rail at all', () => {
|
|
// The Ice House above. A Modifier is scenery beside the rails — Jesse confirmed a rail may
|
|
// point at a building — so what stops a train is the same `joins` test, not a placement rule.
|
|
const dests = reachableDestinations(ctxFor(board(true)), at(1, 0), 'w');
|
|
assert.ok(!has(dests, 2, -1), 'a train drove into an Ice House');
|
|
});
|
|
|
|
it('still allows an exit that faces a BLANK square', () => {
|
|
// Unchanged by the reversal, and the reason a district can grow at all: a turnout laid on the
|
|
// Running Track with nothing yet beside its diverging leg is a perfectly good play.
|
|
const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
|
|
assert.ok(
|
|
canPlaceAt(area, at(0, 1), turnout({ stem: 'w', through: 'e', diverge: 'n' })),
|
|
'a turnout whose leg faces open space was refused',
|
|
);
|
|
});
|
|
});
|
|
|
|
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('allows a coach, and only a coach, to be dropped at the Office (v0.5.0 §A.4 exception)', () => {
|
|
const area = areaFrom(
|
|
{ [coordKey(at(0, 0))]: officeCard(), [coordKey(at(0, 1))]: straight() },
|
|
at(0, 0),
|
|
);
|
|
assert.ok(!canDropCarsAt(area, at(0, 0), 1, false), 'not asking for the coach exception');
|
|
assert.ok(canDropCarsAt(area, at(0, 0), 1, true), 'one coach');
|
|
assert.ok(canDropCarsAt(area, at(0, 0), 2, true), 'more than one coach');
|
|
});
|
|
|
|
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: [], standingWest: 0, 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: [], standingWest: 0, 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: [], standingWest: 0, 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: [], standingWest: 0, 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: [],
|
|
standingWest: 0,
|
|
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: [], standingWest: 0, 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: [],
|
|
standingWest: 0,
|
|
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`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// docs/plans/switching-paths.md — two routes to the same square
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('two routes to one square', () => {
|
|
const limitsCard = (): TrackCard => ({
|
|
geometry: { kind: 'limits' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
/**
|
|
* A passing loop: a turnout diverges off the Running Track, curves down to an industry, and
|
|
* curves back up to rejoin the main two columns west. Both routes from (0,4) to (0,0) are legal
|
|
* under §2.4 (neither ever leaves a card by the port it entered); they differ in what they
|
|
* couple. This is the fixture in the plan's §1, verified identical on v0.4.7 and v0.4.8.
|
|
*/
|
|
function loopArea(midCard: (col: number) => TrackCard = () => straight()): OfficeArea {
|
|
return areaFrom(
|
|
{
|
|
[coordKey(at(0, 0))]: straight(),
|
|
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
|
|
[coordKey(at(0, 2))]: midCard(2),
|
|
[coordKey(at(0, 3))]: turnout({ stem: 'e', through: 'w', diverge: 's' }),
|
|
[coordKey(at(0, 4))]: limitsCard(),
|
|
[coordKey(at(-1, 1))]: curve('ne'),
|
|
[coordKey(at(-1, 2))]: midCard(-1),
|
|
[coordKey(at(-1, 3))]: curve('nw'),
|
|
},
|
|
at(9, 9), // office coord, deliberately away from the fixture
|
|
);
|
|
}
|
|
|
|
const tanker: RollingStock = { type: 'tank', loaded: false };
|
|
|
|
it('offers both routes, and only one couples the tanker on the spur', () => {
|
|
const area = loopArea((col) => (col === -1 ? straight([tanker]) : straight()));
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 4), 'w');
|
|
const atZero = dests.filter((d) => d.coord.row === 0 && d.coord.col === 0);
|
|
assert.equal(atZero.length, 2, `expected two distinct routes to (0,0), got ${atZero.length}`);
|
|
const withTanker = atZero.filter((d) => d.couples.some((c) => c.type === 'tank'));
|
|
const without = atZero.filter((d) => d.couples.length === 0);
|
|
assert.equal(withTanker.length, 1, 'exactly one route should couple the tanker');
|
|
assert.equal(without.length, 1, 'exactly one route should couple nothing');
|
|
});
|
|
|
|
it('offers two routes coupling the SAME car type off DIFFERENT cards, and neither is dropped', () => {
|
|
// The dedupe key must include the origin card, not just the car type (plan §3) — two boxcars,
|
|
// one on the bypass and one on the main, must not collapse into one destination.
|
|
const area = loopArea(() => straight([{ type: 'boxcar', loaded: false }]));
|
|
const dests = reachableDestinations(ctxFor(area), at(0, 4), 'w');
|
|
const atZero = dests.filter((d) => d.coord.row === 0 && d.coord.col === 0);
|
|
assert.equal(atZero.length, 2, `expected two distinct routes to (0,0), got ${atZero.length}`);
|
|
for (const d of atZero) {
|
|
assert.deepEqual(d.couples.map((c) => c.type), ['boxcar'], 'each route should couple its own boxcar');
|
|
}
|
|
});
|
|
|
|
it('a `via`-qualified switch.move resolves to the route it names', () => {
|
|
function freshGame(): GameState {
|
|
const s = gameWith(loopArea((col) => (col === -1 ? straight([tanker]) : straight())));
|
|
s.trays.set('crew', {
|
|
id: 'crew', trainNumber: null, trainIsExtra: false, engineAt: 0, consist: [],
|
|
direction: 'west', facing: 'w', position: { at: 'grid', seat: 0, coord: at(0, 4) }, movesUsed: 0,
|
|
});
|
|
s.clock.phase = 'localOps';
|
|
s.clock.currentActor = 0;
|
|
turnOf(s, 0).option = 'switch';
|
|
turnOf(s, 0).movesRemaining = 6;
|
|
return s;
|
|
}
|
|
|
|
const probe = freshGame();
|
|
const options = legalActions(probe, 0).filter(
|
|
(i): i is Extract<Intent, { type: 'switch.move' }> =>
|
|
i.type === 'switch.move' && i.to.row === 0 && i.to.col === 0,
|
|
);
|
|
assert.equal(options.length, 2, 'expected two distinct switch.move intents to (0,0)');
|
|
assert.ok(options.every((i) => i.via !== undefined), 'an ambiguous route must carry a distinguishing via');
|
|
const vias = new Set(options.map((i) => `${i.via!.row},${i.via!.col}`));
|
|
assert.equal(vias.size, 2, 'the two routes must carry DIFFERENT via');
|
|
|
|
const outcomes = options.map((intent) => {
|
|
const s = freshGame();
|
|
const r = applyIntent(s, 0, intent);
|
|
assert.ok(r.ok, 'a via-qualified move to a square the engine offered must be legal');
|
|
return s.trays.get('crew')!.consist.map((c) => c.type);
|
|
});
|
|
assert.ok(outcomes.some((c) => c.includes('tank')), 'one via should have coupled the tanker');
|
|
assert.ok(outcomes.some((c) => c.length === 0), 'the other via should have coupled nothing');
|
|
|
|
// `via` absent still resolves — the first route enumerated, exactly as before this feature.
|
|
const s = freshGame();
|
|
const r = applyIntent(s, 0, { type: 'switch.move', trayId: 'crew', to: at(0, 0), reverse: false });
|
|
assert.ok(r.ok, 'a plain move with no via must still resolve to SOME legal route');
|
|
});
|
|
|
|
it('a dense district terminates and stays bounded, preferring the routes BFS finds first', () => {
|
|
// Chained passing loops: each one independently offers "through" or "detour", so N of them
|
|
// offer 2^N routes to the far end with no global memo to stop it — exactly the exponential case
|
|
// the cap exists for (plan §3). 15 segments is 32,768 routes; the cap is 4,000 frontier nodes.
|
|
const segments = 15;
|
|
const start = 3 * segments;
|
|
const grid: Record<string, TrackCard> = { [coordKey(at(0, start + 1))]: limitsCard() };
|
|
for (let k = 0; k < segments; k++) {
|
|
const divergeCol = start - 3 * k;
|
|
const midCol = divergeCol - 1;
|
|
const mergeCol = divergeCol - 2;
|
|
grid[coordKey(at(0, divergeCol))] = turnout({ stem: 'e', through: 'w', diverge: 's' });
|
|
grid[coordKey(at(0, midCol))] = straight();
|
|
grid[coordKey(at(0, mergeCol))] = turnout({ stem: 'w', through: 'e', diverge: 's' });
|
|
grid[coordKey(at(-1, divergeCol))] = curve('nw');
|
|
grid[coordKey(at(-1, midCol))] = straight();
|
|
grid[coordKey(at(-1, mergeCol))] = curve('ne');
|
|
}
|
|
grid[coordKey(at(0, 0))] = straight();
|
|
const area = areaFrom(grid, at(9, 9));
|
|
|
|
const startedAt = Date.now();
|
|
const dests = reachableDestinations(ctxFor(area), at(0, start + 1), 'w');
|
|
const elapsed = Date.now() - startedAt;
|
|
|
|
assert.ok(elapsed < 5000, `exploreMoves took ${elapsed}ms — the cap should keep this fast`);
|
|
assert.ok(dests.length > 0, 'the cap must not eliminate every route');
|
|
assert.ok(
|
|
dests.length < 2 ** segments,
|
|
`${dests.length} destinations found — the cap should have cut off full 2^${segments} enumeration`,
|
|
);
|
|
});
|
|
});
|