v0.8.2 — every district opens on a Depot, and the docs are pages now

A second-digit bump for a playtest read back against the save file. Nine questions
were asked of one three-Day game; three were bugs, three were the rules working
and undocumented, three were decisions. Every save on the test server was replayed
against this build BEFORE release, which is how the cost of each rule was known
before it was chosen rather than discovered after.

EVERY DISTRICT OPENS ON A DEPOT. A Whistle Post has one A/D track and is not a
Passenger Facility, so the opening of every game was spent unable to work a
passenger and one arrival away from a collision. Two A/D tracks and passengers
from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder
game, set when the game is created. The deck follows the choice — starting on
Depots the four Depot upgrade cards are left out, because an upgrade must be to
the next tier and a Depot card at a table of Depots is a dead draw. How much
easier it is showed up as a test failure rather than an argument: the cue-coverage
pool needed widening from 24 seeded games to 60 before it held one collision.

NO SAVE WAS STRANDED BY IT, which took care. This is the one house rule that
changes how a game is DEALT rather than how it plays, so replaying a save under
the wrong opening is a different railroad from intent one — silently, with no
error. `withSavedOpening` fills it on the replay paths ONLY. Putting it in the
resolver instead made a fresh Cutthroat game deal Whistle Posts and read as
Custom, which is how the distinction was found.

THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE.

An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing
standing, the train the Interlocking had been holding at the Limits was moved into
the free slot, and the arriving train was pushed in after it without anyone asking
again whether there was room — so the collision §8.3 calls for never happened. The
held train keeps priority; the newcomer now takes the consequence it would have
met had the held train arrived first.

THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each
seat's "what have I sent you" bookmark was an INDEX into an array the game trims,
so once a seat's bookmark reached the limit the slice returned nothing for the
rest of the game — at a different moment per seat, because each holds its own.
That game's log ended at exactly the cap. Lines carry a sequence number now, which
survives trimming; proven by pushing twice the cap through a simulated seat.

§8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the
Subdivision was looked at, so a train entering a Double Track was released however
busy the rest of it was — that, not anything about Control Points, is what let
Train 8 out with no ruling. And a train standing at an Office was invisible to the
scan, so one about to re-enter the very Subdivision being entered counted for
nothing. Capacity is the test, not presence: a Depot with a track free is not in
the way; a Whistle Post with its one track taken is.

THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a
train released from the Limits (a side effect of somebody else's arrival, so it
simply appeared at the Office), and the train an Interlocking is holding, whose
explanatory tooltip has existed since #99 with NO renderer ever reading the flag.

WHERE A MOVE IS REFUSED, AND WHY. `exploreMoves` decides where the rails go and the
pick-up restrictions are enforced afterwards in `check`, so a square the rails
reached and the card forbade was reachable, un-offered, and absent from the block
list with nothing said. Those squares are blocked with the rule that blocks them
now, and the reasons are got by ASKING `check` rather than re-deriving: a second
implementation of the rules is exactly the failure the block list exists to avoid.
A train may also always recover its own caboose — X13 prints "may drop but not
pick up anything", and a train needs its caboose to be made up, so one that parted
with it could never legally leave again.

RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its
host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both
were built, measured, held back for a fortnight so a playtest could finish, and
applied now. A Second Section costs its card: `SECOND_SECTION` was declared in
content.ts and never dealt, so the action was free and the bot ordered 26
accidental ones in a measured round. The card is dealt and spent — gating on a card
the deck never holds would have deleted the mechanic rather than fixed it.

THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a
card reference is mostly tables, and as plain text a table is rows of pipes.
Markdown is still the one copy; the build renders it, and publishes the .md beside
each page. No Markdown library: this project has no runtime dependencies and one
would be a poor first. The pages add what Markdown cannot carry without drifting —
a nav across the set, a contents list built from the headings actually rendered,
an anchor on every heading, a 70-character measure, and tables that are tables.
They print as ink on paper.

The references caught up with the rules, checked rather than assumed: two
statements had gone from stale to misleading (the Quickstart told a new player to
"get a Depot down as soon as one appears"), and four rules nobody could look up
are written down — the Office tier table, §8.1 in practice, what the Circus Train
pays for, and that a Realignment can be a card with no legal target.

Adding one card to the deck reshuffles every seeded deal, which broke five
fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so
seeds moved and pools widened rather than assertions weakening, and the clearance
fixture pins its terrain the way `enhancements.test.ts` already does. The three
published replays were re-recorded.

Closes TODO #40, #42a, #108, #109 and #110.

