make turnout description more clear. fix curves from breaking running track.

This commit is contained in:
Jesse
2026-08-05 10:03:50 -04:00
parent 9c76b1a5e2
commit 1c321d4cea
9 changed files with 226 additions and 28 deletions
+7 -1
View File
@@ -969,7 +969,13 @@ describe('the freight figures count both halves (regression)', () => {
let loads = 0;
let unloads = 0;
let begun = 0;
for (const seed of [1000, 8919, 16838]) {
// A WIDER SAMPLE than the three seeds this used to take. The subject is the instrument — does
// `freightUnload` count Revenue EARNED rather than unloads STARTED — and `unloads > 0` is only
// the precondition that makes the comparison mean anything. Three deals is a thin thread to hang
// that on: an unload needs an inbound industry built, reachable, and a loaded car spotted at it,
// and whether the bot manages all three on a given deal is luck, not the thing under test.
// Measured across these twelve: 34 unloads earned, on 12 of 40 deals sampled more widely.
for (const seed of [1000, 8919, 16838, 24757, 32676, 40595, 48514, 56433, 64352, 72271, 80190, 88109]) {
const s = createGame({
id: `fu-${seed}`, seed,
config: { ...config, length: 'standard' },
+98
View File
@@ -23,6 +23,7 @@ import {
allReachable,
canDropCarsAt,
canPlaceAt,
carriesThroughTrack,
exitsFrom,
hasPort,
neighbour,
@@ -803,3 +804,100 @@ describe('Operational Rail — where a train may stop and leave cars (Appendix A
}
});
});
// ---------------------------------------------------------------------------
describe('the Running Track must stay a through route', () => {
const runningArea = (): OfficeArea =>
areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: straight(),
},
at(0, 0),
);
it('refuses a curve in the running row, which would dead-end the main', () => {
// A curve has ONE road — from an east or west edge round to a 45° leg — so a card of it standing
// in the Running Track stops the main dead at that square, cutting the Office off from its own
// Limits. Every other card that may stand there carries an east-west road across it.
const area = runningArea();
for (const arc of ['ne', 'nw', 'se', 'sw'] as TrackArc[]) {
assert.ok(!carriesThroughTrack(curve(arc)), `a ${arc} curve has no through road`);
assert.ok(!canPlaceAt(area, at(0, 2), curve(arc)), `a ${arc} curve must not go in the Running Track`);
}
});
it('still accepts everything that does carry the road through', () => {
const area = runningArea();
assert.ok(carriesThroughTrack(straight()));
assert.ok(carriesThroughTrack(turnout({ stem: 'w', through: 'e', diverge: 's' })));
assert.ok(carriesThroughTrack(officeCard()));
assert.ok(canPlaceAt(area, at(0, 2), straight()), 'a straight extends the main');
assert.ok(
canPlaceAt(area, at(0, 2), turnout({ stem: 'w', through: 'e', diverge: 's' })),
'a turnout keeps the road AND opens a leg — which is why a district hangs off one',
);
});
it('leaves a curve free to go anywhere off the main', () => {
const area = areaFrom(
{
[coordKey(at(0, 0))]: officeCard(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
},
at(0, 0),
);
assert.ok(canPlaceAt(area, at(-1, 1), curve('ne')), 'the siding is exactly where a curve belongs');
});
});
describe('a turnout may be laid to face another, making a crossover', () => {
/** A turnout stated the way the cards read: who may come in, and where they may go. */
const facing = (stem: Port, through: Port, diverge: Port, hand: 'left' | 'right'): TrackCard => ({
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem, through, diverge }, hand },
baseOperationalRail: false, standing: [], facility: null, modifiers: [], enhancements: [],
});
it('joins a south-diverging turnout to the north-diverging one beneath it', () => {
// "Traffic from the east to travel west or turn to the south", with directly beneath it "traffic
// from the west to travel east or turn to the north". The two 45° legs are one continuous rail
// across the card edge — a crossover, and the way a siding gets a track parallel to the main.
const above = facing('e', 'w', 's', 'right');
const below = facing('w', 'e', 'n', 'right');
assert.ok(joins(above, 's', below), 'the crossover does not join');
// And its mirror, which is the same move built from the other hand.
const aboveL = facing('w', 'e', 's', 'left');
const belowL = facing('e', 'w', 'n', 'left');
assert.ok(joins(aboveL, 's', belowL), 'the mirrored crossover does not join');
});
it('refuses the pair whose legs lie on opposite diagonals', () => {
// Both cards have the port and both legs meet the same point on the shared edge. They still form
// a V: one descends to the right and the other to the left.
assert.ok(!joins(facing('e', 'w', 's', 'right'), 's', facing('e', 'w', 'n', 'left')));
assert.ok(!joins(facing('w', 'e', 's', 'left'), 's', facing('w', 'e', 'n', 'right')));
});
it('lets a crew run down the crossover onto the parallel track', () => {
// The point of building one: the lower turnout carries its own east-west road, so the siding
// runs alongside the main rather than dead-ending under it.
const area = areaFrom(
{
[coordKey(at(0, -1))]: straight(),
[coordKey(at(0, 0))]: facing('w', 'e', 's', 'left'),
[coordKey(at(0, 1))]: straight(),
// Coming down the leg, the lower turnout is entered through its diverging north end, so the
// only way on is its stem — east, along the parallel track.
[coordKey(at(-1, 0))]: facing('e', 'w', 'n', 'left'),
[coordKey(at(-1, 1))]: straight(),
},
at(0, 5),
);
const dests = reachableDestinations(ctxFor(area), at(0, -1), 'e');
assert.ok(has(dests, -1, 1), 'a crew cannot reach the parallel track through the crossover');
assert.ok(!has(dests, -1, 0), 'and may not stop on the turnout itself');
});
});
+27 -9
View File
@@ -557,9 +557,14 @@ describe('the page explains itself', () => {
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
assert.match(cell.what, /stem east/);
assert.match(cell.what, /diverges south/);
assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out');
// Said the way a player would ask it: if my train comes in from over there, where can it go?
// "stem east, through west, diverges south" is three pieces of jargon and a compass reading.
assert.match(
cell.what,
/allows traffic from the east to travel west or turn to the south/,
`the turnout does not say what it does: ${cell.what}`,
);
assert.match(cell.what, /the two roads never join/, 'the missing edge is not spelled out');
});
it('describes every card kind in the deck, not just the easy ones', () => {
@@ -649,13 +654,20 @@ describe('the page explains itself', () => {
variantsFor(geometry, hand).forEach((v, variant) => {
const label = variantLabel(geometry, variant, hand);
if (v.turnout) {
assert.match(label, new RegExp(`stem ${say[v.turnout.stem]}`), `${geometry}/${hand} v${variant}: ${label}`);
assert.match(label, new RegExp(`diverges ${say[v.turnout.diverge]}`), `${geometry}/${hand} v${variant}: ${label}`);
assert.equal(
label.trim(),
`— allows traffic from the ${say[v.turnout.stem]} to travel ${say[v.turnout.through]} ` +
`or turn to the ${say[v.turnout.diverge]}`,
`${geometry}/${hand} v${variant}`,
);
} else if (v.arc) {
const [a, b] = [v.arc[0]!, v.arc[1]!];
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
assert.match(label, new RegExp(say[side]!), `${geometry}/${hand} v${variant}: ${label}`);
assert.match(label, new RegExp(`${say[leg]} edge`), `${geometry}/${hand} v${variant}: ${label}`);
assert.equal(
label.trim(),
`— carries traffic from the ${say[side!]} round to the ${say[leg!]}`,
`${geometry}/${hand} v${variant}`,
);
}
});
}
@@ -682,7 +694,7 @@ describe('the page explains itself', () => {
.filter((sp) => sp.coord.row === under.row && sp.coord.col === under.col);
assert.equal(spots.length, 1, 'exactly one turnout rotation should meet the leg coming down');
assert.match(spots[0]!.label, /diverges north/, 'the crossover turnout must point back up');
assert.match(spots[0]!.label, /turn to the north/, 'the crossover turnout must point back up');
assert.match(spots[0]!.label, /joins the track above/, 'the spot must say what it connects to');
});
@@ -1278,7 +1290,13 @@ describe('every square the menu offers can actually be clicked (regression)', ()
// is offered a square OFF the Running Track, which is the case that used to draw off-canvas.
const { option: turnout } = dealTrack(game, 'turnout', 'left');
if (turnout) submit(game, turnout);
dealTrack(game, 'curved', 'left');
// Hold BOTH the curve its leg calls for and a straight: the curve is offered the square off
// the Running Track, the straight the growth points on it. A curve may no longer be laid in
// the running row at all — it has no east-west road and would dead-end the main — so on its
// own it now offers a single square and the bounds check would barely exercise anything.
const curve = dealTrack(game, 'curved', 'left');
const straight = dealTrack(game, 'straight', 'none');
game.state.decks.hands.set(0, [curve.cardId, straight.cardId]);
const menu = actionMenu(game);
const f = view(game);