fix tracks to all be EW. no more NS tracks

This commit is contained in:
Jesse
2026-08-05 01:00:11 -04:00
parent 821d3cdffa
commit 2d348e1ecf
16 changed files with 1157 additions and 355 deletions
+49 -31
View File
@@ -282,24 +282,32 @@ describe('Red Flags protect a stopped train', () => {
});
describe('Flying Switch rolls a cut into an industry', () => {
/** A crew on a straight with a facility next door, mid-switch. */
/** A crew on a siding below the main, with a facility next door along the same track. */
function setup(s: GameState) {
const area = areaOf(s, 0);
// A flying switch rolls the cut ALONG THE TRACK, so the spur and the industry must be joined
// north-south. Built east-west, they are two unconnected cards that merely look adjacent.
const spur: TrackCard = {
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
// A flying switch rolls the cut ALONG THE TRACK, so the crew and the industry must be joined by
// rail. Every run is east-west now, and the siding gets down off the main the only way there is:
//
// row 0: [office] [turnout, leg south] <- Running Track
// row -1: [curve ne] [straight, crew] [industry] <- the siding
//
const plain = (): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
area.grid.set(coordKey(at(-1, 0)), spur);
});
area.grid.set(coordKey(at(0, 1)), {
...plain(),
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left' },
baseOperationalRail: false,
});
area.grid.set(coordKey(at(-1, 1)), { ...plain(), geometry: { kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left' } });
area.grid.set(coordKey(at(-1, 2)), plain());
const industry: TrackCard = {
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
baseOperationalRail: true,
standing: [],
...plain(),
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
@@ -310,19 +318,14 @@ describe('Flying Switch rolls a cut into an industry', () => {
laborers: 1, porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
};
area.grid.set(coordKey(at(-2, 0)), industry);
area.grid.set(coordKey(at(-1, 3)), industry);
s.trays.set('crew', {
id: 'crew', trainNumber: 1, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
// Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
// track needs its actual port — without it the engine looks for an east exit the card has not
// got, and the crew has no legal moves at all.
direction: 'east', facing: 's',
position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: at(-1, 2) }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
@@ -337,7 +340,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
const cardId = hand(s, 'maneuver', 'flyingSwitch');
const r = applyIntent(s, 0, {
type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 0),
type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 3),
});
assert.ok(r.ok, 'the flying switch should be accepted');
@@ -346,7 +349,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
assert.deepEqual(
s.trays.get('crew')!.position,
{ at: 'grid', owner: 0, coord: at(-1, 0) },
{ at: 'grid', owner: 0, coord: at(-1, 2) },
'the engine never enters the industry',
);
assert.equal(s.turn.movesRemaining, 5, 'the manoeuvre costs a Move');
@@ -366,7 +369,8 @@ describe('Flying Switch rolls a cut into an industry', () => {
const s = game();
setup(s);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
// (0,0) is the Office — track-connected to the crew, but not a freight industry.
// (0,0) is the Office — track-connected to the crew back up through the turnout, but not a
// freight industry.
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
'CANNOT_DROP_HERE',
@@ -377,13 +381,13 @@ describe('Flying Switch rolls a cut into an industry', () => {
const s = game();
setup(s);
const area = areaOf(s, 0);
// An industry sitting beside the crew, but joined east-west while the spur runs north-south.
const stranded = { ...area.grid.get(coordKey(at(-2, 0)))! };
stranded.geometry = { kind: 'track', geometry: 'straight', axis: 'ew' };
area.grid.set(coordKey(at(-1, 1)), stranded);
// An industry sitting directly beneath the crew. Both cards are plain east-west track, so
// nothing crosses the edge between them: adjacency is not a rail.
const stranded = { ...area.grid.get(coordKey(at(-1, 3)))! };
area.grid.set(coordKey(at(-2, 2)), stranded);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 1) }),
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 2) }),
'NOT_CONNECTED',
);
});
@@ -393,7 +397,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
setup(s);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-2, 0) }),
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-1, 3) }),
'CONSIST_EMPTY',
);
});
@@ -454,14 +458,28 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
'laying on the Limits sign must be how the track extends',
);
// The district hangs below the Running Track and is not bounded by the Limits at all.
// A district opens with a TURNOUT on the Running Track — no office or industry card carries a
// stub of its own — so extend onto the east sign with one.
s.turn.laidThisTurn = false;
const turnoutCol = area.limitsEast.col;
assert.ok(
applyIntent(s, 0, {
type: 'track.lay', geometry: 'turnout', hand: 'left',
placement: { row: area.runningRow, col: turnoutCol }, variant: 0,
}).ok,
'a turnout must be layable on the Limits sign',
);
// The district hangs off that turnout and is not bounded by the Limits at all. Variant 1 of a
// left-hand curve is the `ne` arc — the one on the same diagonal as the turnout's leg.
s.turn.laidThisTurn = false;
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'straight', hand: 'none',
placement: { row: area.runningRow - 1, col: 0 }, variant: 1,
type: 'track.lay', geometry: 'curved', hand: 'left',
placement: { row: area.runningRow - 1, col: turnoutCol }, variant: 1,
}),
null,
'Secondary Track below the Office must stay legal',
'Secondary Track below the turnout must stay legal',
);
});
+59 -15
View File
@@ -8,11 +8,11 @@ import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import { refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
import { applyIntent, check, refillDivisionYardIfEmpty } from '../src/engine/apply.ts';
import { TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import type { GameConfig, GameState, OfficeArea } from '../src/engine/state.ts';
import type { Intent } from '../src/engine/intents.ts';
import { connectionsFor, exitsFrom, hasPort, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
import { connectionsFor, exitsFrom, hasPort, joins, neighbour, opposite, variantsFor } from '../src/engine/track.ts';
import type { Port } from '../src/engine/track.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.ts';
@@ -192,17 +192,57 @@ describe('the revenue chain works end to end (regression)', () => {
assert.ok(freight > 0, 'no freight load completed across 40 games');
});
it('grows the Office Area downward from the Running Track (Q7)', () => {
// The card art puts the through-track at the top edge, so Secondary Track hangs beneath it.
// Nothing should ever be built above row 0.
it('grows the Office Area off the Running Track, on either side', () => {
// The through track runs across the middle of every card and a turnout's 45° leg reaches north
// as readily as south, so a district hangs off the Running Track rather than beneath it. Q7's
// "downward only" is gone, and with it the rule that rejected every square above row 0.
const rows = new Set<number>();
for (const seed of [3, 9, 27, 81]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
}
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built below the Running Track');
assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE the Running Track');
assert.ok([...rows].some((r) => r !== 0), 'nothing was ever built off the Running Track');
});
it('offers squares above the Running Track, not only below', () => {
// Asserted against LEGALITY rather than against what the bot happened to build: whether its
// heuristics favour one side is a tuning question, but the rules must allow both.
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['bot'] });
const area = s.officeAreas.get(0)!;
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'draw';
// A north-diverging turnout on the Running Track, and the arc that climbs to meet it. The
// column is captured BEFORE the lay: laying on the sign moves the sign outward, so reading
// `limitsEast` again afterwards names the next square along, not the turnout.
const col = area.limitsEast.col;
assert.ok(
applyIntent(s, 0, {
type: 'track.lay', geometry: 'turnout', hand: 'left',
placement: { row: area.runningRow, col }, variant: 1,
}).ok,
'a turnout must be layable on the Limits sign',
);
s.turn.laidThisTurn = false;
// Left-hand variant 0 is the `sw` arc — the one on the same diagonal as that turnout's leg.
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'curved', hand: 'left',
placement: { row: area.runningRow + 1, col }, variant: 0,
}),
null,
'a curve ABOVE the Running Track must be legal',
);
// And the opposite diagonal must not be, at the same square, for the same turnout.
assert.equal(
check(s, 0, {
type: 'track.lay', geometry: 'curved', hand: 'right',
placement: { row: area.runningRow + 1, col }, variant: 0,
}),
'NOT_CONNECTED',
'the wrong diagonal must not join, however well the ports line up',
);
});
});
@@ -314,16 +354,18 @@ describe('switching accomplishes something (regression)', () => {
describe('the bot builds sidings that are actually sidings (regression)', () => {
/**
* Walk south off one Running Track column and report which OTHER main-line columns the walk can
* climb back up to. Uses the engine's own `exitsFrom`, so the test cannot credit a connection the
* rules do not have — a turnout's two legs never join each other (§A.1), and a re-implementation
* of the traversal would quietly invent that join.
* climb back up to. Uses the engine's own `exitsFrom` and `joins`, so the test cannot credit a
* connection the rules do not have — a turnout's two legs never join each other (§A.1), and two
* 45° legs on opposite diagonals meet at a point on the card edge without being a rail. A
* re-implementation of the traversal would quietly invent both.
*/
const rejoinsFrom = (area: OfficeArea, col: number): { rejoins: Set<number>; seen: Set<string> } => {
const main = area.runningRow;
const rejoins = new Set<number>();
const seen = new Set<string>();
const top = area.grid.get(`${main},${col}`);
const start = area.grid.get(`${main - 1},${col}`);
if (!start || !hasPort(start, 'n')) return { rejoins, seen };
if (!top || !start || !joins(top, 's', start)) return { rejoins, seen };
const stack: { row: number; col: number; entry: Port }[] = [{ row: main - 1, col, entry: 'n' }];
const visited = new Set<string>();
@@ -338,7 +380,7 @@ describe('the bot builds sidings that are actually sidings (regression)', () =>
for (const exit of exitsFrom(card, at.entry)) {
const n = neighbour({ row: at.row, col: at.col }, exit);
const next = area.grid.get(`${n.row},${n.col}`);
if (!next || !hasPort(next, opposite(exit))) continue;
if (!next || !joins(card, exit, next)) continue;
if (n.row >= main) {
if (n.col !== col) rejoins.add(n.col);
continue;
@@ -745,20 +787,22 @@ describe('the bot does not lay track that cannot work (regression)', () => {
if (pick.type === 'track.lay') {
laid++;
const area = st.officeAreas.get(p)!;
const v = variantsFor(pick.geometry)[pick.variant ?? 0];
const v = variantsFor(pick.geometry, pick.hand)[pick.variant ?? 0];
const probe = {
geometry: {
kind: 'track', geometry: pick.geometry,
...(v?.axis ? { axis: v.axis } : {}),
...(v?.arc ? { arc: v.arc } : {}),
...(v?.turnout ? { turnout: v.turnout } : {}),
...(pick.hand !== 'none' ? { hand: pick.hand } : {}),
},
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never;
for (const port of new Set(connectionsFor(probe).flat())) {
const n = neighbour(pick.placement, port);
const nb = area.grid.get(`${n.row},${n.col}`);
if (nb && !hasPort(nb, opposite(port))) dead++;
// `joins`, not `hasPort`: on a north or south edge the two 45° legs must also lie on
// the same diagonal, or the rails meet at a point and still form a V.
if (nb && !joins(probe, port, nb)) dead++;
}
}
return pick;
+184 -51
View File
@@ -11,6 +11,7 @@ import type {
GridCoord,
OfficeArea,
RollingStock,
TrackArc,
TrackCard,
TurnoutOrientation,
} from '../src/engine/state.ts';
@@ -28,8 +29,12 @@ import {
opposite,
reachableDestinations,
connectionsFor,
joins,
slopeAt,
slopeOfPair,
variantsFor,
} from '../src/engine/track.ts';
import { TRACK_SUPPLY } from '../src/engine/content.ts';
// ---------------------------------------------------------------------------
// Fixture helpers
@@ -44,9 +49,13 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
enhancements: [],
});
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
/**
* 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,
@@ -100,7 +109,7 @@ const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
function straightCard(): TrackCard {
return {
geometry: { kind: 'track', geometry: 'straight', axis: 'ew' },
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
};
}
@@ -174,26 +183,33 @@ describe('ports and geometry', () => {
assert.ok(exitsFrom(t, 's').includes('e'), 'diverging leg back to stem');
});
it('gives the Office card a junction stub BELOW only', () => {
// Q7 — the card art draws the through-track along the top edge with everything diverging
// downward, so a district grows beneath the Running Track. Supersedes Gap 8.
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 ['s', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
assert.ok(!hasPort(o, 'n'), 'nothing may attach above the Running Track');
assert.ok(exitsFrom(o, 's').includes('e'));
assert.ok(exitsFrom(o, 's').includes('w'));
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('joins a curve from one end of the through track downward', () => {
// Curves come in left and right hands as distinct cards; the leg always goes DOWN (Q7), and
// handedness decides which end of the through track it leaves from.
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: [],
enhancements: [],
};
assert.deepEqual(exitsFrom(r, 's'), ['w']);
});
@@ -201,16 +217,107 @@ describe('ports and geometry', () => {
// ---------------------------------------------------------------------------
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 left turnout to drop off the main, a right curve to
// climb back. This is the pairing that makes that true.
const leftTurnout = turnoutOf({ stem: 'w', through: 'e', diverge: 's' });
const rightTurnout = turnoutOf({ stem: 'e', through: 'w', diverge: 's' });
assert.ok(joins(leftTurnout, 's', arcCard('ne')));
assert.ok(!joins(leftTurnout, 's', arcCard('nw')));
assert.ok(joins(rightTurnout, 's', arcCard('nw')));
assert.ok(!joins(rightTurnout, '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_SUPPLY) {
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_SUPPLY) {
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', 'ne_sw'], ['right', 'nw_se']] 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: [D] [E office ] [F] <- Running Track
// row -1: [B n-s straight] <- Secondary Track hangs BELOW (Q7)
// 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))]: straight(),
[coordKey(at(-1, 0))]: nsStraight(),
[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),
);
@@ -218,15 +325,24 @@ describe('Move legality', () => {
it('travels any distance in a straight line', () => {
// §2.4 — "regardless of distance".
const area = basicArea();
const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
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 the Office stub', () => {
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, 1), 'w');
assert.ok(has(dests, -1, 0), 'reaches the card below the office');
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', () => {
@@ -239,19 +355,20 @@ describe('Move legality', () => {
it('separates forward and reverse into different Moves', () => {
const area = basicArea();
const both = allReachable(ctxFor(area), at(0, 0), 'e');
assert.ok(has(both.forward, 0, 1));
// 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 ]
// row -1: [C n-s straight]
// 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))]: nsStraight(),
[coordKey(at(-1, 1))]: curve('ne'),
},
at(0, 0),
);
@@ -372,32 +489,46 @@ describe('occupancy', () => {
// ---------------------------------------------------------------------------
describe('placement and drop-off', () => {
it('lets the first Facility attach to the Office stub', () => {
// Gap 8 — the case that was impossible before Office cards carried junction stubs.
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))]: straight(),
[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, 0), nsStraight()), 'below the Office');
assert.ok(!canPlaceAt(area, at(1, 0), nsStraight()), 'nothing attaches above the Running Track');
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', () => {
// Gap 11 in miniature: an east-west straight placed north of the Office has no south port,
// so it cannot attach to the stub. Orientation is not cosmetic.
// An east-west straight below a turnout has no north port at all, so nothing can reach it.
const area = areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
},
at(0, 0),
);
assert.ok(!canPlaceAt(area, at(-1, 0), straight()), 'e-w straight cannot meet a n-s stub');
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', () => {
@@ -464,21 +595,20 @@ describe('curves are arcs, and arcs make sidings possible', () => {
}
});
it('can reach NORTH, which nothing but an n-s straight could before', () => {
// This is the whole point. With every leg diverging south, a district could only ever be a
// vertical column — no siding, no parallel track, no way back up to the Running Track.
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 all four rotations when placed', () => {
// A two-port arc has no handedness that survives turning — its mirror IS one of its rotations —
// so the printed hand governs supply, not what can be built.
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 `sw` and `ne` and can never
// become `se` or `nw` — those are the right-hand card.
for (const g of ['curved', 'sharpCurved'] as const) {
const arcs = variantsFor(g).map((v) => v.arc).sort();
assert.deepEqual(arcs, ['ne', 'nw', 'se', 'sw'], `${g} does not offer all four rotations`);
assert.deepEqual(variantsFor(g, 'left').map((v) => v.arc).sort(), ['ne', 'sw'], `${g} left`);
assert.deepEqual(variantsFor(g, 'right').map((v) => v.arc).sort(), ['nw', 'se'], `${g} right`);
}
});
@@ -493,16 +623,19 @@ describe('curves are arcs, and arcs make sidings possible', () => {
});
it('lets a crew run a siding and rejoin the main', () => {
// The shape the whole change exists for: leave the Running Track, run parallel, come back up.
// THE CANONICAL SIDING, and the reason handedness matters: leave the Running Track on a LEFT
// turnout, drop onto its matching left curve (`ne`), run parallel, and climb back on a RIGHT
// curve (`nw`) into a RIGHT 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': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,-2': ew(),
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'-1,-1': arcCard('ne'),
'-1,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'-1,0': ew(),
'-1,1': arcCard('nw'),
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,0': { geometry: { kind: 'track', geometry: 'straight', axis: 'ew' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'right' }, 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 };
+91 -4
View File
@@ -209,7 +209,7 @@ describe('the board shows freight work happening', () => {
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
@@ -248,7 +248,7 @@ describe('the board shows freight work happening', () => {
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
@@ -270,7 +270,7 @@ describe('the board shows freight work happening', () => {
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ns' },
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: ['overpass'],
facility: {
kind: 'freight', subtype: 'mineTipple',
@@ -295,6 +295,77 @@ describe('the board shows freight work happening', () => {
});
});
describe('the board draws the printed card (docs/tracks.png)', () => {
/** One card, alone, so the measurements are in card coordinates. */
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [],
tray: null, cars: [], facility: null, links, what: '',
};
const svg = officeSvg([cell] as never, 0);
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
.map((m) => ({ x1: Number(m[1]), y1: Number(m[2]), x2: Number(m[3]), y2: Number(m[4]) }));
};
/** Undirected segment angles in degrees, deduplicated. 0 is horizontal. */
const angles = (links: string[]): number[] => [
...new Set(
draw(links).map((r) => {
const a = (Math.atan2(r.y2 - r.y1, r.x2 - r.x1) * 180) / Math.PI;
return Math.round((((a % 180) + 180) % 180) * 10) / 10;
}),
),
].sort((a, b) => a - b);
it('runs the through track dead centre, edge to edge', () => {
// The measurement from the printed sheet: on every one of its nine rows the east-west rail sits
// at the card's exact vertical middle. It used to be drawn at 31% down the card, on the belief
// that the art put it along the top edge.
const rails = draw(['ew']);
assert.equal(rails.length, 2, 'a track is two rails');
const H = 96;
for (const r of rails) {
assert.equal(r.y1, r.y2, 'the through track must be level');
assert.ok(Math.abs(r.y1 - H / 2) <= 3, `through rail at y=${r.y1}, not the card's middle (${H / 2})`);
assert.deepEqual([Math.min(r.x1, r.x2), Math.max(r.x1, r.x2)], [0, 166], 'it must span the whole card');
}
});
it('leaves at exactly 45°, through the middle of the north or south edge', () => {
for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) {
const found = angles(links);
assert.ok(found.includes(0), `${links} has no level run along the centre line`);
assert.ok(
found.includes(45) || found.includes(135),
`${links} draws its leg at ${found.join('/')}° — the card prints 45°`,
);
assert.equal(found.length, 2, `${links} draws ${found.join('/')}° — a leg is a run and one 45° angle`);
}
});
it('draws the two diagonals as two different diagonals', () => {
// The matching rule made visible. `ne` and `sw` lie on one line and `nw` and `se` on the other,
// so a stacked pair on the same diagonal reads as one unbroken rail and a mismatched pair reads
// as the V it is. Drawn alike, the picture would claim a join the engine refuses.
assert.deepEqual(angles(['ne']), angles(['sw']), 'ne and sw must lie on the same diagonal');
assert.deepEqual(angles(['nw']), angles(['se']), 'nw and se must lie on the same diagonal');
assert.notDeepEqual(angles(['ne']), angles(['nw']), 'the two diagonals must be distinguishable');
});
it('meets the card edge at its midpoint, so abutting cards line up', () => {
// A leg that met the edge anywhere else would join to nothing, however right its angle.
const W = 166;
const H = 96;
// Only the sloping segments, and only the end of each that lands on the south edge.
const atEdge = draw(['sw'])
.filter((r) => r.y1 !== r.y2)
.map((r) => (Math.abs(r.y1 - H) < Math.abs(r.y2 - H) ? r.x1 : r.x2));
assert.equal(atEdge.length, 2, 'the leg is two rails');
const mid = (atEdge[0]! + atEdge[1]!) / 2;
assert.ok(Math.abs(mid - W / 2) < 0.5, `the leg crosses the south edge at x=${mid}, not the middle (${W / 2})`);
});
});
describe('board highlighting', () => {
it('gives every spot a coordinate to highlight', () => {
const game = newGame(555);
@@ -316,6 +387,22 @@ describe('board highlighting', () => {
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
/**
* Open the district first, with a TURNOUT on the Running Track.
*
* Every office and industry card is a plain east-west straight and the Office carries no stub,
* so on the opening board the only legal squares are the two Limits signs — on the Running Track
* itself, inside the existing bounds, and this test would prove nothing. A turnout is what puts
* a square OFF the main in reach. The lay flag is then cleared rather than playing out a second
* turn: the subject here is the drawn canvas, not the turn economy.
*/
const lay = actionGroups(game).options.find(
(o) => o.type === 'track.lay' && o.geometry === 'turnout' && o.placement !== undefined,
);
assert.ok(lay, 'no turnout could be laid — the district can never open');
assert.ok(submit(game, lay), 'the turnout was refused');
game.state.turn.laidThisTurn = false;
const cells = view(game).cells;
const minCol = Math.min(...cells.map((c) => c.col));
const maxCol = Math.max(...cells.map((c) => c.col));
@@ -489,7 +576,7 @@ describe('the page explains itself', () => {
const game = newGame(9);
const area = game.state.officeAreas.get(0)!;
const industry = {
geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const, axis: 'ew' as const },
geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight' as const, subtype: 'grocersWarehouse',