1046 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
This commit is contained in:
Jesse.Markowitz
2026-09-23 07:07:21 -04:00
co-authored by Claude Opus 5
parent 517238a727
commit 3befc420da
66 changed files with 6828 additions and 3324 deletions
+4
View File
@@ -23,6 +23,10 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
+86 -71
View File
@@ -23,6 +23,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
@@ -1215,6 +1219,7 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
assert.ok(waiting, 'no Waiting Area card in the deck');
const [cardId] = waiting!;
s.decks.hands.set(0, [cardId]);
openOffice(s); // a Waiting Area needs a Passenger Facility, which a Whistle Post is not
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const spots = legalActions(s, 0).filter(
@@ -1247,6 +1252,7 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
assert.ok(copies.length >= 2, 'the deck should hold more than one Waiting Area');
s.decks.hands.set(0, [copies[0]!]);
openOffice(s);
const first = legalActions(s, 0).find(
(i) => i.type === 'card.play' && i.cardId === copies[0] && i.placement !== undefined,
) as { type: 'card.play'; cardId: string; placement: { row: number; col: number } };
@@ -1271,6 +1277,25 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
// ---------------------------------------------------------------------------
/**
* Raise a player's Office to a tier that IS a Passenger Facility.
*
* A Waiting Area, Restaurant or Hotel may not be played at a Whistle Post (2026-09-23), and these
* fixtures name the Whistle Post opening, so a test about those cards has to open the Office first.
* Mirrors the `officeUpgraded` reducer: the tier, and the passenger flow that comes with it.
*/
function openOffice(s: GameState, player = 0 as never, tier: 'depot' | 'station' | 'terminal' = 'depot'): void {
const area = areaOf(s, player);
const to = officeProfile(tier);
area.tier = tier;
const card = area.grid.get(coordKey(area.officeCoord));
if (card?.facility) {
card.facility.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
card.facility.capacity = { outbound: to.passengerOut, inbound: to.passengerIn };
card.facility.porters = to.porters;
}
}
describe('the Limits bound the district, and the nine spots reach round a Facility', () => {
/**
* A district whose Running Track has been extended one square east, so the sign stands at col 2
@@ -1388,12 +1413,15 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
);
});
it('offers a Modifier the DIAGONAL spots around its host, not just the four orthogonal ones', () => {
it('refuses a Modifier on a diagonal, and offers only the four orthogonal spots', () => {
/**
* REPORTED by Jesse: a Modifier could not be placed to the south-east of his industry. §9 places
* one "adjacent to a Facility, on any of the nine nearby spots" and `check` has always accepted
* all eight neighbours — it was `placementCandidates` that walked north, south, east and west
* only, so a diagonal square with no orthogonal neighbour was legal and never offered.
* JESSE'S RULING, 2026-09-23, REVERSING HIS OWN EARLIER REPORT. This asserted the opposite: he
* had reported that a Modifier could not be placed to the south-east of his industry, and §9's
* "any of the nine nearby spots" was read as all eight neighbours. A Modifier must sit SQUARE
* against what it serves now — a card on a corner touches it at a point, not along an edge.
*
* The fixture is unchanged so the reversal is asserted on the very square that prompted the
* original change.
*/
const s = game();
const under = district(s);
@@ -1408,13 +1436,25 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
.map((i) => coordKey((i as { placement: GridCoord }).placement));
// South-east of the host, and orthogonally adjacent to nothing at all.
// South-east of the host: touching it at a corner only.
const southEast = at(under.row - 1, under.col + 1);
assert.ok(
offered.includes(coordKey(southEast)),
`the south-east spot (${southEast.row}, ${southEast.col}) is legal but was never offered — offered: ${offered.join(' ')}`,
!offered.includes(coordKey(southEast)),
`the diagonal spot (${southEast.row}, ${southEast.col}) is still offered — offered: ${offered.join(' ')}`,
);
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: southEast }),
'NOT_CONNECTED',
'a Modifier was accepted on a diagonal',
);
// The orthogonal neighbours are still there, or the card would have nowhere to go at all.
const east = at(under.row, under.col + 1);
assert.ok(
offered.includes(coordKey(east)),
`the square east of the host is not offered — offered: ${offered.join(' ')}`,
);
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: east }), null);
});
it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
@@ -1555,9 +1595,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
// Forced open so this tests the freight/passenger distinction rather than the `allows` gating —
// the Office starts as a Whistle Post, which is not a Passenger Facility and takes nothing.
openOffice(s);
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
const office = officeCard.facility!;
office.allows = { outbound: true, inbound: true };
const slotsBefore = office.capacity.outbound;
const portersBefore = office.porters;
const waiting = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
@@ -1569,29 +1611,27 @@ describe("a Modifier grants only what its host's flow can use", () => {
assert.ok(spot, 'a Waiting Area has nowhere legal beside the Office');
assert.ok(applyIntent(s, 0, spot!).ok);
assert.equal(office.capacity.outbound, 1, 'the passenger slot should still be granted');
assert.equal(office.porters, 1, 'the porter should still be granted');
// Relative, so the assertion says what the Modifier is worth rather than what a Depot prints.
assert.equal(office.capacity.outbound, slotsBefore + 1, 'the passenger slot should still be granted');
assert.equal(office.porters, portersBefore + 1, 'the porter should still be granted');
assert.equal(
carsOn(officeCard), officeCard.standing,
'a Modifier gave a Passenger Facility an industry track to spot cars on',
);
});
it('suppresses a grant the host cannot use — and gives it back when it can', () => {
it('refuses a passenger Modifier at a Whistle Post, which cannot use it at all', () => {
/**
* REPORTED from play: "Restaurant attached to a whistle stop, then upgrade to depot — depot only
* shows one green / one red box. I expected two, because Restaurant increases outbound by one."
* JESSE'S RULING, 2026-09-23, REVERSING the 2026-09-17 call that let these stand dormant.
*
* `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility, so the +1
* outbound is genuinely unusable while the Office is a Whistle Post — suppressing it is right,
* and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied
* only the difference between two tiers and knew nothing about what had been discarded.
* `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility — it allows
* neither direction — so the card's +1 outbound was discarded on the spot and only its porter
* landed. Dormant was defensible while the panel explained itself, but a card that can be played
* to no effect is a trap however well it is labelled.
*
* This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again
* now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the
* suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across
* that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently
* and tests nothing about the upgrade path.
* THE RECOVERY PATH IN `officeUpgraded` IS LEFT IN PLACE and is now unreachable by play: it
* restores a grant suppressed at a Whistle Post, and no such grant can be created any more. It
* is kept because it is correct, and relaxing this rule would need it back.
*/
const s = game();
const area = areaOf(s, 0);
@@ -1603,57 +1643,32 @@ describe("a Modifier grants only what its host's flow can use", () => {
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'restaurant',
)!;
s.decks.hands.set(0, [restaurant[0]]);
assert.ok(applyIntent(s, 0, {
type: 'card.play',
cardId: restaurant[0],
placement: { row: area.officeCoord.row - 1, col: area.officeCoord.col },
}).ok, 'the Restaurant could not be played beside the Office');
const beside = { row: area.officeCoord.row - 1, col: area.officeCoord.col };
assert.equal(
check(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }),
'OFFICE_NOT_PASSENGER',
'a Restaurant was accepted at a Whistle Post',
);
const offered = legalActions(s, 0).filter(
(i) => i.type === 'card.play' && i.cardId === restaurant[0] && i.placement !== undefined,
);
assert.equal(offered.length, 0, 'a Restaurant was offered a square at a Whistle Post');
// Upgrade the Office and the very same square becomes legal.
reduce(s, { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' });
assert.equal(
check(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }),
null,
'a Restaurant is still refused at a Depot, which IS a Passenger Facility',
);
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }).ok);
const f = office.facility!;
assert.equal(f.capacity.outbound, 0, 'a Whistle Post gained a passenger slot it cannot have');
assert.equal(f.porters, 1, 'the porter has no direction gate and should have landed');
const fv = snapshot(s, [], null).facilities.find((v) => v.name.includes('Whistle'));
assert.ok(fv, 'the Office is missing from the panel');
assert.equal(fv!.suppressed.length, 1, 'the dropped bonus is not reported');
assert.match(fv!.suppressed[0]!, /Restaurant/);
assert.deepEqual(fv!.modifiers, ['Restaurant'], 'the modifier is not listed against its host');
// Upgrading makes it a Passenger Facility, and the slot the Restaurant always printed arrives.
reduce(s, { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' });
assert.equal(f.allows.outbound, true);
assert.equal(
f.capacity.outbound,
officeProfile('depot').passengerOut + 1,
"the Restaurant's slot did not come back when the Office could finally use it",
);
// And it is paid ONCE: a second upgrade must not grant it again.
const afterDepot = f.capacity.outbound;
reduce(s, { type: 'officeUpgraded', player: 0, from: 'depot', to: 'station' });
assert.equal(
f.capacity.outbound,
afterDepot + (officeProfile('station').passengerOut - officeProfile('depot').passengerOut),
'the Restaurant was paid a second time on the next upgrade',
);
assert.equal(f.capacity.outbound, officeProfile('depot').passengerOut + 1, 'the slot did not land');
assert.equal(f.porters, officeProfile('depot').porters + 1, 'the porter did not land');
});
});
// ---------------------------------------------------------------------------
describe('Industry cards go on a stub, and lock each other out', () => {
/**
* A district with a siding hanging off the Running Track, which is the only place an industry may
* go. Returns the siding square east of the curve.
*
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track
* row -1: [curve ne] [siding square]
*
* The turnout is laid ON the east Limits sign, which is how the Running Track grows — so the sign
* MOVES OUT with it (§2.1, Gap 4a), exactly as `extendLimitsIfNeeded` does when the card is played
* rather than written straight into the grid. Without that the siding square would be outside the
* district's own Limits, which is no longer a place track may go.
*/
function withSiding(s: GameState): GridCoord {
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
+39 -15
View File
@@ -7,7 +7,8 @@
* that now carries what the cards say", so the code sent readers to a table its own banner told them
* not to trust. Nothing failed, because nothing checked.
*
* `docs/rules/as-built.md` is emitted from the same exported catalogues the engine instantiates
* The card tables in `docs/home-deck.md` and `docs/mainline-deck.md` are emitted from the same
* exported catalogues the engine instantiates
* from, and this re-runs the generator and compares. Change a card face without regenerating and
* this goes red — which is the whole point: a document nothing verifies is a document that will be
* wrong, and this project's own history is the evidence.
@@ -22,33 +23,56 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const doc = join(root, 'docs/rules/as-built.md');
const DOCS = ['docs/home-deck.md', 'docs/mainline-deck.md'].map((rel) => join(root, rel));
describe('docs/rules/as-built.md is generated, and current', () => {
describe('the deck references carry generated card tables, and they are current', () => {
it('matches what the generator emits from content.ts today', () => {
const before = readFileSync(doc, 'utf8');
const before = DOCS.map((d) => readFileSync(d, 'utf8'));
execFileSync(process.execPath, [join(root, 'scripts/build-card-reference.ts')], { cwd: root });
const after = readFileSync(doc, 'utf8');
assert.equal(
after,
before,
'the checked-in card reference is stale — run `npm run build:cards` and commit the result',
);
DOCS.forEach((d, i) => {
assert.equal(
readFileSync(d, 'utf8'),
before[i],
`${d} is stale — run \`npm run build:cards\` and commit the result`,
);
});
});
it('puts every generated block inside a marker pair that exists', () => {
/**
* The generator throws on a block with nowhere to go, so this guards the other direction: a
* marker pair left in a document with no block to fill it would sit there empty and silent.
*/
for (const d of DOCS) {
const text = readFileSync(d, 'utf8');
const begins = [...text.matchAll(/<!-- BEGIN CARDS: ([a-z]+) -->/g)].map((m) => m[1]!);
const ends = [...text.matchAll(/<!-- END CARDS: ([a-z]+) -->/g)].map((m) => m[1]!);
assert.deepEqual(begins, ends, `${d}: card markers are unbalanced`);
for (const key of begins) {
const body = text.slice(
text.indexOf(`<!-- BEGIN CARDS: ${key} -->`) + `<!-- BEGIN CARDS: ${key} -->`.length,
text.indexOf(`<!-- END CARDS: ${key} -->`),
);
assert.match(body, /\|/, `${d}: the "${key}" block has no table in it`);
}
}
});
it('carries the current train catalogue, not the v0.4.5 deck', () => {
// The specific drift that went unnoticed for several releases, asserted by name so a future
// regeneration against an old content.ts cannot quietly reintroduce it.
const md = readFileSync(doc, 'utf8');
const md = readFileSync(join(root, 'docs/home-deck.md'), 'utf8');
assert.match(md, /Crack Limited/);
assert.match(md, /\| 3 \| Express \|/);
assert.ok(!/Mail-Express/.test(md), 'the superseded v0.4.5 train names are back');
assert.ok(!/Manifest Freight/.test(md), 'the superseded v0.4.5 train names are back');
});
it('says it is generated, so nobody edits it by hand', () => {
const md = readFileSync(doc, 'utf8');
assert.match(md, /Generated from `src\/engine\/content\.ts`/);
assert.match(md, /Do not edit by/);
it('says the tables are generated, so nobody edits them by hand', () => {
for (const d of DOCS) {
const md = readFileSync(d, 'utf8');
assert.match(md, /GENERATED from/, `${d} does not say its tables are generated`);
assert.match(md, /`npm run build:cards`/, `${d} does not say what regenerates them`);
}
});
});
+70 -1
View File
@@ -11,7 +11,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { applyIntent, areaOf, check, movesFor } from '../src/engine/apply.ts';
import { legalActions } from '../src/engine/legal.ts';
import { describeIntent } from '../src/sim/view.ts';
import { badlyMadeUp } from '../src/engine/advance.ts';
@@ -26,6 +26,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
@@ -622,3 +626,68 @@ describe('the Small Yard says what each re-order would build', () => {
);
});
});
/**
* WHY A SQUARE IS REFUSED, AND THE ONE PICK-UP EVERY TRAIN MAY MAKE.
*
* Asked from a table, 2026-09-23: "where does it show that you can't make a particular move because
* of a rule that's violated… how does a user know what rule is violated and why you can't go
* there?" `exploreMoves` decides where the rails go; the train's own card is enforced afterwards in
* `check` — so a square the rails reach and the card forbids was reachable, un-offered, and absent
* from the block list with no reason given.
*/
describe("a train's own card explains the squares it may not enter", () => {
/** A drop-only Extra (X13 "may drop MTs but not pick up anything") on row 1, with track beside it. */
const dropOnlyAt = (s: GameState, cars: RollingStock[]): { trayId: string; there: GridCoord } => {
row(s, 3);
switching(s);
const trayId = placeTray(s, at(1, 0), [], 'e');
const tray = s.trays.get(trayId)!;
tray.trainNumber = 13;
tray.trainIsExtra = true;
const there = at(1, 1);
areaOf(s, 0).grid.get(coordKey(there))!.standing = cars;
return { trayId, there };
};
it('reports a drop-only train as blocked, in words, rather than silently', () => {
const s = game();
const { trayId, there } = dropOnlyAt(s, [{ type: 'boxcar', loaded: false }]);
const { to, blocked } = movesFor(s, 0, trayId);
const k = (c: GridCoord): string => `${c.row},${c.col}`;
assert.ok(!to.some((c) => k(c) === k(there)), 'a square the card forbids is still offered');
const b = blocked.find((x) => k(x.coord) === k(there));
assert.ok(b, 'the forbidden square is missing from the block list entirely — no reason is shown');
assert.equal(b!.kind, 'cardRule', "the block is not attributed to the train's card");
assert.match(b!.why, /forbids picking cars up/, `the reason does not name the rule: ${b!.why}`);
assert.match(b!.why, /coupling is mandatory/, `the reason does not say why it bites: ${b!.why}`);
});
it('lets a drop-only train recover its OWN caboose', () => {
/**
* JESSE'S RULING, 2026-09-23. X13 prints "may drop MTs but not pick up anything", and a train
* needs its caboose at the far end to be made up (§8.2) — so a train that parted with its
* caboose could never legally leave again. It stranded itself, permanently and silently.
*
* The caboose only, not "your own cars" generally: it is the one car whose absence stops the
* train departing, so recovering it repairs a consist rather than doing fresh work.
*/
const s = game();
const { trayId, there } = dropOnlyAt(s, [{ type: 'caboose', loaded: false }]);
assert.equal(
check(s, 0, { type: 'switch.move', trayId, to: there, reverse: false }),
null,
'a drop-only train may not recover its own caboose, so it can never be made up again',
);
// A boxcar in the same place is still a pick-up and still refused.
areaOf(s, 0).grid.get(coordKey(there))!.standing = [{ type: 'boxcar', loaded: false }];
assert.equal(
check(s, 0, { type: 'switch.move', trayId, to: there, reverse: false }),
'PICKUP_NOT_ALLOWED',
'the caboose exemption leaked into ordinary cars',
);
});
});
+4
View File
@@ -34,6 +34,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
+86
View File
@@ -12,6 +12,8 @@ import assert from 'node:assert/strict';
import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts';
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
import { officeProfile } from '../src/engine/content.ts';
import { narrate } from '../src/sim/narrate.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey, decisionActor, subdivisions, turnOf } from '../src/engine/state.ts';
@@ -23,6 +25,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
@@ -279,6 +285,86 @@ describe('Interlocking and Yard Office relieve the Office', () => {
assert.ok(areaOf(s, 0).heldAtLimits.includes(id), 'train is held at the Limits');
});
it('does not let a released train and a newcomer share one A/D track', () => {
/**
* REPORTED FROM A TABLE, 2026-09-23, and measured on the save: a Whistle Post with ONE A/D
* track held Trains 8 and 19 at once. The capacity test passed (nothing standing), the train
* the Interlocking had been holding at the Limits was then moved into the free slot, and the
* arriving train was pushed in after it without anyone asking again whether there was room.
*
* The held train has priority — it has been waiting — so the NEWCOMER takes the consequence,
* and it is the same consequence it would have met had the held train arrived first: held at
* the Limits where there is an Interlocking, a collision where there is not.
*/
const s = game();
const area = areaOf(s, 0);
const capacity = officeProfile(area.tier).adTracks;
// One train already waiting at the Limits, and the Office just cleared.
s.trays.set('waiting', {
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
} as never);
area.heldAtLimits = ['waiting'];
area.adOccupancy = [];
const card = straight();
card.enhancements.push('interlocking');
addCard(s, at(0, 2), card);
const arriving = inbound(s, []);
advance(s);
assert.ok(
area.adOccupancy.length <= capacity,
`the Office holds ${area.adOccupancy.length} trains on ${capacity} A/D track(s)`,
);
assert.ok(area.adOccupancy.includes('waiting'), 'the train that had been waiting did not get the track');
assert.ok(!area.adOccupancy.includes(arriving), 'the newcomer squeezed onto an occupied track');
// With an Interlocking it waits its turn rather than wrecking.
assert.ok(area.heldAtLimits.includes(arriving), 'the newcomer was neither held nor collided');
});
it('collides the newcomer when a released train takes the last track and there is no Interlocking', () => {
// Same situation, no Interlocking: §8.3's collision is what should happen, and did not.
const s = game();
const area = areaOf(s, 0);
s.trays.set('waiting', {
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
} as never);
area.heldAtLimits = ['waiting'];
area.adOccupancy = [];
inbound(s, []);
advance(s);
assert.equal(s.players[0]!.revenue, -5, 'no collision was scored for the train with nowhere to go');
assert.ok(area.adOccupancy.length <= officeProfile(area.tier).adTracks, 'the Office is over capacity');
});
it('says so in the history when a held train takes the track that just freed', () => {
// The release used to be a silent side effect of somebody else's arrival — reported as "wasn't
// clear what changed and why train 8 was suddenly released".
const s = game();
const area = areaOf(s, 0);
s.trays.set('waiting', {
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
} as never);
area.heldAtLimits = ['waiting'];
area.adOccupancy = [];
const card = straight();
card.enhancements.push('interlocking');
addCard(s, at(0, 2), card);
inbound(s, []);
const released = advance(s).events.find((e) => e.type === 'trainReleasedFromLimits');
assert.ok(released, 'the release is still silent');
const line = narrate(released as never, { playerName: () => 'A' });
assert.match(line.text, /RELEASED from the Limits/, `the line does not say what happened: ${line.text}`);
assert.match(line.text, /freed the A\/D track/, `the line does not say why now: ${line.text}`);
});
it('still collides without an Interlocking', () => {
const s = game();
areaOf(s, 0).adOccupancy = ['blocker'];
+104 -1
View File
@@ -15,7 +15,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fromSave, newGame, submit, toSave, view } from '../src/web/game.ts';
import { LOG_LIMIT, fromSave, newGame, newMultiplayerGame, pushLine, submit, toSave, view } from '../src/web/game.ts';
import { actionGroups, currentActor } from '../src/web/game.ts';
import { narrate } from '../src/sim/narrate.ts';
@@ -92,6 +92,12 @@ const KNOWN_UNREDUCED = [
'trainHeld',
'trainHighballed',
'trainMadeUp',
/**
* A train the Interlocking held at the Limits taking the A/D track that just freed. Emitted by
* `arriveAtOffice` in the phase driver, which moves the tray itself — so this describes rather
* than reduces, like every entry on this list.
*/
'trainReleasedFromLimits',
'trainStoodStill',
'trainsDestroyed',
];
@@ -283,3 +289,100 @@ describe('the log names the Mainline card an Enhancement was built on', () => {
assert.match(line.text, /\(2,-1\)/, `the square is gone or in the wrong order: ${line.text}`);
});
});
/**
* WHOSE REVENUE IT IS.
*
* Reported from a table: "in the history on +1 revenue and gives the current score, it doesn't list
* the player name." The history prefixes lines with the ACTOR, and revenue is not always the
* actor's — a train completing its run pays every player with no actor at all, so those lines
* carried no name whatsoever.
*/
describe('a Revenue line names the player who earned it', () => {
const ctx = { playerName: (p: number) => ['Alice', 'Bob'][p] ?? `Seat ${p}` };
it('names the earner on a gain', () => {
const line = narrate({ type: 'revenueChanged', player: 1, delta: 1, total: 12, reason: 'boarding' } as never, ctx);
assert.match(line.text, /Bob/, `no player named: ${line.text}`);
assert.match(line.text, /\+1 Revenue/, `the change is gone: ${line.text}`);
assert.match(line.text, /now 12/, `the running total is gone: ${line.text}`);
});
it('names the earner on a loss', () => {
const line = narrate({ type: 'revenueChanged', player: 0, delta: -5, total: 7, reason: 'collision' } as never, ctx);
assert.match(line.text, /Alice/, `no player named: ${line.text}`);
assert.match(line.text, /now 7/, `the running total is gone: ${line.text}`);
});
it('names the player it belongs to, not the one who acted', () => {
// The distinction that matters: a train completing its run pays everybody.
const a = narrate({ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'a train completed its run' } as never, ctx);
const b = narrate({ type: 'revenueChanged', player: 1, delta: 1, total: 9, reason: 'a train completed its run' } as never, ctx);
assert.match(a.text, /Alice/, `the first payee is unnamed: ${a.text}`);
assert.match(b.text, /Bob/, `the second payee is unnamed: ${b.text}`);
assert.notEqual(a.text, b.text, 'both payees produced the same line');
});
it('is excluded from the history prefix, so no line names a player twice', () => {
// Gitea#31. `record` prefixes `Player <actor>` onto events carrying a `player`; this one
// resolves its own name, so it must be on the exclusion list or it reads "Player Bob Bob +1".
const src = readFileSync(join(import.meta.dirname, '..', 'src', 'web', 'game.ts'), 'utf8');
const guard = src.slice(src.indexOf('const SELF_NAMED'), src.indexOf('const SELF_NAMED') + 400);
assert.match(guard, /'revenueChanged'/, 'revenueChanged is not excluded from the actor prefix');
assert.match(guard, /!SELF_NAMED\.includes\(e\.type\)/, 'the exclusion is not applied to `mine`');
});
});
/**
* THE HISTORY MUST NOT FREEZE WHEN THE LOG IS TRIMMED.
*
* Reported from a two-player game that did not reach Day 5: one player's history stopped gaining
* lines at Day 2 Stage 8 and the other's at Day 2 Stage 4. The log is trimmed to a limit, and each
* seat's "what have I sent you" bookmark was an INDEX into that array — so once a seat's bookmark
* reached the limit, the array never grew past it again and the slice returned nothing for the rest
* of the game. Different moments per seat because each holds its own bookmark.
*/
describe('a trimmed log still delivers every line', () => {
const config = {
mode: 'competitive' as const, days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
it('numbers lines by sequence, which survives trimming', () => {
const g = newMultiplayerGame(7, config as never, ['A', 'B']);
const first = g.log[g.log.length - 1]!.seq;
pushLine(g, 'one', 'plain');
pushLine(g, 'two', 'plain');
assert.equal(g.log[g.log.length - 1]!.seq, first + 2, 'sequence numbers do not advance');
// Trim the front; the survivors keep the numbers they were given.
const keep = g.log[g.log.length - 1]!.seq;
g.log.splice(0, g.log.length - 1);
assert.equal(g.log[0]!.seq, keep, 'trimming renumbered the lines');
});
it('delivers every line to a seat across many trims', () => {
const g = newMultiplayerGame(7, config as never, ['A', 'B']);
// The same bookmark the server keeps per seat (`linesSince` in server/session.ts).
let bookmark = -1;
const since = (): number => {
const fresh = g.log.filter((l) => l.seq > bookmark);
const last = g.log[g.log.length - 1];
if (last) bookmark = last.seq;
return fresh.length;
};
since();
const pushes = LOG_LIMIT * 2;
let delivered = 0;
for (let i = 0; i < pushes; i++) {
pushLine(g, `line ${i}`, 'plain');
if (g.log.length > LOG_LIMIT) g.log.splice(0, g.log.length - LOG_LIMIT);
delivered += since();
}
// Every line reaches the seat, though the log holds only the last LOG_LIMIT of them.
assert.equal(delivered, pushes, `only ${delivered} of ${pushes} lines were delivered`);
assert.equal(g.log.length, LOG_LIMIT, 'the log is not being trimmed at all');
});
});
+4
View File
@@ -36,6 +36,10 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
...over,
});
+4
View File
@@ -15,6 +15,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
+4
View File
@@ -34,6 +34,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
+92
View File
@@ -38,6 +38,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
@@ -727,6 +731,18 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
describe('a turnout may be laid on top of a card already down', () => {
/** Lays a straight inside the Limits and returns where it went. */
/** Force an industry card into hand and return its id, the way `trackInHand` does for track. */
const facilityInHand = (s: GameState): string => {
for (const [id, card] of s.cards) {
if ((card.kind as { kind: string }).kind !== 'freightFacility') continue;
const hand = s.decks.hands.get(0) ?? [];
if (!hand.includes(id)) hand.push(id);
s.decks.hands.set(0, hand);
return id;
}
throw new Error('no freight facility card in the deck');
};
const layStraight = (s: GameState): GridCoord => {
const area = areaOf(s, 0);
const target = { row: area.runningRow, col: area.limitsEast.col };
@@ -799,6 +815,82 @@ describe('a turnout may be laid on top of a card already down', () => {
assert.deepEqual(matching, ['right/1'], 'exactly the turnout diverging onto `ne` should be accepted');
});
it('builds an industry over a straight already laid, off the Running Track', () => {
/**
* REPORTED FROM A TABLE: "just like you could play a turnout over a straight or a curve, the
* game should allow placing an industry over a straight on a non-running track."
*
* A player who lays the rail first and draws the industry afterwards otherwise has no move,
* which punishes building a district in the sensible order. The swap is safe for the same
* reason the turnout upgrade is: `protoCard` builds every Facility as plain east-west track, so
* replacing a straight is port-for-port and no neighbour loses a join.
*/
const s = game();
const area = areaOf(s, 0);
turnOf(s, 0).option = 'draw';
// A stub below the main: a turnout on the Running Track, a straight hanging under it.
const turnoutAt = { row: area.runningRow, col: area.limitsEast.col };
assert.ok(applyIntent(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: turnoutAt, variant: 0,
}).ok, 'the turnout should lay on the Limits sign');
const curveAt = { row: area.runningRow - 1, col: turnoutAt.col };
assert.ok(applyIntent(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: curveAt, variant: 1,
}).ok, 'the matching curve should hang under the turnout');
// Laying on the Limits sign moved it outward, so the square east of the curve is now inside.
const stub = { row: curveAt.row, col: curveAt.col + 1 };
assert.ok(applyIntent(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: stub, variant: 0,
}).ok, 'the straight should run east off the curve');
const industry = facilityInHand(s);
assert.equal(
check(s, 0, { type: 'card.play', cardId: industry, placement: stub, variant: 0 }),
null,
'an industry could not be built over a straight on a stub',
);
// And the menu offers it, or the rule exists and is never presented.
const offered = legalActions(s, 0).some(
(i) => i.type === 'card.play' && i.cardId === industry &&
i.placement?.row === stub.row && i.placement.col === stub.col,
);
assert.ok(offered, 'the square is legal but never enumerated, so it cannot be chosen');
// It really replaces the track, rather than being refused after the fact.
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: industry, placement: stub, variant: 0 }).ok);
assert.equal(area.grid.get(`${stub.row},${stub.col}`)?.geometry.kind, 'facility');
});
it('will not build an industry over the Running Track, a curve or a turnout', () => {
// The Running Track is §11.2's own rule. Curves and turnouts carry ports a Facility does not,
// so building over one could sever a neighbour's join — which is why only a straight is allowed.
const s = game();
const area = areaOf(s, 0);
turnOf(s, 0).option = 'draw';
const turnoutAt = { row: area.runningRow, col: area.limitsEast.col };
assert.ok(applyIntent(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: turnoutAt, variant: 0,
}).ok, 'the turnout should lay on the Limits sign');
assert.equal(
check(s, 0, { type: 'card.play', cardId: facilityInHand(s), placement: turnoutAt, variant: 0 }),
'ON_RUNNING_TRACK',
'an industry was allowed onto the Running Track',
);
const curveAt = { row: area.runningRow - 1, col: turnoutAt.col };
assert.ok(applyIntent(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: curveAt, variant: 1,
}).ok, 'the curve should hang under the main');
assert.equal(
check(s, 0, { type: 'card.play', cardId: facilityInHand(s), placement: curveAt, variant: 0 }),
'NOT_UPGRADEABLE_TRACK',
'an industry was allowed over a curve, whose ports it does not carry',
);
});
it('refuses to swap the track out from under a car, or out from under an Interlocking', () => {
const s = game();
const area = areaOf(s, 0);
+113
View File
@@ -0,0 +1,113 @@
/**
* The documentation renderer.
*
* The five player-facing documents are written in Markdown — that is the one copy, and the whole
* reason TODO #15a exists — and rendered to pages at build time. This covers the subset those
* documents actually use, and the two properties that matter most: a table comes out as a TABLE
* (the entire point of rendering rather than serving text), and nothing in the prose can become
* markup by accident.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { renderMarkdown } from '../scripts/markdown.ts';
const html = (src: string): string => renderMarkdown(src).html;
describe('the documentation renderer', () => {
it('turns a pipe table into a real table, with its alignment', () => {
// This is what rendering is FOR. A card reference is mostly tables, and as plain text a table
// is rows of pipes — which is exactly how the guide read when it was served as text/plain.
const out = html(
['| Card | Regions | Passes |', '| --- | ---: | :---: |', '| Plains | 1 | no |', '| Tunnel | 2 | yes |'].join('\n'),
);
assert.match(out, /<table>/, 'the table is not a table');
assert.match(out, /<thead><tr><th>Card<\/th>/, 'the header row is not a header');
assert.match(out, /<th class="ta-right">Regions<\/th>/, 'a right-aligned column lost its alignment');
assert.match(out, /<th class="ta-center">Passes<\/th>/, 'a centred column lost its alignment');
assert.match(out, /<td>Plains<\/td><td class="ta-right">1<\/td>/, 'a body row lost its cells');
assert.equal((out.match(/<tr>/g) ?? []).length, 3, 'wrong number of rows');
// Wrapped, so a wide table scrolls inside the page instead of widening it on a phone.
assert.match(out, /<div class="tablewrap">/, 'the table can widen the page on a narrow screen');
});
it('gives every heading an id and an anchor, numbered prefixes stripped', () => {
const { html: out, headings } = renderMarkdown('## 4.2 Local Operations\n\ntext\n');
assert.deepEqual(headings, [{ level: 2, text: '4.2 Local Operations', id: 'local-operations' }]);
assert.match(out, /<h2 id="local-operations">/, 'the heading has no id to link to');
assert.match(out, /<a class="anchor" href="#local-operations"/, 'the heading has no anchor');
});
it('numbers a repeated heading rather than pointing two links at one place', () => {
const { headings } = renderMarkdown('## Trains\n\na\n\n## Trains\n\nb\n');
assert.deepEqual(headings.map((h) => h.id), ['trains', 'trains-2']);
});
it('renders lists, quotes, rules and fenced code', () => {
assert.match(html('- one\n- two\n'), /<ul><li>one<\/li><li>two<\/li><\/ul>/);
assert.match(html('1. first\n2. second\n'), /<ol><li>first<\/li><li>second<\/li><\/ol>/);
assert.match(html('> a note\n> continued\n'), /<blockquote><p>a note continued<\/p><\/blockquote>/);
assert.match(html('---\n'), /<hr>/);
assert.match(html('```\nconst x = 1;\n```\n'), /<pre><code>const x = 1;<\/code><\/pre>/);
});
it('renders a table inside a block quote', () => {
// The rules reference puts one there, so this is not hypothetical.
const out = html('> | A | B |\n> | --- | --- |\n> | 1 | 2 |\n');
assert.match(out, /<blockquote><div class="tablewrap"><table>/, 'a quoted table did not render');
});
it('handles bold, italic and code spans, and leaves markup inside code alone', () => {
assert.match(html('**loud** and *quiet*\n'), /<strong>loud<\/strong> and <em>quiet<\/em>/);
// `**` inside backticks is a literal, which matters: the docs quote field names that way.
assert.match(html('`**not bold**`\n'), /<code>\*\*not bold\*\*<\/code>/);
assert.ok(!/<strong>/.test(html('`**not bold**`\n')), 'markup inside a code span was rendered');
});
it('escapes everything, so prose can never become markup', () => {
const out = html('A < B & C > D, and "quoted".\n');
assert.match(out, /A &lt; B &amp; C &gt; D/, 'angle brackets or ampersands reached the page raw');
assert.ok(!/<script/i.test(html('<script>alert(1)</script>\n')), 'raw HTML passed through');
assert.match(html('<script>alert(1)</script>\n'), /&lt;script&gt;/, 'raw HTML was not escaped');
});
it('rewrites links between documents, and opens external ones in a new tab', () => {
const out = renderMarkdown(
'[Rules](rules.md) and [anchor](rules.md#draw) and [site](https://example.com)\n',
(href) => (/^https?:/.test(href) ? href : href.replace(/\.md(#|$)/, '.html$1')),
).html;
assert.match(out, /<a href="rules\.html">Rules<\/a>/, 'a link between documents still points at the Markdown');
assert.match(out, /<a href="rules\.html#draw">/, 'an anchored link lost its fragment');
assert.match(out, /<a href="https:\/\/example\.com" target="_blank" rel="noopener">/, 'an external link is not safe');
});
it('drops HTML comments, so the generated-card markers never show', () => {
// `build-card-reference.ts` writes its tables between `<!-- BEGIN CARDS: … -->` markers.
const out = html('before\n\n<!-- BEGIN CARDS: track -->\n| A |\n| --- |\n| 1 |\n<!-- END CARDS: track -->\n\nafter\n');
assert.ok(!/BEGIN CARDS/.test(out), 'a build marker is visible on the page');
assert.match(out, /<table>/, 'the generated table inside the markers was lost with them');
assert.match(out, /before/, 'content before the markers was lost');
assert.match(out, /after/, 'content after the markers was lost');
});
it('renders each real document without losing its tables or headings', () => {
// The documents themselves, not a fixture: what has to render is what is actually written.
for (const name of ['quickstart', 'rules', 'home-deck', 'mainline-deck', 'components']) {
const src = readFileSync(join(import.meta.dirname, '..', 'docs', `${name}.md`), 'utf8');
const { html: out, headings } = renderMarkdown(src);
assert.ok(headings.length > 2, `${name}.md rendered only ${headings.length} headings`);
assert.ok(out.length > 1000, `${name}.md rendered almost nothing`);
// No pipe table survives as text — that would mean a table failed to parse.
const stripped = out.replace(/<[^>]+>/g, '');
assert.ok(
!/^\s*\|\s*---/m.test(stripped),
`${name}.md has a table the renderer did not recognise`,
);
assert.ok(!/BEGIN CARDS/.test(out), `${name}.md leaked a build marker onto the page`);
}
});
});
+4
View File
@@ -39,6 +39,10 @@ const competitive: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
+4
View File
@@ -26,6 +26,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
+4
View File
@@ -29,6 +29,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
+21 -8
View File
@@ -22,6 +22,7 @@ import { developerBot, playGame } from '../src/sim/bot.ts';
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
import { summarize } from '../src/sim/stats.ts';
import { officeProfile } from '../src/engine/content.ts';
// Mirrors `record()`'s own default exactly (`replay.ts`) — "does not drift from the engine" below
// plays the same seed through both paths and compares outcomes, so they must share one floor.
@@ -32,6 +33,8 @@ const config: GameConfig = {
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
// NO house rules, deliberately: `record()` names none either, so both take today's defaults and
// "does not drift from the engine" below compares two games that were dealt the same way.
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
@@ -63,6 +66,7 @@ const SAMPLES: GameEvent[] = [
// line whose whole job is to be read when nothing happened, so an empty or fallback sentence
// would reproduce the silence it exists to fix.
{ type: 'freightAgentIdled', player: 0 },
{ type: 'trainReleasedFromLimits', trainNumber: 8, office: 'Whistle Post', owner: 0, freedBy: 14 },
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
@@ -127,7 +131,7 @@ describe('narration', () => {
/**
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
*
* `SAMPLES` exercises the TEXT of 31 of the 56 declared events; the other 25 have a narration
* `SAMPLES` exercises the TEXT of 32 of the 57 declared events; the other 25 have a narration
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
* 2026-09-09 — the old test built both of its sets from `SAMPLES` and compared them to each
* other, so it could only ever assert that the sample list had 30 distinct entries, and the one
@@ -217,9 +221,12 @@ describe('impediments', () => {
});
it('warns when every A/D track is occupied', () => {
// Gap 2d — the next arrival is an automatic collision.
// Gap 2d — the next arrival is an automatic collision. Filled to CAPACITY rather than to one
// train, so the fixture says what it means whatever the Office opens as: a Depot has two A/D
// tracks and one occupied is not a warning.
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
s.officeAreas.get(0)!.adOccupancy = ['t0'];
const area = s.officeAreas.get(0)!;
area.adOccupancy = Array.from({ length: officeProfile(area.tier).adTracks }, (_, i) => `t${i}`);
const found = impediments(s, 0);
assert.ok(found.some((b) => /A\/D/.test(b.why) && b.severity === 'risk'));
});
@@ -720,8 +727,14 @@ describe('the replay behaves like the game it is replaying', () => {
* TWENTY-FOUR, not twelve, and the difference is instructive: on this stride the first game that
* couples anything is index 13, so a twelve-seed pool still contained none. The rate is what
* matters, not the count — measured 39 in 200, with couplers at indices 13, 19, 22, 24, 28 …
*
* SIXTY, not twenty-four, since every player opens on a Depot. Two A/D tracks instead of one is
* the single biggest reason a game used to end in a wreck, so collisions went from rare to much
* rarer: measured on this stride, no crash cue appears in the first 24 seeds or the first 40,
* and the pool needs 60 to contain one. Widened rather than dropped, per the note below — §10
* is the one event a player most needs to hear.
*/
const recs = Array.from({ length: 24 }, (_, i) => record(1000 + i * 7919, 'standard', 4000));
const recs = Array.from({ length: 60 }, (_, i) => record(1000 + i * 7919, 'standard', 4000));
const withCues = recs.flatMap((rec) => rec.frames.filter((f) => (f.cues?.length ?? 0) > 0));
assert.ok(withCues.length > 20, `only ${withCues.length} frames carry a cue`);
@@ -736,10 +749,10 @@ describe('the replay behaves like the game it is replaying', () => {
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound');
assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound');
// Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not
// guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current
// stride and seed count. If this starts failing after either changes, widen the pool rather than
// deleting the assertion — §10 is the one event a player most needs to hear.
// Collisions are rarer still now that every Office opens as a Depot — see the note above. This
// is the one cue the pool is not guaranteed to contain on every stride. If it starts failing
// after the stride, the seed count or the opening changes, widen the pool rather than deleting
// the assertion — §10 is the one event a player most needs to hear.
assert.ok(kinds.has('crash'), 'a collision never made a sound');
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
+4
View File
@@ -37,6 +37,10 @@ const config = (): GameConfig => {
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
};
+4
View File
@@ -24,6 +24,10 @@ const competitive: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
+4
View File
@@ -15,6 +15,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
+4
View File
@@ -16,6 +16,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
+85 -8
View File
@@ -7,7 +7,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { CarType } from '../src/engine/content.ts';
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK } from '../src/engine/content.ts';
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK, withSavedOpening } from '../src/engine/content.ts';
import {
DECK_SIZE,
EXTRA_TRAINS,
@@ -27,6 +27,7 @@ import {
nextOfficeTier,
officeProfile,
} from '../src/engine/content.ts';
import { coordKey } from '../src/engine/state.ts';
import { createRng } from '../src/engine/rng.ts';
import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts';
import type { StartingHand } from '../src/engine/content.ts';
@@ -40,6 +41,10 @@ const solitaireConfig: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
@@ -55,7 +60,8 @@ const gameDealtWith = (startingHand: StartingHand, seed = 1234) =>
createGame({
id: 'g1',
seed,
config: { ...solitaireConfig, houseRules: { startingHand } },
// Spread, not replaced: `solitaireConfig` names the Whistle Post opening these counts assume.
config: { ...solitaireConfig, houseRules: { ...solitaireConfig.houseRules, startingHand } },
playerNames: ['Jesse'],
});
@@ -73,7 +79,7 @@ describe('card catalogue (component 1)', () => {
// industry tripling (27 → 9) — because both were measured against a deck holding 96 track
// cards, and sheet 5 halves that. content.ts carries the measurements that decided it.
//
// We are at 143 rather than the sheet's 155 for ONE reason: the ten Safety, Event, Inspection
// We are at 144 rather than the sheet's 155 for ONE reason: the ten Safety, Event, Inspection
// and Space-use cards sheet 5 adds are not built, and stay out until they are (Jesse,
// 2026-08-26) — Cargo Theft, Civic Improvement, Civilian angel, Delayed Clearance, Flares 2,
// Robbery, Service Delays, Shipper complaints, Strike, Union Hall 2. Twelve copies in all.
@@ -85,9 +91,9 @@ describe('card catalogue (component 1)', () => {
// "TBD"; and the sharp curves, whose only difference from an ordinary curve was a Move cost
// nothing ever charged — sheet 5 deals those zero too, so the catalogue and the design agree.
//
// DECK_SIZE is the CATALOGUE, 143. The deck actually dealt is smaller: the 20 opponent-directed
// DECK_SIZE is the CATALOGUE, 144. The deck actually dealt is smaller: the 20 opponent-directed
// cards are held back in every mode until they are implemented, so `buildDeck` returns 123.
assert.equal(DECK_SIZE, 143);
assert.equal(DECK_SIZE, 144);
assert.equal(buildDeck().length, SOLITAIRE_DECK_SIZE);
});
@@ -104,6 +110,8 @@ describe('card catalogue (component 1)', () => {
industry: 9,
modifier: 23,
train: 22,
// Q9 — dealt since 2026-09-23, which is what makes `newTrain.secondSection` cost something.
secondSection: 1,
spaceUse: 11,
// 6 — the dispatching ladder and Facing Point Locks are dealt 0 copies (see
// ENHANCEMENT_CARDS), and Interlocking, Water column and ABS Signals came down to the sheet's
@@ -123,12 +131,12 @@ describe('card catalogue (component 1)', () => {
// Q6 took Space-use and Action cards out of solitaire, where they have no legal target. They are
// now out of the COMPETITIVE deck too, until they are implemented: `checkPlay` answers both
// categories NOT_IMPLEMENTED, so dealing them would be a dead draw.
// 121, not 123: the 20 opponent-directed cards come out, and so do the TWO that exist only to
// 122, not 124: the 20 opponent-directed cards come out, and so do the TWO that exist only to
// answer them — one Water Column and one Overpass. A defence with nothing to defend against is
// the same dead draw as the attack would be. `SimpleCard.answers` names the pairing, so they
// return together. It was seven until Gitea#14 dealt Facing Point Locks zero copies: a card at
// zero is already out, so it no longer needs holding back.
assert.equal(SOLITAIRE_DECK_SIZE, 121);
assert.equal(SOLITAIRE_DECK_SIZE, 122);
assert.equal(DEFENCE_ONLY_COPIES, 2);
for (const c of DEFENCE_ONLY_CARDS) {
assert.ok(c.answers, `${c.name} is held back without saying what it answers`);
@@ -164,7 +172,7 @@ describe('card catalogue (component 1)', () => {
});
it('makes track the largest category in the deck', () => {
// 48 of 121. Building a district is paid for in the industry or train you did not draw, which
// 48 of 122. Building a district is paid for in the industry or train you did not draw, which
// is the whole reason it matters that track is a card rather than a private supply.
//
// This asked for a THIRD of the deck until Gitea#14, which was only ever a rule of thumb. It
@@ -616,3 +624,72 @@ describe('game setup (component 2)', () => {
);
});
});
/**
* WHICH OFFICE EVERY PLAYER OPENS ON.
*
* Jesse's call, 2026-09-23: a Whistle Post has one A/D track and is not a Passenger Facility, so the
* opening of every game was spent unable to work a passenger and one arrival away from a collision.
* The default is a Depot now; the Whistle Post opening stays as the harder setting.
*/
describe('the starting Office, and the deck that goes with it', () => {
const withRules = (houseRules: Record<string, unknown>) =>
createGame({
id: 'so', seed: 7,
config: { ...solitaireConfig, mode: 'competitive', houseRules } as never,
playerNames: ['A', 'B'],
});
const officeCards = (g: ReturnType<typeof withRules>, tier: string): number =>
[...g.cards.values()].filter((c) => c.kind.kind === 'office' && (c.kind as { tier: string }).tier === tier).length;
it('deals Depots by default, and leaves the Depot upgrades out of the deck', () => {
// A Depot card at a table that already has Depots is a dead draw: `check` refuses it, because an
// upgrade must be to the NEXT tier. Station and Terminal are still upgrades and stay in.
const g = createGame({
id: 'd', seed: 7,
config: { ...solitaireConfig, mode: 'competitive', houseRules: {} } as never,
playerNames: ['A', 'B'],
});
assert.deepEqual([...g.officeAreas.values()].map((a) => a.tier), ['depot', 'depot']);
assert.equal(officeCards(g, 'depot'), 0, 'Depot upgrades are still in the deck');
assert.ok(officeCards(g, 'station') > 0, 'Station upgrades were dropped too');
assert.ok(officeCards(g, 'terminal') > 0, 'Terminal upgrades were dropped too');
});
it('deals Whistle Posts when the table asks for the harder game, Depot cards and all', () => {
const g = withRules({ startingOffice: 'whistlePost' });
assert.deepEqual([...g.officeAreas.values()].map((a) => a.tier), ['whistlePost', 'whistlePost']);
assert.ok(officeCards(g, 'depot') > 0, 'the Depot upgrade is missing from a Whistle Post game');
});
it('gives a Depot two A/D tracks and a working passenger facility from Stage 1', () => {
// This is the whole of why the default moved: one A/D track is what made an arrival a collision,
// and a Whistle Post earns nothing from a passenger however well the district is built.
const g = createGame({
id: 'p', seed: 7, config: { ...solitaireConfig, houseRules: {} } as never, playerNames: ['A'],
});
const area = g.officeAreas.get(0 as never)!;
assert.equal(officeProfile(area.tier).adTracks, 2, 'a Depot should have two A/D tracks');
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
assert.equal(f.allows.outbound, true, 'a Depot should board passengers from the start');
assert.equal(f.allows.inbound, true, 'a Depot should detrain passengers from the start');
assert.ok(f.porters > 0, 'a Depot should have a Porter');
});
it('replays a save written before the setting as the Whistle Post game it was', () => {
/**
* The one house rule that changes how a game is DEALT rather than how it plays, so replaying it
* under the wrong opening is a different railroad from intent one — silently. `withSavedOpening`
* fills it for a save that names other rules and cannot name this one.
*/
const saved: { houseRules: { startingHand: 'sixRandom'; startingOffice?: 'depot' | 'whistlePost' } } =
{ houseRules: { startingHand: 'sixRandom' } };
assert.equal(withSavedOpening(saved).houseRules.startingOffice, 'whistlePost');
// A config that names it is left exactly as it is, in both directions.
assert.equal(withSavedOpening({ houseRules: { startingOffice: 'depot' as const } }).houseRules.startingOffice, 'depot');
// And a config with no house rules at all is a fresh game, not an old save.
assert.deepEqual(withSavedOpening({}), {});
});
});
+11 -1
View File
@@ -27,6 +27,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
@@ -1024,7 +1028,13 @@ describe('the bot does not lay track that cannot work (regression)', () => {
//
// Now that track is drawn rather than taken from a private supply, this is a real and frequent
// situation rather than a constructed one: the curve you need may simply not be in hand.
const lays = laysIn(4242);
/**
* SEED CHANGED, NOT THE FLOOR. 4242 laid 25 pieces when every district opened on a Whistle
* Post; opening on a Depot gives the bot passenger work from Stage 1, so it spends fewer turns
* laying track and that seed fell to 3 — below the sample this needs to mean anything. The
* floor is what makes the assertion below worth making, so the seed moved instead.
*/
const lays = laysIn(2024);
assert.ok(lays.length > 3, `the bot laid only ${lays.length} pieces`);
});
+4
View File
@@ -25,6 +25,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
+11 -1
View File
@@ -41,6 +41,10 @@ const config = (): GameConfig => {
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
};
@@ -68,7 +72,13 @@ describe('switching planner', () => {
it('never changes the game it plans for, and every plan replays to the position it promised', () => {
let checked = 0;
let withSteps = 0;
for (const seed of [1000, 8919, 16838]) {
/**
* EIGHT SEEDS, NOT THREE. Adding the Second Section card to the deck (Q9) reshuffles every
* seeded deal, and none of the first three produced a non-empty plan any more — so `withSteps`
* below, which is what proves the replay path is exercised at all, fell to zero. Widened on the
* same stride rather than weakening the assertion; TODO #84 is about exactly this fixture shape.
*/
for (const seed of [1000, 8919, 16838, 24757, 32676, 40595, 48514, 56433]) {
const s = createGame({ id: `plan-${seed}`, seed, config: config(), playerNames: ['bot'] });
const r = playGame(s, developerBot, pump, 50_000, undefined, (st) => {
const p = actingPlayer(st);
+14 -9
View File
@@ -32,6 +32,10 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
...over,
});
@@ -151,14 +155,15 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
it('splits Revenue into what was earned and what was given back', () => {
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
// lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
// lost IS the score the engine kept. Seed 9 is named because it is one where Revenue actually
// moves in both directions — it earns 2 and gives back 5 to a collision — so the two halves are
// being told apart rather than both sitting at zero.
//
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
for (const seed of [1, 7, 44]) {
// It was seed 42 until v0.8.0.10, and seed 44 until 0.8.2. Each time the SEED moved, not the
// assertion: 42's collision went away with the Gitea#26 ruling, and 44's deal changed when the
// Second Section card joined the deck (Q9) and reshuffled everything. This is the fixture shape
// TODO #84 is about — the seed means "a game like this", so it is expected to move.
for (const seed of [1, 7, 9]) {
const { state } = playKeepingEvents(seed);
const me = state.tally.byPlayer[0]!;
assert.equal(
@@ -167,10 +172,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
);
}
const { state } = playKeepingEvents(44);
const { state } = playKeepingEvents(9);
const me = state.tally.byPlayer[0]!;
assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
assert.ok(me.revenueGained > 0, 'seed 9 earned nothing — the gained half is not being counted');
assert.ok(me.revenueLost > 0, 'seed 9 lost nothing — the lost half is not being counted');
});
it('records a Circus set-up as the one-off it is, not as a streak', () => {
+4
View File
@@ -24,6 +24,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
+12
View File
@@ -32,6 +32,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
@@ -403,6 +407,14 @@ describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', (
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
} as never);
if (node?.kind === 'mainline') {
/**
* PIN THE TERRAIN, as `enhancements.test.ts` does for the same reason. Mainline types come
* from the SHUFFLED deck, so deck composition decides them — and Double Track and Uncontrolled
* Siding print "trains may pass", which legitimately removes the §8.1 bar this test is about.
* Adding the Second Section card (Q9) reshuffled seed 7 into one of those and the ruling
* stopped being called for.
*/
node.card = 'plains';
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
+116 -13
View File
@@ -20,11 +20,11 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import { BOARD_CSS, divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import type { DivisionView } from '../src/sim/view.ts';
import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile } from '../src/engine/content.ts';
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
import { turnChartHtml } from '../src/sim/turnchart.ts';
import { TURNCHART_CSS, turnChartHtml } from '../src/sim/turnchart.ts';
import { fieldSelectors } from '../src/web/settings-form.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import type { Frame } from '../src/sim/view.ts';
@@ -56,6 +56,15 @@ const root = join(import.meta.dirname, '..');
* other test in this file built it. `npm run test` directly (skipping `npm test`'s `pretest` hook)
* will not have run it.
*/
/**
* A solitaire game that opens on a WHISTLE POST rather than the default Depot.
*
* Two tests below are about the Whistle Post itself — its single A/D track, and the fact that a
* Station is not the next tier up from it — so they name the opening rather than inheriting it.
*/
const whistlePostGame = (seed: number): ReturnType<typeof newGame> =>
newGame(seed, { ...SOLO_CONFIG, houseRules: { ...SOLO_CONFIG.houseRules, startingOffice: 'whistlePost' } });
const dist = join(root, 'dist');
/**
* A directory the actual "run the build command" test below builds into, kept separate from the
@@ -1145,7 +1154,7 @@ describe('the page explains itself', () => {
// A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential
// (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
// action that did nothing.
const game = newGame(111);
const game = whistlePostGame(111);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
// A Station upgrade is PUT in hand rather than drawn for. This used to take whatever seed 111
// happened to deal, which made it luck: the moment deck composition changed it dealt no upgrade
@@ -2570,7 +2579,7 @@ describe('the static build', () => {
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
// decides whether the next arrival is an automatic collision (§8.3). The Roster Pass replaced
// the pips with one roster chip per A/D track (docs/plans/switching-paths.md), free or occupied.
const game = newGame(555);
const game = whistlePostGame(555);
const area = game.state.officeAreas.get(0)!;
const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
@@ -3116,6 +3125,10 @@ describe('the Division map shows the whole route', () => {
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
@@ -3123,6 +3136,72 @@ describe('the Division map shows the whole route', () => {
return divisionSvg(snapshot(s, [], null).division);
};
it('flashes who is being waited on, but only when it is you', () => {
/**
* REPORTED FROM A TABLE: "when my turn and waiting on me — make the waiting on flash brightly on
* and off." The commonest way a table stalls is a player not noticing their turn came round, and
* the chip was the same violet whoever it named.
*
* ONLY WHEN IT IS ACTUALLY YOUR MOVE. `renderTurnChart` reads the actor ON SCREEN rather than
* the live one, so it does not start flashing while your board is still replaying somebody
* else's turn and you cannot act yet.
*/
const frame = { day: 1, stage: 1, clock: '00:00', phase: 'Local Operations', phaseKey: 'localOps', actor: 0 };
const theirs = turnChartHtml(frame, 'Bob', null, false);
assert.match(theirs, /waiting on/, 'the chart stopped saying who is waited on');
assert.ok(!/tc-yours/.test(theirs), 'someone else\'s turn is flashing at you');
const yours = turnChartHtml(frame, 'Alice', null, true);
assert.match(yours, /tc-yours/, 'your own turn does not flash');
assert.match(yours, /waiting on/, 'the flashing line stopped saying what it is about');
// An automatic phase waits on nobody, so there is nothing to flash even for the viewer.
const auto = turnChartHtml({ ...frame, actor: null, phaseKey: 'mainline', phase: 'Mainline' }, null, null, true);
assert.ok(!/tc-yours/.test(auto), 'an automatic phase flashed as though it were your move');
// And the style is actually shipped, or the class is decoration with no effect.
assert.match(TURNCHART_CSS, /\.tc-who\.tc-yours/, 'the flash has no styling');
assert.match(TURNCHART_CSS, /@keyframes tc-flash/, 'the flash does not animate');
assert.match(TURNCHART_CSS, /prefers-reduced-motion/, 'the flash has no reduced-motion fallback');
});
it('draws a train the Interlocking is holding at the Limits', () => {
/**
* REPORTED 2026-09-23: "should there be a tooltip on a train holding at limits due to
* interlocking that clearly states it is holding at limits because of interlocking?" The
* tooltip was already there — the view has carried `heldAtLimits` since #99 — and NO renderer
* read the flag, so the train drew like any other chip and nothing told a player to hover.
*/
const game = newGame(555);
const s = game.state;
const area = s.officeAreas.get(0)!;
// A held train has no grid position at all — that is the whole of #99 — so it is built here and
// named only on `heldAtLimits`, exactly as `arriveAtOffice` leaves it.
s.trays.set('held1', {
id: 'held1', trainNumber: 8, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }], direction: 'east',
position: { at: 'mainline', index: 1 }, movesUsed: 0,
} as never);
area.heldAtLimits = ['held1'];
// An eastbound train entered from the west, so it is held at the WESTERN Limits (view.ts).
const at = area.limitsWest;
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
assert.ok(cell.trains?.some((x) => x.heldAtLimits), 'the view lost the held flag');
const svg = officeSvg([cell], area.runningRow);
assert.match(svg, /bs-held/, 'a held train draws like any other');
assert.match(svg, /HELD AT THE LIMITS/, 'the held train says nothing about why it stopped');
assert.match(svg, /Interlocking/, 'the tooltip does not name what is holding it');
assert.match(BOARD_CSS, /\.bs-crew\.bs-held rect/, 'the held mark has no styling');
// An ordinary train is unmarked, or the cue means nothing.
area.heldAtLimits = [];
const officeCell = view(game).cells.find((c) => c.kind === 'office')!;
assert.ok(!/bs-held/.test(officeSvg([officeCell], area.runningRow)), 'an ordinary square draws as held');
});
it('draws a signal on a Mainline card carrying ABS Signals, not only a tooltip', () => {
/**
* REPORTED FROM A TABLE, Day 1 Stage 1 of v0.8.0.16: "when played on the trestle, there was no
@@ -3136,6 +3215,10 @@ describe('the Division map shows the whole route', () => {
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['A', 'B'],
@@ -3173,6 +3256,10 @@ describe('the Division map shows the whole route', () => {
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['A', 'B', 'C', 'D'],
@@ -3678,7 +3765,12 @@ describe('the sounds fire on the events they name', () => {
// The model names WHAT happened and the page decides what it sounds like. Getting this wrong is
// not a silent failure — it is a whistle every few seconds, or a bell that never rings — so the
// count is checked against the clock rather than trusted.
const game = newGame(555);
/**
* SEED CHANGED, NOT THE ASSERTION. Adding the Second Section card to the deck (Q9) reshuffles
* every seeded deal, and 555 stopped scheduling a train inside the window. This is the fixture
* shape TODO #84 is about: the seed means "a game like this", not this exact game.
*/
const game = newGame(9999);
const cues: Record<string, number> = {};
let stageBoundaries = 0;
let dayBoundaries = 0;
@@ -3941,6 +4033,10 @@ describe('the Day rolling over says so (Gitea#10)', () => {
maxCollisionsPerDay: 3,
maxCollisionsTotal: 10,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
@@ -5713,17 +5809,19 @@ describe('the Quickstart guide reaches the site', () => {
it('is published into dist and linked from the splash page', () => {
const guide = join(dist, 'quickstart.md');
assert.ok(existsSync(guide), 'the build did not publish quickstart.md');
assert.ok(existsSync(join(dist, 'quickstart.html')), 'the build did not RENDER the guide');
const text = readFileSync(guide, 'utf8');
assert.match(text, /^# Station Master — Quickstart/, 'quickstart.md is not the guide');
// The version is the first thing on the page, before anything else — see TODO's process rules.
assert.match(
text,
/Describes the game as built at v/,
'the guide does not say which build it describes',
text.split('\n').slice(0, 4).join('\n'),
/\*\*Version \d+\.\d+/,
'the guide does not carry its version at the top',
);
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide');
assert.match(splash, /href="\.\/quickstart\.html"/, 'the splash page does not link the rendered guide');
});
it('brings the lobby doors back every time the lobby is shown', () => {
@@ -5769,17 +5867,22 @@ describe('the Quickstart guide reaches the site', () => {
* references it guards did.
*/
const guide = readFileSync(join(dist, 'quickstart.md'), 'utf8');
const section = guide.slice(guide.indexOf('## 8. Where to read more'));
assert.ok(section.length > 0, 'the guide no longer has a "Where to read more" section');
const section = guide.slice(guide.indexOf('## 8. Documentation / References'));
assert.ok(section.length > 0, 'the guide no longer has a "Documentation / References" section');
// Markdown links, minus anchors and absolute URLs — what a reader can actually click.
const targets = [...section.matchAll(/\]\(([^)#][^)]*)\)/g)]
.map((m) => m[1]!.replace(/^`|`$/g, ''))
.filter((t) => !/^https?:/.test(t));
assert.ok(targets.length >= 4, `only ${targets.length} references parsed out of the guide`);
assert.ok(targets.length >= 3, `only ${targets.length} references parsed out of the guide`);
for (const t of targets) {
assert.ok(existsSync(join(dist, t)), `the guide links ${t}, which the build does not publish`);
// And the rendered page it becomes, since that is what a reader actually follows.
assert.ok(
existsSync(join(dist, t.replace(/\.md$/, '.html'))),
`the guide links ${t}, whose rendered page the build does not publish`,
);
}
});
@@ -5800,7 +5903,7 @@ describe('the Quickstart guide reaches the site', () => {
assert.ok(bundle.includes('GUIDE_DOCS') || bundle.includes('quickstart.md'), 'the bundle has no guide links');
// Every document offered in-game must be a file the build published.
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.md)["'`]/g)].map((m) => m[1]!);
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.html)["'`]/g)].map((m) => m[1]!);
assert.ok(hrefs.length >= 5, `only ${hrefs.length} in-game guide links found`);
for (const h of hrefs) {
assert.ok(existsSync(join(dist, h.replace(/^\.\//, ''))), `the game links ${h}, which is not published`);