v0.4.9h — a leg that is part of the row, the deck the sheet prints, and regions not miles per hour

The 0.4.9 line's port of v0.7.2, minus Gitea#18: the Division map redraw is a
multiplayer redesign and does not belong in a build people are mid-playtest on.

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, and a crew
pulling out through a leg takes its own cut with it.

Gitea#14 — every count is docs/Deck cards5.xlsx, including removing the Q12
office doubling and the Gap 12 industry tripling.

Gitea#15 — RAR reversed it: the placement is legal; what must hold is that no
train crosses the gap.

Gitea#3 — the printed speeds are scenery. One Stage per printed region, and
where a train starts is what varies.
This commit is contained in:
Jesse.Markowitz
2026-08-26 15:21:49 -04:00
parent f684c60f2e
commit e9683cc8c5
22 changed files with 4079 additions and 3055 deletions
+258 -48
View File
@@ -12,6 +12,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { MainlineKind } from '../src/engine/content.ts';
import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { legalActions } from '../src/engine/legal.ts';
@@ -40,6 +41,20 @@ const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, player
const at = (row: number, col: number): GridCoord => ({ row, col });
/** Puts a card of `kind`/`key` in hand and returns its id. */
/**
* Puts a specific rules card in hand and returns its id, MINTING ONE IF THE DECK NO LONGER DEALS IT.
*
* A card at `copies: 0` is still a card: the catalogue keeps its row and the engine keeps its rule,
* so the design stays visible and the mechanic works the moment it is dealt again. Poling and the
* sharp curves have been treated that way for a while, and Gitea#14 put the dispatching ladder,
* Facing Point Locks, Flying Switch, Section House and Vandalism there too — none of them are in
* `docs/Deck cards5.xlsx`.
*
* This used to throw when it could not find one, which made "dealt zero copies" and "deleted"
* indistinguishable from a test's point of view: zeroing Flying Switch took five passing tests of a
* rule that had not changed at all down with it. Minting keeps the rule under test independently of
* whether the deck currently deals the card, which is the whole reason for keeping the row.
*/
function hand(s: GameState, kind: string, key: string): string {
for (const [id, card] of s.cards) {
const k = card.kind as { kind: string; key?: string };
@@ -48,7 +63,10 @@ function hand(s: GameState, kind: string, key: string): string {
return id;
}
}
throw new Error(`no ${kind} card: ${key}`);
const id = `zero-copy-${kind}-${key}`;
s.cards.set(id, { id, kind: { kind, key } } as never);
s.decks.hands.set(0, [id]);
return id;
}
/**
@@ -87,53 +105,124 @@ function drawTurn(s: GameState): void {
// ---------------------------------------------------------------------------
describe('grade modifiers change crossing time', () => {
it('a Heavy Grade takes two Stages bare', () => {
assert.equal(crossingStages('heavyGrade', 'fast', false), 2);
/**
* Crossing time on the region model (Gitea#3). `cross` fills in the parts each test does not care
* about, so the numbers below read as the table RAR gave rather than as argument lists.
*/
const cross = (
kind: Parameters<typeof crossingStages>[0],
over: Partial<Parameters<typeof crossingStages>[1]> = {},
): number =>
crossingStages(kind, {
trainSpeed: 'fast',
direction: 'east',
gradeUp: 'east',
modifiers: [],
...over,
});
describe('a card costs one Stage per printed region (Gitea#3)', () => {
/**
* RAR, 2026-08-26, and this REPLACES the two rules that were here before — Q1, "the printed 60/30
* are mph expressed as crossing time", and Q2, "a Slow train adds one Stage to every card".
*
* "Ignore speed signs, they are just graphics. Regions shown on cards indicate how many stages it
* takes to cross. Plains is 1. Double track is 1, tunnel is 2, curves is 2, heavy grade is 3
* unless you have help."
*
* The report that opened the issue was a Slow train taking two Stages to clear Double Track. Q2 is
* what did that, and it is gone.
*/
it('crosses in the number of regions the card prints, whatever the train', () => {
for (const speed of ['fast', 'slow'] as const) {
assert.equal(cross('plains', { trainSpeed: speed }), 1, `plains, ${speed}`);
assert.equal(cross('doubleTrack', { trainSpeed: speed }), 1, `double track, ${speed}`);
assert.equal(cross('trestle', { trainSpeed: speed }), 1, `trestle, ${speed}`);
assert.equal(cross('curves', { trainSpeed: speed }), 2, `curves, ${speed}`);
assert.equal(cross('tunnel', { trainSpeed: speed }), 2, `tunnel, ${speed}`);
assert.equal(cross('heavyGrade', { trainSpeed: speed }), 3, `heavy grade, ${speed}`);
}
});
it('reads Fast/Slow on Hilly and on nothing else', () => {
// "Some cards say fast / slow… Fast / Slow does not apply to every card — just those that say
// fast / slow on them. Currently this is only hilly." A fast train starts in the second region.
assert.equal(cross('hilly', { trainSpeed: 'fast' }), 1);
assert.equal(cross('hilly', { trainSpeed: 'slow' }), 2);
});
it('does not read the consist any more', () => {
// Hilly used to take its split off the printed P60/F30 and decide by whether the train carried a
// coach, so a fast freight crossed slower than a slow passenger train. RAR: "I notice that you
// are basing stages in mainline cards off coach/non-coach. Actually, all trains are rated as
// FAST and SLOW." `crossingStages` no longer takes a consist at all — this test is here so the
// deletion is deliberate rather than incidental.
assert.equal(cross('hilly', { trainSpeed: 'fast' }), cross('hilly', { trainSpeed: 'fast' }));
});
it('runs a train through a siding or an Interchange in one Stage', () => {
// Both print a back region that is not part of the road, so a train passing through starts past
// it. What that region is FOR is tested below and in the collision tests.
assert.equal(cross('uncontrolledSiding'), 1);
assert.equal(cross('interchange'), 1);
});
it('costs the extra Stage to anything starting in that back region', () => {
// The Uncontrolled Siding with a train already on it, and an Extra beginning its run at an
// Interchange. Both start at the back and have the whole card to run.
assert.equal(cross('uncontrolledSiding', { startsAtBack: true }), 2);
assert.equal(cross('interchange', { startsAtBack: true }), 2);
});
});
describe('grade modifiers move the start, not the clock', () => {
it('a Heavy Grade takes three Stages bare', () => {
// Three regions, up from the two the old 30mph reading gave it.
assert.equal(cross('heavyGrade'), 3);
});
it('Brakeman speeds the descent but not the climb', () => {
// Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which
// way is UPHILL. With up = east, a westbound train is descending.
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2);
// Q11 — the card prints "(Up)" and "Player sets orientation", so `gradeUp` is which way is
// UPHILL. With up = east, a westbound train is descending.
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2);
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east' }), 3);
});
it('follows the orientation the player chose, not a fixed compass direction', () => {
// The same train on the same card, with the card turned around: Brakeman helps a westbound
// train on an east-climbing grade, and an eastbound one when the grade climbs west.
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2);
it('follows the orientation the card was dealt, not a fixed compass direction', () => {
// The same train on the same card, with the card turned around.
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'east', gradeUp: 'west' }), 2);
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west', gradeUp: 'west' }), 3);
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'west', gradeUp: 'west' }), 2);
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'east', gradeUp: 'west' }), 3);
});
it('Helpers speed the climb but not the descent', () => {
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2);
// RAR: "helpers… helps all trains going up hill by starting 1 region easier — so 2 to traverse,
// not 3."
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'east' }), 2);
assert.equal(cross('heavyGrade', { modifiers: ['helpers'], direction: 'west' }), 3);
});
it('Airbrakes stack with Brakeman on a slow train', () => {
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3);
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2);
assert.equal(
crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'),
1,
);
it('Airbrakes stack on top of Brakeman', () => {
// "Airbrakes is an upgrade from brakemen (which must be played first)", so a fully-equipped
// grade is one Stage downhill — and `check` refuses Airbrakes without Brakeman already there.
assert.equal(cross('heavyGrade', { direction: 'west' }), 3);
assert.equal(cross('heavyGrade', { modifiers: ['brakeman'], direction: 'west' }), 2);
assert.equal(cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes'], direction: 'west' }), 1);
});
it('never lets a train cross in no time', () => {
// Three modifiers on a three-region card would otherwise put the start past the far edge.
assert.equal(
crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'),
cross('heavyGrade', { modifiers: ['brakeman', 'airbrakes', 'helpers'], direction: 'west' }),
1,
);
});
it('leaves non-grade cards alone', () => {
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1);
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2);
assert.equal(cross('plains', { modifiers: ['brakeman'], direction: 'west' }), 1);
assert.equal(cross('curves', { modifiers: ['helpers'] }), 2);
});
});
@@ -206,8 +295,8 @@ describe('Realignment converts one Mainline type to another', () => {
assert.ok(r.ok);
assert.equal(node.card, 'plains', 'Curves realigns to Plains');
// The point of the card: Curves is a 30 (two Stages), Plains a 60 (one).
assert.equal(crossingStages(node.card, 'fast', false), 1);
// The point of the card: Curves prints two regions, Plains one, so realigning halves the time.
assert.equal(cross(node.card), 1);
});
it('refuses a card with no conversion listed', () => {
@@ -793,18 +882,35 @@ describe('Q13 — a train that catches the one ahead runs into it', () => {
* then region 1 — so it catches up whichever order the phase happens to process them in.
*/
const twoTrains = (opts: { absSignals?: boolean } = {}): { s: GameState; events: GameEvent[] } => {
const s = createGame({
id: 'rear', seed: 3,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['bot'],
});
const index = s.division.nodes.findIndex(
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
);
assert.ok(index >= 0, 'no single-track Mainline card in this Division');
/**
* THE SEED IS SEARCHED FOR, NOT WRITTEN DOWN.
*
* This asked for seed 3 and asserted that its Division held a single-track Mainline card. It
* does not any more: the Division is laid out from the same RNG stream the card deck is
* shuffled from, so changing the SIZE of that deck re-deals the Division too. Gitea#14's deck
* counts moved it, and the test failed on its own precondition — "no single-track Mainline card
* in this Division" — which says nothing about the rule under test.
*
* The fixture needs A Division with a card trains may not pass on, not one particular one, so
* it now takes the first seed that provides one. That is stable across any future retune, and
* it fails loudly if such a Division stops being reachable at all.
*/
let s!: GameState;
let index = -1;
for (let seed = 3; seed < 200 && index < 0; seed++) {
s = createGame({
id: 'rear', seed,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['bot'],
});
index = s.division.nodes.findIndex(
(n) => n.kind === 'mainline' && !mainlineProfile(n.card).trainsMayPass,
);
}
assert.ok(index >= 0, 'no seed under 200 deals a Division holding a single-track Mainline card');
const node = s.division.nodes[index]!;
assert.ok(node.kind === 'mainline');
if (node.kind !== 'mainline') throw new Error('unreachable');
@@ -860,12 +966,116 @@ describe('Q13 — a train that catches the one ahead runs into it', () => {
);
});
it('leaves trains alone on a card that prints "trains may pass"', () => {
// Double Track and Uncontrolled Siding hold two trains because they HAVE two roads. Catching up
// there means going past, which is what the card is for. Without this the mechanic fired 0.41
// times a game while the bot never once granted clearance — the tell that they were all
// passing cards.
/**
* ENTERING an occupied region, as opposed to catching up inside the card (Gitea#3).
*
* A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train
* granted clearance is in the same place as the train ahead the moment it arrives. Nothing tested
* that: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage crossing never
* reaches, so entering behind another train on a Plains was silently free.
*/
const enteringBehind = (card: MainlineKind, opts: { absSignals?: boolean } = {}) => {
const s = game();
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[index]!;
assert.ok(node.kind === 'mainline');
if (node.kind !== 'mainline') throw new Error('unreachable');
node.card = card;
node.transits = [];
if (opts.absSignals) node.absSignals = true;
/**
* THE TRAIN ALREADY THERE IS THE JUNIOR ONE, and that is what makes the situation reachable.
*
* Trains move lowest number first, so a card's occupant normally clears before anything behind
* it is even considered — put train 9 on the card and train 11 at the Division Point and 9 has
* gone by the time 11 enters. The conflict is a SUPERIOR train catching an inferior one that has
* not got out of the way yet, so the numbers run the other way round here.
*/
const leader = s.freeTrays.pop()!;
s.trays.set(leader, {
id: leader, trainNumber: 11, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }], direction: 'east',
position: { at: 'mainline', index },
} as never);
const total = crossingStages(card, { trainSpeed: 'fast', direction: 'east', gradeUp: 'east', modifiers: [] });
node.transits.push({ tray: leader, stagesRemaining: total, stagesTotal: total, direction: 'east' });
// And the one arriving, held at the Division Point west of it.
const dp = s.division.nodes[index - 1];
assert.ok(dp && dp.kind === 'divisionPoint', 'expected a Division Point west of the first card');
if (!dp || dp.kind !== 'divisionPoint') throw new Error('unreachable');
const follower = s.freeTrays.pop()!;
s.trays.set(follower, {
id: follower, trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }], direction: 'east',
position: { at: 'divisionPoint', side: dp.side },
} as never);
dp.holding.push(follower);
/**
* THE SUPERINTENDENT LETS IT IN, which is the whole point.
*
* A same-direction train in the Subdivision is not an absolute bar — §8.1 makes it a judgment
* call, and `advance` stops and asks. Granting it is what puts one train in behind another, and
* §10 is then explicit that the wreck is the Superintendent's fault. So the fixture answers
* `allow: true` whenever it is asked, and the collision below is the consequence of that
* ruling rather than of a rule firing on its own.
*/
s.clock.phase = 'mainline';
const events: GameEvent[] = [];
for (let i = 0; i < 6; i++) {
events.push(...advance(s).events);
if (s.clock.pendingDecision !== null) {
const who = s.clock.superintendent;
const r = applyIntent(s, who, { type: 'mainline.clearance', allow: true });
assert.ok(r.ok, `clearance refused: ${r.ok ? '' : r.code}`);
events.push(...r.events);
}
}
return { s, node, events, follower };
};
it('runs a train into the one ahead when it ENTERS an occupied region', () => {
const { events } = enteringBehind('plains');
const smash = events.find((e) => e.type === 'trainsDestroyed');
assert.ok(smash, 'a train entered a one-region card behind another and nothing happened');
});
it('holds it short instead when the card carries ABS Signals', () => {
// RAR: "ABS. This is played on a mainline card to prevent collisions. If a collision would
// normally occur, the train moving onto the card is instead held back."
const { events } = enteringBehind('plains', { absSignals: true });
assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'ABS Signals did not prevent it');
assert.ok(
events.some((e) => e.type === 'trainHeld' && /ABS Signals/.test(e.reason)),
'nothing was held short of the train ahead',
);
});
it('takes the siding instead of colliding on an Uncontrolled Siding', () => {
// "If a train already exists when you arrive, you go in the second stage back — you are in the
// siding and are one behind the other train. This prevents a collision, since you are not in
// same exact location." So: no wreck, both trains on the card, and the newcomer paying the
// extra Stage for the detour.
const { node, events, follower } = enteringBehind('uncontrolledSiding');
assert.ok(!events.some((e) => e.type === 'trainsDestroyed'), 'the siding did not prevent a collision');
const mine = node.transits.find((t) => t.tray === follower);
assert.ok(mine, 'the arriving train never made it onto the card');
assert.equal(mine.stagesTotal, 2, 'it should have entered at the back of the card, not the front');
});
it('leaves trains alone on the one card that prints "trains may pass"', () => {
// Double Track holds two trains because it HAS two roads. Catching up there means going past,
// which is what the card is for.
//
// THE UNCONTROLLED SIDING USED TO BE IN THIS LIST AND IS NOT ANY MORE (Gitea#3). It holds two
// trains as well, but not by letting them share a place: the second one takes the siding and
// sits a region behind, which is what keeps them apart — "you are in the siding and are one
// behind the other train. This prevents a collision, since you are not in same exact location."
// Marked "may pass" it skipped the collision test entirely, so the siding did nothing at all and
// two trains could occupy the same region of it unchallenged.
const passing = MAINLINE_PROFILES.filter((m) => m.trainsMayPass).map((m) => m.kind);
assert.deepEqual(passing, ['doubleTrack', 'uncontrolledSiding']);
assert.deepEqual(passing, ['doubleTrack']);
});
});