Compare commits

...
5 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5 6f2a8dff09 The guide still has the workshop showing — TODO #111
Jesse's read of the rendered documentation after 0.8.2: dramatically better, and still
carrying too much of what was said about the game rather than what the game is. Four
things to fix, recorded with the measurements so the pass can start without re-surveying.

SECTION NUMBERS, 25 of them — rules 11, home-deck 5, mainline-deck 5, components 4,
quickstart 0. Two problems in the same notation. Sections 8.1, 8.2, 8.3, 10, 2.2 and the
7 cited by the deck documents resolve to nothing published: they are the prototype
rulebook's numbering, and rules.md:456 is a section whose own heading is named after it.
Sections 3.5 and 4.2 through 4.6 do resolve, because rules.md numbers its headings, but a
reader has to go counting. The renderer settles the fix: slug() in scripts/markdown.ts
strips a leading number, so a numbered reference has no anchor to point at and a
cross-reference has to become a named link. Do that first and the rest is mechanical.

REPOSITORY PATHS — four documents cite src/engine/content.ts to vouch that a table is
generated. The guarantee is worth keeping; the path is not.

CARD COUNTS, which reverses a standing ruling. #15a's "deliberately NOT including card
counts per category" was Jesse's call of 2026-08-22 and this is the same person reversing
it, so that entry is marked reversed here rather than left to read as current. The two
places the ruling is written into the documents are named. Generation answers the
staleness the ruling guarded against: every catalogue row already carries copiesInDeck or
copies, so the generator gains a column and the existing test keeps it honest. The one
hard part is recorded too — since 0.8.2 the office cards dealt depend on the starting
Office, so a printed count has to say it describes the default Depot opening.

BUILD-STATUS COMMENTARY, with the line that must not be crossed. "It is not implemented,
and never has been" and the live/dormantSolo/unbuilt column are an engineering status
printed for players, and they go. The facts underneath them do not: a player needs to know
the opponent-directed cards are not dealt and that the Interchange does not sort cars. A
pass that deletes the sentence and the fact together makes the documents wrong instead of
clean.

No code, no documents and no version touched — this is the worklist only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-23 07:43:24 -04:00
Jesse.MarkowitzandClaude Opus 5 3befc420da 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
2026-09-23 07:07:21 -04:00
Jesse.MarkowitzandClaude Opus 5 517238a727 v0.8.1.0 — the car comes back empty, and the lobby lets you leave
A second-digit bump, deliberately. 0.8.1 had been reserved for the seatless
display table; that work is getting more thought, and this table pass over
v0.8.0.17 earned the number on its own. Six reports: one was a rules question,
one a wording complaint with a real bug underneath, four straightforward.

A CAR CLEARED FROM A RED INBOUND BOX CAME BACK STILL LOADED. Reported as
wording — "technically accurate but doesn't make any sense" — and the wording
was the visible half. `inboundCleared` pushed `pooled(e.stock)` under a comment
reading "a car back in a yard is back in the common supply, carrying nothing",
and `pooled` does not do that: it strips the load's origin stamp and keeps
`loaded` ON PURPOSE, because a train can retire at a Division Point with freight
aboard. So the comment described an intention the call never carried out, and
every car the Freight Agent cleared reached the Classification Yard carrying a
load already delivered and already paid for.

It bites hardest on coaches: `passengersDetrained` takes `coach && !loaded` out
of the Division Yard and §2.2 refills that yard from Classification, so a
cleared coach came back as stock that could never unload another passenger.
Measured over five three-Day solitaire games: 18 loaded coaches in
Classification against 6 empty. The red box is where a journey ENDS; clearing it
sends the passengers out of the station, or the delivered load into the
industry, and returns the CAR, empty. The option says that now instead of
describing the counter that moves.

SCOPED TO THE RED BOX ON PURPOSE. `retireTrain` also returns loaded cars and is
left alone: that is what `pooled`'s own documentation describes, and a loaded
car in a yard is pre-loaded cargo rather than dead stock — it can be made up and
delivered, and a loaded coach can still detrain. Only the red box's contents had
already finished their journey.

GAMES IN PROGRESS DO RESUME, MEASURED RATHER THAN ARGUED. Yard contents change,
so the worry was real. All twelve saves on the test server were pulled and
replayed through `tryResumeSession` — the server's own boot check — against this
build. Six resume, six refuse, and the six refusals are the SAME six, at the
same moves, with the same codes, that 0.8.0.17 already logged. Nothing new was
stranded. That pre-install replay is a better check than reading the next boot
log, because it answers before the install rather than after.

THE HISTORY SAID "Mainline card 7" and left the reader to remember what card 7
was — with two Plains dealt, which is why the slot is kept beside the name
rather than replaced by it. A second fault sat one word to its left and nobody
reported it: the name was built as `e.key.replace(/([A-Z])/g, ' $1')`, so
`absSignals` printed as "abs Signals" while the action list directly above said
"ABS Signals". `narrate` takes `mainlineAt` and `enhancementName` beside the
`facilityAt` it already had, and `simpleCardName` is exported so the log reads
the same table the buttons do. `mainlineModified` had both faults and is fixed
with it.

LEAVING A RUNNING GAME WAS A DEAD END. `enterSeating` hides the choice section
and only the lobby's own two leave paths put it back; leaving a running game is
a third route, so the lobby came back holding nothing but "Games you are in"
with both doors on the page at display:none and no control able to reveal them.
Reset in `runLobby`, which is the one function every route onto that screen goes
through — which is exactly why the two paths that did it themselves missed a
third.

THE LOBBY'S ACTION BUTTONS CARRY THE BOARD'S AMBER. A list of the actions rather
than `#lobby button`: the settings form under Create is a field of inputs, and
amber on all of it would say everything is a move and so say nothing. A disabled
Start game drops back to chrome.

THE DEPARTMENT REFILL IS A RULE AND IS NOW WRITTEN DOWN. §6.2 — "if any of the
Department decks is empty, draw a Home Office card and place it in the empty
spot" — firing only when the draw actually empties the pile. Kept as implemented
(Jesse's ruling) and stated in rules.md and home-deck.md, neither of which had
ever mentioned it. A rule implemented from the prototype and never written down
is a rule that surprises the table.

1016 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-22 21:20:52 -04:00
Jesse.MarkowitzandClaude Opus 5 b90c0413d2 v0.8.0.17 — four things the game knew and the screen did not say
All four reported from a table on Day 1 of v0.8.0.16, and all the same shape.

ABS SIGNALS COULD ONLY BE PLAYED ON ONE MAINLINE CARD, while its tooltip said
"any Mainline card". The engine was never wrong: check accepts any node whose
kind is mainline and legalActions filters by check, so all of them were legal.
The failure was the LABEL — describeIntent named i.placement and never i.node,
so every placement described itself as plain "play ABS Signals", and the action
list drops duplicate labels. All but the lowest-index node were discarded before
the menu saw them. This is the THIRD time that trap has fired and the file
documents the other two three lines apart: a turnout's two rotations, and three
Department discards. Same fix — name what distinguishes them.

The card is also called what the card face calls it. prettyKey rendered
absSignals as "Abs Signals" beside a tooltip saying ABS, an acronym no
key-splitter can recover, so the authored names now win. Three of those names
were transcribed in sentence case and were CORRECTED rather than adopted: the
repository says "Yard Office" 36 times against "Yard office" twice. A lookup
that imports its own source's typos is the drift it exists to prevent.

NOTHING ON A MAINLINE CARD SHOWED WHAT WAS STANDING ON IT. Played, ABS left no
mark and you found out by hovering — the same complaint the Heavy Grade wedge
answered, and it matters more here because ABS decides whether a second train on
that card is safe. It draws a signal mast with a lit lamp now; a signal is the
literal object and needs no room for words, which is what lets it sit clear of a
name as long as "Uncontrolled Siding" on a 152px cell. The Mainline modifiers
draw as BRK, AIR and HLP. Realignment is deliberately not among them: reduce
takes the `became` branch and changes node.card, so a realigned Trestle IS an
Uncontrolled Siding afterwards. Asserted, so the absence reads as a finding.

A FREIGHT AGENT TURN SAID A CAR MOVED WHEN NONE HAD. Three faults behind one
line. It asserted an outcome, where §6.3 requires no action and the bot declines
deliberately — unjamming a healthy box destroys a load that cost a whole action
to stock. An idle Agent was then silent, which read as a dropped turn; a new
freightAgentIdled event says so and why, reducing to nothing exactly like
switchingEnded. And the work named a coordinate rather than the industry, though
a `place` helper has existed for precisely that since the switching lines moved
to it. "Loaded a loaded boxcar INTO the green Outbound box at the Freight House",
with the direction in capitals because to-or-from was the question asked.

THE LOG AND THE ACTION MENU SPELLED THE SAME SQUARE DIFFERENTLY. view.ts wrote
(col,row) — X,Y, east/west then north/south — with a comment saying why;
narrate.ts wrote the internal storage order with no comment at all. So the menu
offered a move to "(1,-1)" and the log reported it at "(-1,1)", side by side.
Pinned by a test that renders one square through BOTH describers and compares
them to each other: a test written against either file alone would have passed.

THE DOCUMENTATION IS REACHABLE FROM A RUNNING GAME, AND ALL OF IT IS PUBLISHED.
v0.8.0.16 published the Quickstart and nothing it points at — its §8 links five
documents by relative path and every one 404'd on the package, verified against
the running container. The build publishes the full set, and the test reads the
links OUT OF the guide rather than listing them. They are linked from the This
Game card, where reference already lives, rather than the header that must not
wrap; no mode awareness is needed, because solitaire and multiplayer are the
same page on the same origin.

THE REFERENCES DROPPED THE VERSION FROM THEIR NAMES. Four described v0.8.0.16
and had since the v0.8.0.15 audit; the v0.4.5 was the prototype edition they
were first written against, kept only because 36 citations pointed at it — and
it read as documentation five minor versions stale. They are quickstart.md,
rules.md, home-deck.md, mainline-deck.md and components.md now, kept current
with each release rather than published as editions. Two errors surfaced while
checking them against this release, which is the argument for doing it:
home-deck.md filed ABS Signals under Enhancements "played into your district"
that "change what a square does" — it does neither, this release's bug written
down — and mainline-deck.md, which lists everything playable onto a Mainline
card, never mentioned it at all.

1010 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-21 05:53:52 -04:00
Jesse.MarkowitzandClaude Opus 5 dc31555625 v0.8.0.16 — the guide a tester can reach, and a flag that named the card instead of the gate
v0.8.0.15 wrote a Quickstart for a tester who has never played and then left it
in docs/, where a tester does not look — reachable only by somebody who already
has the repository. Nobody handed the box had it. build-web.ts now copies it to
dist/quickstart.md and the splash page offers it under the three doors, as a
line rather than a fourth door: reading the guide is not a way to play, and
giving it equal weight in that grid would say it is.

COPIED, NEVER RE-WRITTEN. The Markdown document stays the one copy. A
hand-written HTML twin drifts from it on the first edit, which is the failure
#15a was raised about and precisely what the v0.8.0.15 pass spent itself
undoing. It is served as PLAIN TEXT, which is honest rather than good — tables
render as pipes and the links do not click. Rendering it into a styled page
wants a small Markdown converter and is filed as TODO #109; build-web.ts's
comment names that number rather than gesturing at "the next step", so the file
and the worklist cannot drift the way the references just did.

Two things had to be true and tsc checks neither, so both are tests. The href on
the splash page and the filename the build writes are two strings with nothing
connecting them: rename the document and the build quietly publishes nothing
while the page keeps offering a link that 404s. And a .md file must not arrive
as a download — the server's MIME fallback is application/octet-stream, which a
browser saves instead of displaying, so the link would have handed a tester a
file to save rather than a page to read. '.md' is in http.ts's table now, and
the test reads that table out of the source rather than asserting on a copy of
it, which would pass while the real one was wrong.

VERIFIED AGAINST A RUNNING SERVER, not only compiled: 200,
text/plain; charset=utf-8, the guide's own first lines, and the splash link
resolving.

THE sortsCars COMMENT. Asked after v0.8.0.15 whether everything now agreed, and
the audit turned up one place that did not — the field's own doc comment named
the card's printed text as though it were the flag's meaning. Nothing reads it
to permit a sort; its two readers, resolveExtraStart in apply.ts and the
enumeration in legal.ts, both ask whether this is the one Mainline card with a
Yard Limit and therefore the one an Extra may be made up and started on. Comment
only, and worth the bump because of where it is: it is what a developer reads
before using the flag, and it is the likeliest source of the sentence v0.8.0.15
had to correct off the board. The name is kept for its link to the card face and
the comment now says outright that the name is not the meaning.

The five references and docs/design.md read v0.8.0.16. They describe this build
because the audit re-checked them against it, not because the number was swept
forward — a stamp bumped without a reading is worth less than none.

1001 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-21 01:28:51 -04:00
72 changed files with 8415 additions and 3549 deletions
+499 -4
View File
@@ -19,6 +19,501 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.8.2 — 2026-09-23
A playtest read back against the save file, and the rules that came out of it. Nine questions were
asked of one three-Day game; three were bugs, three were the rules working and undocumented, and
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.
### Every district opens on a Depot
**The single biggest change.** 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. Every district opens on a **Depot** now: two A/D tracks, passengers from Stage 1.
**"Players start with Whistle Posts, not Depots"** is a setting for a table that wants the harder
game. 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 in a test rather than an argument: the cue-coverage pool needed
widening from 24 seeded games to 60 before it contained a single 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. Saves written before the setting existed name the rules that
existed then and cannot name this one, so `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.
### An Office held two trains on one A/D track
Reported from the table and confirmed on the save: at Day 2 Stage 9 a Whistle Post with one A/D
track held Trains 8 and 19 at once.
An ordering fault in `arriveAtOffice`. The capacity test passed with 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. **So the
collision §8.3 calls for never happened.**
The held train keeps its priority, because it has been waiting. The NEWCOMER takes the consequence,
and it is the same one it would have met had the held train arrived first: held at its own Limits
where there is an Interlocking, a collision where there is not.
### The history froze, permanently, and the log cap was not really the cause
One player's history stopped gaining lines at Day 2 Stage 8 and the other's at Day 2 Stage 4, in a
two-player game that never reached Day 5.
The log is trimmed to a limit — but each seat's "what have I sent you" bookmark was an **index** into
that array. Once a seat's bookmark reached the limit the array never grew past it again, so the
slice returned nothing for the rest of the game. Different moments per seat because each holds its
own bookmark. That game's log ended at exactly the cap.
Raising the cap only delays it. Lines carry a **sequence number** now, which survives trimming, so
the bookmark stays meaningful however much is dropped. Proven by pushing twice the cap through a
simulated seat and asserting all of it arrives. The limit is also much larger, on its own merits: a
four-player game over ten Days is several times the game that first hit it.
### §8.1 asked the wrong question twice
**"Trains may pass" was short-circuiting the whole Subdivision.** The check returned `clear` before
the Subdivision was looked at, so a train entering a Double Track was released however busy the rest
of it was — including against a train coming the other way three cards deeper in. That is what let
Train 8 out of the Western Division Point with no ruling asked, and the reason had nothing to do
with Control Points. The card prints that TWO TRAINS MAY SHARE IT, so it excuses occupants on that
card and nothing else.
**A train standing at an Office was invisible to it.** Train 19 was released from the Eastern
Division Point towards Train 14 and nobody was asked — because at that moment Train 14 was not in
transit at all, it was standing in a district. §8.1 was only ever reading trains on Mainline cards,
so a train about to re-enter the very Subdivision being entered counted for nothing.
**Capacity is the test, not presence** (Jesse's reasoning exactly): at a Whistle Post, one A/D track
with a train on it means there is nowhere for the two to pass and no choice to be made. At a Depot
or a Terminal with a track still free, the train at the Office is not in the way.
### Things that happened silently now say so
**A train held against a facing one.** An absolute bar that returned without a word — the train
simply did not depart, Stage after Stage. Only the ABS Signals case announced itself, and it had
been given a line for exactly this reason. The line names the train that is coming and says there is
no Control Point between them to pass at.
**A train released from the Limits.** It happened as a side effect of somebody else's arrival, so
the held train appeared at the Office with nothing said — "wasn't clear what changed and why train 8
was suddenly released". The line names the train whose arrival freed the track, because *why now* is
the whole question.
**A train the Interlocking is holding.** The tooltip explaining it has existed since #99 and **no
renderer ever read the flag**, so the train drew like any other crew and nothing told a player to
hover. It is drawn held now — red and dashed, the same "stopped, and not by choice" the Red Flag
means elsewhere on the map.
### Where a move is refused, and why
Asked directly: *"how does a user know what rule is violated and why you can't go there?"* Nowhere,
was the answer. `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 no reason given.
Those squares are blocked with the rule that blocks them now, as `cardRule`. The reasons are got by
**asking `check`**, not by re-deriving the rules: a second implementation is exactly the failure the
block list was built to avoid, and a reason that does not match the refusal is worse than none.
**And a train may always recover its own caboose.** X13 prints "may drop MTs but not pick up
anything", and a train needs its caboose at the far end to be made up — 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.
### Two Modifier rules, decided in September and applied here
**A Modifier must sit square against its host** — north, south, east or west. No diagonals: touching
at a corner is not touching. This reverses an earlier report in the other direction, and the test
that asserted the old rule now asserts the new one on the very square that prompted it.
**A passenger Modifier may not be played at a Whistle Post.** A Waiting Area, Restaurant or Hotel
needs an Office upgraded to at least a Depot. It reverses the ruling that let them stand dormant: a
Whistle Post allows neither direction, so the outbound slot was discarded on the spot and only the
porter landed, and a card that can be played to no effect is a trap however well it is labelled.
The grant-recovery path in `officeUpgraded` is kept and is now unreachable by play.
Both were built, measured, backed out for a fortnight so a playtest could finish, and applied here.
The cost is known rather than guessed: of the thirteen saves on the test server, the Whistle Post
rule is what strands six of them.
### The documentation is a set of pages now, not five text files
They were served as `text/plain`, which is honest and unreadable: a card reference is mostly tables,
and as plain text a table is rows of pipes. That was TODO #109, taken deliberately as the short
version to get the references in front of testers for one round.
**Markdown is still the one copy.** `docs/*.md` is what is written and reviewed; the build renders
it. A hand-written HTML twin drifts on the first edit, which is the whole lesson of #15a. The
Markdown is published beside each page too — it costs nothing, it is what a reader wanting a diff
actually wants, and it keeps every link handed out while the documents were text working.
**No Markdown library.** `scripts/markdown.ts` covers the subset these five documents use. This
project has no runtime dependencies at all and one would be a poor first — and the renderer is
ten tests' worth of behaviour, not a general-purpose parser: it escapes unconditionally, and there
is no raw-HTML passthrough.
What the page adds over the text, each because the Markdown cannot carry it without drifting: a nav
across the five documents; a contents list built from the headings actually rendered; an anchor on
every heading, so a section can be linked in a bug report; a ~70-character measure, because long
lines are the single biggest thing making long documents hard to read; and tables that are tables,
with numeric columns right-aligned and wide ones scrolling inside the page rather than widening it
on a phone. It prints as ink on paper, nav and contents dropped — a rules reference is a thing
people print.
### The references caught up with the rules
Checked against this release rather than assumed, and two statements had gone from stale to
misleading. The Quickstart told a new player they start on a **Whistle Post** and to "get a Depot
down as soon as one appears" — advice for a game that no longer exists. Components listed "Whistle
Post cards ×4" as what everyone starts on.
Also added, because the playtest showed each was a rule nobody could look up: the **Office tier
table** (A/D tracks, Porters, passengers, Control Point) and what A/D tracks decide; **§8.1 in
practice**, including that a card printing "trains may pass" excuses only that card and that an
Office with no free A/D track occupies the Subdivision; the three conditions the **Circus Train**
pays on; and that a **Realignment can be a card with no legal target**, which is exactly what
happened at the table.
### Smaller, from the same session
**An industry may be built over a straight** off the Running Track, the way a turnout may upgrade
one — so rail can go down before the industry that will serve it. Only a plain straight: a Facility
carries east–west track, so the swap cannot break a neighbour's join, where a curve or turnout
could.
**Revenue lines name the player who earned them.** The history prefixes the ACTOR, and revenue is
not always the actor's — a train completing its run pays everybody with no actor at all, so those
lines carried no name whatsoever.
**"Waiting on" flashes when it is your turn**, amber, reading the move on screen rather than the
live one so it does not flash while your board is still catching up.
**The Circus Train says what it pays for** on its own line while it is being made up, instead of as a
clause trailing the consist — it pays for STOPPING, once per district, and only if every car but the
caboose is loaded. The history line says the same when the point lands.
**A Realignment says what it can convert.** "Convert one Mainline type to another" is true and
useless when only four of the nine types convert at all and the Division may have dealt none of
them — which is exactly what happened.
## 0.8.1.0 — 2026-09-22
A second-digit bump, and a deliberate one. **0.8.1 had been reserved for the seatless display
table**; that work is getting more thought, and this batch — a table pass over v0.8.0.17 — earned
the number on its own. Six reports, one of which turned out to be a rules question, one a wording
complaint with a real bug underneath, and four straightforward.
### A car cleared from a red Inbound box came back still loaded
Reported as a wording problem — *"the option to send the loaded coach in the red inbound box to the
classification yard is technically accurate but doesn't make any sense"* — and the wording was only
the visible half.
`inboundCleared` did this:
```ts
// `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts).
s.yards.classificationYard.push(pooled(e.stock));
```
**`pooled` does not do that.** It strips the load's `origin` stamp and keeps `loaded` on purpose,
because a train can retire at a Division Point with freight still aboard (`advance.ts`). So the
comment described an intention the call never carried out, and every car the Freight Agent cleared
reached the Classification Yard still carrying a load that had already been delivered and already
been paid for.
It bites hardest on coaches. `passengersDetrained` takes `type === 'coach' && !loaded` out of the
Division Yard, and §2.2 refills the Division Yard from Classification — so a cleared coach came back
as stock that could never unload another passenger. Measured over five three-Day solitaire games
before the fix: **18 loaded coaches in the Classification Yard against 6 empty.**
The red box is where a journey ENDS. Clearing it sends the passengers out of the station, or the
delivered load into the industry, and returns the **car** — empty — to the common supply. The
option now says that rather than describing the counter that moves: *"let the passengers out of the
station — the coach in the red Inbound box at (2,-1) goes back to the Classification Yard empty"*.
**Not everything loaded in a yard is this bug**, and the fix is scoped to the red box. A train that
completes its run returns its cars as they stand, loaded ones included — that is `retireTrain`, it
is what `pooled`'s own documentation describes, and a loaded car in a yard is pre-loaded cargo
rather than dead stock: it can be made up into a train and delivered, and a loaded coach can still
detrain. Only the red box's contents had already finished their journey.
**Games in progress DO resume, and it was measured rather than argued.** What is in the yards
changes, so the worry was real: a save whose later moves depend on a cleared car being loaded would
stop replaying. All twelve saves on the test server were pulled and replayed through
`tryResumeSession` — the server's own boot check — against this build. Six resume and six refuse,
and **the six refusals are the same six, at the same moves, with the same codes**, that 0.8.0.17
already logged: three `CARD_NOT_IN_HAND` at move 3 in saves from engines 0.5.3, 0.7.0 and 0.7.1,
two `NO_PENDING_DECISION` from the Gitea#26 clearance fix, and WHISTLE-6945 stopping at move 529 on
v0.8.0.14's Modifier ruling. Nothing new was stranded.
**That replay is the cheap check to repeat on any release that touches state**, and it is a better
one than reading the next boot log: it answers before the install rather than after it.
### The history said "Mainline card 7" and left you to remember what that was
*"It should specify both. Note there were two plains cards dealt in this hand."* — which is exactly
why both: the name informs, and the slot is the only thing telling two Plains apart.
The line also had a second fault nobody reported, one word to its left. It built the card's name as
`e.key.replace(/([A-Z])/g, ' $1')`, so `absSignals` printed as **"abs Signals"** — uncapitalised,
and bypassing the authored names `cardName` started honouring in v0.8.0.17 for precisely this
reason: no key-splitter recovers an acronym. The action list one line above it said "ABS Signals"
while the log said "abs Signals".
Both are resolver-shaped, so `narrate` takes `mainlineAt` and `enhancementName` alongside the
`facilityAt` it already had, and `simpleCardName` is exported so the log reads the same table the
buttons do. `mainlineModified` had both faults in the neighbouring case and is fixed with it.
### Leaving a running game was a dead end
*"I see the games that I'm in, and I have the ability to rejoin or forget. But what if I want to
join a different game or start a new one? How do I get back there?"*
There was no way. `enterSeating` hides `#lb-choice-section`, and the only things that put it back
were the lobby's own two leave paths. Leaving a **running** game is a third route — `main.ts` calls
`runLobby` again — so the section stayed hidden from the previous seating and the lobby came back
holding nothing but "Games you are in". The two doors were on the page the whole time, at
`display:none`, with no control that could reveal them.
Reset in `runLobby` rather than in the leave handler, because that function is the one thing every
route onto this screen goes through — which is why the two paths that did it themselves missed a
third.
### The lobby's buttons say they are buttons
*"Create new game should have that same amber border when it's enabled and ready to click, just
like the other action buttons in the rest of the game."*
`#actions button` has meant "this is a move you can make" all game. Look up game, Join this game,
Create new game, Copy code, Copy invite link, Start game and Rejoin now carry it. The selector is a
list of those actions rather than `#lobby button`: the settings form under Create is a field of
inputs, and amber on all of it would say everything is a move and so say nothing. A disabled Start
game drops back to plain chrome, because the amber is a promise the button would not keep.
### The Department refill is a rule, and now it is written down
*"Why was a department slot refilled? If I take the last card there, then there should just be no
cards available in that stack."*
The game is right and the documentation was silent. §6.2: *"If any of the Department decks is empty,
draw a Home Office card and place it in the empty spot."* It fires only when the draw actually
empties the pile — refilling on every draw would grow the Departments without limit and drain the
Home Office deck into them. **Kept as implemented** (Jesse's ruling), and stated in `rules.md` and
`home-deck.md`, neither of which had ever mentioned it. A rule implemented from the prototype and
never written down is a rule that surprises the table.
### Games in the Manage Game dropdown say how old they are
*"I've got four games out there, but I have no idea which one's which or how old they are."* The
label carried the code, the seat count and the position — everything except the fact that says
whether a game is live or abandoned. `lastMoveAt` was already on the wire from the server's
`summary()` and simply unused.
Both forms, because they answer different questions: **"5h ago"** answers the one that was asked,
and the timestamp beside it is what you quote when deciding to end a game. A lobby reports when it
was created and says so. The date formatter moved to `utils.ts`, shared with Games in Progress so
the two cannot drift.
## 0.8.0.17 — 2026-09-21
Four things a table found on Day 1 of v0.8.0.16, all of them the same shape: the game knew
something and the screen did not say it.
### ABS Signals could only ever be played on one Mainline card
Its tooltip says "any Mainline card". Exactly one was ever on offer — the Trestle, on the board it
was reported from.
**The engine was never wrong.** `check` accepts any node whose kind is `mainline`, and
`legalActions` filters by `check`, so every Mainline card was enumerated and legal. The whole
failure was in the LABEL: `describeIntent` named `i.placement` and never `i.node`, so all of them
described themselves as plain "play ABS Signals" — and the action list drops duplicate labels, which
discarded every one but the lowest-index node before the menu saw it.
**This is the third time that trap has been sprung**, and the file documents the other two three
lines apart: a turnout's two rotations produced one label each until the rotation was named, and
three Department discards collapsed into one button until the pile was named. The fix is the same
one both times: name the thing that distinguishes them. Pinned by a test that counts the Mainline
cards in the division and requires a distinct, named spot for each.
The card is also called what the card face calls it. `prettyKey` rendered `absSignals` as "Abs
Signals" on a button while the tooltip beside it said ABS — an acronym no key-splitter can recover —
so the authored names in the content tables now win. Three of those names were transcribed in
sentence case and were corrected rather than adopted: the repository says "Yard Office" 36 times
against "Yard office" twice, and a lookup that imports its own source's typos is the drift it exists
to prevent.
### Nothing on a Mainline card showed what was standing on it
Played, ABS left no mark. You found out it existed by hovering the card — which is the complaint the
Heavy Grade wedge answered in v0.8.0.7, and it matters more here, because ABS is what decides
whether running a second train onto that card is safe.
A card carrying it now draws a **signal mast with a lit lamp** at its top-right corner. A signal is
the literal object, and unlike a text badge it needs no room for words, which is what lets it sit
clear of a name as long as "Uncontrolled Siding" on a 152px cell.
The Mainline modifiers had the same defect and are drawn too, as **BRK**, **AIR** and **HLP** beside
the card's name. Tags rather than names only because the measurements leave no choice — "Brakeman ·
Airbrakes · Helpers" is thirty characters where about eleven fit — and the tooltip has always spelled
them out. **Realignment is deliberately not among them:** it never sits on a card, because `reduce`
takes the `became` branch and changes `node.card` outright, so a realigned Trestle simply IS an
Uncontrolled Siding afterwards. Asserted in the test, so the absence reads as a finding rather than
an omission.
### A Freight Agent turn said a car moved when none had
"Chose FREIGHT AGENT work — one car moved to or from a facility", and then nothing. Three faults
behind one line.
The line **asserted an outcome**. §6.3 requires no action at all, and the bot takes that route
deliberately — unjamming a healthy box destroys a load that cost a whole Local Operations action to
stock, so an idle Stage is strictly better. It now says what the Freight Agent MAY do.
**An idle Agent was silent.** A new `freightAgentIdled` event says so, and gives the reason. It
reduces to nothing, exactly like `switchingEnded`: it reports a choice the state already holds.
**The work named a coordinate, not the industry.** A `place` helper has existed for this since the
switching lines were moved to it, and its own comment makes the argument — "(-1,1)" is the grid's
notation and means nothing at a table where people are looking at cards. These three lines were
missed. A Freight Agent turn now reads "loaded a loaded boxcar INTO the green Outbound box at the
Freight House", with the direction in capitals because to-or-from was the question asked.
### The log and the action menu spelled the same square differently
`view.ts` wrote `(col,row)` — X,Y, east/west then north/south — with a comment saying why.
`narrate.ts` wrote `(row,col)`, the internal storage order, with no comment at all. So the menu
offered a move to "(1,-1)" and the log then reported it at "(-1,1)", in two panels read side by
side. The log follows the map now.
Pinned by a test that renders one square through BOTH describers and compares them to each other
rather than to a literal — a test written against either file alone would have passed all along.
### The documentation is reachable from inside a game, and all of it is published
**v0.8.0.16 published the Quickstart and nothing it points at.** Its §8 "Where to read more" links
five further documents by relative path, and every one of them 404'd on the package — verified
against the running container, five of six paths missing. The whole table was dead. The build
publishes the full set now, and the test reads the links OUT OF the guide rather than listing them,
so it cannot go stale the way the references themselves did.
**The guide is linked from the This Game card** (Jesse's call), which is where reference already
lives — the seed, the seat, the house rules — rather than from the header, which is the line that
must not wrap. It needs no mode awareness: solitaire and multiplayer are the same page on the same
origin, so one relative link resolves in both, on the public site and on a StartOS box alike. Every
link opens in a new tab, because a player reading the rules mid-turn must not lose the game behind
them.
### The references dropped the version from their names
`StationMaster-Rules-v0.4.5.md` and three like it described **v0.8.0.16** and had done since the
v0.8.0.15 audit. The `v0.4.5` was the prototype rules edition they were first written against, kept
in the filename only because thirty-six citations pointed at it — and it read, to anyone opening the
published guide, as documentation five minor versions out of date.
They are `quickstart.md`, `rules.md`, `home-deck.md`, `mainline-deck.md` and `components.md` now,
with every citation rewritten. **These are kept current with each release rather than published as
editions**, so the name is always the latest and the build each describes is stated at the top.
Two errors surfaced while checking them against this release, which is the argument for doing it:
`home-deck.md` listed ABS Signals among the Enhancements "played into your district" that "change
what a square does" — it does neither, and that miscategorisation is this release's bug written
down. And `mainline-deck.md`, which lists everything that may be played onto a Mainline card, never
mentioned ABS Signals at all. Both corrected.
## 0.8.0.16 — 2026-09-20
The Quickstart put where a tester can actually reach it, the last place that still told the old
story about the Interchange, and the release that carries v0.8.0.15's corrected card text into a
package.
### The Quickstart is published beside the game
v0.8.0.15 wrote a Quickstart for a tester who has never played, and then left it in `docs/`, where
a tester does not look — reachable only by someone who already has the repository. Nobody being
handed the box has it.
`build-web.ts` now copies `docs/quickstart.md` into `dist/quickstart.md`, and the
splash page offers it under the three doors: *New to Station Master? Read the Quickstart guide.*
**Not a fourth door** — reading the guide is not a way to play, and giving it equal weight in that
grid would say it is.
**Copied, never re-written.** The Markdown document stays the one copy. A hand-written HTML twin
would drift from it on the first edit, which is exactly the failure #15a was raised about and
exactly what the v0.8.0.15 pass spent its time undoing: four references a month and two minor
versions out of date.
**Served as plain text, which is honest rather than good.** Tables render as rows of pipes and the
links do not click. Rendering it into a styled page wants a small Markdown converter and is filed
as **TODO #109**; this is the fifteen-minute version that gets the guide in front of testers for
this round instead of leaving them without one.
Two things had to be true and neither is checked by `tsc`, so both are tests:
- **The link has to find the file.** The href on the splash page and the filename the build writes
are two strings with nothing connecting them — rename the document and the build quietly
publishes nothing while the page keeps offering a link that 404s. The test asserts the guide is
in `dist`, is the guide, and is the file the splash page names. The build also warns loudly
rather than silently skipping a missing document.
- **A `.md` file must not arrive as a download.** The server's MIME fallback is
`application/octet-stream`, which a browser saves instead of displaying, so the link would hand a
tester a file rather than a page. `'.md': 'text/plain; charset=utf-8'` was added to the table in
`http.ts`, and the test reads that table out of the source rather than asserting on a copy of it,
which would pass while the real one was wrong.
### `sortsCars` says what it gates, not what the card prints
Asked directly after v0.8.0.15 — does everything now agree? — and the audit turned up one place that
did not. The field's own doc comment read:
```ts
/** Interchange: "Sort cars in new order". */
sortsCars: boolean;
```
which names the printed text as though it were the flag's meaning. Nothing reads it to permit a
sort. Its two readers — `resolveExtraStart` in `apply.ts` and the enumeration in `legal.ts` — both
ask the same question, which is whether this is the one Mainline card with a Yard Limit and
therefore the one an Extra may be made up and started on.
**Comment only, and worth the bump because of where it is.** This is what a developer reads before
using the flag, and it is the most likely source of the sentence v0.8.0.15 had to correct — the one
telling players, on the board, that cars could be sorted at an Interchange. The name is kept for its
link to the card face, and the comment now says outright that the name is not the meaning.
Everything else already agreed, and was checked rather than assumed: `mainlineDescription`, the
generated `rules/as-built.md`, the Mainline deck reference and the Rules FAQ all say the printed
sorting is unimplemented and that a consist is re-ordered at a Small Yard. `rules/implications.md`
is the deliberate exception and was already correct — it transcribes what the card face prints and
then records that the concept is "still unimplemented".
### The reference stamps, and what was filed rather than done
The five documents v0.8.0.15 stamped — the Quickstart, the Rules, Components, the Home deck and the
Mainline deck — plus `docs/design.md` now read **v0.8.0.16**, and the Rules book's implementation
note about `card.discard` says the same. They describe this build because the audit above re-checked
them against it, not because the number was swept forward: the point of the stamp is that it was
earned, and a stamp bumped without a reading is worth less than none.
**TODO #109** holds the part deliberately not done — rendering the guide into a styled page instead
of serving it as plain text. `build-web.ts`'s comment names that number rather than gesturing at
"the next step", so the file and the worklist cannot drift apart the way the references just did.
### Why this is a release at all
One player-visible change, the link to the guide, and one that only a developer reads. The
Interchange correction a player actually sees on the board is v0.8.0.15's; this is the version the
wrapper bundles, so that correction reaches the box rather than only the repository — and a tag
with an uncommitted comment sitting on top of it is not a thing to package.
## 0.8.0.15 — 2026-09-20
The reference documentation brought up to the game as it actually runs, ahead of the next round of
@@ -47,7 +542,7 @@ is the prototype rules edition they were first written against, and the names ar
`src/`, `CHANGELOG.md` and `docs/rules/` all cite them, and several of those citations are historical
records of what a document said at the time.
- **`StationMaster-Quickstart.md` — NEW.** What the game is, how you win, the shape of a Stage, what
- **`quickstart.md` — NEW.** What the game is, how you win, the shape of a Stage, what
is on the screen, a first twenty minutes, the things that surprise new players, and what to report.
Written for somebody about to play rather than somebody building it.
- **Rules.** §3.4 replaced outright — the `firstToTarget` / `highestAfterDays` victory model and the
@@ -3424,7 +3919,7 @@ that same occupant list.
`buildDivision` drew uniformly from the nine card TYPES **with replacement**, so a Division could be
dealt two Interchanges or two Tunnels, and Plains — printed twice in the deck — carried the same
weight as cards printed once. `docs/StationMaster-Mainline-Deck-v0.4.5.md` had flagged the mismatch
weight as cards printed once. `docs/mainline-deck.md` had flagged the mismatch
as needing correction; "an Extra may start at the Interchange if one is on the board" is what forced
it, since that only reads as a rule if the board holds at most one. Now dealt from `MAINLINE_DECK`
without replacement, verified over 1600 deals across 1–4 players.
@@ -3461,7 +3956,7 @@ cannot see is a board with nothing to click and no reason given.
**1/2 Crack Limited 3 coaches → 2. 5/6 The Sparrow 2 → 3.** A change to the cards, not a
transcription fix, so `Trains3.pdf` and the transcription in `implications.md` §5 keep the original
numbers with a footnote; `content.ts` and `StationMaster-Home-Deck-v0.4.5.md` carry what the game
numbers with a footnote; `content.ts` and `home-deck.md` carry what the game
plays. Both consists remain inside the four-car Crew Tray limit.
A test had to follow: `multiplayer.test.ts` used Train 1 *because* it had three cars, to exercise a
@@ -3595,7 +4090,7 @@ before v0.4.9 — 6 copies, one slot each direction — so §9.3 names it, and t
The card set says the same thing without needing the rules text. All three Refinery modifiers —
Pipelines, Oil Depot, Viscosity Breakers — grant **+1 outbound**; a two-way Refinery would be the only
industry in the game with no card able to raise one of its two directions.
`StationMaster-Home-Deck-v0.4.5.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
`home-deck.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
Warehouse · Inbound · 0 out / 1 in".
So the Refinery ships and the Grocer's receives, and the **Freight House is the one two-way industry**
+118 -11
View File
@@ -24,6 +24,29 @@ at all. One item per place now.
Not items. Things that are true of every change, and that have gone wrong when skipped.
- **Update the documentation set in the same change.** `docs/quickstart.md`, `rules.md`,
`home-deck.md`, `mainline-deck.md` and `components.md` describe the game as built, and every
release restamps them — `**Version x.y.z** · date` is the second line of each. **The wrapper's
`instructions.md` is part of the set**: it is what a StartOS operator reads, so a change to how
the service is set up, run or recovered belongs there in the same commit. If a change alters what
a player does, sees or may rely on, the affected document changes with it. Four rules govern what
goes in them:
- **Version at the top**, before anything else on the page.
- **No history and no rationale.** No "this used to", no "corrected in v0.8.x", no ruling dates,
no TODO numbers. The documents say what the rules ARE. The reasoning belongs in `CHANGELOG.md`
and the argument in this file.
- **`instructions.md` is a manual, not a changelog.** It accumulated twenty "What changed in …"
blocks — 380 of its 469 lines — before they were deleted in 0.8.2. What changed in a release
goes in the wrapper's `releaseNotes`, which is what StartOS actually shows on update; the
instructions say how to run the service as it is now.
- **Card tables are generated, never typed.** `npm run build:cards` writes them into `home-deck.md`
and `mainline-deck.md` between `<!-- BEGIN CARDS: … -->` markers, and
`test/card-reference.test.ts` fails if a checked-in table disagrees with `content.ts`.
- **The Markdown is the source; the pages are built.** `scripts/build-web.ts` renders each
document to `<name>.html` through `scripts/markdown.ts`. Never edit a published page — and if a
document needs a construct the renderer does not cover, extend the renderer and test it rather
than writing HTML into the Markdown.
- **Ask Jesse what the version bump should be.** Third digit is a bug fix, second is a new set of
features, 1.0 is the first release worth the name — but which one a batch deserves is a judgment
about how finished it feels, and it is his. The number lives only in `package.json`;
@@ -83,7 +106,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
9. **Code health and housekeeping** — #46 #45 #84 #87
10. **Documentation and assets** — #15a #86 #88
10. **Documentation and assets** — #15a #86 #88 #111
Then, at the back: **Reference** (the measurements, rulings and rejected approaches behind the
items above) and **Done** (everything closed, kept because several of them are the only record of a
@@ -214,14 +237,13 @@ right — most of this release's defects were legible-but-wrong rather than brok
laptop, are unanswered. **Needs a `days: 1` game — the timetable does not run out in five
Days, so extended play fired in 0/10 measured games.** See **Reference · #35**.
- [ ] **#42a** — **Nobody has clicked through the solitaire setup screen's own fields** and confirmed
the dealt game matches what was chosen. It took three attempts to become reachable at all —
reachable is not the same as correct. See **Reference · #42a**.
- [x] **#42a** — **CONFIRMED at a table, 2026-09-23.** The solitaire setup screen's own fields were
clicked through and the dealt game matched what was chosen.
- [ ] **#40** — **An older save may not replay, and players are not told so anywhere they will see
it.** Not a v0.7.4 fact and not a bug: a save is re-played through the current rules, so any
narrowing of what is legal can stop one. The rule is written down now (`README.md` § Design
notes); what is still owed is a line **wherever a build is announced**. See **Reference · #40**.
- [x] **#40** — **DONE, 2026-09-23.** One sentence, in the four places a player meets a save: the
Quickstart's reporting section, a Rules FAQ entry ("Will an old save still replay?"), the
replay viewer's own page, and a tooltip on the **replays** link in the game — which had no
tooltip at all before. Also in the package's `instructions.md`.
---
@@ -424,7 +446,9 @@ need RAR or Jesse rather than code.**
to back" depends on which way the train points and the board has reversed east-facing consists
since v0.8.0. Each says `MADE UP, ready to leave` or `HELD at the Office: <why>`.
- [ ] **#108** — **The coach ratchet: every coach ends up in the Classification Yard and never comes
- [x] **#108** — **RULED AND CLOSED, 2026-09-23.** It stands: further table evidence supports
it, and part of the mid-game is players deliberately adding cars to clear the Division Yard so
the Classification refresh can happen. Originally: **The coach ratchet: every coach ends up in the Classification Yard and never comes
back.** RULED 2026-09-17 — *the rule stands, the game says so loudly* — and recorded here
because the ruling was made on one game's evidence and the balance question behind it is open.
@@ -582,6 +606,11 @@ What the project says about itself, and what it ships alongside the code.
- [ ] **#88** — `card-reference.md`'s industry table may still be stale beyond Grocer's Warehouse and
the Oil Refinery. See **Reference · #88**.
- [ ] **#111** — A full pass over the five player-facing documents: strip the playtest commentary and
the pointers into the code, publish the card counts, and take out every section number and
repository-file reference. Jesse's read after the 0.8.2 rendering landed — much better, still
too much of the workshop showing. See **Reference · #111**.
---
## Reference — measurements, rulings and rejected approaches
@@ -1992,7 +2021,7 @@ source Start-position art for the ten card types before it can begin.
2026-08-22, immediately after Gitea#7 changed the coach counts on four train cards and the
answer to "where do we keep track of that?" turned out to be **five places of three different
vintages**: `src/engine/content.ts` (the truth), `docs/StationMaster-Home-Deck-v0.4.5.md` (a
vintages**: `src/engine/content.ts` (the truth), `docs/home-deck.md` (a
readable per-card table, a version-stamped snapshot), `docs/rules/implications.md` §5 (the
transcription of `Trains3.pdf`, deliberately frozen at what the design SAYS),
`docs/Trains3.pdf` (the artwork), and `docs/rules/card-reference.md` (an invented placeholder
@@ -2017,7 +2046,9 @@ unbuilt, each row citing the file that reads it); the same honesty is what makes
reference worth more than a transcription. `enhancementText()` and `mainlineDescription()` are
the model: prose composed from the data, so a tooltip cannot drift from the rule it describes.
**Deliberately NOT including card counts per category.** Jesse's call in the same breath: the
**Deliberately NOT including card counts per category — REVERSED 2026-09-23, see #111.** The
ruling below stood from 2026-08-22 until Jesse asked for the counts to be published; generation
answers the staleness it was guarding against. Read it as history. Jesse's call in the same breath: the
counts move with play balance, so a document that prints them is stale on the next retune. The
same rule was applied to `content.ts`'s own comments on 2026-08-22 — see the pass recorded in
CHANGELOG for what came out and what was kept.
@@ -2084,6 +2115,82 @@ placeholder said. **The balance question the entry was really guarding is #70**
supply and the uniform 1/1/1 industry model, both marked provisional in `content.ts`) — that is
where it belongs, and it is still open.
#### #111 — A full editorial pass over the player-facing documentation.
**Raised by Jesse 2026-09-23**, on reading the rendered guide and the rewritten wrapper
instructions that shipped in 0.8.2. The verdict was that both are dramatically better and that the
workshop is still visible through them: *"There is still far too much of the feedback from
playtesting, pointing directly into code, commentary about decisions made versus gaps… People
playing the game do not need reference to old, outdated source material. They just want the
rules."* Four things to fix, and they are separable.
**1. Take the section numbers out.** Measured on 2026-09-23: `rules.md` 11, `home-deck.md` 5,
`mainline-deck.md` 5, `components.md` 4, `quickstart.md` 0. They are two different problems wearing
the same notation:
- **References to a rulebook nobody has.** §8.1, §8.2, §8.3, §10, §2.2 and the §7 cited in the deck
documents do not resolve to anything published — they are the numbering of the prototype rules
document. `rules.md:456` is the worst of them: a section whose own heading is
`## §8.1 in practice`, named after a document the reader cannot open. `rules.md:203` quotes one
outright — `§6.2: *"If any of the Department decks is empty…"*`.
- **References that do resolve, but only by number.** §3.5, §4.2–§4.6 and §6 are real sections of
`rules.md`, which numbers its own headings 1–7. These are not wrong, they are brittle and
unfriendly: `home-deck.md:207` "see Rules §4.4" asks a player to go count.
**The renderer settles how to fix these.** `scripts/markdown.ts`'s `slug()` deliberately strips a
leading section number, so §4.6 has no anchor to link to — the target is
`rules.html#passenger-work`. A cross-reference therefore becomes a named link
(`[Passenger work](rules.md#passenger-work)`) and the heading numbers themselves come off. Do
that first: it is what makes the rest of the pass mechanical.
**2. Take the pointers into the repository out.** Four of the five documents cite
`src/engine/content.ts` by path — `home-deck.md:8`, `mainline-deck.md:8`, `rules.md:8`,
`components.md:10` — to vouch that a table is generated. The guarantee is worth keeping and the
path is not; say the tables are generated from the game itself. No `.ts`, `.md` or `docs/` path
belongs in a player's document. (`docs/design.md` is a developer document and out of scope.)
**3. Publish the card counts.** *"It will change as playtesting evolves and things alter, but the
current count should be listed here. That is crucial information."*
> **This reverses a standing ruling — #15a's "Deliberately NOT including card counts per
> category", Jesse's call of 2026-08-22 — and it is the same person reversing it.** Two years of
> that ruling are baked into the files and all of it has to come out: the banner at
> `home-deck.md:12` (*"Card counts are not published…"*) and the clause at `components.md:11`
> (*"per-category CARD counts are not published, because they move with play balance"*). Update
> #15a's reference entry so it does not read as still in force.
The staleness the old ruling guarded against is answered by generation, not by omission. Every
catalogue row already carries its own count — `copiesInDeck` on track and office cards, `copies` on
industries, modifiers, enhancements, Mainline cards and Second Section — so
`scripts/build-card-reference.ts` gains a **Copies** column and `test/card-reference.test.ts` keeps
it honest for free. Nothing is typed by hand, and the count cannot drift from the deck.
**The one hard part is which deck a printed count describes.** Since 0.8.2 the office cards dealt
depend on the house rule: a game opening on Depots pulls the four Depot cards, one opening on
Whistle Posts keeps them. A single number is wrong for one of those two games. Print the default
game — every district opens on a Depot — and say so where the office table gives its counts.
`deckComposition()` and `SOLITAIRE_DECK_SIZE` are the totals to reconcile against.
**4. Strip the build-status commentary, without deleting true information.** Measured lines:
`rules.md` 5, `home-deck.md` 4, `mainline-deck.md` 3, `quickstart.md` 2, `components.md` 0. The
distinction that matters:
- **Commentary about the project — goes.** `rules.md:6` "this document reports **executable
behaviour** and marks unimplemented material"; `mainline-deck.md:104` "**It is not implemented,
and never has been.**"; the `live` / `dormantSolo` / `unbuilt` vocabulary in `home-deck.md`'s
enhancement table (251, 265, 273, 276) — that is an engineering status column printed for
players.
- **The fact underneath it — stays, in the player's language.** A player does need to know that
the opponent-directed cards are not in the deck, that the Interchange does not sort cars, and
that Facing Point Locks and the Water Column do nothing to a solitaire opponent. Say what happens
at the table ("this card is not dealt", "this card has no effect in a solitaire game"), not what
the code has got round to. A pass that deletes the sentence and the fact together makes the
documents wrong rather than clean.
**Where this leaves #15a.** Nothing here reopens it — the tables are generated and that holds. This
is the editorial half that generation was never going to do.
## Done
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
-153
View File
@@ -1,153 +0,0 @@
# Station Master — Home Deck
**Describes the game as built at v0.8.0.15** (2026-09-20). Previously stamped "v0.4.5", the
prototype rules edition these references were first written against; the filename keeps that stamp
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
rules are for playing each kind of card out of it.
> **Per-card facts live in [`rules/as-built.md`](rules/as-built.md)**, which is GENERATED from
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with
> the game. Read it for every card's name, effect, placement and whether its printed effect actually
> resolves yet. This document is how the deck WORKS; that one is what is in it.
>
> **No card counts appear here, deliberately** (TODO #15a, Jesse's call 2026-08-22): counts move
> with play balance, so a document printing them is answering a question that has a different answer
> after the next retune. Where a count matters it is rendered as a yes/no — whether the deck deals
> the card at all — which is a fact about the design. This page used to print a full counts table
> and it was wrong for a month before anyone noticed.
## The piles
- **Home Office deck** — face down. The pile a Draw comes from.
- **Three Departments** — face-up discard piles. A discard goes onto one, which is precisely so a
rival may take it; a Draw may take the top card of a Department instead of the deck.
- **Salvage Yard** — where a played-out card ends up. An Extra's card goes here after its run.
When the Home Office deck runs out it is rebuilt from the Salvage Yard and **all three Departments
in full**, reshuffled from the seeded stream. A **spent timetabled train** is not collected — its
number is on the timetable and it cannot run twice — but a *discarded* train was never played and
is still runnable, so it comes back.
## Hand and turn
The hand limit is **three**, or four while you hold a Red Flag. You may not end a turn over the
limit: play a card or discard one to a Department. Some cards cannot be discarded at all — an Extra
never can, and a timetabled train cannot when the `discardTimetabled` house rule is off — so a hand
of nothing but those has exactly one way forward, which is to play one.
The opening deal is a house-rule choice made when the game is dealt. The default (`threeRandom`) is
three cards from one shuffled deck; `threeTrackThreeOther` deals three track and three others from
two separately shuffled piles, deliberately over the hand limit, so the first turn is spent choosing
which district you can afford to build.
Drawing is one of the three Local Operations options — see [Rules](StationMaster-Rules-v0.4.5.md)
§4.2. Taking the option lets you draw **and** play or discard within the same turn.
## Track cards
Track cards are ordinary Home Office cards, not a separate personal supply.
- A placed card must **connect to existing rail**: at least one neighbour must join it.
- The **Running Track** is the row from Limit to Limit. A card placed there must carry an east–west
through route, or it breaks the main.
- Placing track **on a Limit sign** extends the Running Track and moves that sign outward. The sign
is a physical card, so it moves rather than being left stranded mid-track.
- **Nothing may be placed outside your Limits** — track, industries and, since v0.8.0.14, Modifiers
too. Your district ends at its sign.
- Curves and turnouts are printed left- or right-handed. A card may be turned 180° but never flipped
over, so its 45° leg never changes diagonal.
- A **turnout may upgrade** an existing straight, or a curve whose arc is exactly the turnout's
diverging arc. Not if the card holds standing cars or an enhancement. Every other occupied square
is unavailable.
- A turnout may be **run through but not stopped on**: it is not Operational Rail, so a Move may not
end there.
## Office cards
Every player begins at a **Whistle Post**, which is not drawn from the deck: one A/D track, no
Porters, no passenger slots, and not a Control Point.
Office cards are **upgrades in strict sequence** — Whistle Post → Depot → Station → Terminal — and
each upgrades the Office in place rather than replacing its card or its attached track. Modifiers
already beside it are preserved. Each tier adds an A/D track, a Porter, and an outbound and inbound
passenger slot; a Depot and above is a Control Point and a Passenger Facility.
An upgrade takes no placement: the Office is where it already is.
## Freight facilities
An industry is placed on a connected straight **stub off the Running Track** — never on the Running
Track itself, and never outside the Limits. A Facility carries its own rails, so placing one places
track.
Each begins with one Laborer and a three-box **MEN | AT | WORK** pipeline. No Office Area may hold a
duplicate industry, or both ends of a lockout pair — a producer and the consumer of the same
commodity cannot be built in one district.
**An industry track holds four cars, like any other card.** It is *not* sized by the industry's box
count. That distinction was a real bug: box count is how much WORK an industry can hold, not how
much RAIL it has, and conflating the two invented a printed siding no industry card carries.
## Facility modifiers
A Modifier sits on an empty square among the **nine spots around its host Facility** — and, since
v0.8.0.14, **inside your Limits**, like everything else. It may not stand in the Running Track row.
One of each kind per Office Area.
**A Modifier adds a BOX, never room for a car.** A Truck Dock beside a Grocer's Warehouse gives it a
second red box — somewhere for one more arriving load to be cleared to — and changes nothing about
how many cars may be spotted there.
**A grant the host cannot use does nothing**, and the game says so rather than pretending. An
outbound bonus beside an inbound-only facility, or the Truck Dock's inbound bonus beside the
outbound-only Packing Sheds, is discarded — the latter leaving that card with no effect at all.
Passenger modifiers beside a **Whistle Post** add Porters but create no outbound slot until the
Office becomes a Passenger Facility; the panel reports that as DORMANT rather than claiming the
facility "only receives", which was wrong in both directions.
## Train cards
Playing a **timetabled** train rolls the seeded D12 and puts its number in the first open timetable
slot at or after the result, wrapping around the 12-slot chart. It then runs at that Stage **every
Day**.
Playing an **Extra** queues it; it is made up when a Crew Tray comes free, and **the player who
played the card chooses where it starts and loads it as they choose** (§7). Where it may start is a
house rule — `divisionPointsOnly`, `ownOffice`, or `anyOffice` (the default) — and the Interchange is
also available, because it is the one Mainline card with a yard. An Extra runs once and its card
goes to the Salvage Yard.
> The v0.4.5 behaviour of launching every Extra eastbound from the Western Division Point was
> replaced in v0.6.2. The number no longer decides an Extra's direction; the start does.
The listed consist is a **maximum, not a minimum**. A train may depart with fewer cars, but not with
more, not of the wrong category, not with a car behind the caboose, and not with the engine buried
among its own cars. A Crew Tray holds four pieces, and a caboose counts toward the four.
A train made up short of what its card calls for is reported as such, with what it wanted and why
the yard could not supply it — see Rules §4.4.
**Per-train consists and printed rules: [`rules/as-built.md`](rules/as-built.md) § Trains.** It
carries the `empties only`, `reefers only`, `drop only` and `pick up empties only` restrictions,
every one of which the engine enforces.
## Enhancements, Mainline modifiers and Maneuvers
- **Enhancements** are played into your district and change what a square does — the **Small Yard**
(re-order a consist for one Move), **Interlocking**, **Yard Office**, **ABS Signals** and the rest.
`as-built.md` marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the
implementation knows.
- **Mainline modifiers** are played onto a Mainline card: the Heavy Grade helpers and Realignment.
See [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md).
- **Maneuvers** are held and spent: **Red Flags** and **Flying Switch** have their own actions.
Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
implement.
## Opponent-directed cards — not dealt
The **Action** and **Space-use** categories are opponent-directed and are **excluded from every
dealt deck**, because their play rules are not implemented. They are catalogued in
`as-built.md` so the composition is on record, and `check` refuses to play one. This is deliberate:
silently accepting them would make a card look playable while doing nothing.
-128
View File
@@ -1,128 +0,0 @@
# Station Master — Mainline Deck
**Describes the game as built at v0.8.0.15** (2026-09-20). Previously stamped "v0.4.5", the
prototype rules edition these references were first written against; the filename keeps that stamp
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a
card does to a train crossing it, and the Home Deck cards played onto one.
> **Per-card numbers live in [`rules/as-built.md`](rules/as-built.md)**, which is GENERATED from
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with
> the game. This document explains how the deck is used; that one is the table of record. Where the
> two ever differ, as-built is right.
## How Mainline cards work
At setup the game lays one Mainline card between each neighbouring pair of Offices and one beyond
each end Office, between it and a Division Point — so a game with *N* players uses **N + 1** cards.
They are **dealt from a finite deck without replacement** (since v0.6.2), so no Division can hold
two of a card printed once. The two Division Points are the fixed ends of the Division and are not
in the deck: `buildDivision` lays them itself.
A card does not belong to either neighbouring Office. It is shared Division.
### Crossing time is REGIONS, not mph
A card is divided into **regions**, and a train advances **one region per Stage**. Crossing time is
therefore `regions − startRegion`, and nothing else. **The printed mph is scenery.**
This is the part most likely to be remembered wrong, because it used to work the other way: mph set
the cost and a Slow train added a Stage to *every* card. It does not. Four things move a train's
start region and nothing else does:
1. **The card's own back region.** The Uncontrolled Siding and the Interchange print a back region
that is not part of the road, so a train running through begins past it.
2. **A card that prints a Fast and a Slow start — and only Hilly does.** A fast train starts one
region along and crosses in 1 Stage; a slow one takes 2. **No other card reads a train's
Fast/Slow rating at all.**
3. **The permanent Heavy Grade modifiers**, each moving a train one region up the hill.
4. **Occupancy**: arriving to find the card occupied can put a train in the siding, a region behind.
No crossing ever takes less than one Stage.
### Traffic
Only **Double Track** lets two trains stand on one card, so a following train is not held behind a
slower one. Every other card holds one train at a time. The **Uncontrolled Siding** is not a passing
card: arriving to find a train already there puts you in the siding a region behind it — you do not
run into it, and it costs you the extra Stage instead.
Whether a following train may enter an occupied card at all is the Superintendent's ruling (§8.1,
Rules §4.5). Getting it wrong is what causes collisions.
## The deck
Ten drawable cards — Plains twice, the other eight once each — plus the two Division Point cards,
which are not drawn.
| Card | Copies |
| --- | ---: |
| Plains | 2 |
| Curves | 1 |
| Hilly | 1 |
| Heavy Grade | 1 |
| Double Track | 1 |
| Uncontrolled Siding | 1 |
| Tunnel | 1 |
| Trestle | 1 |
| Interchange | 1 |
| East / West Division Point | 1 each, not dealt |
The PDF art labels the Interchange "Yard". This reference uses **Interchange** throughout, to keep
it apart from the Division Yard, the Classification Yard, the Yard Office and the Small Yard — five
different things.
**Region counts and entry points per card are in [`rules/as-built.md`](rules/as-built.md).**
## The Interchange, and what it does NOT do
The Interchange prints a car-sorting capability. **It is not implemented, and never has been.** The
one thing the card's `sortsCars` flag actually gates is that an **Extra Train may be made up and
started here** — it is the Mainline card with a yard, which is why §7 allows it (v0.6.2). An Extra
starting here begins in the back region and takes the extra Stage.
Re-ordering a consist is done at a **Small Yard** in an Office Area, for one switching Move. See
Rules §4.3.
> **Corrected 2026-09-20.** Until this pass the card said "Cars may be sorted into any new order
> here" — on the board, in the tooltip a player reads, and in the generated reference. A card
> advertising an action the game will not offer sends a player hunting for a button that does not
> exist. The description now says what the card does.
## Heavy Grade modifiers
Home Office cards, played onto a Mainline card during a player's Draw option.
| Card | Placement and effect |
| --- | --- |
| Brakeman | Heavy Grade only. A **downhill** train starts one region further on. |
| Airbrakes | Heavy Grade only, and **Brakeman must already be on that card**. A downhill train starts one region further again. |
| Helpers | Heavy Grade only. An **uphill** train starts one region further on. |
| Realignment | Only onto an **unoccupied** Mainline card. Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, Trestle → Uncontrolled Siding. No other card may be realigned. |
A Heavy Grade is 3 regions, so it is 3 Stages to climb and 3 to run down before help. Modifiers
never reduce a crossing below one Stage.
## Heavy Grade orientation is settled, not missing
Heavy Grade orientation is **rolled from the seed**, not chosen by a player. **This is a decision,
not a gap.**
The card prints "(Up)" and "Player sets orientation", which assumes the card has an owner. This one
does not: the Division is laid `DP · Mainline · Office · Mainline · … · DP`, so a Heavy Grade always
sits **between two districts**, or beyond an end Division Point beside one — never inside a single
player's district.
Orientation is not cosmetic. Brakeman and Airbrakes help a train running **downhill**, Helpers helps
one running **uphill**, and odd-numbered trains run west while even run east. Turning the card
around decides which modifiers are worth anything and which direction of traffic is favoured, for
the whole game. Handing that to one of two neighbours advantages them over the other, and neither
has a fair claim to it.
**Re-opened and closed again on 2026-08-23**, when giving the choice to the Superintendent was
considered and rejected. Jesse's call: v0.5.0's ruling stands. Rolling from the seed is
deterministic, roughly even (51/49 east/west over 400 games), identical in solitaire and
multiplayer, and keeps setup non-interactive — the game has no setup phase, so the question would
have to interrupt play before the first Local Operations, in the minority of games that deal the
card at all (20% at one player, rising to 50% at four).
@@ -1,16 +1,14 @@
# Station Master — Components and Markers
**Describes the game as built at v0.8.0.15** (2026-09-20). Previously stamped "v0.4.5", the
prototype rules edition these references were first written against; the filename keeps that stamp
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
**Version 0.8.2** · 2026-09-23
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles,
hand state, timetable state and other markers are documented with their cards or in the
[rules book](StationMaster-Rules-v0.4.5.md).
[rules book](rules.md).
The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in
`src/engine/content.ts`. They are physical inventory rather than deck tuning, which is why they are
printed here at all — per-category CARD counts are deliberately not published anywhere (TODO #15a).
printed here at all — per-category CARD counts are not published, because they move with play balance.
## Rolling stock
@@ -33,7 +31,7 @@ The engine is not rolling stock and does not count against the four-car Crew Tra
| Component | Count | Note |
| --- | ---: | --- |
| Crew Trays | players + 3 | Engine and tray are one combined resource; there is no "engine without a tray". |
| Whistle Post cards | 4 | Every player starts on one; it is not drawn from the deck. |
| Office cards | 4 | Every player starts on one. The starting Office is not drawn from the deck. |
| Limits signs | 8 | "2N + spares", so relocating one is never a supply question. |
## The two yards
@@ -60,7 +58,7 @@ player-selectable source. It returns to service only when the Division Yard is e
> here; detraining takes a fresh empty out of the *Division* Yard. Nothing returns a coach to the
> Division Yard except the bare-yard refill — and a yard kept topped up with freight cars returning
> from industries may never run bare. Measured over one three-Day game, every coach was here by the
> middle of Day 2 and stayed. See [Rules](StationMaster-Rules-v0.4.5.md) §4.6.
> middle of Day 2 and stayed. See [Rules](rules.md) §4.6.
## Crew Trays and trains
@@ -75,10 +73,10 @@ following-train clearance decisions, takes the Yard Office and Red Flag question
every round round the table starts with. A Mainline collision is their fault by §10, and costs 5
Revenue. The initial holder is the first player tied for the highest Superintendent setup D12 roll.
It passes at the end of Stages 3, 6, 9 and 12 — every third Stage, at the Supervisor Shift. Since
v0.8.0.13 the handover is **announced on screen and written into the history**: it is the one thing
in the game that changes hands on the clock rather than because somebody did something, so nobody is
watching for it. Note that the Supervisor Shift refreshes every Laborer and Porter *every* Stage
It passes at the end of Stages 3, 6, 9 and 12 — every third Stage, at the Supervisor Shift. The
handover is **announced on screen and written into the history**: it is the one thing in the game
that changes hands on the clock rather than because somebody did something, so nobody is watching
for it. Note that the Supervisor Shift refreshes every Laborer and Porter *every* Stage
while the Fedora moves only every third.
## D12 and seeded randomness
@@ -91,3 +89,30 @@ The D12 is used by the engine for:
- all shuffled deck order and automatic setup selection through the same seeded random stream.
The rules engine uses a deterministic 32-bit seeded random generator. The same numeric seed, player configuration, house rules, and accepted intent sequence reconstruct the same game. A seed by itself is not enough when house rules differ.
## The Office, tier by tier
Every player has one Office card. It is a property of the district rather than a card that is
swapped, so an upgrade raises the numbers in place and leaves anything built beside it alone.
| Office | A/D tracks | Porters | Passengers out / in | Control Point |
| --- | ---: | ---: | --- | :---: |
| Whistle Post | 1 | 0 | 0 / 0 | — |
| Depot | 2 | 1 | 1 / 1 | yes |
| Station | 3 | 2 | 2 / 2 | yes |
| Terminal | 4 | 3 | 3 / 3 | yes |
**A/D tracks are what decide whether an arrival is a collision.** A train pulling in to an Office
with every track occupied collides (§8.3) unless an **Interlocking** holds it out on the Limit
Track. A train held that way takes the first track to free, ahead of anything arriving after it.
**A Whistle Post is not a Passenger Facility.** It has no Porters and no passenger boxes, so nobody
boards or gets off there however well the district is built, and the passenger Modifiers — Waiting
Area, Restaurant, Hotel — cannot be played at one.
**A Control Point divides the Mainline into Subdivisions**, which is what §8.1 reasons about. A
table of Whistle Posts is one Subdivision from end to end; every upgrade splits one in two and buys
the Division capacity.
**Games open on a Depot** unless the table turns on "Players start with Whistle Posts, not Depots"
when the game is created. See the Home deck reference.
+15 -13
View File
@@ -26,16 +26,17 @@ Written to be handed to somebody who is about to play, rather than to somebody b
| Document | What it is |
| --- | --- |
| [`StationMaster-Quickstart.md`](StationMaster-Quickstart.md) | **Start here if you have never played.** The point of the game, how a Stage runs, what is on screen, how you win, a first twenty minutes, and what to report. |
| [`StationMaster-Rules-v0.4.5.md`](StationMaster-Rules-v0.4.5.md) | **The rules in full**, as the engine actually runs them, with a FAQ. |
| [`rules/as-built.md`](rules/as-built.md) | **Every card, GENERATED from `src/engine/content.ts`** and checked by a test, so it cannot disagree with the game. The table of record for per-card facts. |
| [`StationMaster-Home-Deck-v0.4.5.md`](StationMaster-Home-Deck-v0.4.5.md) | How the Home Office deck is dealt, drawn and played out. |
| [`StationMaster-Mainline-Deck-v0.4.5.md`](StationMaster-Mainline-Deck-v0.4.5.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. |
| [`StationMaster-Components-v0.4.5.md`](StationMaster-Components-v0.4.5.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
| [`quickstart.md`](quickstart.md) | **Start here if you have never played.** The point of the game, how a Stage runs, what is on screen, how you win, a first twenty minutes, and what to report. |
| [`rules.md`](rules.md) | **The rules in full**, as the engine actually runs them, with a FAQ. |
| [`home-deck.md`](home-deck.md) | How the Home Office deck is dealt, drawn and played out. |
| [`mainline-deck.md`](mainline-deck.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. |
| [`components.md`](components.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
The `v0.4.5` in four of those filenames is the **prototype rules edition they were first written
against**, not the version they describe — each says at the top which build it is current to. The
names are kept because `src/`, `CHANGELOG.md` and `docs/rules/` cite them.
**None of these carry a version in the filename**, and that is deliberate (Jesse, 2026-09-21): they
are kept current with every release rather than published as editions, so the name is always the
latest and each says at the top which build it describes. Four of them were stamped `v0.4.5` until
v0.8.0.17 — the prototype rules edition they were first written against, never the version they
described — which read as though they documented a build five minor versions old.
## Rules
@@ -43,7 +44,7 @@ names are kept because `src/`, `CHANGELOG.md` and `docs/rules/` cite them.
| --- | --- |
| [`rules/rules-v0.1.md`](rules/rules-v0.1.md) | Faithful markdown transcription of the PDFs. No corrections. The baseline everything diffs against. |
| [`rules/rules-v0.2.md`](rules/rules-v0.2.md) | **The working ruleset.** v0.1 with all ten gaps resolved, each change marked with its gap number. |
| [`rules/card-reference.md`](rules/card-reference.md) | **⚠ SUPERSEDED** — an invented 52-card placeholder, kept for its economy summary and its history. For what is printed on every card, read [`rules/as-built.md`](rules/as-built.md), which is generated from the code. |
| [`rules/card-reference.md`](rules/card-reference.md) | **⚠ SUPERSEDED** — an invented 52-card placeholder, kept for its economy summary and its history. For what is printed on every card, read the generated tables in [`home-deck.md`](home-deck.md) and [`mainline-deck.md`](mainline-deck.md). |
| [`rules/glossary.md`](rules/glossary.md) | Every defined term, alphabetized. |
| [`rules/open-questions.md`](rules/open-questions.md) | All thirteen gaps, each with the options considered, the decision, and the rationale. |
| [`rules/implications.md`](rules/implications.md) | **Read this first.** What the four recovered design files (`Deck cards2.xlsx`, `Mainline Cards.pdf`, `Trains3.pdf`, `tracks.png`) change — and which decisions they supersede. |
@@ -66,7 +67,7 @@ must do.
## Current status
**v0.8.0.15.** Rules formalized, card faces specified, architecture documented, and the game
**v0.8.2.** Rules formalized, card faces specified, architecture documented, and the game
playable **solitaire and multiplayer** in a browser against an authoritative server. See
[`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and [`../TODO.md`](../TODO.md) for
what is open; this section is the shape of the project, not a running tally, because a
@@ -107,8 +108,9 @@ TypeScript natively, so there is no build step during development, which also me
only**: no `enum`, no parameter properties, no namespaces.
Running alongside, and independent of all of it: **print-and-play components.**
[`rules/as-built.md`](rules/as-built.md) carries every card face as the game actually deals it, so
layout and art are the only remaining work before a table playtest — which answers the one question
The generated tables in [`home-deck.md`](home-deck.md) and [`mainline-deck.md`](mainline-deck.md)
carry every card face as the game actually deals it, so layout and art are the only remaining work
before a table playtest — which answers the one question
simulation cannot, whether it is fun.
Run the harness with `node src/sim/harness.ts [games] [length]`.
+398
View File
@@ -0,0 +1,398 @@
# Station Master — Home Deck
**Version 0.8.2** · 2026-09-23
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
rules are for playing each kind of card out of it.
> **The card tables in this document are GENERATED from `src/engine/content.ts`** and checked by a
> test, so they cannot disagree with the game. `npm run build:cards` rebuilds them; do not edit a
> table by hand. The prose around them is how the deck WORKS; the tables are what is in it.
>
> **Card counts are not published.** Counts move with play balance, so a printed count answers a
> question that has a different answer
> after the next retune. Where a count matters it is rendered as a yes/no — whether the deck deals
> the card at all — which is a fact about the design.
## The piles
- **Home Office deck** — face down. The pile a Draw comes from.
- **Three Departments** — face-up discard piles. A discard goes onto one, which is precisely so a
rival may take it; a Draw may take the top card of a Department instead of the deck. **Taking the
last card off a Department refills it immediately** from the Home Office deck (§6.2), so there
are always three piles to choose from; see the Rules.
- **Salvage Yard** — where a played-out card ends up. An Extra's card goes here after its run.
When the Home Office deck runs out it is rebuilt from the Salvage Yard and **all three Departments
in full**, reshuffled from the seeded stream. A **spent timetabled train** is not collected — its
number is on the timetable and it cannot run twice — but a *discarded* train was never played and
is still runnable, so it comes back.
## Hand and turn
The hand limit is **three**, or four while you hold a Red Flag. You may not end a turn over the
limit: play a card or discard one to a Department. Some cards cannot be discarded at all — an Extra
never can, and a timetabled train cannot when the `discardTimetabled` house rule is off — so a hand
of nothing but those has exactly one way forward, which is to play one.
The opening deal is a house-rule choice made when the game is dealt. The default (`threeRandom`) is
three cards from one shuffled deck; `threeTrackThreeOther` deals three track and three others from
two separately shuffled piles, over the hand limit, so the first turn is spent choosing
which district you can afford to build.
Drawing is one of the three Local Operations options — see [Rules](rules.md)
§4.2. Taking the option lets you draw **and** play or discard within the same turn.
## Track cards
Track cards are ordinary Home Office cards, not a separate personal supply.
- A placed card must **connect to existing rail**: at least one neighbour must join it.
- The **Running Track** is the row from Limit to Limit. A card placed there must carry an east–west
through route, or it breaks the main.
- Placing track **on a Limit sign** extends the Running Track and moves that sign outward. The sign
is a physical card, so it moves rather than being left stranded mid-track.
- **Nothing may be placed outside your Limits** — track, industries and Modifiers alike. Your
district ends at its sign.
- Curves and turnouts are printed left- or right-handed. A card may be turned 180° but never flipped
over, so its 45° leg never changes diagonal.
- A **turnout may upgrade** an existing straight, or a curve whose arc is exactly the turnout's
diverging arc. An **industry may be built over a straight** off the Running Track, on the same
principle. Neither is allowed if the card holds standing cars or an Enhancement, and every other
occupied square is unavailable.
- A turnout may be **run through but not stopped on**: it is not Operational Rail, so a Move may not
end there.
<!-- BEGIN CARDS: track -->
| Track | Geometry | Hand | Operational rail | Move cost | Dealt |
| --- | --- | --- | :---: | ---: | :---: |
| Straight track | straight | none | yes | 1 | yes |
| Curved track (right) | curved | right | yes | 1 | yes |
| Curved track (left) | curved | left | yes | 1 | yes |
| Sharp Curved Track (right) | sharpCurved | right | yes | 2 | no |
| Sharp Curved Track (left) | sharpCurved | left | yes | 2 | no |
| Turnout (right) | turnout | right | — | 1 | yes |
| Turnout (left) | turnout | left | — | 1 | yes |
A row marked "no" is a shape the engine understands but the deck does not currently print.
<!-- END CARDS: track -->
## Office cards
Every player begins at a **Whistle Post**, which is not drawn from the deck: one A/D track, no
Porters, no passenger slots, and not a Control Point.
Office cards are **upgrades in strict sequence** — Whistle Post → Depot → Station → Terminal — and
each upgrades the Office in place rather than replacing its card or its attached track. Modifiers
already beside it are preserved. Each tier adds an A/D track, a Porter, and an outbound and inbound
passenger slot; a Depot and above is a Control Point and a Passenger Facility.
An upgrade takes no placement: the Office is where it already is.
<!-- BEGIN CARDS: office -->
Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in
order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**
to Porters rather than one more.
| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |
| --- | :---: | :---: | ---: | ---: | ---: | ---: |
| Whistle Post | — | — | 1 | 0 | 0 | 0 |
| Depot | yes | yes | 2 | 1 | 1 | 1 |
| Station | yes | yes | 3 | 2 | 2 | 2 |
| Terminal | yes | yes | 4 | 3 | 3 | 3 |
Whistle Posts are a fixed supply of 4 outside the deck, and Limits signs a
supply of 8.
<!-- END CARDS: office -->
## Freight facilities
An industry is placed on a connected straight **stub off the Running Track** — never on the Running
Track itself, and never outside the Limits. A Facility carries its own rails, so placing one places
track.
**It may also be built over a straight already laid**, in the same way a turnout may upgrade one, so
rail can go down before the industry that will serve it. Only a plain straight may be built over: a
Facility carries east–west track, so replacing a straight cannot break a neighbour's connection,
while a curve or a turnout carries rails the Facility does not. The square must be clear of standing
cars and carry no Enhancement — the replaced card leaves play.
Each begins with one Laborer and a three-box **MEN | AT | WORK** pipeline. No Office Area may hold a
duplicate industry, or both ends of a lockout pair — a producer and the consumer of the same
commodity cannot be built in one district.
**An industry track holds four cars, like any other card.** It is *not* sized by the industry's box
count. That distinction was a real bug: box count is how much WORK an industry can hold, not how
much RAIL it has, and conflating the two invented a printed siding no industry card carries.
<!-- BEGIN CARDS: facilities -->
Each lists the car types it works, which way its traffic flows, and the industries it may not sit
beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may
build one end of a chain or the other, never both, which is what forces traffic to run between
districts rather than in circles inside one. No two of the same industry may share an Office Area,
and that rule is enforced for every kind rather than repeated in each row.
| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |
| --- | --- | --- | ---: | ---: | ---: | --- |
| Freight House | boxcar | both | 1 | 1 | 1 | Grocer's Warehouse |
| Mine Tipple | hopper | outbound | 1 | 0 | 1 | Power Plant |
| Refinery | tank | outbound | 1 | 0 | 1 | Power Plant |
| Power Plant | hopper, tank | inbound | 0 | 1 | 1 | Mine Tipple, Refinery |
| Packing Sheds | reefer | outbound | 1 | 0 | 1 | Grocer's Warehouse |
| Grocer's Warehouse | boxcar, reefer | inbound | 0 | 1 | 1 | Packing Sheds, Freight House |
<!-- END CARDS: facilities -->
## Facility modifiers
A Modifier sits on an empty square **square against its host Facility — north, south, east or
west**. It may not go on a diagonal: touching at a corner is not touching. It must be **inside your
Limits**, like everything else, and may not stand in the Running Track row. One of each kind per
Office Area.
**A Modifier adds a BOX, never room for a car.** A Truck Dock beside a Grocer's Warehouse gives it a
second red box — somewhere for one more arriving load to be cleared to — and changes nothing about
how many cars may be spotted there.
**A passenger Modifier needs a Passenger Facility.** A Waiting Area, Restaurant or Hotel may not be
played at an Office that is still a **Whistle Post** — a Whistle Post is not a Passenger Facility, so
there is nothing for the card to add to. Upgrade the Office to a **Depot** or better first.
**A grant the host cannot use does nothing**, and the game says so rather than pretending. An
outbound bonus beside an inbound-only facility, or the Truck Dock's inbound bonus beside the
outbound-only Packing Sheds, is discarded — the latter leaving that card with no effect at all.
<!-- BEGIN CARDS: modifiers -->
Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger
Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which
is not one.
| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |
| --- | --- | ---: | ---: | ---: | ---: |
| Waiting area | any Passenger Facility | 1 | — | — | 1 |
| Restaurant | any Passenger Facility | 1 | — | — | 1 |
| Hotel | any Passenger Facility | 1 | — | — | 1 |
| Truck dock | Freight House, Packing Sheds, Grocer's Warehouse | — | 1 | — | — |
| Railroad Express Agency | Freight House | 1 | — | 1 | — |
| Forklifts | Freight House, Packing Sheds | 1 | — | 1 | — |
| Prep Plant | Mine Tipple | 1 | — | 1 | — |
| Coal Piles | Mine Tipple | 1 | — | 1 | — |
| Conveyor Belts | Mine Tipple | 1 | — | 1 | — |
| Pipelines | Refinery | 1 | — | 1 | — |
| Oil Depot | Refinery | 1 | — | 1 | — |
| Viscosity breakers | Refinery | 1 | — | 1 | — |
| Transmission lines | Power Plant | — | — | 1 | — |
| Rotary Dumps | Power Plant | — | — | 1 | — |
| Steam Turbines | Power Plant | — | — | 1 | — |
| Ice House | Packing Sheds, Grocer's Warehouse | 1 | — | 1 | — |
| Local small groceries | Grocer's Warehouse | — | — | 1 | — |
<!-- END CARDS: modifiers -->
## Train cards
Playing a **timetabled** train rolls the seeded D12 and puts its number in the first open timetable
slot at or after the result, wrapping around the 12-slot chart. It then runs at that Stage **every
Day**.
Playing an **Extra** queues it; it is made up when a Crew Tray comes free, and **the player who
played the card chooses where it starts and loads it as they choose** (§7). Where it may start is a
house rule — `divisionPointsOnly`, `ownOffice`, or `anyOffice` (the default) — and the Interchange is
also available, because it is the one Mainline card with a yard. An Extra runs once and its card
goes to the Salvage Yard.
The listed consist is a **maximum, not a minimum**. A train may depart with fewer cars, but not with
more, not of the wrong category, not with a car behind the caboose, and not with the engine buried
among its own cars. A Crew Tray holds four pieces, and a caboose counts toward the four.
A train made up short of what its card calls for is reported as such, with what it wanted and why
the yard could not supply it — see Rules §4.4.
**Per-train consists and printed rules are in the table under § Train cards below.** It
carries the `empties only`, `reefers only`, `drop only` and `pick up empties only` restrictions,
every one of which the engine enforces.
<!-- BEGIN CARDS: trains -->
### Timetabled
| # | Class | Speed | Runs | Consist | Printed rules |
| ---: | --- | --- | --- | --- | --- |
| 1 | Crack Limited | fast | west | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
| 2 | Crack Limited | fast | east | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
| 3 | Express | fast | west | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
| 4 | Express | fast | east | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
| 5 | The Sparrow | fast | west | 3 coaches (3 pieces) | no switching; expedite |
| 6 | The Sparrow | fast | east | 3 coaches (3 pieces) | no switching; expedite |
| 7 | Local | slow | west | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
| 8 | Local | slow | east | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
| 9 | Heavy Freight | slow | west | 3 freight + 1 caboose (4 pieces) | — |
| 10 | Heavy Freight | slow | east | 3 freight + 1 caboose (4 pieces) | — |
| 11 | Drag Freight | slow | west | 2 freight + 1 caboose (3 pieces) | — |
| 12 | Drag Freight | slow | east | 2 freight + 1 caboose (3 pieces) | — |
### Extras
| # | Class | Speed | Runs | Consist | Printed rules |
| ---: | --- | --- | --- | --- | --- |
| X13 | Appleseed Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces), empties only | drop only; *"May drop MTs but not pick up anything."* |
| X14 | Fruit Growers Express | fast | player's choice | 2 reefers + 1 caboose (3 pieces) | expedite; *"Reefers only. May pick up one extra loaded reefer."* |
| X15 | Yard Xfer | slow | player's choice | 2 freight + 1 caboose (3 pieces) | — |
| X16 | Light Engine Move | fast | player's choice | engine only | no switching; *"No cars at all."* |
| X17 | Campaign Train | fast | player's choice | 1 coach (1 piece) | no switching; stop then expedite; stop earns point; must run loaded; *"One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard."* |
| X18 | Circus Train | slow | player's choice | 2 freight + 1 coach + 1 caboose (4 pieces) | no switching; stop earns point; must run loaded; *"One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded."* |
| X19 | Military Train | slow | player's choice | 1 freight + 2 coaches (3 pieces) | no switching; no passenger work; expedite; must run loaded; *"Troops and materiel: runs loaded where the yard can supply it."* |
| X20 | Director's private car | slow | player's choice | 2 freight + 1 coach (3 pieces) | no passenger work |
| X21 | Freight Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces) | — |
| X22 | Pee-Dee | slow | player's choice | 1 caboose (1 piece) | pick up empties only; *"Per-diem train. May only pick up MTs."* |
<!-- END CARDS: trains -->
## Enhancements, Mainline modifiers and Maneuvers
- **Enhancements** are played into your district and change what a square does — the **Small Yard**
(re-order a consist for one Move), **Interlocking**, **Yard Office** and the rest. The table below
marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the implementation
knows.
- **ABS Signals is the exception, and it matters.** It is dealt as an Enhancement but is **not
played into your district**: it goes onto a **Mainline card** — any one of them — and stops
trains there rear-ending each other. It takes no square and is not part of anybody's Office
Area. See [Mainline deck](mainline-deck.md).
- **Mainline modifiers** are played onto a Mainline card: the Heavy Grade helpers and Realignment.
See [Mainline deck](mainline-deck.md).
- **Maneuvers** are held and spent: **Red Flags** and **Flying Switch** have their own actions.
Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
implement.
<!-- BEGIN CARDS: enhancements -->
The column that only the implementation can fill in: **whether the printed effect actually
resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack
but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a
solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription
cannot carry this column, which is the argument for generating the page rather than writing it.
| Enhancement | Placement | Requires | Effect resolves |
| --- | --- | --- | :---: |
| Interlocking | runningTrackStraight | — | **live** |
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
| Yard Office | secondaryTrackStraight | — | **live** |
| Small Yard | secondaryTrackStraight | — | **live** |
| Water Column | runningTrackStraight | — | **dormantSolo** |
| Overpass | onCard | — | **unbuilt** |
| Telegraph | runningTrackStraight | — | **live** |
| Telephone | onCard | telegraph on the same card | **live** |
| Radio | onCard | telephone on the same card | **live** |
| ABS Signals | mainlineCard | — | **live** |
<!-- END CARDS: enhancements -->
## Opponent-directed cards — not dealt
The **Action** and **Space-use** categories are opponent-directed and are **excluded from every
dealt deck**, because their play rules are not implemented. They are catalogued in
the table below so the composition is on record, and `check` refuses to play one. This is deliberate:
silently accepting them would make a card look playable while doing nothing.
<!-- BEGIN CARDS: opponent -->
**None of these is dealt in any deck today.** A card that can only be played at another player
has no legal target in a solitaire game, and a defence with nothing to defend against is as dead
a draw as the attack — so both halves are held out until the attacks are implemented. They are
listed because they are the design, and because what a defence answers is the only record of why
it exists.
### Action cards — opponent-directed
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Derail | a moving train in the Local Phase | That train must stop for the remainder of the turn. | — |
| Broken coupler | a moving train in the Mainline Phase | That train must stop and not move. | — |
| Railroad crossing | any Secondary Track Straight | May not be used as a stop point for switching. May not become an Industry. | — |
| Per Diem inventory | another player | Lose one point per 2 empty cars on Secondary Tracks. | — |
| Demurrage charge | another player | Lose one point per 2 loaded freight cars on Secondary Tracks. | — |
| Customer complaints | another player | Lose one point per 2 coaches in loading boxes. | — |
| Vandalism | another player | A train passing a Hobo Jungle has a boxcar looted (converted to empty). | — |
| Hotbox | another player | A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs. | — |
| Outlawed | another player | A train just arrived may not depart for one turn — the crew’s hours have expired. | — |
### Space-use cards — opponent-directed
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Bean house | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
| Flop house | adjacent to any straight, curve, turnout | Burns tablespace. | — |
| Watertower | adjacent to any straight, turnout on Running Track | Burns tablespace. | — |
| Hobo Jungle | adjacent to any straight, turnout, Limit on Running Track | Burns tablespace. Vandalism can loot a boxcar passing it. | — |
| Section House | adjacent to any straight, curve, turnout | Burns tablespace. | — |
| City blocks | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
| Engine Shops | adjacent to any straight, curve, turnout | Burns tablespace. | — |
| Tenderloin District | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
| Engineer cemetery | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
### Maneuver cards
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Red Flags | any time | A stopped train is prevented from being hit; the approaching train is prevented from moving. | — |
| Flying Switch | any time | Break a cut of cars away from behind the engine and roll them into an industry. | — |
| Poling | any time | TBD in the source. | — |
Mainline modifier cards, for completeness — these ARE dealt:
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Brakeman | a GRADE Mainline card | Faster passage downhill. | — |
| Airbrakes | a GRADE Mainline card | Faster passage downhill. Brakeman must be in effect. | — |
| Helpers | a GRADE Mainline card | Faster passage uphill. | — |
| Realignment | a Mainline card | Convert one Mainline type to another. Not while a train is on it. | — |
| Facing Point Locks | adjacent to Interlocking | Prevents Derail being played on you. | Derail |
<!-- END CARDS: opponent -->
## The Second Section card
**Ordering a Second Section costs the card.** Played on a train that is **due out this Stage**, it
sends a second, identical train out right behind the first. That second train needs a Crew Tray of
its own, so there has to be one free.
It is the one card that deliberately creates the **following-train** situation §8.1 makes the
Superintendent rule on — so playing it is choosing to put that question to them.
One copy in the deck, spent when it is played.
## Trains that pay for stopping
Two Extras pay for **standing still** rather than for running: the **Circus Train** (X18) and the
**Campaign Train** (X17). Their card is worth nothing if it is run like an ordinary train.
Three conditions, all of them required:
- **Stopped.** The train spent a whole Mainline Phase without moving. Switching around inside a
district does not break it — what breaks it is leaving before a Mainline Phase passes.
- **In an Office Area.** Any square in a player's district. Standing on the Mainline or at a
Division Point pays nothing. A **Whistle Post district counts** — the rule is about the area, not
the Office's tier.
- **Fully loaded.** Every car except the caboose is carrying something. A train made up short, or
carrying an empty, earns nothing however long it stands.
**One point per district**, and the point goes to whoever sits in the district it stopped in. A
Circus touring three districts is paid three times; one parked in the same district all game is paid
once. Neither train may switch, so it cannot load itself — it must be made up loaded before it goes.
## The Office you start on
Every district opens on a **Depot** unless the table chooses otherwise. A Depot has two A/D tracks
and is a Passenger Facility, so passengers earn from the first Stage and a second train can stand at
an Office without wrecking.
**"Players start with Whistle Posts, not Depots"** is the harder game, set when the game is created.
A Whistle Post has **one** A/D track and is not a Passenger Facility: no passenger earns anything
until somebody draws and plays a Depot upgrade, and a second train arriving is a collision unless an
Interlocking holds it at the Limits.
The deck follows the choice. Starting on Depots, the **Depot upgrade cards are left out** — an
upgrade must be to the next tier up, so a Depot card at a table that already has Depots is a dead
draw. Station and Terminal are still upgrades and stay in.
## What a train may pick up
Coupling is mandatory, so a train forbidden to pick cars up may not make the move that would pick
them up — there is no "move but leave them". A square the rails reach but the train's card forbids
is shown as blocked, with the rule that forbids it.
**A train may always recover its own caboose.** A caboose at the far end is what makes a train
ready to leave, so a card that says "may drop but not pick up anything" would otherwise let a train
strand itself for good the moment it set its caboose down.
+165
View File
@@ -0,0 +1,165 @@
# Station Master — Mainline Deck
**Version 0.8.2** · 2026-09-23
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a
card does to a train crossing it, and the Home Deck cards played onto one.
> **The card table in this document is GENERATED from `src/engine/content.ts`** and checked by a
> test, so it cannot disagree with the game. `npm run build:cards` rebuilds it; do not edit it by
> hand. The prose explains how the deck is used; the table is the record of what is in it.
## How Mainline cards work
At setup the game lays one Mainline card between each neighbouring pair of Offices and one beyond
each end Office, between it and a Division Point — so a game with *N* players uses **N + 1** cards.
They are **dealt from a finite deck without replacement**, so no Division can hold
two of a card printed once. The two Division Points are the fixed ends of the Division and are not
in the deck: `buildDivision` lays them itself.
A card does not belong to either neighbouring Office. It is shared Division.
### Crossing time is REGIONS, not mph
A card is divided into **regions**, and a train advances **one region per Stage**. Crossing time is
therefore `regions − startRegion`, and nothing else. **The printed mph is scenery.**
This is the part most likely to be remembered wrong. The printed mph does not set
the cost and a Slow train added a Stage to *every* card. It does not. Four things move a train's
start region and nothing else does:
1. **The card's own back region.** The Uncontrolled Siding and the Interchange print a back region
that is not part of the road, so a train running through begins past it.
2. **A card that prints a Fast and a Slow start — and only Hilly does.** A fast train starts one
region along and crosses in 1 Stage; a slow one takes 2. **No other card reads a train's
Fast/Slow rating at all.**
3. **The permanent Heavy Grade modifiers**, each moving a train one region up the hill.
4. **Occupancy**: arriving to find the card occupied can put a train in the siding, a region behind.
No crossing ever takes less than one Stage.
### Traffic
Only **Double Track** lets two trains stand on one card, so a following train is not held behind a
slower one. Every other card holds one train at a time. The **Uncontrolled Siding** is not a passing
card: arriving to find a train already there puts you in the siding a region behind it — you do not
run into it, and it costs you the extra Stage instead.
Whether a following train may enter an occupied card at all is the Superintendent's ruling (§8.1,
Rules §4.5). Getting it wrong is what causes collisions.
## The deck
Ten drawable cards — Plains twice, the other eight once each — plus the two Division Point cards,
which are not drawn.
| Card | Copies |
| --- | ---: |
| Plains | 2 |
| Curves | 1 |
| Hilly | 1 |
| Heavy Grade | 1 |
| Double Track | 1 |
| Uncontrolled Siding | 1 |
| Tunnel | 1 |
| Trestle | 1 |
| Interchange | 1 |
| East / West Division Point | 1 each, not dealt |
The PDF art labels the Interchange "Yard". This reference uses **Interchange** throughout, to keep
it apart from the Division Yard, the Classification Yard, the Yard Office and the Small Yard — five
different things.
**Region counts and entry points per card are in the table under § The deck.**
<!-- BEGIN CARDS: mainline -->
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |
| --- | ---: | ---: | --- | :---: | :---: |
| Plains | 1 | 0 | — | — | — |
| Curves | 2 | 0 | — | — | — |
| Hilly | 2 | 0 | 1 / 0 | — | — |
| Heavy Grade | 3 | 0 | — | — | — |
| Double Track | 1 | 0 | — | yes | — |
| Uncontrolled Siding | 2 | 1 | — | — | — |
| Tunnel | 2 | 0 | — | — | — |
| Trestle | 1 | 0 | — | — | — |
| Interchange | 2 | 1 | — | — | yes |
The Mainline deck is 10 cards; the two Division Points are the fixed ends of
the Division and are not dealt. What each card does, in the words the game uses on screen:
- **Plains** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear.
- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear.
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear.
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · This is the one Mainline card with a yard, so an Extra Train may be made up and started here. Its printed car-sorting is NOT implemented — a consist is re-ordered at a Small Yard in a district.
<!-- END CARDS: mainline -->
## The Interchange, and what it does NOT do
The Interchange prints a car-sorting capability. **It is not implemented, and never has been.** The
one thing the card's `sortsCars` flag actually gates is that an **Extra Train may be made up and
started here** — it is the Mainline card with a yard, which is why §7 allows it. An Extra
starting here begins in the back region and takes the extra Stage.
Re-ordering a consist is done at a **Small Yard** in an Office Area, for one switching Move. See
Rules §4.3.
## Heavy Grade modifiers
Home Office cards, played onto a Mainline card during a player's Draw option.
| Card | Placement and effect |
| --- | --- |
| Brakeman | Heavy Grade only. A **downhill** train starts one region further on. |
| Airbrakes | Heavy Grade only, and **Brakeman must already be on that card**. A downhill train starts one region further again. |
| Helpers | Heavy Grade only. An **uphill** train starts one region further on. |
| Realignment | Only onto an **unoccupied** Mainline card, and only these four conversions: Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, Trestle → Uncontrolled Siding. |
A Heavy Grade is 3 regions, so it is 3 Stages to climb and 3 to run down before help. Modifiers
never reduce a crossing below one Stage.
> **A Realignment can be a card with no legal target.** Only four of the nine Mainline types convert
> at all — Plains, Curves, Uncontrolled Siding and Trestle — so a Division dealt Heavy Grade, Tunnel
> and Double Track has nowhere to play one. A card already converted cannot be converted again, and
> a card with a train on it is refused while the train is there. The card says which four it can
> convert when you look at it in hand.
### ABS Signals — an Enhancement, but it lives out here
**ABS Signals is dealt from the Home Office deck as an Enhancement, and it is the one Enhancement
that is not played into a district.** It goes onto a **Mainline card — any of them**, not only a
grade — and takes no square in anybody's Office Area.
What it does: trains on that card **will not rear-end each other**. A following train is held short
of the one ahead instead of running into it, so the Superintendent has no §8.1 judgment to make and
no collision is scored. It is the only thing besides a Red Flag that prevents a rear-end collision
out on the Mainline, and unlike a Red Flag it stays on the card for the rest of the game.
On the board a card carrying it draws a **signal mast with a lit lamp** at its top-right corner; the
three grade helpers draw as **BRK**, **AIR** and **HLP** beside the card's name. Realignment draws
nothing, because a realigned card simply becomes the card it was converted into.
## Heavy Grade orientation is settled, not missing
Heavy Grade orientation is **rolled from the seed**, not chosen by a player. **This is a decision,
not a gap.**
The card prints "(Up)" and "Player sets orientation", which assumes the card has an owner. This one
does not: the Division is laid `DP · Mainline · Office · Mainline · … · DP`, so a Heavy Grade always
sits **between two districts**, or beyond an end Division Point beside one — never inside a single
player's district.
Orientation is not cosmetic. Brakeman and Airbrakes help a train running **downhill**, Helpers helps
one running **uphill**, and odd-numbered trains run west while even run east. Turning the card
around decides which modifiers are worth anything and which direction of traffic is favoured, for
the whole game. Handing that to one of two neighbours advantages them over the other, and neither
has a fair claim to it.
So the orientation is **rolled from the game's seed**. That is deterministic, roughly even
(51/49 east/west), identical in solitaire and multiplayer, and keeps setup non-interactive: the game
has no setup phase, so asking would mean interrupting play before the first Local Operations.
@@ -1,6 +1,8 @@
# Station Master — Quickstart
**For a tester who has never played. Describes the game as built at v0.8.0.15** (2026-09-20).
**Version 0.8.2** · 2026-09-23
For a player who has never played.
Read this once before you sit down. It is about twenty minutes of reading and will save you an hour
of confusion. The deeper references are listed at the end.
@@ -32,21 +34,26 @@ and do not wait.**
## 2. How you win
The game runs a set number of **Days** — five by default. Each Day is **12 Stages**, which you can
think of as two-hour clock periods from midnight.
A game runs for a set number of **Days**, chosen when the game is created. Each Day is **12
Stages**, which you can think of as two-hour clock periods from midnight.
At the end of the last Day:
At the end of the last Day, the table's **combined Revenue** is checked against a floor. **Miss it
and everybody loses**, however well you personally did — this is the number to watch. The floor is
worked out from the number of players and Days when the game is dealt, and whoever sets the game up
can raise or lower it.
1. **The table's combined Revenue is checked first**, against a floor of **3 × players × Days**. At
three players over five Days that is 45. **Miss it and everybody loses**, however well you
personally did. This is the number to watch.
2. **Co-op:** meeting the floor is the win, together.
3. **Competitive:** meeting the floor puts the game on, and the **highest individual Revenue** wins.
4. **Solitaire:** meet the floor by yourself.
Clear the floor, and how you win depends on the game type:
- **Co-op** — clearing the floor is the win, together.
- **Competitive** — clearing the floor puts the game on, and the **highest individual Revenue**
wins. Everyone is still working towards the same floor first.
- **Cutthroat** — competitive, with the opponent-directed cards in play, so players can act against
each other directly. Those cards are not implemented yet, so this plays as Competitive today.
- **Solitaire** — clear the floor by yourself.
**Collisions can end it early and badly.** Breaching the collision limit stops play at once in a
collective loss — the railroad has been declared unsafe. Default limits are 3 in one Day and 5 in
the game.
collective loss: the railroad has been declared unsafe. The limits are configurable, and can be
switched off entirely.
**Falling short offers another Day** rather than just ending, so a game that misses the floor can be
played on. A game stopped by collisions cannot.
@@ -59,11 +66,11 @@ played on. A game stopped by collisions cannot.
| A passenger getting off at your platform | 1 |
| Completing an outbound freight load | 1 |
| Completing an inbound freight unload | 1 |
| A train completing its run across the whole Division | 0 by default, to every player |
| A train completing its run across the whole Division | paid to every player; usually set to 0 |
Those rates are set when the game is dealt and can be changed. The default means **your score comes
almost entirely from working cars in your own district** — trains passing through pay nothing by
themselves.
Every rate is set when the game is dealt and can be changed. As they usually stand, **your score
comes almost entirely from working cars in your own district** — trains passing through pay nothing
by themselves.
---
@@ -100,7 +107,13 @@ Stage not spent switching.
## 4. The screen
Left column, top to bottom:
**Across the top:** your Revenue, the target, the Day and Stage, collisions, and the game code.
**When other players are acting**, their turns are replayed on your board a step at a time rather
than arriving already rearranged, with a `[N behind]` counter, **Pause** and **Skip**. Your own moves
are not replayed at you — they are already on your screen.
**The main display, on the left**, top to bottom:
- **The Division — west to east.** The shared main line: every Office and the Mainline cards between
them, with trains drawn where they are. **West is always on the left**, and a train's engine is
@@ -109,7 +122,7 @@ Left column, top to bottom:
outside the phases that change it, unless you pin it open.
- **History.** What has happened, most recent first.
Right column:
**The right-hand column:**
- **Your Move** — the buttons. If it is not your turn this is empty, and the board tells you who is
acting.
@@ -121,23 +134,20 @@ Right column:
- **Blocked — why nothing is moving.** *Read this panel.* When something will not work, this is
where the game explains why, in rules terms.
- **Facilities** — the load pipelines at each industry.
Across the top: your Revenue, the target, the Day and Stage, collisions, and the game code.
**When other players are acting**, their turns are replayed on your board a step at a time rather
than arriving already rearranged, with a `[N behind]` counter, **Pause** and **Skip**. Your own moves
are not replayed at you — they are already on your screen.
- **This Game** — the seed or your seat, the game code, the rules this game was dealt under, and
links to this guide and the other references.
---
## 5. Your first twenty minutes
## 5. Start with solitaire
Play a **solitaire** game first. It needs no server and nobody else, and it is the same rules.
1. Open the site, choose **Play solitaire**, accept the defaults, **Deal**.
2. **Stage 1 — Draw.** You start on a **Whistle Post** with almost nothing. Play a track card or two
to extend your Running Track, and get a **Depot** down as soon as one appears: it is the upgrade
that makes you a passenger facility and gives you a second A/D track.
2. **Stage 1 — Draw.** You start on a **Depot** with almost nothing else. Play a track card or two to
extend your Running Track. A Depot already works passengers and has two A/D tracks, so a second
train can stand at your Office without wrecking; a **Station** upgrade, when one appears, buys a
third track and another Porter.
3. **Build one industry** on a stub off the main — not on the Running Track itself, which the game
will not allow.
4. **Play a train card** when you get one. It rolls onto the timetable and then runs at that Stage
@@ -147,8 +157,6 @@ Play a **solitaire** game first. It needs no server and nobody else, and it is t
6. Watch the **Blocked** panel whenever you are stuck. It is usually one missing thing: no empty car
spotted, no loaded car in the yard, a full box, a locked industry track.
Then play a Day or two of multiplayer with bots filling the other seats, to see the table take turns.
---
## 6. Things that surprise new players
@@ -167,33 +175,37 @@ Then play a Day or two of multiplayer with bots filling the other seats, to see
- **Passengers may dry up completely.** Coaches move one way — boarding sends the emptied coach to
the Classification Yard, and it only comes back when the Division Yard is bare, which may never
happen. When it does, the yard panel warns you and passenger trains are made up empty. **This is
the rules working as designed**, not a bug; report how it felt, not that it happened.
the rules working as designed.**
- **Expedited trains leave the same Stage they arrived**, after the Cargo phase. Ordinary ones wait.
---
## 7. What to report
Most useful, in order:
1. **What you expected versus what happened**, with the Day and Stage. "Day 2 Stage 9, train 5 had
no coaches" is worth more than "passengers seem broken".
2. **Save the game** (the **Save replay** button) and send the file. A save is the seed and the moves
made, so it replays exactly and the bug can be looked at directly.
3. **Anything the screen did not explain.** If you had to guess a rule, that is a finding even when
the game was right.
4. **Anything you went looking for and could not find.**
Bugs go to the tracker; anything unclear in this guide is also worth saying.
After playing some solitaire, play a day or two of multiplayer with bots or friends to get a feel
for how players interact. Then you are ready to run a real division.
---
## 8. Where to read more
## 7. Issues / Suggestions
Please do report anything that looks like a bug or an area to be improved.
- **What you expected versus what happened**, with the Day and Stage. "Day 2 Stage 9, train 5 had
no coaches" is worth more than "passengers seem broken".
- **Save the game** (the **Save replay** button) and send the file. A save is the seed and the moves
made, so it replays exactly and the problem can be looked at directly.
- **Anything the screen did not explain.** If you had to guess a rule, that is worth saying even
when the game was right.
- **Anything you went looking for and could not find.**
**A save replays under the rules of the build that opens it.** When a rule changes, a save made
before it may stop part-way — the game says which move it stopped on and leaves your file untouched,
so the build you played on will still finish it.
---
## 8. Documentation / References
| For | Read |
| --- | --- |
| The rules in full, with the FAQ | [Rules](StationMaster-Rules-v0.4.5.md) |
| Every card, generated from the code | [`rules/as-built.md`](rules/as-built.md) |
| How the Home Office deck is dealt and played | [Home deck](StationMaster-Home-Deck-v0.4.5.md) |
| The Mainline cards and what they do to a train | [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md) |
| Rolling stock, yards, trays, the Fedora | [Components](StationMaster-Components-v0.4.5.md) |
| The rules in full, with the FAQ | [Rules](rules.md) |
| How the Home Office deck is dealt and played | [Home deck](home-deck.md) |
| The Mainline cards and what they do to a train | [Mainline deck](mainline-deck.md) |
| Rolling stock, yards, trays, the Fedora | [Components](components.md) |
@@ -1,15 +1,13 @@
# Station Master — Rules
**Describes the game as built at v0.8.0.15** (2026-09-20). Previously stamped "v0.4.5", the
prototype rules edition this reference was first written against; the filename keeps that stamp
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
**Version 0.8.2** · 2026-09-23
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable
behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
Per-card numbers are not repeated here — [`rules/as-built.md`](rules/as-built.md) is generated from
Per-card numbers are not repeated here — the Home deck and Mainline deck references carry tables generated from
`src/engine/content.ts` and is the table of record.
**New to the game? Start with the [Quickstart](StationMaster-Quickstart.md).**
**New to the game? Start with the [Quickstart](quickstart.md).**
## 1. Overview and background
@@ -27,10 +25,10 @@ This book is divided as follows:
6. the implemented multiplayer/engine status; and
7. FAQs and implementation limits.
The companion references are the [Quickstart](StationMaster-Quickstart.md) for a new player,
[Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md), [Home deck](StationMaster-Home-Deck-v0.4.5.md),
[components](StationMaster-Components-v0.4.5.md), and the generated per-card table
[`rules/as-built.md`](rules/as-built.md).
The companion references are the [Quickstart](quickstart.md) for a new player,
[Mainline deck](mainline-deck.md), [Home deck](home-deck.md),
[components](components.md), and the generated per-card table
[Home deck](home-deck.md) and [Mainline deck](mainline-deck.md).
## 2. Definitions
@@ -119,9 +117,7 @@ The browser also stores the current local game and resumes it automatically when
Three modes: **Solitaire**, **Competitive** and **Co-op**. All three are playable.
**Length is a free `days` count**, not a preset. The old `short`/`standard`/`campaign` presets
carried a `target` and were dropped in 2026-08; they survive only as a convenience argument for the
simulation tooling, resolving to 3, 5 and 10 Days. The default is 5.
**Length is a free `days` count**, not a preset — any number of Days may be set. The default is 5.
**How a game ends and who wins:**
@@ -196,7 +192,7 @@ On a player’s Local Operations turn, choose exactly one available option.
**Switch.** Select any Crew Tray currently in that player’s Office Area. It gets six Moves, or five under Reduced Visibility on the listed night Stages. A Move travels any connected distance in one direction and must end on Operational Rail. Reversing is a separate Move. A train may pass through a turnout but cannot stop on it. It may not share or pass through another train except through an Office with a free A/D track.
Standing cars couple automatically when the train reaches them; it may not pass them, and the resulting consist may not exceed four cars. Coupling forward places cars ahead of the engine; coupling while backing places them behind it. Setting out cars does not spend a Move, but the cut must come from an outer end of the consist and may not be left on the Office. A **Small Yard** re-orders a consist for one Move, and since v0.8.0.14 may also place cars **ahead
Standing cars couple automatically when the train reaches them; it may not pass them, and the resulting consist may not exceed four cars. Coupling forward places cars ahead of the engine; coupling while backing places them behind it. Setting out cars does not spend a Move, but the cut must come from an outer end of the consist and may not be left on the Office. A **Small Yard** re-orders a consist for one Move, and may also place cars **ahead
of the engine** — which is how a cut is set up to be shoved into a facing industry. Each option on
the menu shows the train it would build, laid out west to east as the board draws it, and says
whether the result may leave the Office or would be held there. Flying Switch spends one Move to
@@ -204,9 +200,21 @@ roll a tail cut into a connected Freight Facility.
**Draw.** Take one card from the face-down Home Office or the exposed top of one Department pile. During this option, play eligible cards and/or discard cards to Department piles, then finish at the hand limit. Track, facilities, offices, modifiers, enhancements, and train cards have the placement or scheduling rules in the deck references. Mainline modifiers are played from this option as well.
> **A Department that is emptied is refilled at once.** §6.2: *"If any of the Department decks is
> empty, draw a Home Office card and place it in the empty spot."* So taking the last card off a
> pile does not leave a gap — a fresh card is turned face up from the Home Office deck to replace
> it, and there are always three Departments to draw from. The refill happens **only** when the
> draw actually empties the pile; a pile with cards buried under the one taken is not refilled, or
> the Departments would grow without limit and drain the deck into themselves.
>
**Freight Agent.** Make one of these operations, then the turn ends: stock one green outbound box from a matching loaded Division Yard car; clear one red inbound box to the Classification Yard; unjam one outbound, inbound, or MEN | AT | WORK load to the Classification Yard; or explicitly end without acting. Freight can be stocked only when an unclaimed empty matching car is already spotted at that industry. Passengers may wait in a green Office box without a train present.
Implementation note, still true at v0.8.0.15: `card.discard` is accepted during Local Operations
> **A car cleared from a red Inbound box comes back empty.** Whatever was in it has arrived — the
> passengers are out of the station, or the load is in the industry — and the Revenue for it was
> paid on arrival, not on clearing. What returns to the Classification Yard is the *car*, in the
> common supply and carrying nothing.
Implementation note: `card.discard` is accepted during Local Operations
without checking that the Draw option was chosen — unlike `card.play`, which does check. This is an
implementation quirk rather than a fourth published turn option.
@@ -243,7 +251,7 @@ Mainline movement is automatic and processes lower train numbers first; a timeta
**Crossing time is the card's REGIONS**, one per Stage — not its printed mph, which is scenery, and
not the train's Fast/Slow rating, which only **Hilly** reads. See the
[Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md) reference for what moves a train's entry
[Mainline deck](mainline-deck.md) reference for what moves a train's entry
point. On a normal card, a train must check the entire next Subdivision before entering it:
- an opposing train normally blocks entry;
@@ -278,10 +286,9 @@ Car permit no passenger work. A Whistle Post has no Porters.
> could board or detrain anywhere for the rest of the game, while trains whose cards call for coaches
> were made up empty.
>
> **This is the rules working as printed and the ruling is that it stands** (Jesse, 2026-09-17, the
> same ruling Gitea#2 got: running out is part of the game). What changed is that the game now says
> it — the yard panel warns while the shortage lasts, and a train made up short reports why. Whether
> the ratchet should be broken is open as TODO #108, to be decided on a second game's evidence.
> **This is the rules working as printed: running out of cars is part of the game.** The yard panel
> warns while the shortage lasts, and a train made up short reports what it wanted and why none is
> coming.
### 4.7 Freight work
@@ -318,7 +325,7 @@ score has to come principally from passenger and freight work.
### 6.1 What the engine supports
The rules engine has always supported multiple named players; since v0.7 the lobby, server and
The rules engine supports multiple named players; the lobby, server and
client around it are delivered too, so this section now describes a game people actually play. The
engine has player, seat, score, Office Area, Division, phase-order, co-op and competitive-mode data
for multiple named players. It deals each player a hand, creates one Office Area per seat, starts acting order at the Superintendent and proceeds eastward, and models the following multiplayer-specific outcomes:
@@ -407,16 +414,15 @@ train is still present for that Stage's Load/Unload phase.
### Can I choose the direction of an Extra or a Heavy Grade?
**An Extra, yes** — the player who played the card chooses where it starts and which way it runs, and
loads it as they choose (v0.6.2). **A Heavy Grade, no**: orientation is rolled from the seed. That is
loads it as they choose. **A Heavy Grade, no**: orientation is rolled from the seed. That is
a decision rather than a gap — the card sits between two districts and belongs to neither, so handing
the choice to one neighbour would advantage them permanently. See the Mainline deck reference.
### Can I use an Interchange to reorder a train?
**No, and the card used to claim otherwise.** Its printed car-sorting has never been implemented; the
description was corrected on 2026-09-20 to stop advertising it. What the Interchange actually offers
is the one Mainline card with a yard, so an **Extra may be made up and started there**. Re-ordering a
consist is done at a **Small Yard** in a district.
**No.** Its printed car-sorting is not implemented. What the Interchange offers is the one Mainline
card with a yard, so an **Extra may be made up and started there**. Re-ordering a consist is done at
a **Small Yard** in a district.
### Can I play attack cards on another player?
@@ -426,8 +432,8 @@ is rejected.
### Is multiplayer playable?
**Yes.** Lobby, game codes, seating, bots, an authoritative server that survives restarts, per-seat
reconnection and a replayed view of everyone else's turns are all delivered — see §3.5 and §6. The
opponent-directed cards remain unimplemented in every mode, so there are still no card attacks.
reconnection and a replayed view of everyone else's turns — see §3.5 and §6. The opponent-directed
cards are unimplemented in every mode, so there are no card attacks.
### Why did my passenger train arrive with no coaches?
@@ -440,3 +446,28 @@ has happened, and a train made up short says so in the log.
Because that train is **already made up**, so every re-order on offer would break it — most often by
moving the caboose off the rear, which §8.2 will not let a train depart with. If the train is *not*
currently fit to run, at least one option will be marked "MADE UP, ready to leave".
### Will an old save still replay?
Not always. A save is a seed and the list of moves, replayed through the rules of whatever build
opens it — so a change that makes a once-legal move illegal stops the replay at that move. The game
names the move and leaves the file untouched; the build the game was played on will still finish it.
## §8.1 in practice — what holds a train at the end of the line
A train is held, released or put to the Superintendent by what is in the **Subdivision** ahead of
it: the run of Mainline cards between two Control Points. Every Office that is not a Control Point
lies inside one, so a table of Whistle Posts is a single Subdivision from end to end.
- A train coming **towards** it is an absolute bar. The train holds, and the history says which
train is coming and that there is no Control Point between them to pass at.
- A train going the **same way** is the Superintendent's ruling.
- A card printing **"trains may pass"** excuses only what is standing on **that card**. It does not
clear the rest of the Subdivision.
- A train **standing at an Office with no free A/D track** occupies the Subdivision too: it is about
to re-enter and there is nowhere for the two to pass. An Office with a track still free does not
hold anyone up.
**An Office never holds more trains than it has A/D tracks.** A train the Interlocking is holding at
the Limits takes the first track to free, ahead of anything arriving afterwards — and the train that
arrives to find it taken is held at its own Limits, or collides if there is no Interlocking.
-260
View File
@@ -1,260 +0,0 @@
# Station Master — the cards as built
> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by
> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file
> and the code disagree.
This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything
else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)
transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in
them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and
[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the
reasoning; read this for the numbers.
The engine instantiates from the same constants this is emitted from, so a disagreement between
this page and the game is a bug in the generator, not a stale table.
**No card counts appear here, deliberately** (TODO #15a, Jesse 2026-08-22): the counts move with
play balance, so a document that prints them is answering a question that will have a different
answer next retune. Where a count matters it is a yes/no — whether the deck deals the card at
all — which is a fact about the design rather than about the current tuning.
---
## Trains
12 timetabled and 10 Extras, 22 in all.
Odd numbers run west, even run east; a pair shares a class and is the same card face in two
directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no
train with a caboose carries more than three revenue cars.
### Timetabled
| # | Class | Speed | Runs | Consist | Printed rules |
| ---: | --- | --- | --- | --- | --- |
| 1 | Crack Limited | fast | west | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
| 2 | Crack Limited | fast | east | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
| 3 | Express | fast | west | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
| 4 | Express | fast | east | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
| 5 | The Sparrow | fast | west | 3 coaches (3 pieces) | no switching; expedite |
| 6 | The Sparrow | fast | east | 3 coaches (3 pieces) | no switching; expedite |
| 7 | Local | slow | west | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
| 8 | Local | slow | east | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
| 9 | Heavy Freight | slow | west | 3 freight + 1 caboose (4 pieces) | — |
| 10 | Heavy Freight | slow | east | 3 freight + 1 caboose (4 pieces) | — |
| 11 | Drag Freight | slow | west | 2 freight + 1 caboose (3 pieces) | — |
| 12 | Drag Freight | slow | east | 2 freight + 1 caboose (3 pieces) | — |
### Extras
| # | Class | Speed | Runs | Consist | Printed rules |
| ---: | --- | --- | --- | --- | --- |
| X13 | Appleseed Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces), empties only | drop only; *"May drop MTs but not pick up anything."* |
| X14 | Fruit Growers Express | fast | player's choice | 2 reefers + 1 caboose (3 pieces) | expedite; *"Reefers only. May pick up one extra loaded reefer."* |
| X15 | Yard Xfer | slow | player's choice | 2 freight + 1 caboose (3 pieces) | — |
| X16 | Light Engine Move | fast | player's choice | engine only | no switching; *"No cars at all."* |
| X17 | Campaign Train | fast | player's choice | 1 coach (1 piece) | no switching; stop then expedite; stop earns point; must run loaded; *"One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard."* |
| X18 | Circus Train | slow | player's choice | 2 freight + 1 coach + 1 caboose (4 pieces) | no switching; stop earns point; must run loaded; *"One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded."* |
| X19 | Military Train | slow | player's choice | 1 freight + 2 coaches (3 pieces) | no switching; no passenger work; expedite; must run loaded; *"Troops and materiel: runs loaded where the yard can supply it."* |
| X20 | Director's private car | slow | player's choice | 2 freight + 1 coach (3 pieces) | no passenger work |
| X21 | Freight Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces) | — |
| X22 | Pee-Dee | slow | player's choice | 1 caboose (1 piece) | pick up empties only; *"Per-diem train. May only pick up MTs."* |
---
## Mainline cards
A card is divided into **regions**, and a train advances one region per Stage — so the regions a
card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast
train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than
part of the road all change the entry point rather than the card's length.
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |
| --- | ---: | ---: | --- | :---: | :---: |
| Plains | 1 | 0 | — | — | — |
| Curves | 2 | 0 | — | — | — |
| Hilly | 2 | 0 | 1 / 0 | — | — |
| Heavy Grade | 3 | 0 | — | — | — |
| Double Track | 1 | 0 | — | yes | — |
| Uncontrolled Siding | 2 | 1 | — | — | — |
| Tunnel | 2 | 0 | — | — | — |
| Trestle | 1 | 0 | — | — | — |
| Interchange | 2 | 1 | — | — | yes |
The Mainline deck is 10 cards; the two Division Points are the fixed ends of
the Division and are not dealt. What each card does, in the words the game uses on screen:
- **Plains** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear.
- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear.
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear.
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · This is the one Mainline card with a yard, so an Extra Train may be made up and started here. Its printed car-sorting is NOT implemented — a consist is re-ordered at a Small Yard in a district.
---
## Office cards
Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in
order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**
to Porters rather than one more.
| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |
| --- | :---: | :---: | ---: | ---: | ---: | ---: |
| Whistle Post | — | — | 1 | 0 | 0 | 0 |
| Depot | yes | yes | 2 | 1 | 1 | 1 |
| Station | yes | yes | 3 | 2 | 2 | 2 |
| Terminal | yes | yes | 4 | 3 | 3 | 3 |
Whistle Posts are a fixed supply of 4 outside the deck, and Limits signs a
supply of 8.
---
## Freight facilities
Each lists the car types it works, which way its traffic flows, and the industries it may not sit
beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may
build one end of a chain or the other, never both, which is what forces traffic to run between
districts rather than in circles inside one. No two of the same industry may share an Office Area,
and that rule is enforced for every kind rather than repeated in each row.
| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |
| --- | --- | --- | ---: | ---: | ---: | --- |
| Freight House | boxcar | both | 1 | 1 | 1 | Grocer's Warehouse |
| Mine Tipple | hopper | outbound | 1 | 0 | 1 | Power Plant |
| Refinery | tank | outbound | 1 | 0 | 1 | Power Plant |
| Power Plant | hopper, tank | inbound | 0 | 1 | 1 | Mine Tipple, Refinery |
| Packing Sheds | reefer | outbound | 1 | 0 | 1 | Grocer's Warehouse |
| Grocer's Warehouse | boxcar, reefer | inbound | 0 | 1 | 1 | Packing Sheds, Freight House |
---
## Modifier cards
Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger
Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which
is not one.
| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |
| --- | --- | ---: | ---: | ---: | ---: |
| Waiting area | any Passenger Facility | 1 | — | — | 1 |
| Restaurant | any Passenger Facility | 1 | — | — | 1 |
| Hotel | any Passenger Facility | 1 | — | — | 1 |
| Truck dock | Freight House, Packing Sheds, Grocer's Warehouse | — | 1 | — | — |
| Railroad Express Agency | Freight House | 1 | — | 1 | — |
| Forklifts | Freight House, Packing Sheds | 1 | — | 1 | — |
| Prep Plant | Mine Tipple | 1 | — | 1 | — |
| Coal Piles | Mine Tipple | 1 | — | 1 | — |
| Conveyor Belts | Mine Tipple | 1 | — | 1 | — |
| Pipelines | Refinery | 1 | — | 1 | — |
| Oil Depot | Refinery | 1 | — | 1 | — |
| Viscosity breakers | Refinery | 1 | — | 1 | — |
| Transmission lines | Power Plant | — | — | 1 | — |
| Rotary Dumps | Power Plant | — | — | 1 | — |
| Steam Turbines | Power Plant | — | — | 1 | — |
| Ice House | Packing Sheds, Grocer's Warehouse | 1 | — | 1 | — |
| Local small groceries | Grocer's Warehouse | — | — | 1 | — |
---
## Track cards
Track is **in the Home Office deck** and is drawn and played like any other card — not a separate
per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout
may be run through but not stopped on.
| Track | Geometry | Hand | Operational rail | Move cost | Dealt |
| --- | --- | --- | :---: | ---: | :---: |
| Straight track | straight | none | yes | 1 | yes |
| Curved track (right) | curved | right | yes | 1 | yes |
| Curved track (left) | curved | left | yes | 1 | yes |
| Sharp Curved Track (right) | sharpCurved | right | yes | 2 | no |
| Sharp Curved Track (left) | sharpCurved | left | yes | 2 | no |
| Turnout (right) | turnout | right | — | 1 | yes |
| Turnout (left) | turnout | left | — | 1 | yes |
A row marked "no" is a shape the engine understands but the deck does not currently print.
---
## Enhancements
The column that only the implementation can fill in: **whether the printed effect actually
resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack
but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a
solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription
cannot carry this column, which is the argument for generating the page rather than writing it.
| Enhancement | Placement | Requires | Effect resolves |
| --- | --- | --- | :---: |
| Interlocking | runningTrackStraight | — | **live** |
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
| Yard office | secondaryTrackStraight | — | **live** |
| Small yard | secondaryTrackStraight | — | **live** |
| Water column | runningTrackStraight | — | **dormantSolo** |
| Overpass | onCard | — | **unbuilt** |
| Telegraph | runningTrackStraight | — | **live** |
| Telephone | onCard | telegraph on the same card | **live** |
| Radio | onCard | telephone on the same card | **live** |
| ABS Signals | mainlineCard | — | **live** |
---
## Opponent-directed cards, and what answers them
**None of these is dealt in any deck today.** A card that can only be played at another player
has no legal target in a solitaire game, and a defence with nothing to defend against is as dead
a draw as the attack — so both halves are held out until the attacks are implemented. They are
listed because they are the design, and because what a defence answers is the only record of why
it exists.
### Action cards — opponent-directed
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Derail | a moving train in the Local Phase | That train must stop for the remainder of the turn. | — |
| Broken coupler | a moving train in the Mainline Phase | That train must stop and not move. | — |
| Railroad crossing | any Secondary Track Straight | May not be used as a stop point for switching. May not become an Industry. | — |
| Per Diem inventory | another player | Lose one point per 2 empty cars on Secondary Tracks. | — |
| Demurrage charge | another player | Lose one point per 2 loaded freight cars on Secondary Tracks. | — |
| Customer complaints | another player | Lose one point per 2 coaches in loading boxes. | — |
| Vandalism | another player | A train passing a Hobo Jungle has a boxcar looted (converted to empty). | — |
| Hotbox | another player | A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs. | — |
| Outlawed | another player | A train just arrived may not depart for one turn — the crew’s hours have expired. | — |
### Space-use cards — opponent-directed
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Bean house | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
| Flop house | adjacent to any straight, curve, turnout | Burns tablespace. | — |
| Watertower | adjacent to any straight, turnout on Running Track | Burns tablespace. | — |
| Hobo Jungle | adjacent to any straight, turnout, Limit on Running Track | Burns tablespace. Vandalism can loot a boxcar passing it. | — |
| Section House | adjacent to any straight, curve, turnout | Burns tablespace. | — |
| City blocks | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
| Engine Shops | adjacent to any straight, curve, turnout | Burns tablespace. | — |
| Tenderloin District | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
| Engineer cemetery | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
### Maneuver cards
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Red Flags | any time | A stopped train is prevented from being hit; the approaching train is prevented from moving. | — |
| Flying Switch | any time | Break a cut of cars away from behind the engine and roll them into an industry. | — |
| Poling | any time | TBD in the source. | — |
Mainline modifier cards, for completeness — these ARE dealt:
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Brakeman | a GRADE Mainline card | Faster passage downhill. | — |
| Airbrakes | a GRADE Mainline card | Faster passage downhill. Brakeman must be in effect. | — |
| Helpers | a GRADE Mainline card | Faster passage uphill. | — |
| Realignment | a Mainline card | Convert one Mainline type to another. Not while a train is on it. | — |
| Facing Point Locks | adjacent to Interlocking | Prevents Derail being played on you. | Derail |
+1 -1
View File
@@ -68,7 +68,7 @@ read "Both" for both of them, and that was the *other half* of the same mistaken
House" named those two, §9.3 had to be describing them, so they had to be two-way. Once the Freight
House is its own card the argument evaporates, and playtesting settled it — "Grocer's Warehouse
should be receive only, does not ship anything out"; "Refinery: only ships out tanks, does not
receive anything" (Jesse). `StationMaster-Home-Deck-v0.4.5.md` prints both that way, and the modifier
receive anything" (Jesse). `home-deck.md` prints both that way, and the modifier
set agrees: all three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1
outbound**, which would be an odd card set for a facility that receives half the time.
<!-- TODO v0.5.0: Mine Tipple, Produce Shed and Power Plant above (3/3/4, 2/2/3, 3/3/4) were NOT
+3 -3
View File
@@ -142,7 +142,7 @@ asked a third time:
rising to 50% at four players. A pre-game interrupt for a rule four games in five never see.
The docs were the actual defect. `README.md` still listed it among three open rules questions (all
three closed in v0.5.0) and `StationMaster-Mainline-Deck-v0.4.5.md` still said "the implementation
three closed in v0.5.0) and `mainline-deck.md` still said "the implementation
needs a player-selection step to match the card". Both now say settled, and why.
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
@@ -555,7 +555,7 @@ Hotel) are what grow them.
now carries **2** coaches and The Sparrow **3**. The table above is left as `Trains3.pdf` prints it,
because that is what this section is for — what the design SAYS. What the game plays is
`src/engine/content.ts`, with the per-card table in
[`../StationMaster-Home-Deck-v0.4.5.md`](../StationMaster-Home-Deck-v0.4.5.md).
[`../home-deck.md`](../home-deck.md).
### Extras (X13–X22) — ten distinct trains, not four generic ones
@@ -910,7 +910,7 @@ any setting** — being a place an Extra can start is part of what upgrading buy
- **The Mainline cards were rolled, not dealt.** `buildDivision` drew uniformly from the nine card
TYPES **with replacement**, so a Division could be dealt two Interchanges or two Tunnels, and
Plains — printed twice in the deck — carried the same weight as cards printed once.
`docs/StationMaster-Mainline-Deck-v0.4.5.md` had already flagged the mismatch as needing
`docs/mainline-deck.md` had already flagged the mismatch as needing
correction; "an Extra may start at the Interchange if one is on the board" is what forced it, since
that only reads as a rule if the board holds at most one. Now dealt from the printed ten-card deck
without replacement.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.8.0.15",
"version": "0.8.2",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+58 -99
View File
@@ -1,31 +1,22 @@
/**
* Generate `docs/rules/as-built.md` — what the cards say, as the code actually has them.
* Generate the card tables inside `docs/home-deck.md` and `docs/mainline-deck.md`.
*
* WHY THIS IS GENERATED RATHER THAN WRITTEN.
* WHY GENERATED RATHER THAN WRITTEN. Nothing fails when a hand-written table falls behind a
* constant, so the tables are emitted from the same exported catalogues the engine instantiates
* from, and `test/card-reference.test.ts` re-runs this generator and asserts the checked-in docs
* match. Change a card face and the suite goes red until the docs are regenerated.
*
* Every other file in `docs/rules/` is a historical record and says so: `rules-v0.1.md` is a
* faithful transcription of the prototype PDFs, `open-questions.md` is the gap tracker,
* `rules-v0.2.md` and `card-reference.md` both carry SUPERSEDED banners. None of them describes the
* game as built, and none of them should be edited to — the record is worth more intact than
* patched.
* EACH TABLE LANDS UNDER THE SECTION IT BELONGS TO, between a marker pair the deck documents carry:
*
* So there was no current reference at all, and `content.ts` spent several releases pointing at
* `card-reference.md` as "the place that now carries what the cards say" while that file's own
* banner said "do not use its numbers". A reader following the code's advice landed on the v0.4.5
* deck: twelve numbered trains, "3 / 4 Mail-Express, 3 coaches", against a `content.ts` whose train
* 3 is the Express with two freight cars and a per-location freight rule.
* <!-- BEGIN CARDS: track --> …generated… <!-- END CARDS: track -->
*
* A HAND-WRITTEN REPLACEMENT WOULD HAVE DRIFTED THE SAME WAY, and for the same reason: nothing
* fails when a table falls behind a constant. So the reference is emitted from the same exported
* catalogues the engine instantiates from, and `test/card-reference.test.ts` re-runs this generator
* and asserts the checked-in file matches byte for byte. Change a card face and the suite goes red
* until the doc is regenerated — which is the only mechanism this project has found that keeps a
* document honest.
* Everything around the markers is hand-written and is never touched. Only the Mainline card table
* goes to `mainline-deck.md`; every other table belongs to the Home Office deck.
*
* `npm run build:cards` writes it. Nothing at runtime reads it; it is for people.
* `npm run build:cards` writes them. Nothing at runtime reads them; they are for people.
*/
import { writeFileSync } from 'node:fs';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -70,40 +61,17 @@ const trainRow = (t: TrainProfile): string =>
`| ${t.isExtra ? 'X' : ''}${t.number} | ${t.name} | ${t.speed} | ` +
`${t.direction === 'playerChoice' ? "player's choice" : t.direction} | ${consistOf(t.consist)} | ${rulesOf(t.rules)} |`;
const lines: string[] = [];
const w = (s = ''): void => void lines.push(s);
/** Generated blocks, keyed by the marker name the deck documents wrap them in. */
const blocks = new Map<string, string[]>();
let current: string[] = [];
/** Start a new generated block. Everything `w` writes lands here until the next `section`. */
const section = (key: string): void => {
current = [];
blocks.set(key, current);
};
const w = (s = ''): void => void current.push(s);
w('# Station Master — the cards as built');
w();
w('> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by');
w('> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file');
w('> and the code disagree.');
w();
w('This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything');
w('else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)');
w('transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in');
w('them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and');
w('[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the');
w('reasoning; read this for the numbers.');
w();
w('The engine instantiates from the same constants this is emitted from, so a disagreement between');
w('this page and the game is a bug in the generator, not a stale table.');
w();
w('**No card counts appear here, deliberately** (TODO #15a, Jesse 2026-08-22): the counts move with');
w('play balance, so a document that prints them is answering a question that will have a different');
w('answer next retune. Where a count matters it is a yes/no — whether the deck deals the card at');
w('all — which is a fact about the design rather than about the current tuning.');
w();
w('---');
w();
w('## Trains');
w();
w(`${TIMETABLED_TRAINS.length} timetabled and ${EXTRA_TRAINS.length} Extras, ${ALL_TRAINS.length} in all.`);
w('Odd numbers run west, even run east; a pair shares a class and is the same card face in two');
w('directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no');
w('train with a caboose carries more than three revenue cars.');
w();
section('trains');
w('### Timetabled');
w();
w('| # | Class | Speed | Runs | Consist | Printed rules |');
@@ -116,16 +84,7 @@ w('| # | Class | Speed | Runs | Consist | Printed rules |');
w('| ---: | --- | --- | --- | --- | --- |');
for (const t of EXTRA_TRAINS) w(trainRow(t));
w();
w('---');
w();
w('## Mainline cards');
w();
w('A card is divided into **regions**, and a train advances one region per Stage — so the regions a');
w('card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast');
w('train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than');
w('part of the road all change the entry point rather than the card\'s length.');
w();
section('mainline');
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |');
w('| --- | ---: | ---: | --- | :---: | :---: |');
for (const m of MAINLINE_PROFILES) {
@@ -138,11 +97,7 @@ w('the Division and are not dealt. What each card does, in the words the game us
w();
for (const m of MAINLINE_PROFILES) w(`- **${m.name}** — ${mainlineDescription(m.kind)}`);
w();
w('---');
w();
w('## Office cards');
w();
section('office');
w('Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in');
w('order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**');
w('to Porters rather than one more.');
@@ -157,11 +112,7 @@ w();
w(`Whistle Posts are a fixed supply of ${WHISTLE_POST_SUPPLY} outside the deck, and Limits signs a`);
w(`supply of ${LIMITS_SUPPLY}.`);
w();
w('---');
w();
w('## Freight facilities');
w();
section('facilities');
w('Each lists the car types it works, which way its traffic flows, and the industries it may not sit');
w('beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may');
w('build one end of a chain or the other, never both, which is what forces traffic to run between');
@@ -177,11 +128,7 @@ for (const f of INDUSTRY_PROFILES) {
w(`| ${f.name} | ${f.carTypes.join(', ')} | ${f.flow} | ${f.baseOut} | ${f.baseIn} | ${f.baseLoaders} | ${lo} |`);
}
w();
w('---');
w();
w('## Modifier cards');
w();
section('modifiers');
w('Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger');
w('Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which');
w('is not one.');
@@ -195,15 +142,7 @@ for (const m of MODIFIER_PROFILES) {
w(`| ${m.name} | ${hosts} | ${m.addOut || '—'} | ${m.addIn || '—'} | ${m.addLoaders || '—'} | ${m.addPorters || '—'} |`);
}
w();
w('---');
w();
w('## Track cards');
w();
w('Track is **in the Home Office deck** and is drawn and played like any other card — not a separate');
w('per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout');
w('may be run through but not stopped on.');
w();
section('track');
w('| Track | Geometry | Hand | Operational rail | Move cost | Dealt |');
w('| --- | --- | --- | :---: | ---: | :---: |');
for (const t of TRACK_CARDS) {
@@ -212,11 +151,7 @@ for (const t of TRACK_CARDS) {
w();
w('A row marked "no" is a shape the engine understands but the deck does not currently print.');
w();
w('---');
w();
w('## Enhancements');
w();
section('enhancements');
w('The column that only the implementation can fill in: **whether the printed effect actually');
w('resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack');
w('but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a');
@@ -235,11 +170,7 @@ for (const r of ENHANCEMENT_RULES) {
w(`| ${card?.name ?? r.key} | ${r.placement} | ${needs} | **${r.effect}** |`);
}
w();
w('---');
w();
w('## Opponent-directed cards, and what answers them');
w();
section('opponent');
w('**None of these is dealt in any deck today.** A card that can only be played at another player');
w('has no legal target in a solitaire game, and a defence with nothing to defend against is as dead');
w('a draw as the attack — so both halves are held out until the attacks are implemented. They are');
@@ -264,5 +195,33 @@ w('| --- | --- | --- | --- |');
for (const c of MAINLINE_MODIFIER_CARDS) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
w();
writeFileSync(join(root, 'docs/rules/as-built.md'), `${lines.join('\n')}\n`);
console.log(`built -> docs/rules/as-built.md (${lines.length} lines)`);
/**
* Splice each block into its document, between the markers that name it.
*
* Strict on purpose: a block with nowhere to go, or a marker pair with no block, is a mistake that
* would otherwise show up as a silently missing table. Both throw.
*/
const WHERE: Record<string, string> = {
mainline: 'docs/mainline-deck.md',
};
const DEFAULT_DOC = 'docs/home-deck.md';
const edited = new Map<string, string>();
for (const [key, body] of blocks) {
const rel = WHERE[key] ?? DEFAULT_DOC;
const text = edited.get(rel) ?? readFileSync(join(root, rel), 'utf8');
const begin = `<!-- BEGIN CARDS: ${key} -->`;
const finish = `<!-- END CARDS: ${key} -->`;
const from = text.indexOf(begin);
const to = text.indexOf(finish);
if (from < 0 || to < 0) throw new Error(`${rel} has no markers for "${key}" — expected ${begin} … ${finish}`);
if (to < from) throw new Error(`${rel}: markers for "${key}" are the wrong way round`);
// Trailing blank lines are trimmed so the block sits the same way however the section ends.
const inner = body.join('\n').replace(/\n+$/, '');
edited.set(rel, `${text.slice(0, from + begin.length)}\n${inner}\n${text.slice(to)}`);
}
for (const [rel, text] of edited) {
writeFileSync(join(root, rel), text);
console.log(`built -> ${rel}`);
}
+66
View File
@@ -14,6 +14,9 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync,
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { DOC_PAGES, docPage } from './docs-page.ts';
import { renderMarkdown } from './markdown.ts';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
/**
* Overridable so a test can point the build at an isolated directory instead of the shared
@@ -188,6 +191,69 @@ if (existsSync(imageSrc)) {
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
}
/**
* The player-facing documentation, RENDERED and published beside the game.
*
* MARKDOWN IS STILL THE ONE COPY. `docs/*.md` is what is written and reviewed; this turns it into
* a page at build time. A hand-written HTML twin would drift from it on the first edit, which is
* the whole lesson of TODO #15a and of the documentation pass that found four references a month
* out of date.
*
* WHY RENDER AT ALL. They were served as `text/plain`, which is honest and unreadable: a card
* reference is mostly tables, and as plain text a table is rows of pipes. That was TODO #109, taken
* deliberately as the short version to get the references in front of testers for one round.
*
* NO MARKDOWN LIBRARY. `scripts/markdown.ts` covers the subset these five documents use, and this
* project has no runtime dependencies at all — one would be a poor first.
*
* THE `.md` IS PUBLISHED TOO, beside the page. It costs nothing, it is what a reader who wants the
* source or a diff actually wants, and it keeps every link that was handed out while the documents
* were served as text working rather than 404ing.
*/
const GUIDE_DOCS: readonly string[] = DOC_PAGES.map((d) => `${d.slug}.md`);
/** `rules.md` → `rules.html`, so a link between documents lands on the rendered page. */
const docLink = (href: string): string =>
/^https?:/.test(href) || href.startsWith('#') ? href : href.replace(/\.md(#|$)/, '.html$1');
/**
* The title and version line, lifted out of the Markdown body.
*
* The documents open with `# Title` then `**Version x.y.z** · date`, and the page draws both in its
* own header — so rendering them again in the body would print each twice. Taken by pattern rather
* than by line count, and the body is only trimmed where the pattern actually matched.
*/
function splitHead(src: string): { title: string; version: string; body: string } {
const m = /^#\s+(.+?)\n+\*\*Version\s+([^*]+)\*\*\s*·\s*([^\n]+)\n/.exec(src);
if (!m) return { title: 'Station Master', version: '', body: src };
return {
title: m[1]!.replace(/^Station Master\s*[—-]\s*/, '').trim(),
version: `<b>Version ${m[2]!.trim()}</b> · ${m[3]!.trim()}`,
body: src.slice(m[0].length),
};
}
for (const rel of GUIDE_DOCS) {
const src = join(root, 'docs', rel);
if (!existsSync(src)) {
// Loud rather than silent: a missing document is a broken link on a page already published, and
// the build is the only place that can still notice.
console.error(`WARNING: docs/${rel} is missing — a published link will 404`);
continue;
}
const md = readFileSync(src, 'utf8');
const out = join(dist, rel);
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, md);
const { title, version, body } = splitHead(md);
const { html, headings } = renderMarkdown(body, docLink);
writeFileSync(
join(dist, rel.replace(/\.md$/, '.html')),
docPage({ slug: rel.replace(/\.md$/, ''), title, version, body: html, headings }),
);
}
// A tiny note for whoever unzips this later and wonders what it needs.
writeFileSync(
join(dist, 'README.txt'),
+197
View File
@@ -0,0 +1,197 @@
/**
* The page a documentation file is rendered into.
*
* ONE TEMPLATE FOR ALL FIVE, so the guide reads as one publication rather than five files that
* happen to be linked. It carries the same dark palette, the same type and the same blue as the
* game, because a player arrives here from the board and should not feel they have left the site.
*
* WHAT THE PAGE ADDS OVER THE MARKDOWN, and why each is here rather than in the source:
*
* - a **nav** across the five documents, so the set is navigable from any one of them. The
* Markdown cannot carry this: it would have to be repeated in every file and would drift.
* - a **contents list** built from the headings actually rendered, so it cannot fall out of step
* with the document the way a hand-written one does.
* - **anchors** on every heading, so a section can be linked to in a bug report.
* - a **measure** of about 70 characters. Long lines are the single biggest thing making plain
* text hard to read, and these documents are long.
*
* PRINTS SANELY TOO: the nav and contents drop out, the palette goes to ink on paper, and tables
* keep their rules. A rules reference is a thing people print.
*/
import type { Heading } from './markdown.ts';
export type DocPage = {
/** Published filename, without the extension — also the nav's identity for "you are here". */
slug: string;
/** What the nav calls it. */
nav: string;
};
export const DOC_PAGES: readonly DocPage[] = [
{ slug: 'quickstart', nav: 'Quickstart' },
{ slug: 'rules', nav: 'Rules' },
{ slug: 'home-deck', nav: 'Home deck' },
{ slug: 'mainline-deck', nav: 'Mainline deck' },
{ slug: 'components', nav: 'Components' },
];
export const DOCS_CSS = `
:root{
--bg:#12161c; --panel:#161b22; --line:#2c333d; --fg:#cfd6e0; --dim:#8b94a3;
--head:#cfe0f5; --link:#5aa9e6; --accent:#9fb6d8; --rule:#39424e;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{
margin:0;background:var(--bg);color:var(--fg);
font:15px/1.65 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
-webkit-text-size-adjust:100%;
}
a{color:var(--link)}
a:hover{color:#9fd0f5}
/* THE NAV. Sticky, because these documents are long and the set has to stay reachable from the
middle of one. Horizontally scrollable on a phone rather than wrapping into three rows. */
.docnav{
position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
display:flex;align-items:center;gap:4px;padding:8px 16px;overflow-x:auto;
}
.docnav .home{color:var(--head);font-weight:700;margin-right:10px;text-decoration:none;white-space:nowrap}
.docnav a.tab{
color:var(--dim);text-decoration:none;padding:4px 10px;border-radius:6px;white-space:nowrap;
border:1px solid transparent;font-size:13px;
}
.docnav a.tab:hover{color:var(--fg);background:#1f2733}
.docnav a.tab[aria-current="page"]{color:#f2e6cf;background:#2b3444;border-color:#c8912f}
.wrap{max-width:78ch;margin:0 auto;padding:22px 16px 72px}
/* THE HEADER — what this document is and which build it describes, lifted out of the prose so the
version is the first thing on the page, as the process rules require. */
.dochead{border-bottom:1px solid var(--rule);padding-bottom:12px;margin-bottom:8px}
.dochead h1{margin:0 0 6px;font-size:26px;line-height:1.25;color:var(--head);letter-spacing:.01em}
.dochead .ver{color:var(--dim);font-size:13px}
.dochead .ver b{color:#c8912f;font-weight:700}
/* CONTENTS, built from the headings actually rendered. Collapsed by default on a phone. */
.toc{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin:18px 0 26px}
.toc summary{cursor:pointer;color:var(--accent);font-size:13px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}
.toc ol{list-style:none;margin:10px 0 2px;padding:0;columns:2;column-gap:26px}
.toc li{margin:0 0 4px;break-inside:avoid}
.toc li.l3{padding-left:14px;font-size:13px}
.toc a{text-decoration:none;color:var(--fg)}
.toc a:hover{color:var(--link)}
@media (max-width:640px){.toc ol{columns:1}}
h2,h3,h4{color:var(--head);line-height:1.3;margin:28px 0 8px}
h2{font-size:20px;border-bottom:1px solid var(--rule);padding-bottom:5px}
h3{font-size:16px}
h4{font-size:14px;color:var(--accent);text-transform:uppercase;letter-spacing:.05em}
p{margin:0 0 12px}
strong{color:#e8eef7}
hr{border:none;border-top:1px solid var(--rule);margin:26px 0}
/* The anchor beside a heading: invisible until the heading is hovered, so it never competes with
the words but is always there to copy. */
.anchor{margin-left:.45em;color:var(--rule);text-decoration:none;font-weight:400;opacity:0}
h1:hover .anchor,h2:hover .anchor,h3:hover .anchor,h4:hover .anchor{opacity:1}
.anchor:hover{color:var(--link)}
ul,ol{margin:0 0 12px;padding-left:22px}
li{margin:0 0 5px}
li>ul,li>ol{margin-top:5px}
code{background:#1d232c;border:1px solid var(--line);border-radius:4px;padding:1px 5px;
font:13px ui-monospace,SFMono-Regular,Menlo,monospace;color:#e0c89a}
pre{background:#1d232c;border:1px solid var(--line);border-radius:7px;padding:12px 14px;overflow-x:auto}
pre code{background:none;border:none;padding:0;color:var(--fg)}
blockquote{
margin:16px 0;padding:10px 14px;background:#181f28;
border-left:3px solid #c8912f;border-radius:0 7px 7px 0;
}
blockquote > :last-child{margin-bottom:0}
/* TABLES THAT LOOK LIKE TABLES. This is the whole reason the documentation is rendered rather than
served as text: a card reference is mostly tables, and as plain text they are rows of pipes. */
.tablewrap{overflow-x:auto;margin:0 0 16px;border:1px solid var(--line);border-radius:8px}
table{border-collapse:collapse;width:100%;font-size:14px}
thead th{
background:#1f2733;color:var(--accent);text-align:left;font-weight:600;
padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap;
}
td{padding:7px 12px;border-bottom:1px solid #222a34;vertical-align:top}
tbody tr:last-child td{border-bottom:none}
tbody tr:nth-child(even){background:#151a21}
tbody tr:hover{background:#1b222b}
.ta-right{text-align:right;font-variant-numeric:tabular-nums}
.ta-center{text-align:center}
th.ta-right,th.ta-center{text-align:inherit}
footer{margin-top:40px;padding-top:14px;border-top:1px solid var(--rule);color:var(--dim);font-size:12px}
footer a{color:var(--accent)}
@media print{
.docnav,.toc,.anchor{display:none}
body{background:#fff;color:#111;font-size:11pt}
h1,h2,h3,h4,strong{color:#000}
a{color:#000;text-decoration:underline}
.wrap{max-width:none;padding:0}
.tablewrap{border-color:#999}
thead th{background:#eee;color:#000;border-bottom-color:#999}
td{border-bottom-color:#ccc}
tbody tr:nth-child(even){background:#f6f6f6}
blockquote{background:#f4f4f4;border-left-color:#888}
code,pre{background:#f4f4f4;border-color:#ccc;color:#111}
}
`;
/** The contents list, from the headings the renderer actually produced. */
function toc(headings: readonly Heading[]): string {
// h2 and h3 only: h1 is the page title, and h4 is a label inside a section rather than a place.
const items = headings.filter((h) => h.level === 2 || h.level === 3);
if (items.length < 3) return '';
const lis = items
.map((h) => `<li class="l${h.level}"><a href="#${h.id}">${h.text.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</a></li>`)
.join('');
return `<details class="toc" open><summary>On this page</summary><ol>${lis}</ol></details>`;
}
export function docPage(opts: {
slug: string;
title: string;
version: string;
body: string;
headings: readonly Heading[];
}): string {
const tabs = DOC_PAGES.map(
(d) =>
`<a class="tab" href="./${d.slug}.html"${d.slug === opts.slug ? ' aria-current="page"' : ''}>${d.nav}</a>`,
).join('');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${opts.title} — Station Master</title>
<style>${DOCS_CSS}</style>
</head>
<body>
<nav class="docnav"><a class="home" href="./index.html">Station Master</a>${tabs}</nav>
<div class="wrap">
<header class="dochead">
<h1>${opts.title}</h1>
<div class="ver">${opts.version}</div>
</header>
${toc(opts.headings)}
${opts.body}
<footer>
Station Master — <a href="./index.html">back to the game</a> ·
these references are kept current with every release.
</footer>
</div>
</body>
</html>
`;
}
+261
View File
@@ -0,0 +1,261 @@
/**
* A small Markdown renderer, for the player-facing documentation only.
*
* WHY NOT A LIBRARY. This project has no runtime dependencies at all, and the guide uses a small,
* known subset of Markdown — headings, paragraphs, lists, tables, links, code spans, block quotes
* and rules. A Markdown library would be the first dependency in the tree, pulled in to render five
* files whose whole vocabulary fits below. `docs/` is written by hand, not by users, so this does
* not have to survive hostile input; it has to render what those five documents actually contain
* and fail loudly on anything else.
*
* WHY NOT HAND-WRITTEN HTML. The Markdown is the one copy (TODO #15a). An HTML twin drifts from it
* on the first edit, which is the failure the whole documentation pass was about.
*
* ESCAPING IS UNCONDITIONAL. Every scrap of text goes through `esc` before any markup is added, and
* the inline pass only ever inserts tags around already-escaped content. A `<` in the prose is a
* less-than sign, not the start of an element — there is no raw-HTML passthrough, deliberately.
*/
/** HTML-escape. Ampersand first, or it double-escapes the entities added after it. */
export function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** A heading found while rendering, for the contents list the page builds from it. */
export type Heading = { level: number; text: string; id: string };
/**
* `## 3. The shape of a Stage` → `the-shape-of-a-stage`.
*
* The leading number is dropped: it is a position in the document, and a link that carries it
* breaks when a section is inserted above. Duplicate slugs get a numeric suffix rather than
* silently pointing at the first one.
*/
function slug(text: string, taken: Set<string>): string {
const base =
text
.toLowerCase()
// A whole section number, dotted or not: "4.2 Local Operations" and "7. FAQ" both lose it.
.replace(/^\d+(?:\.\d+)*[.)]?\s+/, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section';
let id = base;
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
taken.add(id);
return id;
}
/**
* Inline markup, applied to text that is ALREADY escaped.
*
* Order matters: code spans are taken out first and put back last, so `**` inside backticks stays
* literal. That is not a corner case here — the rules reference quotes field names like
* `**Version**` when describing the page header.
*/
function inline(escaped: string, linkHref: (href: string) => string): string {
const code: string[] = [];
let s = escaped.replace(/`([^`]+)`/g, (_m, body: string) => {
code.push(`<code>${body}</code>`);
return `\u0000${code.length - 1}\u0000`;
});
// Links: [text](target). The target is rewritten so a link between documents lands on the
// rendered page rather than the Markdown source.
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, href: string) => {
const target = linkHref(href);
const external = /^https?:/.test(target);
return `<a href="${target}"${external ? ' target="_blank" rel="noopener"' : ''}>${text}</a>`;
});
// Bold before italic, or `**x**` is read as an empty italic wrapping a bold.
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
return s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)]!);
}
/** One table row's cells, from `| a | b |`. */
function cells(line: string): string[] {
return line
.replace(/^\s*\|/, '')
.replace(/\|\s*$/, '')
.split('|')
.map((c) => c.trim());
}
/** `---`, `:--`, `--:` and `:-:` → the CSS alignment a column wants. */
function alignments(sep: string): (string | null)[] {
return cells(sep).map((c) => {
const left = c.startsWith(':');
const right = c.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
if (left) return 'left';
return null;
});
}
const isTableSep = (line: string): boolean => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-');
export type Rendered = { html: string; headings: Heading[] };
/**
* Render a Markdown document to HTML.
*
* `linkHref` rewrites link targets — the build uses it to send `rules.md` to `rules.html` — and
* defaults to leaving them alone so the function is testable on its own.
*/
export function renderMarkdown(src: string, linkHref: (href: string) => string = (h) => h): Rendered {
const lines = src.replace(/\r\n/g, '\n').split('\n');
const out: string[] = [];
const headings: Heading[] = [];
const taken = new Set<string>();
const ln = (s: string): void => void out.push(s);
const text = (s: string): string => inline(esc(s), linkHref);
let i = 0;
while (i < lines.length) {
const line = lines[i]!;
// The generated-card markers, and any other HTML comment: structural, never shown.
if (/^\s*<!--/.test(line)) {
while (i < lines.length && !lines[i]!.includes('-->')) i++;
i++;
continue;
}
if (line.trim() === '') {
i++;
continue;
}
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
ln('<hr>');
i++;
continue;
}
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
const level = heading[1]!.length;
const raw = heading[2]!.trim();
const id = slug(raw, taken);
headings.push({ level, text: raw.replace(/[*`]/g, ''), id });
// The anchor is a link to itself, so a section can be pointed at without a separate widget.
ln(
`<h${level} id="${id}">${text(raw)}` +
`<a class="anchor" href="#${id}" aria-label="Link to this section">#</a></h${level}>`,
);
i++;
continue;
}
// Fenced code.
if (/^\s*```/.test(line)) {
i++;
const body: string[] = [];
while (i < lines.length && !/^\s*```/.test(lines[i]!)) body.push(lines[i++]!);
i++;
ln(`<pre><code>${esc(body.join('\n'))}</code></pre>`);
continue;
}
// Tables: a header row, an alignment row, then body rows.
if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1]!)) {
const head = cells(line);
const align = alignments(lines[i + 1]!);
i += 2;
const body: string[][] = [];
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim() !== '') body.push(cells(lines[i++]!));
const th = head
.map((c, n) => `<th${align[n] ? ` class="ta-${align[n]}"` : ''}>${text(c)}</th>`)
.join('');
const rows = body
.map(
(r) =>
'<tr>' +
r.map((c, n) => `<td${align[n] ? ` class="ta-${align[n]}"` : ''}>${text(c)}</td>`).join('') +
'</tr>',
)
.join('');
// Wrapped so a wide table scrolls inside the page rather than widening it on a phone.
ln(`<div class="tablewrap"><table><thead><tr>${th}</tr></thead><tbody>${rows}</tbody></table></div>`);
continue;
}
// Block quote: consecutive `>` lines, rendered through this same function so a quote may hold
// a list or a table — the rules reference puts both inside one.
if (/^\s*>/.test(line)) {
const body: string[] = [];
while (i < lines.length && /^\s*>/.test(lines[i]!)) body.push(lines[i++]!.replace(/^\s*>\s?/, ''));
ln(`<blockquote>${renderMarkdown(body.join('\n'), linkHref).html}</blockquote>`);
continue;
}
// Lists. A bullet or a number opens one; continuation lines are indented under their item.
const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
if (bullet) {
const ordered = /\d/.test(bullet[2]!);
const baseIndent = bullet[1]!.length;
const items: string[] = [];
let current: string[] | null = null;
while (i < lines.length) {
const l = lines[i]!;
if (l.trim() === '') {
// A blank line ends the list unless the next line is still inside it.
const next = lines[i + 1] ?? '';
const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next);
if (!continues) break;
i++;
continue;
}
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l);
if (m && m[1]!.length <= baseIndent) {
if (current) items.push(current.join(' '));
current = [m[3]!];
i++;
continue;
}
if (m || /^\s{2,}\S/.test(l)) {
// A nested item or a wrapped continuation. Nesting is rendered by recursion on the block.
if (!current) break;
current.push(l.trim());
i++;
continue;
}
break;
}
if (current) items.push(current.join(' '));
const tag = ordered ? 'ol' : 'ul';
ln(`<${tag}>${items.map((it) => `<li>${text(it)}</li>`).join('')}</${tag}>`);
continue;
}
// Anything else is a paragraph, running to the next blank line or block opener.
const para: string[] = [];
while (i < lines.length) {
const l = lines[i]!;
if (
l.trim() === '' ||
/^(#{1,6})\s/.test(l) ||
/^\s*>/.test(l) ||
/^\s*```/.test(l) ||
/^\s*<!--/.test(l) ||
/^\s*(---|\*\*\*|___)\s*$/.test(l) ||
/^(\s*)([-*+]|\d+[.)])\s+/.test(l) ||
(l.includes('|') && isTableSep(lines[i + 1] ?? ''))
) {
break;
}
para.push(l.trim());
i++;
}
if (para.length) ln(`<p>${text(para.join(' '))}</p>`);
}
return { html: out.join('\n'), headings };
}
+96 -5
View File
@@ -1101,9 +1101,20 @@ function evaluateClearance(
const node = s.division.nodes[targetIndex];
if (!node || node.kind !== 'mainline') return 'clear';
// Double Track and Uncontrolled Siding print "Trains may pass", so occupancy does not block.
/**
* "TRAINS MAY PASS" IS A PROPERTY OF ONE CARD, NOT OF THE SUBDIVISION (Jesse, 2026-09-23).
*
* This returned `clear` outright, before the subdivision was looked at — so a train entering a
* Double Track was released however busy the rest of the Subdivision was, including against a
* train coming the other way three cards deeper in. Reported from a table: Train 8 highballed
* from the Western Division Point with no ruling asked, and the reason was this line rather than
* anything about Control Points.
*
* What the card actually prints is that TWO TRAINS MAY SHARE IT. So it excuses occupants ON THIS
* CARD and nothing else, which is what `passesHere` below is for.
*/
const profile = MAINLINE_PROFILES.find((m) => m.kind === node.card);
if (profile?.trainsMayPass) return 'clear';
const passesHere = profile?.trainsMayPass === true;
/**
* §8.1 asks about the next SUBDIVISION, not the next card.
@@ -1138,9 +1149,32 @@ function evaluateClearance(
const occupants: { tray: TrayId; onCard: number }[] = [];
for (const i of subdivision) {
const n = s.division.nodes[i];
if (!n || n.kind !== 'mainline') continue;
if (behind(i)) continue;
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
if (n?.kind === 'mainline') {
// A card that lets trains pass is not an obstruction on its own account.
if (i === targetIndex && passesHere) continue;
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
continue;
}
/**
* A TRAIN STANDING AT AN OFFICE WITH NOWHERE TO PUT IT OCCUPIES THE SUBDIVISION TOO.
*
* Jesse's ruling, 2026-09-23, from a table where Train 19 was released from the Eastern
* Division Point towards Train 14 and nobody was asked: at the moment of the decision Train 14
* was not in `transits` at all, it was standing in a district. §8.1 was only ever reading
* trains in transit, so a train about to re-enter the very Subdivision being entered counted
* for nothing.
*
* CAPACITY IS THE TEST, not the mere presence of a train — his reasoning exactly. At a Whistle
* Post, one A/D track and a train on it means there is nowhere for the two to pass and no
* choice to be made. At a Depot or a Terminal with a track still free there is somewhere to go,
* and the train at the Office is not in the way.
*/
if (n?.kind === 'office') {
const area = areaAtSeat(s, n.seat);
if (area.adOccupancy.length < officeProfile(area.tier).adTracks) continue;
for (const held of area.adOccupancy) occupants.push({ tray: held, onCard: i });
}
}
/**
@@ -1167,6 +1201,24 @@ function evaluateClearance(
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
// way to pass the order, so the train simply holds.
if (spendDispatchBonus(s, tray, otherTray, events) > 0) continue;
/**
* SAY SO (Jesse, 2026-09-23: "does it make sense to have something listed in history or
* somewhere else when the train is not allowed to pass?").
*
* A facing train is an absolute bar and this returned silently — the train simply did not
* depart, Stage after Stage, with nothing on screen saying why. Only the ABS Signals case
* below announced itself, and it was given a line for exactly this reason.
*
* NAMES WHAT IS IN THE WAY, because the answer to "why is nothing happening" is a specific
* train somewhere specific, not a rule number.
*/
events.push({
type: 'trainHeld',
trainNumber: tray.trainNumber ?? 0,
reason:
`Train ${otherTray.trainNumber ?? '?'} is coming the other way in the same Subdivision — ` +
'§8.1 holds a train against a facing one, and there is no Control Point between them to pass at',
});
return 'blocked';
}
@@ -1495,12 +1547,51 @@ function arriveAtOffice(
return 'moved';
}
// A train held at the Limits takes the first free A/D track before any newcomer.
/**
* A TRAIN HELD AT THE LIMITS TAKES THE FIRST FREE A/D TRACK BEFORE ANY NEWCOMER — and taking it
* FILLS IT, which is what this used to forget.
*
* Reported from a table, 2026-09-23: a Whistle Post with one A/D track held Trains 8 and 19 at
* once. The capacity test above had passed (nothing standing), this block then moved the held
* train in, and the arriving train was pushed in after it without anyone asking again whether
* there was room. So the Office ended up over capacity and the collision §8.3 calls for never
* happened.
*
* 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 got there first: held at its
* own Limits where there is an Interlocking, and a collision where there is not.
*
* IT IS ALSO ANNOUNCED. The release used to be a silent side effect of somebody else's arrival:
* the train simply appeared at the Office, and the report was "wasn't clear what changed and why
* train 8 was suddenly released".
*/
if (area.heldAtLimits.length > 0 && area.heldAtLimits[0] !== id) {
const first = area.heldAtLimits.shift()!;
area.adOccupancy.push(first);
const held = s.trays.get(first);
if (held) held.position = { at: 'grid', seat, coord: area.officeCoord };
events.push({
type: 'trainReleasedFromLimits',
trainNumber: held?.trainNumber ?? 0,
office: officeProfile(area.tier).name,
owner: playerAtSeat(s, seat),
freedBy: tray.trainNumber ?? 0,
});
// The slot it just took is gone. Ask again for the train that is arriving now.
if (area.adOccupancy.length >= capacity) {
if (hasEnhancement('interlocking')) {
area.heldAtLimits.push(id);
events.push({
type: 'trainDiverted',
trainNumber: tray.trainNumber ?? 0,
to: 'the Limits',
reason: 'Interlocking held it clear of a full Office instead of a collision',
});
return 'moved';
}
collide(s, playerAtSeat(s, seat), [id], events, 'no free A/D track', 'the Office');
return 'moved';
}
}
area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);
+195 -20
View File
@@ -352,6 +352,35 @@ function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCo
return null;
}
/**
* May this Facility be built ON TOP of the card already on this square?
*
* Reported from a table: a player who has already laid a straight down a stub and then draws the
* industry they wanted has no move — the square had to have been empty when the card came up, so
* building your district in the sensible order (rail first, then what it serves) is punished.
* Turnouts have upgraded a straight since the same complaint was made about branching.
*
* A STRAIGHT ONLY, and the reason is geometry rather than taste. `protoCard` builds every Facility
* as plain east-west track, so replacing a straight is a port-for-port swap: `{e,w}` before and
* `{e,w}` after, and no neighbour can lose a join it was relying on. A curve or a turnout carries
* ports a Facility does not, so building over one COULD sever a join — those stay refused.
*
* The Running Track row is already barred by the caller (§11.2's "not on Running Track"), so this
* only ever sees stub track.
*
* Two things block it, both about the card being in use rather than its shape — the same pair that
* blocks a turnout upgrade: you cannot swap the track out from under a standing car, and an
* Interlocking or Telegraph built on it would have to be lifted with it. The replaced card leaves
* play, as a lifted card does at a table.
*/
function checkFacilityUpgrade(existing: TrackCard): RejectionCode | null {
const g = existing.geometry;
if (g.kind !== 'track' || g.geometry !== 'straight') return 'NOT_UPGRADEABLE_TRACK';
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED';
return null;
}
/**
* The MEN | AT | WORK pipeline of a Freight Facility.
*
@@ -899,8 +928,22 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
* those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent:
* the restriction has to bite on the pick-up itself, the same reasoning as the comment above.
*/
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
/**
* A TRAIN MAY ALWAYS RECOVER ITS OWN CABOOSE (Jesse's ruling, 2026-09-23).
*
* `ownCutFor` above exempts only what is standing on the square the crew is on, so a
* caboose set out and then moved away from became a fresh pick-up — and X13 prints "may
* drop MTs but not pick up anything". A train needs its caboose at the far end to be made
* up (§8.2), so a dropOnly train that parted with its caboose could never legally leave
* again: it stranded itself, permanently, with nothing on screen saying so.
*
* Narrow on purpose. It is the caboose only, not "your own cars" generally: the caboose is
* the one car whose absence makes the train unable to depart, so recovering it is repairing
* a consist rather than doing fresh work.
*/
const notOwnCaboose = fresh.filter((c) => c.type !== 'caboose');
if (rules.noSwitching && notOwnCaboose.length > 0) return 'PICKUP_NOT_ALLOWED';
if (rules.dropOnly && notOwnCaboose.length > 0) return 'PICKUP_NOT_ALLOWED';
if (rules.pickUpEmptiesOnly && fresh.some(carriesLoad)) return 'EMPTIES_ONLY';
const freight = fresh.filter(isFreight).length;
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
@@ -1224,6 +1267,18 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
// Only on a train that is actually due out this Stage — a second section follows a first.
if (s.timetable[s.clock.stage - 1] !== i.trainNumber) return 'NO_SUCH_TRAY';
if (s.freeTrays.length === 0) return 'NO_SUCH_TRAY';
/**
* Q9 — IT COSTS THE CARD (Jesse's ruling, 2026-09-23).
*
* This was offered free on every train due out. `SECOND_SECTION` has existed in `content.ts`
* the whole time and `setup.ts` never dealt it, so the card that gates the action did not
* exist and the action was unpriced — the bot ordered 26 accidental Second Sections in one
* measured round, every one of them from the New Train fallback taking `options[0]`.
*
* The card is dealt now (`setup.ts`) and spent here, so ordering a Second Section is a
* decision with a cost, and it can be done once per deal.
*/
if (secondSectionCard(s, player) === null) return 'NO_SUCH_CARD';
return null;
}
@@ -1430,6 +1485,13 @@ function checkPlay(
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
const proto = protoCard(card.kind, variant);
if (!proto) return 'NO_PLACEMENT';
/**
* An occupied square is a build-over, which has its own rule — `canPlaceAt` refuses every
* occupied square except a movable Limits sign, and a sign is not something to build an
* industry on top of.
*/
const under = area.grid.get(coordKey(placement));
if (under) return checkFacilityUpgrade(under);
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
}
case 'modifier': {
@@ -1462,8 +1524,25 @@ function checkPlay(
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
// the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
/**
* A PASSENGER MODIFIER NEEDS A PASSENGER FACILITY, and a Whistle Post is not one (§9).
*
* Jesse's ruling, 2026-09-23. A Waiting Area, Restaurant or Hotel may not be built at an
* Office until it is a Depot or better: a Whistle Post allows neither direction, so the extra
* outbound slot is discarded on the spot and only the porter lands. A card that can be played
* to no effect is a trap however well the panel labels it.
*
* It bites far less often than it would have before the same day's other ruling, which opens
* every district on a Depot — this is now the harder game's rule.
*/
if (
modifierProfile(card.kind.modifier).hosts.includes('office') &&
!officeProfile(area.tier).isPassengerFacility
) {
return 'OFFICE_NOT_PASSENGER';
}
// §9 — a Modifier is not track. It must sit square against a Facility THAT CAN HOST IT —
// north, south, east or west — or it does nothing at all.
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
}
case 'enhancement': {
@@ -1644,6 +1723,45 @@ export function movesFor(
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b);
}
/**
* AND THE SQUARES THE WALK ALLOWS BUT THE TRAIN'S OWN CARD DOES NOT.
*
* 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?" Nowhere, was the answer. `exploreMoves` decides where the RAILS go, and the pick-up
* restrictions — `noSwitching`, `dropOnly`, empties-only, the per-location freight budget — are
* enforced afterwards in `check`. So a square the walk reached and the card forbids was reachable,
* un-offered, and absent from this list: it simply was not there, with no reason given.
*
* ASKED THROUGH `check` RATHER THAN RE-DERIVED. A second implementation of the pick-up rules is
* exactly the failure this function's own header warns about — a reason that does not match the
* refusal is worse than no reason. `check` is the authority, so the answer comes from asking it.
*/
const WHY: Partial<Record<string, string>> = {
PICKUP_NOT_ALLOWED:
"this train's card forbids picking cars up, and coupling is mandatory — there are cars here it would have to take",
EMPTIES_ONLY: 'this train may only pick up empties, and there is a loaded car here it would have to take',
FREIGHT_WORKED_HERE: 'this train has already worked its freight allowance at this location',
TOO_MANY_CARS: 'the cars here would take the train over four',
};
for (const [key, coord] of [...to.entries()]) {
if (blocked.has(key)) continue;
/**
* BOTH DIRECTIONS BEFORE DECLARING IT BLOCKED. A square can be reachable forwards and
* backwards, and the two do not pick up the same cars — `ownCutFor` depends on which end the
* train pulls out through. If either way is legal the square stays offered.
*/
const codes = [false, true].map((reverse) =>
check(s, player, { type: 'switch.move', trayId, to: coord, reverse }),
);
if (codes.includes(null)) continue;
const why = codes.map((c) => (c === null ? undefined : WHY[c])).find((w) => w !== undefined);
if (!why) continue;
// Not reachable after all, so it must not stay in `to` claiming otherwise.
to.delete(key);
blocked.set(key, { coord, kind: 'cardRule', why });
}
return { to: [...to.values()], blocked: [...blocked.values()] };
}
@@ -1825,9 +1943,21 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
}
case 'draw.end':
case 'freightAgent.end':
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
case 'freightAgent.end':
/**
* An idle Freight Agent SAYS SO. §6.3 requires no action and choosing to take none is a real
* decision — see `freightAgentIdled`. Without the line the log announced the option and then
* fell silent, which reads as the game having dropped the turn.
*/
return [
...(turnOf(s, player).freightAgentUsed
? []
: [{ type: 'freightAgentIdled', player } as const]),
{ type: 'phaseEnded', player, phase: 'localOps' },
];
case 'draw.fromHomeOffice': {
const events: GameEvent[] = [
{
@@ -2037,7 +2167,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
}
case 'newTrain.secondSection':
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
// `check` has established the card is in hand, so the id resolves.
return [
{
type: 'secondSectionOrdered',
player,
trainNumber: i.trainNumber,
cardId: secondSectionCard(s, player)!,
},
];
case 'mainline.clearance':
return [
@@ -2549,8 +2687,28 @@ export function reduce(s: GameState, e: GameEvent): void {
const f = facilityAt(s, e.player, e.at)!;
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
if (idx >= 0) f.inboundBox.splice(idx, 1);
// `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts).
s.yards.classificationYard.push(pooled(e.stock));
/**
* THE CAR COMES BACK EMPTY, and until v0.8.1.0 it did not.
*
* The line here said "`pooled` — a car back in a yard is back in the common supply, carrying
* nothing", and `pooled` does no such thing: it strips the `origin` stamp and DELIBERATELY
* keeps `loaded`, because a train can retire at a Division Point with freight still aboard
* (`advance.ts`). So the comment described an intent the call never carried out, and every
* car the Freight Agent cleared went to the Classification Yard still loaded.
*
* That is not cosmetic. `passengersDetrained` needs `type === 'coach' && !loaded` out of the
* Division Yard, and §2.2 refills the Division Yard from Classification — so a coach cleared
* from a red box came back as stock that could never unload another passenger. Measured over
* five three-Day solitaire games before the fix: 18 loaded coaches in the Classification Yard
* against 6 empty, and 9 loaded in the Division Yard against 33 empty.
*
* The red box is where the journey ENDS for what is in it — the Revenue was paid when the
* load arrived or the passengers detrained (`view.ts`, `freightAgent.clearInbound`). Clearing
* it sends the passengers out of the station, or the delivered load into the industry, and
* returns the CAR to the common supply. A car in a yard carrying a load nobody can unload is
* not supply.
*/
s.yards.classificationYard.push({ ...pooled(e.stock), loaded: false });
turnOf(s, e.player).freightAgentUsed = true;
break;
}
@@ -2589,6 +2747,8 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'secondSectionOrdered':
s.pendingSecondSections.push(e.trainNumber);
// The card is spent on the order, like every other card played from hand.
spendCard(s, e.player, e.cardId);
break;
case 'trainScheduled':
@@ -2914,19 +3074,26 @@ function adjacentFacilityCoord(
coord: GridCoord,
modifier?: ModifierKind,
): GridCoord | null {
// A turnout's 45° leg reaches north as readily as south (turn the card 180°), so a district grows
// on both sides of the Running Track and §9's "nine nearby spots" really is nine. The old Q7
// guard here rejected the three above outright.
/**
* NORTH, SOUTH, EAST OR WEST — NOT THE DIAGONALS (Jesse's ruling, 2026-09-23).
*
* This offered all eight surrounding squares, reading §9's "nine nearby spots" as every
* neighbour. A Modifier has to sit SQUARE against what it serves: a card on a corner touches it
* at a point, not along an edge.
*/
const hosts = modifier ? modifierProfile(modifier).hosts : null;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const c = { row: coord.row + dr, col: coord.col + dc };
const f = area.grid.get(coordKey(c))?.facility;
if (!f) continue;
if (hosts && !hosts.includes(f.subtype)) continue;
return c;
}
const ORTHOGONAL = [
{ dr: -1, dc: 0 },
{ dr: 1, dc: 0 },
{ dr: 0, dc: -1 },
{ dr: 0, dc: 1 },
];
for (const { dr, dc } of ORTHOGONAL) {
const c = { row: coord.row + dr, col: coord.col + dc };
const f = area.grid.get(coordKey(c))?.facility;
if (!f) continue;
if (hosts && !hosts.includes(f.subtype)) continue;
return c;
}
return null;
}
@@ -3043,6 +3210,14 @@ function emptyCard(): TrackCard {
}
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
/** The Second Section card in this player's hand, or null. Q9 — the action costs it. */
function secondSectionCard(s: GameState, player: PlayerIndex): CardId | null {
for (const id of s.decks.hands.get(player) ?? []) {
if (s.cards.get(id)?.kind.kind === 'secondSection') return id;
}
return null;
}
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
s.decks.salvageYard.push(cardId);
+79 -8
View File
@@ -289,7 +289,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
* the game with no way to raise the direction it is supposed to use half its capacity on.
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
* `home-deck.md` prints it "Outbound, 1 out / 0 in".
*/
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
@@ -297,7 +297,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
/**
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
* not ship anything out". `home-deck.md` prints it "Inbound, 0 out / 1 in".
*
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
@@ -481,7 +481,7 @@ export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
* still print the old numbers, so `docs/rules/as-built.md` is the place that now carries what
* still print the old numbers, so `docs/home-deck.md` and `docs/mainline-deck.md` carry what
* the cards say — GENERATED from the constants below by `scripts/build-card-reference.ts`, with
* `test/card-reference.test.ts` failing if the two disagree. This comment used to name
* `card-reference.md`, which describes the v0.4.5 deck and carries a banner saying not to use its
@@ -589,7 +589,20 @@ export type MainlineProfile = {
speedStarts?: { fast: number; slow: number };
/** Double Track: "Trains may pass". */
trainsMayPass: boolean;
/** Interchange: "Sort cars in new order". */
/**
* Interchange only. The card prints "Sort cars in new order" — **and that is not what this flag
* does**, which is why it is worth spelling out where the field is declared.
*
* The printed sorting has never been implemented: nothing reads this to permit a sort, and a
* consist is re-ordered at a Small Yard in a district (`switch.sortConsist`). What this actually
* marks is the one Mainline card with a Yard Limit, and therefore the one an Extra may be made up
* and started on (`apply.ts` § resolveExtraStart, `legal.ts`).
*
* Named for the printed text, and kept that way deliberately — renaming it would lose the link to
* the card face — but the name has already misled once: `mainlineDescription` grew a sentence
* telling players cars could be sorted here, which reached the board and the generated card
* reference before it was caught on 2026-09-20.
*/
sortsCars: boolean;
/** Named entry points printed on the card; some are unlocked by modifier cards. */
entryPoints: readonly string[];
@@ -655,7 +668,7 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
* used it as one: `buildDivision` drew uniformly from those types with replacement, which made two
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
* inventory, transcribed in `docs/StationMaster-Mainline-Deck-v0.4.5.md`, which had already flagged
* inventory, transcribed in `docs/mainline-deck.md`, which had already flagged
* the mismatch as needing correction.
*
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
@@ -995,14 +1008,25 @@ export function enhancementRule(key: string): EnhancementRule | null {
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
}
/**
* THE NAMES HERE ARE WHAT A PLAYER READS, since v0.8.0.17 — `cardName` takes them in preference to
* `prettyKey`'s guess, which cannot recover an acronym (it rendered `absSignals` as "Abs Signals"
* beside a tooltip saying ABS).
*
* So their CASE matters, and three were transcribed in sentence case while the rules, the panels
* and every other line of this repository treat them as proper terms: "Yard Office" outnumbered
* "Yard office" 36 to 2, "Small Yard" 36 to 2, "Water Column" 10 to 2. Corrected here rather than
* special-cased in `cardName`, because this table is the source and a lookup that second-guesses
* its own source is the drift it exists to prevent.
*/
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
{ key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an
// Event held out until built, so at zero it defends against nothing that can be dealt anyway.
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
{ key: 'waterColumn', name: 'Water column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
{ key: 'yardOffice', name: 'Yard Office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
{ key: 'smallYard', name: 'Small Yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
{ key: 'waterColumn', name: 'Water Column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
/**
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
@@ -1200,6 +1224,8 @@ export type HouseRules = {
startingHand: StartingHand;
revenue: RevenueRules;
extraStart: ExtraStartRule;
/** Which Office every player opens on — see `StartingOffice`. */
startingOffice: StartingOffice;
/**
* §6.2 — MAY A TIMETABLED TRAIN BE THROWN AWAY? (Gitea#9, superseding Gitea#6.)
*
@@ -1220,12 +1246,22 @@ export type HouseRules = {
discardTimetabled: boolean;
};
/**
* Which Office every player starts on.
*
* `depot` is the default: a Depot is a Passenger Facility with two A/D tracks, so passengers work
* from the first Stage and a second train can stand at an Office. `whistlePost` is the harder game
* — one A/D track, no passenger work at all until somebody draws and plays an upgrade.
*/
export type StartingOffice = 'depot' | 'whistlePost';
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
export type HouseRuleOverrides = {
startingHand?: StartingHand;
revenue?: Partial<RevenueRules>;
extraStart?: ExtraStartRule;
discardTimetabled?: boolean;
startingOffice?: StartingOffice;
};
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
@@ -1243,6 +1279,14 @@ export const DEFAULT_HOUSE_RULES: HouseRules = {
// recently gave rather than the one it replaced. This keeps main and the 0.4.9 playtest line —
// which has no setting and simply allows it — playing the same game.
discardTimetabled: true,
/**
* EVERYBODY STARTS ON A DEPOT (Jesse, 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
* unable to hold a second train — and a single A/D track is what makes an arriving train a
* collision. Starting on a Depot makes the game markedly easier; `whistlePost` is offered as the
* harder setting for a table that wants it.
*/
startingOffice: 'depot',
};
/**
@@ -1262,6 +1306,8 @@ export const LEGACY_HOUSE_RULES: HouseRules = {
// These games predate Gitea#6 as well as Gitea#9: a train card could simply be discarded. `true`
// is what they were played under, and a replay that discards a Timetabled train needs it.
discardTimetabled: true,
// Every game before 2026-09-23 opened on a Whistle Post.
startingOffice: 'whistlePost',
};
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
@@ -1282,9 +1328,31 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
},
extraStart: given.extraStart ?? d.extraStart,
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
startingOffice: given.startingOffice ?? d.startingOffice,
};
}
/**
* THE OPENING A SAVE THAT PREDATES THE SETTING WAS DEALT UNDER.
*
* `startingOffice` is unlike every other house rule: the others change how a game PLAYS, and
* getting one wrong stops a replay part-way where it can be seen. This one changes how the game is
* DEALT — a different Office, and a deck with four more cards in it — so a save replayed under the
* wrong opening is a different railroad from intent one, and the failure is silent.
*
* Every game saved before 2026-09-23 opened on a Whistle Post and its `houseRules` cannot say so.
* So a saved config that names house rules but not this one gets what it was played under.
*
* APPLIED ON THE REPLAY PATHS ONLY, never inside `houseRules()`. A preset, the setup form and the
* lobby all build configs that name some rules and not others, and they mean today's default —
* putting this in the resolver made a fresh Cutthroat game deal Whistle Posts and read as Custom.
*/
export function withSavedOpening<T extends { houseRules?: HouseRuleOverrides }>(config: T): T {
const given = config.houseRules;
if (!given || given.startingOffice !== undefined) return config;
return { ...config, houseRules: { ...given, startingOffice: 'whistlePost' } };
}
/** What the dialog calls each option, in the order it offers them. */
export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string }[] = [
{ value: 'threeRandom', label: 'Three random cards' },
@@ -1379,6 +1447,9 @@ export function deckComposition(): { category: string; count: number }[] {
{ category: 'mainlineModifier', count: sum(MAINLINE_MODIFIER_CARDS) },
{ category: 'maneuver', count: sum(MANEUVER_CARDS) },
{ category: 'action', count: sum(ACTION_CARDS) },
// Q9 — dealt since 2026-09-23. It was declared here and left out of `buildDeck`, which is what
// made `newTrain.secondSection` a free action rather than one that costs a card.
{ category: 'secondSection', count: SECOND_SECTION.copies },
];
}
+27 -1
View File
@@ -115,6 +115,32 @@ export type GameEvent =
movesAllowed: number;
lastMove?: { trayId: TrayId; to: GridCoord };
}
/**
* THE FREIGHT AGENT WAS CHOSEN AND DID NOTHING, which is a decision rather than an absence.
*
* §6.3 requires no action, and the bot deliberately takes that route — unjamming a healthy box
* destroys a load that cost a whole Local Operations action to stock, so an idle Stage is
* strictly better. Reported from a table on Day 1 Stage 3 of v0.8.0.16: the log announced
* FREIGHT AGENT work and then said nothing at all, so the turn read as a bug.
*
* Reduces to nothing, like `switchingEnded` above: it reports a choice the state already holds.
*/
| { type: 'freightAgentIdled'; player: PlayerIndex }
/**
* A train the Interlocking was holding at the Limits has taken the A/D track that just freed.
*
* It happens as a side effect of ANOTHER train arriving and clearing the Office, so without this
* the held train simply appeared at the Office with nothing said — reported from a table as
* "wasn't clear what changed and why train 8 was suddenly released". `freedBy` names the train
* whose arrival did it, because "why now" is the whole question.
*/
| {
type: 'trainReleasedFromLimits';
trainNumber: number;
office: string;
owner: SeatIndex;
freedBy: number;
}
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
/**
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
@@ -174,7 +200,7 @@ export type GameEvent =
*/
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number }
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number; cardId: CardId }
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
| { type: 'trainHeld'; trainNumber: number; reason: string }
/** A train whose card pays for standing still (X18 Circus) collected on it. */
+5
View File
@@ -313,6 +313,11 @@ export type RejectionCode =
| 'INBOUND_BOX_FULL'
| 'NO_EMPTY_COACH_IN_YARD'
| 'NOT_A_CONTROL_POINT'
/**
* A passenger Modifier — Waiting Area, Restaurant, Hotel — played at an Office that is still a
* Whistle Post. A Whistle Post is not a Passenger Facility (§9), so it has nothing to add to.
*/
| 'OFFICE_NOT_PASSENGER'
| 'NO_EXTRA_PENDING'
/** §7 gives an Extra to the player who played the card; another seat may not place it for them. */
| 'NOT_YOUR_EXTRA'
+13 -2
View File
@@ -313,12 +313,19 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
*
* Deduplicated because the two lists overlap: `placements` already contains the Limits signs.
*/
/**
* A TURNOUT AND AN INDUSTRY MAY BOTH BUILD OVER TRACK ALREADY DOWN, so both are offered the
* occupied cells as well as the empty ones and `check` decides which it will take. Without this
* the rule exists in the engine and is never once presented — which is how the 18 Enhancement
* cards came to be permanently dead.
*/
const isTurnout = kind?.kind === 'track' && kind.geometry === 'turnout';
const isFacility = kind?.kind === 'freightFacility';
const targets = onMainline
? []
: kind?.kind === 'enhancement'
? attachments
: isTurnout
: isTurnout || isFacility
? dedupe([...placements, ...attachments])
: placements;
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
@@ -375,7 +382,11 @@ function newTrainCandidates(s: GameState, player: PlayerIndex): Intent[] {
}
const due = s.timetable[s.clock.stage - 1];
if (due !== null && due !== undefined) {
out.push({ type: 'newTrain.secondSection', trainNumber: due });
// Q9 — the order costs the card, so it is only a candidate while the card is in hand.
// `check` is still the authority; this keeps the menu from offering what it will refuse.
if ((s.decks.hands.get(player) ?? []).some((id) => s.cards.get(id)?.kind.kind === 'secondSection')) {
out.push({ type: 'newTrain.secondSection', trainNumber: due });
}
}
/**
* WHERE A PENDING EXTRA MAY START (§7, Jesse's ruling) — every candidate offered, with `check`
+37 -7
View File
@@ -23,11 +23,13 @@ import {
TRACK_CARDS,
ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY,
SECOND_SECTION,
TIMETABLED_TRAINS,
crewTrayCount,
mainlineCardCount,
officeProfile,
} from './content.ts';
import type { StartingOffice } from './content.ts';
import type { Rng } from './rng.ts';
import { createRng } from './rng.ts';
import type {
@@ -53,7 +55,11 @@ export type SetupOptions = {
};
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
export function buildDeck(mode: GameConfig['mode'] = 'competitive', pvpCardsAllowed = false): Card[] {
export function buildDeck(
mode: GameConfig['mode'] = 'competitive',
pvpCardsAllowed = false,
startingOffice: StartingOffice = 'whistlePost',
): Card[] {
/**
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK REGARDLESS OF `pvpCardsAllowed`, for now.
*
@@ -81,7 +87,16 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive', pvpCardsAllo
for (const t of TIMETABLED_TRAINS) push({ kind: 'timetabledTrain', number: t.number });
for (const t of EXTRA_TRAINS) push({ kind: 'extraTrain', number: t.number });
/**
* OFFICE UPGRADES, MINUS THE TIER EVERYBODY ALREADY HAS.
*
* A table that starts on Depots has no use for a Depot card: `check` refuses it, because an
* upgrade must be to the NEXT tier and a Depot is not an upgrade on a Depot. Leaving them in
* would deal four dead cards into a 90-odd card deck — the same dead draw the opponent-directed
* cards are held out for. Station and Terminal are still upgrades and stay.
*/
for (const o of OFFICE_PROFILES) {
if (o.tier === startingOffice) continue;
for (let i = 0; i < o.copiesInDeck; i++) push({ kind: 'office', tier: o.tier });
}
for (const f of FREIGHT_PROFILES) {
@@ -90,6 +105,18 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive', pvpCardsAllo
for (const m of MODIFIER_PROFILES) {
for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind });
}
/**
* Q9 — THE SECOND SECTION CARD, WHICH WAS DECLARED AND NEVER DEALT.
*
* `content.ts` has carried `SECOND_SECTION` (1 copy) all along and `setup.ts` never built it into
* the deck, while `newTrain.secondSection` was offered free on every train due out — so the one
* card that is supposed to gate the action did not exist and the action cost nothing. The bot ran
* 26 accidental Second Sections in one measured round because of it.
*
* Jesse's ruling, 2026-09-23: the action requires the card. Dealing it is the other half — gating
* on a card the deck never holds would delete the mechanic rather than fix it.
*/
for (let i = 0; i < SECOND_SECTION.copies; i++) push({ kind: 'secondSection' });
if (opponentCardsInDeck) {
for (const c of SPACE_USE_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key });
@@ -147,7 +174,7 @@ export function buildRollingStock(): RollingStock[] {
* branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
* drawn turnout cards only.
*/
function buildOfficeArea(seat: SeatIndex): OfficeArea {
function buildOfficeArea(seat: SeatIndex, tier: StartingOffice): OfficeArea {
const row = 0;
const officeCoord = { row, col: 0 };
const limitsWest = { row, col: -1 };
@@ -158,7 +185,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: buildPassengerFacility('whistlePost'),
facility: buildPassengerFacility(tier),
modifiers: [],
enhancements: [],
};
@@ -180,7 +207,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
return {
seat,
tier: 'whistlePost',
tier,
grid,
officeCoord,
runningRow: row,
@@ -230,7 +257,7 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
* `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so
* a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the
* deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory
* (`docs/StationMaster-Mainline-Deck-v0.4.5.md`, which flagged this as needing correction), and the
* (`docs/mainline-deck.md`, which flagged this as needing correction), and the
* deal is now a deal: take cards out of it and do not put them back.
*
* The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is
@@ -285,6 +312,9 @@ export function createGame(opts: SetupOptions): GameState {
const rng = createRng(seed);
const playerCount = playerNames.length;
// Resolved once, here, because it decides BOTH the Office every area is built on and which office
// cards the deck holds — and the two must not be able to disagree.
const startingOffice = houseRules(config).startingOffice;
const players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
@@ -292,7 +322,7 @@ export function createGame(opts: SetupOptions): GameState {
// the players occupying them, and starts as the identity mapping, which is what makes the
// seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else.
const officeAreas = new Map<SeatIndex, OfficeArea>();
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat));
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat, startingOffice));
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
const divisionRolls = players.map(() => rng.d12());
@@ -339,7 +369,7 @@ export function createGame(opts: SetupOptions): GameState {
*/
const rules = houseRules(config);
const deal = OPENING_DEALS[rules.startingHand];
const deck = buildDeck(config.mode, config.pvpCardsAllowed);
const deck = buildDeck(config.mode, config.pvpCardsAllowed, rules.startingOffice);
const cards = new Map<CardId, Card>();
for (const c of deck) cards.set(c.id, c);
+2
View File
@@ -554,6 +554,8 @@ export type CardKind =
| { kind: 'timetabledTrain'; number: number }
| { kind: 'extraTrain'; number: number }
| { kind: 'office'; tier: OfficeTier }
/** Q9 — ordered on a train that is due out; a second, identical train runs right behind it. */
| { kind: 'secondSection' }
| { kind: 'freightFacility'; facility: FreightKind }
| { kind: 'modifier'; modifier: ModifierKind }
/** Handedness is printed on the card: it is the diagonal the 45° leg lies on. */
+13 -1
View File
@@ -389,7 +389,19 @@ export function reachableDestinations(
* and only the obstructions are worth listing in the "why nothing is moving" panel, where every
* turnout in the district would otherwise appear.
*/
export type MoveBlockKind = 'noJoin' | 'occupied' | 'locked' | 'tooManyCars' | 'noStopping';
export type MoveBlockKind =
| 'noJoin'
| 'occupied'
| 'locked'
| 'tooManyCars'
| 'noStopping'
/**
* The rails go there and the TRAIN'S OWN CARD does not allow it — a no-switching or drop-only
* train that would have to couple something, an empties-only train facing a loaded car, or a
* freight allowance already spent at that location. Decided by `check` rather than by the walk,
* so it is added in `movesFor` rather than emitted by `exploreMoves`.
*/
| 'cardRule';
export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
/**
+8
View File
@@ -85,6 +85,14 @@ const MIME: Record<string, string> = {
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
/**
* The Quickstart guide, published by `build-web.ts` as `quickstart.md`.
*
* text/plain ON PURPOSE. The fallback below is `application/octet-stream`, which makes a browser
* DOWNLOAD the file instead of showing it — so without this line the splash page's "read the
* guide" link hands a tester a file to save rather than a page to read.
*/
'.md': 'text/plain; charset=utf-8',
};
const HEARTBEAT_MS = 20_000;
+25 -5
View File
@@ -18,6 +18,7 @@
* checked, before `submit` is ever called — see `intent()` below.
*/
import { withSavedOpening } from '../engine/content.ts';
import { check } from '../engine/apply.ts';
import { legalActions } from '../engine/legal.ts';
import type { Intent } from '../engine/intents.ts';
@@ -212,10 +213,25 @@ function buildSession(
return snapshot(game.state, [], null, null, null, false, seat);
}
function linesSince(seat: PlayerIndex): { text: string; tone: string }[] {
const already = sentLines.get(seat) ?? 0;
sentLines.set(seat, game.log.length);
return game.log.slice(already);
/**
* WHAT THIS SEAT HAS NOT BEEN SENT YET, bookmarked by SEQUENCE rather than by position.
*
* This was `game.log.slice(sentLines.get(seat))` against an array the game trims to `LOG_LIMIT`.
* Once a seat's bookmark reached that limit the array stopped growing past it, so the slice
* returned an empty list on every push from then on and that seat's history froze permanently —
* at a different moment for each seat, because each holds its own bookmark. Reported from a
* two-player game that did not reach Day 5.
*
* A sequence number cannot run past the end: lines that have been trimmed are simply gone, and
* everything still held with a higher `seq` is sent. A seat that has missed more than the log
* keeps gets what remains rather than nothing.
*/
function linesSince(seat: PlayerIndex): { text: string; tone: string; seq: number }[] {
const already = sentLines.get(seat) ?? -1;
const fresh = game.log.filter((l) => l.seq > already);
const last = game.log[game.log.length - 1];
if (last) sentLines.set(seat, last.seq);
return fresh;
}
function menuFor(seat: PlayerIndex): Menu | null {
@@ -511,7 +527,11 @@ export function createSession(
export type ResumeFailure = { stoppedAt: number; of: number; intent: string; code: string };
export function tryResumeSession(saved: SavedGame): { ok: true; session: GameSession } | { ok: false; failure: ResumeFailure } {
const { game, stopped } = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
// A save written before `startingOffice` existed opened on a Whistle Post and cannot say so —
// replaying it under today's Depot default would deal a different railroad. See `withSavedOpening`.
const { game, stopped } = fromMultiplayerSave(
saved.seed, withSavedOpening(saved.config), saved.playerNames, saved.history,
);
if (stopped) {
return {
ok: false,
+96 -1
View File
@@ -71,6 +71,22 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
* anything outside its own body.
*/
/**
* The Mainline modifiers, as the three letters drawn on a card.
*
* DECLARED INSIDE THIS FUNCTION, like every constant around it, because `sim/replay.ts` emits
* `divisionSvg.toString()` into the replay page — a module-scope const it closed over would be
* undefined there, and the page threw exactly that way before this was moved.
*
* Keyed by the name the VIEW builds (`prettyKey`'d in `view.ts`), because that is what arrives
* here — not the engine's key, which this file never sees. A modifier with no tag draws nothing
* rather than a raw key: `flatMap` over a missing entry yields none, so a card added upstream is
* silently unmarked instead of printing "rotaryDumps" at a player.
*
* ABS Signals is deliberately absent: it has a signal mast of its own.
*/
const MOD_TAGS: Record<string, string> = { Brakeman: 'BRK', Airbrakes: 'AIR', Helpers: 'HLP' };
const CW = { dp: 118, ml: 152, run: 78 };
/**
* TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row.
@@ -144,6 +160,10 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
tip: string;
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
flash?: boolean;
/** ABS Signals standing on this Mainline card — drawn as a signal mast, not only described. */
abs?: boolean;
/** The Mainline modifiers on this card, as short tags — see `MOD_TAGS`. */
mods?: string[];
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
seat: number | null;
/** Set on an Office cell when a roster was supplied: whose district this is. */
@@ -291,6 +311,10 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
// words with no gameplay attached — reported exactly that way.
(n.what ? `\n\n${n.what}` : ''),
seat: null,
// Attachments are drawn, not only described: `modifiers` carries ABS Signals alongside the
// Mainline modifiers, and the name is the one the view already built for the tooltip.
abs: !dp && n.modifiers.includes('ABS Signals'),
mods: dp ? [] : n.modifiers.flatMap((m) => MOD_TAGS[m] ?? []),
// A Division Point is one region — the queue trains enter and leave the Division through.
regions: dp ? 1 : (n.regions ?? 0),
gradeUp: dp ? null : (n.gradeUp ?? null),
@@ -416,6 +440,57 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
out += `</g>`;
}
/**
* ABS SIGNALS, DRAWN RATHER THAN ONLY SAID — reported from a table on Day 1 Stage 1 of
* v0.8.0.16: "when played on the trestle, there was no on-the-card indication; it's only when
* you look at the tooltip for trestle that you see that ABS exists."
*
* Exactly the complaint the grade wedge below answers, and the card it protects is the one a
* player is deciding whether to run a second train onto. A signal is the literal object — ABS
* is Automatic Block Signals — so the mark is a mast with a lit lamp rather than a badge
* reading "ABS", and it needs no room for text, which is what lets it sit clear of a name as
* long as "Uncontrolled Siding" on a 152px card.
*
* TOP-RIGHT, above the rail and clear of the chip registers: the flag owns the cell ends at
* rail height and the grade wedge owns the bottom-right, so this is the corner left.
*/
if (c.abs) {
const mx = c.x + c.w - 10;
const top = c.y + 5;
out += `<g class="bs-abs">`;
out += `<line class="bs-abs-mast" x1="${mx}" y1="${top}" x2="${mx}" y2="${top + 15}"/>`;
// Two lamps, the upper one lit: a signal showing an aspect, not a bare post.
out += `<circle class="bs-abs-lit" cx="${mx}" cy="${top + 3}" r="2.6"/>`;
out += `<circle class="bs-abs-dark" cx="${mx}" cy="${top + 9}" r="2.6"/>`;
out += `</g>`;
}
/**
* THE MAINLINE MODIFIERS, DRAWN RATHER THAN ONLY SAID (Jesse, 2026-09-21) — the other half of
* the ABS report above: "Brakeman, Airbrakes, Helpers and Realignment should also be drawn on
* the card, not just the tooltip."
*
* ONLY THREE OF THOSE FOUR CAN EVER BE HERE. Realignment does not sit on a card — `reduce`
* takes the `became` branch and CHANGES `node.card`, so a realigned Trestle simply is an
* Uncontrolled Siding afterwards and the card face already says so. The other three are
* `gradeOnly`, so in practice this is a Heavy Grade's row.
*
* TAGS, NOT NAMES, and only because the measurements leave no choice: "Brakeman · Airbrakes ·
* Helpers" is 30 characters where about eleven fit beside a card name on a 152px cell. Three
* letters is the most that fits while still mapping to one card each, and the tooltip — which
* has always named them in full — is what expands it. The mark says THAT there is one, which
* is the half that was missing.
*
* Right-aligned on the name row: the only band on the cell that is clear, with the chip
* registers starting at `CHIP_Y` below and the capacity line and grade wedge at the foot.
* Shifted left of the signal when a card carries both.
*/
if (c.mods && c.mods.length > 0) {
out +=
`<text class="bs-mod" text-anchor="end" x="${c.x + c.w - (c.abs ? 20 : 7)}" y="${c.y + 14}">` +
`${esc(c.mods.join('·'))}</text>`;
}
/**
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
*
@@ -1184,7 +1259,17 @@ export function officeSvg(
const tx = W / 2 - tw / 2;
const facingWord = t.facing === 'e' ? 'east' : 'west';
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
out += `<g class="bs-crew" data-tip="${esc(
/**
* HELD AT THE LIMITS, DRAWN RATHER THAN ONLY DESCRIBED (Jesse, 2026-09-23).
*
* The view has carried this flag since #99 and NO renderer read it, so the train drew like
* any other crew and nothing told a player to hover — and hovering was the only way to learn
* why a train had stopped short of the Office for several Stages. The tooltip text is already
* on `t.what`; this is the mark that sends you to it. Red, the same "stopped, and not by
* choice" the Red Flag means elsewhere on this map, and dashed because it is waiting.
*/
const held = t.heldAtLimits === true;
out += `<g class="bs-crew${held ? ' bs-held' : ''}" data-tip="${esc(
`${t.label} — engine pointing ${facingWord}, carrying ${consistWords}` + (t.what ? `\n\n${t.what}` : ''),
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
@@ -1392,6 +1477,16 @@ export const BOARD_CSS = `
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
.bs-mod{font:10px ui-monospace,monospace}
text.bs-mod{fill:#c8a04a}
/* A signal standing on a Mainline card carrying ABS Signals. Green is otherwise unused on the
Division row apart from the Division Point's dashed border, so a lit lamp does not compete with
the amber "it is happening here" or the red flag for meaning. */
/* A train the Interlocking is holding on the Limit Track. Red, matching the Red Flag's "stopped and
not by choice"; dashed, because it is waiting rather than parked. */
.bs-crew.bs-held rect{fill:#2c1d1d;stroke:#d2453f;stroke-width:1.6;stroke-dasharray:4 2}
.bs-crew.bs-held .bs-tlab{fill:#f0c2be}
.bs-abs-mast{stroke:#9aa3b0;stroke-width:1.6}
.bs-abs-lit{fill:#4fae6a;stroke:#2c6b40;stroke-width:0.8}
.bs-abs-dark{fill:#2a3038;stroke:#59626f;stroke-width:0.8}
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
.bs-enh-spent{fill:#5b6b7d;text-decoration:line-through}
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
+106 -13
View File
@@ -74,7 +74,16 @@ export function carsLabel(cars: RollingStock[]): string {
return cars.map(carLabel).join(', ');
}
const at = (c: GridCoord): string => `(${c.row},${c.col})`;
/**
* X,Y — EAST/WEST THEN NORTH/SOUTH, exactly as `view.ts` writes it, and NOT the internal row/col
* storage order.
*
* These two disagreed until 2026-09-21: the action menu said "(1,-1)" and the log said "(-1,1)" for
* the same square, side by side on the same screen. `view.ts` carried the comment explaining why
* the display order is X,Y; this one had no comment at all and was simply the storage order
* reaching the page. Jesse's call — the log and the action menu spell a square the same way.
*/
const at = (c: GridCoord): string => `(${c.col},${c.row})`;
const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const;
const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`;
@@ -135,6 +144,17 @@ export type NarrateContext = {
* industry is the whole point of the move, so it is what the line should say.
*/
facilityAt?: (player: PlayerIndex, at: GridCoord) => string | null;
/**
* Names the Mainline card at a Division node — "Plains", "Heavy Grade" — or null if it cannot.
*
* A NODE INDEX IS NOT A NAME. The log said "ENHANCEMENT built: abs Signals on Mainline card 7",
* which asks the reader to remember what card 7 was (Jesse, 2026-09-22, playing ABS Signals).
* The SLOT still matters and is kept beside it, because a division can hold two Plains and the
* number is the only thing that tells them apart.
*/
mainlineAt?: (node: number) => string | null;
/** The printed name of an Enhancement or Mainline modifier, by its content key. */
enhancementName?: (key: string) => string | null;
};
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
@@ -143,6 +163,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
// The industry on a square when there is one, and the coordinate when there is not — a crew works
// plain track too, and "at nowhere" would be worse than the notation.
const place = (player: PlayerIndex, c: GridCoord): string => ctx.facilityAt?.(player, c) ?? at(c);
/** "Mainline card 7 (Plains)" — the slot, which disambiguates, plus the card, which informs. */
const mainlineCard = (node: number | undefined): string => {
if (node === undefined) return 'the Mainline';
const name = ctx.mainlineAt?.(node) ?? null;
return `Mainline card ${node}${name ? ` (${name})` : ''}`;
};
switch (e.type) {
// -- clock
@@ -188,7 +214,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? 'Chose to SWITCH — six Moves to shunt cars around the yard. Watch the crew chip on the grid: it carries its consist with it, and cars it passes over are coupled automatically.'
: e.option === 'draw'
? 'Chose to DRAW a card'
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
/**
* SAYS WHAT MAY BE DONE, NOT WHAT WAS. This read "one car moved to or from a
* facility" — an assertion — and §6.3 requires no action at all, so when the Freight
* Agent went idle the log claimed a car had moved and then fell silent about which.
* Reported from a table on Day 1 Stage 3 of v0.8.0.16. The work itself is narrated by
* `stockToOutbound`, `inboundCleared` and `facilityUnjammed`, each naming the car and
* the industry; an idle Agent is narrated by `freightAgentIdled`.
*/
: 'Chose FREIGHT AGENT work — may stock a green Outbound box, clear a red Inbound one, or free a jam',
};
case 'trayMoved':
// `via` rides on the event only when there was another legal route to the same square
@@ -304,7 +338,10 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'plain',
text: e.became
? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}`
: `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`,
// Same two faults as `enhancementPlaced` above, in the neighbouring case: a raw key for a
// name, and a slot with nothing to say which card it is when `from` is absent.
: `Played ${ctx.enhancementName?.(e.key) ?? e.key} on Mainline card ${e.node}` +
` (${e.from ? kindName(e.from) : (ctx.mainlineAt?.(e.node) ?? 'that card')})`,
};
}
case 'redFlagSpent':
@@ -354,11 +391,17 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
};
case 'enhancementPlaced': {
const name = e.key.replace(/([A-Z])/g, ' $1');
/**
* THE CARD'S OWN NAME, not a key with spaces wedged into it. This was
* `e.key.replace(/([A-Z])/g, ' $1')`, which rendered `absSignals` as "abs Signals" —
* uncapitalised, and bypassing the authored names `cardName` started honouring in v0.8.0.17
* for exactly this reason: an acronym is not recoverable by splitting on capitals.
*/
const name = ctx.enhancementName?.(e.key) ?? e.key.replace(/([A-Z])/g, ' $1');
// Out on the Mainline is not a square in anyone's district, so it is named rather than
// given a coordinate the Office Area does not have.
if (e.at === undefined) {
return { tone: 'good', text: `ENHANCEMENT built: ${name} on Mainline card ${e.node}` };
return { tone: 'good', text: `ENHANCEMENT built: ${name} on ${mainlineCard(e.node)}` };
}
return { tone: 'good', where: e.at, text: `ENHANCEMENT built: ${name} at ${at(e.at)}` };
}
@@ -398,11 +441,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
text: `${e.isExtra ? `EXTRA X${e.trainNumber}` : `TRAIN ${e.trainNumber}`} MADE UP at the ${e.at}, running ${e.direction} — crew assigned, now taking cars`,
};
case 'trainStoodStill':
/**
* SPELLS OUT WHAT EARNED IT (Jesse, 2026-09-23: "make sure history calls out when point
* earned"). The line said the train had stood still and a point arrived; it did not say that
* the point is per DISTRICT and can be earned again in the next one, which is the whole of
* how the card is played.
*/
return {
tone: 'good',
text:
`Train ${e.trainNumber} stood still for a whole Stage at ${e.where} and earned a point — ` +
'its card pays for the stop, not for the run (circus set-up)',
`CIRCUS SET-UP — Train ${e.trainNumber} stood still for a whole Stage at ${e.where}, ` +
'fully loaded, and earned 1 Revenue. Its card pays for the STOP, not for the run: one ' +
'point per district, and it can earn again in the next district it stands a Stage in.',
};
case 'trainHeld':
return {
@@ -466,23 +516,54 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
};
// -- freight agent
/**
* THE INDUSTRY, NOT THE COORDINATE — `place` over `at`, for the reason its own comment gives:
* "(-1,1)" is the grid's notation and means nothing at a table where people are looking at
* cards. The switching lines were moved to it and these three were missed, so a Freight Agent
* turn was the one place the log still spoke in coordinates. Asked directly from a table on
* Day 1 Stage 3 of v0.8.0.16: "can we tell what car, what facility and whether it was to or
* from." The car and the direction were already here; the facility was not.
*/
case 'stockToOutbound':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent put a ${carLabel(e.stock)} into the green Outbound box at ${at(e.at)}`,
text: `Freight Agent loaded a ${carLabel(e.stock)} INTO the green Outbound box at ${place(e.player, e.at)}`,
};
case 'inboundCleared':
return {
tone: 'plain',
where: e.at,
text: `Freight Agent cleared a ${carLabel(e.stock)} from the red Inbound box at ${at(e.at)}`,
text: `Freight Agent cleared a ${carLabel(e.stock)} OUT of the red Inbound box at ${place(e.player, e.at)}`,
};
case 'facilityUnjammed':
return {
tone: 'bad',
text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
where: e.at,
text: `UNJAMMED ${at(e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
};
case 'trainReleasedFromLimits': {
/**
* WHY NOW, which is the whole question. The train has been sitting at the Limits for however
* many Stages, and what changed is that somebody else's train cleared the Office.
*/
const who = ctx.playerName?.(e.owner as never) ?? null;
return {
tone: 'good',
text:
`Train ${e.trainNumber} RELEASED from the Limits into the ${e.office}` +
`${who ? ` at ${who}'s district` : ''} — the Interlocking had been holding it clear of a ` +
`full Office, and Train ${e.freedBy} arriving freed the A/D track it was waiting for. ` +
'A held train takes the first track to free, ahead of anything arriving after it.',
};
}
case 'freightAgentIdled':
return {
tone: 'quiet',
text:
'Freight Agent found nothing worth doing — no green box could be stocked, no red box needed ' +
'clearing, and no load was jammed. §6.3 requires no action, and unjamming a healthy box ' +
'would destroy a load that cost a whole action to stock.',
};
// -- trains
@@ -642,10 +723,22 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
};
// -- consequences
case 'revenueChanged':
case 'revenueChanged': {
/**
* WHOSE REVENUE, NAMED HERE RATHER THAN LEFT TO THE PREFIX.
*
* Reported from a table: the line gives the change and the running total and no player. The
* history's own prefix names 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. `e.player` is the seat that earned it, which is the only right answer, so the
* line resolves its own name and `record` leaves it alone (`SELF_NAMED` in `web/game.ts`).
*/
const whose = ctx.playerName?.(e.player) ?? null;
const owner = whose === null ? '' : `${whose} `;
return e.delta < 0
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${owner}${e.delta} Revenue (now ${e.total})` }
: { tone: 'good', text: `${owner}+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
}
case 'phaseEnded':
return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
+28 -2
View File
@@ -40,7 +40,17 @@ export type TurnChartFrame = {
* screen. `Frame` has carried `superintendent` all along and the standalone replay printed it; the
* live game never did.
*/
export function turnChartHtml(f: TurnChartFrame, actorName: string | null, superName: string | null = null): string {
/**
* `yours` is true when the player reading this is the one being waited on. It makes the line flash,
* because the commonest way a table stalls is somebody not noticing their own turn has come round
* (reported from a playtest). The replay viewers pass nothing: a recording waits on nobody.
*/
export function turnChartHtml(
f: TurnChartFrame,
actorName: string | null,
superName: string | null = null,
yours = false,
): string {
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
{
key: 'localOps',
@@ -138,7 +148,8 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
`<span class="dim">${esc(f.clock)}</span></div>` +
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
`<div class="tc-who">${waiting}<b>${esc(who)}</b>${asked}</div>` +
`<div class="tc-who${yours && actorName !== null ? ' tc-yours' : ''}">` +
`${waiting}<b>${esc(who)}</b>${asked}</div>` +
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
// between the phases and everything above them, which put a thing that changes every third
// Stage in the middle of the things that change every Stage. The row it belongs beside is the
@@ -169,6 +180,21 @@ export const TURNCHART_CSS = `
.tc-asks{color:#a99ac4;font-style:italic}
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
border-radius:11px;padding:1px 9px;font-size:12px}
/* IT IS YOUR TURN. The commonest way a table stalls is a player not noticing their turn came round,
so the chip flashes rather than merely changing colour — motion is what catches an eye that is
somewhere else on the board. Amber, because that is what "you can act" means everywhere else on
this screen, and it separates "waiting on YOU" from the violet "where we are" news around it.
A reduced-motion preference holds it steady and lit instead of dropping the cue. */
.tc-who.tc-yours{color:#f0b64a}
.tc-who.tc-yours b{color:#1a1f27;background:#f0b64a;border-color:#f0b64a;font-weight:700;
animation:tc-flash 1s steps(1,end) infinite}
@keyframes tc-flash{
0%,49%{background:#f0b64a;border-color:#f0b64a;color:#1a1f27;box-shadow:0 0 0 3px rgba(240,182,74,.25)}
50%,100%{background:transparent;border-color:#f0b64a;color:#f0b64a;box-shadow:none}
}
@media (prefers-reduced-motion: reduce){
.tc-who.tc-yours b{animation:none;box-shadow:0 0 0 3px rgba(240,182,74,.25)}
}
/* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not
something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one
that changes every turn, while this changes four times a Day. */
+93 -4
View File
@@ -33,6 +33,7 @@ import {
MANEUVER_CARDS,
MODIFIER_PROFILES,
REALIGNMENTS,
SECOND_SECTION,
OFFICE_ORDER,
SPACE_USE_CARDS,
STAGES_PER_SHIFT,
@@ -953,9 +954,22 @@ export function describeIntent(s: GameState, i: Intent): string {
? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`)
: undefined;
const upgrade = over?.geometry.kind === 'track';
/**
* NAME THE MAINLINE CARD, for exactly the reason the rotation is named above — reported from
* a table on Day 1 Stage 1 of v0.8.0.16 and the THIRD time this trap has been sprung.
*
* ABS Signals is played on a Division NODE rather than a grid square, so `i.placement` is
* absent and every one of its placements described itself as plain "play ABS Signals". The
* action list drops duplicate labels, so all but the lowest-index Mainline card were discarded
* before the menu saw them: the tooltip promised "any Mainline card" and the board offered
* one. Naming the card is what makes the choice both legible and survivable.
*/
const onNode = i.node === undefined ? undefined : s.division.nodes[i.node];
const mainline =
onNode?.kind === 'mainline' ? ` on the ${mainlineProfile(onNode.card).name}, out on the Mainline` : '';
return (
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
`${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`
`${i.placement ? ` at ${at(i.placement)}` : ''}${mainline}${turn}`
);
}
case 'card.discard': {
@@ -1137,8 +1151,20 @@ export function describeIntent(s: GameState, i: Intent): string {
? 'the Revenue was paid when the passengers detrained'
: 'the Revenue was paid when the load reached the box';
const frees = f?.kind === 'passenger' ? 'more passengers can detrain here' : 'another car can be unloaded here';
/**
* SAY WHAT HAPPENS, NOT WHAT MOVES (Jesse, 2026-09-22). This read "send the loaded coach in
* the red Inbound box to the Classification Yard", which is true of the counters and
* nonsense as a sentence — it sounds like shipping a coachful of people off to a siding.
* What the red box holds is a journey that has ENDED: the passengers are at the station, or
* the load is at the industry, and both were paid for on arrival. Clearing it sends them on
* their way and returns the CAR to the common supply, empty.
*/
const what =
f?.kind === 'passenger'
? `let the passengers out of the station — the ${car ? carLabel(car) : 'coach'} in the red Inbound box at ${at(i.at)} goes back to the Classification Yard empty`
: `send the delivered load into the industry — the ${car ? carLabel(car) : 'car'} in the red Inbound box at ${at(i.at)} goes back to the Classification Yard empty`;
return (
`send the ${car ? carLabel(car) : 'car'} in the red Inbound box at ${at(i.at)} to the Classification Yard` +
`${what}` +
` — pays nothing (${paid}); it frees${full ? ' the last' : ' a'} slot so ${frees}`
);
}
@@ -1898,15 +1924,53 @@ export function cardName(s: GameState, id: string): string {
// The hand is on the card face and decides which diagonal its 45° leg lies on, so it belongs
// in the name: "curve" alone does not tell you what it can be joined to.
return `${k.hand === 'none' ? '' : `${k.hand}-hand `}${geometryLabel(k.geometry)}`;
case 'secondSection':
return SECOND_SECTION.name;
case 'spaceUse':
case 'enhancement':
case 'mainlineModifier':
case 'maneuver':
case 'action':
return prettyKey(k.key);
/**
* THE PRINTED NAME WINS over `prettyKey`'s guess, on the same reasoning as the facility and
* modifier lookups above: the tables are for names `prettyKey` cannot derive.
*
* It guessed wrong more often than the fallback comment implies. `absSignals` came out as
* "Abs Signals" on the button while the rules, the tooltip and the card face all say **ABS**
* Signals — an acronym no key-splitter can recover — and `brokenCoupler` and `beanHouse`
* were title-cased past their printed "Broken coupler" and "Bean house".
*/
return SIMPLE_CARD_NAMES.get(k.key) ?? prettyKey(k.key);
}
}
/**
* Every authored card name, keyed as the card kinds key themselves.
*
* Built from the content tables rather than restated, so a card renamed there is renamed here and
* the two cannot drift — which is the whole argument of TODO #15a, applied to names.
*/
/**
* The printed name of a card that has one, by its content key, or null.
*
* Exported so the LOG can reach the same table the buttons do. `narrate.ts` rendered its own name
* from the key (`absSignals` → "abs Signals") and so disagreed with the action list one line above
* it; it takes this through its `enhancementName` resolver now.
*/
export function simpleCardName(key: string): string | null {
return SIMPLE_CARD_NAMES.get(key) ?? null;
}
const SIMPLE_CARD_NAMES: ReadonlyMap<string, string> = new Map(
[
...SPACE_USE_CARDS,
...ENHANCEMENT_CARDS,
...MAINLINE_MODIFIER_CARDS,
...MANEUVER_CARDS,
...ACTION_CARDS,
].map((c) => [c.key, c.name]),
);
/**
* What a card actually DOES, in one line.
*
@@ -1984,6 +2048,14 @@ export function cardDescription(s: GameState, id: string): string {
: '';
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}${warn}`;
}
case 'secondSection':
// Q9. Worth spelling out: it is the one card that creates the following-train situation §8.1
// makes the Superintendent rule on, so playing it is choosing to put that question to them.
return (
'order a second, identical train right behind one due out this Stage · it needs its own ' +
'Crew Tray, and it deliberately creates the following-train situation the Superintendent ' +
'must rule on (§8.1)'
);
case 'track': {
// Track is the largest category in the deck, so a player holds it constantly — and what it
// can be joined to is decided by the hand, which is not something the name alone conveys.
@@ -2027,7 +2099,24 @@ export function cardDescription(s: GameState, id: string): string {
: rule?.effect === 'dormantSolo'
? ' · never fires in solitaire — it answers an opponent card the solo deck omits'
: '';
return `${card.effect} · played on ${card.placement}${note}`;
/**
* WHICH MAINLINE CARDS A REALIGNMENT CAN ACTUALLY CONVERT (Jesse, 2026-09-23: "how does the
* user know what the realignment card can be used on?").
*
* The card printed "Convert one Mainline type to another. Not while a train is on it", which
* is true and useless: only four of the nine types convert at all, and a table whose Division
* dealt none of them has a card that can never be played. That is exactly what happened —
* Uncontrolled Siding, Heavy Grade and Tunnel, with the Siding already converted, so the
* second Realignment had no target and nothing said why.
*
* Named from `REALIGNMENTS` rather than written out, so the list cannot drift from the rule.
*/
const converts =
card.key === 'realignment'
? ` · only converts ${REALIGNMENTS.map((r) => mainlineProfile(r.from).name).join(', ')}` +
' — no other Mainline card can be realigned'
: '';
return `${card.effect} · played on ${card.placement}${converts}${note}`;
}
}
}
+86 -23
View File
@@ -37,22 +37,14 @@ import { collectStep, newCollector } from '../sim/display-step.ts';
import type { DisplayCollector } from '../sim/display-step.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
import {
cardDescription,
cardName,
currentActorOfState,
describeIntent,
geometryLabel,
snapshot,
trainName,
variantLabel,
} from '../sim/view.ts';
import { cardDescription, cardName, currentActorOfState, describeIntent, geometryLabel, simpleCardName, snapshot, trainName, variantLabel } from '../sim/view.ts';
import {
DEFAULT_DAYS,
DEFAULT_HOUSE_RULES,
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
LEGACY_HOUSE_RULES,
withSavedOpening,
collectiveRevenueFloor,
houseRules,
industryProfile,
@@ -238,8 +230,19 @@ export type Game = {
seed: number;
/** Every intent submitted, in order — the save file. */
history: Intent[];
/** Narrated lines, newest last. */
log: { text: string; tone: string }[];
/**
* Narrated lines, newest last.
*
* `seq` IS A RUNNING COUNT, NOT A POSITION. The log is trimmed to `LOG_LIMIT` below, and a seat's
* "what have I sent you" bookmark used to be an index into this array — so once the array stopped
* growing, the bookmark ran past its end and `slice` returned nothing FOREVER. Reported from a
* two-player game that did not reach Day 5: the history froze for one player at Day 2 Stage 8 and
* the other at Day 2 Stage 4, at different moments because each seat had its own bookmark.
*
* A sequence number survives trimming, so the bookmark stays meaningful however much is dropped —
* and a reader can tell a gap from a quiet spell, which an index could never do.
*/
log: { text: string; tone: string; seq: number }[];
/**
* True when the hand is OVER the limit, so the turn cannot end until it is played down.
*
@@ -322,13 +325,30 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
*/
export const SOLO_PLAYER = 'Solitaire';
/**
* The next sequence number for a game's log, and the only way a line should ever be added.
*
* Kept off `Game` so the type stays serialisable: the counter is derived from the last line, which
* survives a save/restore and a trim alike.
*/
/**
* How many narrated lines a game keeps in memory. Everything older is dropped; `history` still holds
* every intent, so a trimmed game replays in full.
*/
export const LOG_LIMIT = 4000;
export function pushLine(game: Game, text: string, tone: string): void {
const last = game.log[game.log.length - 1];
game.log.push({ text, tone, seq: (last?.seq ?? -1) + 1 });
}
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
game.log.push({ text: `Solitaire · one player · seed ${seed}`, tone: 'quiet' });
pushLine(game, 'Game Begins', 'start');
pushLine(game, `Solitaire · one player · seed ${seed}`, 'quiet');
drain(game);
return game;
}
@@ -344,7 +364,7 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
game.log.push({ text: 'Game Begins', tone: 'start' });
pushLine(game, 'Game Begins', 'start');
/**
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
*
@@ -359,7 +379,7 @@ export function newMultiplayerGame(seed: number, config: GameConfig, playerNames
* less down". The seed remains in `game.seed`, in every save (`session.ts` persistence) and in the
* lobby record, so nothing administrative or replayable loses it.
*/
game.log.push({ text: `${config.mode} · ${playerNames.length} players`, tone: 'quiet' });
pushLine(game, `${config.mode} · ${playerNames.length} players`, 'quiet');
drain(game);
return game;
}
@@ -1020,7 +1040,21 @@ function trainCardTitle(number: number, isExtra: boolean): string | null {
const calls = parts.length > 0 ? parts.join(' + ') : 'no cars at all';
const note = p.rules.note ? ` — ${p.rules.note}` : '';
const name = `${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}”`;
return `Making up ${name}: its card calls for ${calls}${note}`;
/**
* WHAT THE CARD PAYS FOR, ON ITS OWN LINE (Jesse, 2026-09-23: "is it clear from the text
* displayed that it earns points only if fully loaded?").
*
* The card's printed note has always been here, but as a clause trailing the consist — so the one
* condition that decides whether the Circus earns anything at all read as flavour. A train whose
* card pays for standing still is played completely differently from one that pays for running,
* and that is worth its own sentence.
*/
const pays = p.rules.stopEarnsPoint
? '\nTHIS TRAIN PAYS FOR STOPPING, not for running: a full Stage standing still in a district ' +
'earns 1 Revenue, once per district — but ONLY if every car except the caboose is loaded. ' +
'Made up short or carrying empties, it earns nothing however long it stands.'
: '';
return `Making up ${name}: its card calls for ${calls}${note}${pays}`;
}
/**
@@ -1157,7 +1191,7 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
const result = applyIntent(game.state, actor, intent);
if (!result.ok) {
game.log.push({ text: `That is not allowed: ${result.code}`, tone: 'bad' });
pushLine(game, `That is not allowed: ${result.code}`, 'bad');
return false;
}
game.history.push(intent);
@@ -1354,6 +1388,19 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
cardName: (id) => cardName(game.state, id),
trainName: (id) => trainName(game.state, id),
facilityAt: (player, at) => facilityOn(game, player, at),
/**
* Which Mainline card a Division node holds. The log used to print the node INDEX alone —
* "on Mainline card 7" — leaving the reader to remember what card 7 was (Jesse, 2026-09-22).
* The index is kept beside the name because a division can deal two Plains, and then the slot
* is the only thing that distinguishes them.
*/
mainlineAt: (node) => {
const n = game.state.division.nodes[node];
return n?.kind === 'mainline' ? mainlineProfile(n.card).name : null;
},
// The printed name, for the same reason `cardName` stopped trusting `prettyKey` in v0.8.0.17:
// no key-splitter recovers "ABS Signals" from `absSignals`.
enhancementName: (key) => simpleCardName(key),
// Whose district a train reached is not the actor — the Mainline Phase has none — so the
// narration resolves the name itself rather than being prefixed with one by the code below.
// NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate
@@ -1391,7 +1438,16 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
*/
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
const ruling = RULINGS.includes(e.type) && who !== null;
const mine = who !== null && 'player' in e;
/**
* EVENTS THAT NAME THEIR OWN PLAYER, and so must not be prefixed with the actor's name as well
* — Gitea#31's rule that no line names a player twice.
*
* `revenueChanged` carries the seat that EARNED it, which is not always the seat that acted: a
* train completing its run pays everybody, with no actor at all. Prefixing it with the actor
* would be wrong on those lines and redundant on the rest, so `narrate` resolves the name.
*/
const SELF_NAMED = ['revenueChanged'];
const mine = who !== null && 'player' in e && !SELF_NAMED.includes(e.type);
const text = ruling
? `Superintendent Player ${who} ${uncapitalise(said)}`
: mine
@@ -1399,7 +1455,7 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
: said;
// `trace` is a tone the history panel does not draw — see `inHistory`. The line exists so the
// step that caused it has narration to caption the board with, and a dwell to be watched for.
game.log.push({ text, tone: inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace' });
pushLine(game, text, inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace');
}
game.cues.push(...cuesFor(events));
@@ -1429,8 +1485,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
game.announced = `${name} is now the Superintendent — the Fedora passed at the end of Stage ${e.stage}.`;
}
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
/**
* Keep the log bounded; the full history lives in `history` and can be replayed.
*
* SAFE TO TRIM ONLY BECAUSE LINES CARRY `seq`. While a seat's bookmark was an index into this
* array, trimming silently froze that seat's history for the rest of the game — see `Game.log`.
* The limit is generous because a four-player game over eight or ten Days is several times the
* two-player, five-Day game that first hit it.
*/
if (game.log.length > LOG_LIMIT) game.log.splice(0, game.log.length - LOG_LIMIT);
}
// ---------------------------------------------------------------------------
@@ -1461,7 +1524,7 @@ export function toSave(game: Game): Save {
* in force then — never the current defaults.
*/
function configFor(save: Save, config: GameConfig): GameConfig {
return { ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES };
return withSavedOpening({ ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES });
}
/**
+10
View File
@@ -27,6 +27,11 @@ h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
/* Not a fourth door: reading the guide is not a way to play, and giving it equal weight in the
grid would say it is. A line under the doors, where somebody who does not know what to click
will already be looking. */
.newhere{margin:16px 2px 0;color:var(--dim);font-size:13px;line-height:1.55}
.newhere a{color:#9fb6d8}
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
.door.disabled .go{color:var(--dim)}
a.door.disabled{pointer-events:none}
@@ -96,6 +101,11 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</a>
</div>
<p class="newhere">New to Station Master?
<a href="./quickstart.html">Read the Quickstart guide</a> &mdash; what the game is, how you win,
how a Stage runs, what is on the screen, and a first twenty minutes. About twenty minutes to
read, and it will save you an hour of guessing.</p>
<div class="rule"></div>
<footer>
+16
View File
@@ -131,6 +131,22 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
// stub only part of the DOM. Bail rather than throwing through the module's caller.
if (!has('lobby') || !has('lb-choice-section')) return;
$('lobby').hidden = false;
/**
* THE TWO DOORS COME BACK EVERY TIME THIS SCREEN DOES.
*
* `enterSeating` hides this section, and until v0.8.1.0 the only things that put it back were the
* lobby's OWN leave paths. Leaving a running game is a different route — `main.ts`'s Leave game
* button calls `runLobby` again — so the section stayed hidden from the previous seating and the
* screen came back holding nothing but "Games you are in". Reported 2026-09-22: "I see the games
* that I'm in, and I have the ability to rejoin or forget. But what if I want to join a different
* game or start a new one? How do I get back there?" There was no way: the doors were on the
* page, display:none, with no control that could reveal them.
*
* Reset here rather than in the leave handler, because this function is the one thing every route
* onto the lobby goes through — which is exactly why the two that did it themselves missed a third.
*/
$('lb-choice-section').hidden = false;
if (has('lb-seating-section')) $('lb-seating-section').hidden = true;
const form = settingsForm('lb-');
let source: EventSource | null = null;
+47 -1
View File
@@ -582,10 +582,18 @@ function renderTurnChart(f: Frame): void {
// chip that can never change is a chip to read past.
const superName =
f.players.length > 1 ? (f.players.find((p) => p.index === table.superintendent)?.name ?? null) : null;
/**
* IS IT YOU BEING WAITED ON? The line flashes if so — the commonest way a table stalls is a
* player not noticing their turn has come round (playtest). Read from the move ON SCREEN, not the
* live one, so it does not start flashing while your board is still catching up on somebody
* else's turn and you cannot act yet.
*/
const yours = !replaying && actor !== null && actor === f.viewer;
$('turnchart').innerHTML = turnChartHtml(
replaying ? { ...f, ...table, awaiting: null } : f,
actorName,
superName,
yours,
);
}
@@ -652,9 +660,47 @@ function renderGameCard(f: Frame): void {
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
$('gamecardbody').innerHTML =
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
rulesListHtml(config, players, f.days);
rulesListHtml(config, players, f.days) +
GUIDE_HTML;
}
/**
* THE DOCUMENTATION, REACHABLE FROM INSIDE A GAME — asked for directly from a table, 2026-09-21:
* "how can we link the documentation so it can be reached from the gameplay, whether someone is
* playing solitaire or multiplayer?"
*
* IT NEEDS NO MODE AWARENESS, which is the whole reason this is three lines rather than a feature.
* Solitaire and multiplayer are the same page on the same origin — `play.html?solitaire` and
* `play.html?lobby` — and the build publishes the documents beside it, so one relative link
* resolves identically in both, on the public site and on a StartOS box alike.
*
* IN THIS CARD RATHER THAN THE HEADER (Jesse's call, 2026-09-21), for the reason he gave the top
* line in the first place on 2026-08-30: it is "not something that they're likely to need all the
* time", and the header is the line that must not wrap. The card is already where reference lives
* — the seed, the seat, the house rules — and it holds the whole set rather than one door.
*
* NEW TAB, every one of them: a player reading the rules mid-turn must not lose the game behind
* them. `rel="noopener"` because `target="_blank"` without it hands the opened page a handle back.
*
* Built once at module scope — it never varies, and rebuilding it on every frame would be work
* nobody sees, the same reasoning the folded body above already follows.
*/
const GUIDE_DOCS: readonly { href: string; label: string; what: string }[] = [
{ href: './quickstart.html', label: 'Quickstart', what: 'What the game is and a first twenty minutes — for anyone who has not played.' },
{ href: './rules.html', label: 'Rules', what: 'The rules in full, with the FAQ.' },
{ href: './home-deck.html', label: 'Home deck', what: 'How the Home Office deck is dealt and played.' },
{ href: './mainline-deck.html', label: 'Mainline deck', what: 'The Mainline cards and what each does to a train.' },
{ href: './components.html', label: 'Components', what: 'Rolling stock, yards, trays, the Fedora.' },
];
const GUIDE_HTML =
`<div class="guide"><h4>Guide</h4><p>` +
GUIDE_DOCS.map(
(d) =>
`<a href="${esc(d.href)}" target="_blank" rel="noopener" data-tip="${esc(d.what)}">${esc(d.label)}</a>`,
).join(' · ') +
`</p></div>`;
/**
* THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30).
*
+49 -1
View File
@@ -268,6 +268,13 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
#gamecardbody dd.changed{color:#f0b64a}
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
/* THE GUIDE, inside the card rather than the header (Jesse, 2026-09-21). Laid out as one wrapping
line of links rather than a list: six references stacked would make the card scroll past the
house rules it sits under, and these are a shelf to reach for, not a thing to read down. */
#gamecardbody .guide{margin-top:10px;border-top:1px solid var(--line);padding-top:6px}
#gamecardbody .guide p{margin:4px 0 0;font-size:12px;line-height:1.7;color:#8b94a3}
#gamecardbody .guide a{color:#9fb6d8;text-decoration:none;border-bottom:1px solid #33404f}
#gamecardbody .guide a:hover{color:#cfe0f5;border-bottom-color:#5aa9e6}
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
so a disabled button must not wear it. */
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
@@ -336,6 +343,27 @@ ul.blocked li{padding:2px 0}
.lb-door:hover{border-color:#4d6fa8}
.lb-door.active{background:#2f3a4b;border-color:#6f8fc8;color:#cfe0f5;font-weight:600}
.lb-saved{display:flex;align-items:center;gap:10px}
/* THE LOBBY'S ACTION BUTTONS CARRY THE SAME AMBER AS THE BOARD'S (Jesse, 2026-09-22).
`#actions button` has meant "this is a move you can make" all game; the lobby is the one screen
where a player is also being asked to act, and it was styling those buttons as plain chrome. The
selector is a LIST OF THE ACTIONS, not `#lobby button` — the settings form beneath Create is a
field of inputs, and amber on all of it would say everything is a move and so say nothing.
Disabled keeps `button:disabled` above: an unfilled Start game must not advertise a click. */
#lb-look, #lb-join, #lb-create, #lb-start, #lb-copy, #lb-copylink, .lb-known-row .lb-rejoin{
background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
color:#f2e6cf;font-weight:600}
#lb-look:hover, #lb-join:hover, #lb-create:hover, #lb-start:hover, #lb-copy:hover,
#lb-copylink:hover, .lb-known-row .lb-rejoin:hover{
background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
#lb-look:focus-visible, #lb-join:focus-visible, #lb-create:focus-visible, #lb-start:focus-visible,
#lb-copy:focus-visible, #lb-copylink:focus-visible, .lb-known-row .lb-rejoin:focus-visible{
outline:2px solid #f0b64a;outline-offset:2px}
/* Copy is still secondary — it carries the amber so it reads as actionable, but not the weight of
the button that starts a game. `button.ghost` set its own border, so it is overridden here. */
#lb-copy, #lb-copylink{border-width:1px;font-size:11px;box-shadow:none}
/* A disabled action is chrome again, or the amber becomes a promise the button will not keep. */
#lb-start:disabled, #lb-start:disabled:hover{
background:#2a3038;border:2px solid var(--line);box-shadow:none;color:inherit}
.lb-known-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--line)}
.lb-known-row:last-child{border-bottom:none}
.lb-known-row .code{font-weight:700;letter-spacing:.06em;color:#f2e6cf;flex:1}
@@ -413,6 +441,7 @@ ul.blocked li{padding:2px 0}
<h2>Join a game</h2>
<p class="ng-note">Ask whoever created the game for its code. You will see the whole rule set
before you take a seat.</p>
<p class="ng-note"><b>Your seat lives in this browser.</b> Rejoining uses a token kept here, so a different browser or device — or clearing this site's data — cannot take your seat back. If that happens, whoever runs the server can issue you a single-use recovery link.</p>
<label class="ng-num"><span>Game code</span><input id="lb-code" type="text" autocomplete="off" placeholder="RAIL-1234"></label>
<button id="lb-look" type="button">Look up game</button>
<p class="lb-error" id="lb-join-err" role="alert"></p>
@@ -434,6 +463,7 @@ ul.blocked li{padding:2px 0}
<section id="lb-create-panel" hidden>
<h2>Create a new game</h2>
<p class="ng-note">You become the host — you choose the game type and, once everyone is seated, start the game. 2 to 4 players.</p>
<p class="ng-note"><b>Your seat lives in this browser.</b> Rejoining uses a token kept here, so a different browser or device — or clearing this site's data — cannot take your seat back. If that happens, whoever runs the server can issue you a single-use recovery link.</p>
<!-- TWO COLUMNS WHERE THERE IS ROOM. One 640px-wide column made this form a very long scroll
for what is really two short lists: what game this is, and what its rules are. -->
@@ -600,6 +630,15 @@ ul.blocked li{padding:2px 0}
<input id="lb-toolbox" type="checkbox"></label>
<span class="set-hint" id="lb-toolbox-hint"></span>
</div>
<div class="set-row" id="lb-whistlestart-row">
<label class="ng-num"><span>Players start with Whistle Posts, not Depots — the harder game.
A Whistle Post has one A/D track and is not a Passenger Facility, so no passenger earns
anything until somebody draws and plays a Depot upgrade, and a second train arriving is a
collision. Leave this off and every district opens as a Depot, with two A/D tracks and
passengers working from Stage 1; the Depot upgrade cards are then left out of the deck</span>
<input id="lb-whistlestart" type="checkbox"></label>
<span class="set-hint" id="lb-whistlestart-hint"></span>
</div>
<div class="set-row" id="lb-tossloco-row">
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
@@ -825,6 +864,15 @@ ul.blocked li{padding:2px 0}
<input id="ss-toolbox" type="checkbox"></label>
<span class="set-hint" id="ss-toolbox-hint"></span>
</div>
<div class="set-row" id="ss-whistlestart-row">
<label class="ng-num"><span>Players start with Whistle Posts, not Depots — the harder game.
A Whistle Post has one A/D track and is not a Passenger Facility, so no passenger earns
anything until somebody draws and plays a Depot upgrade, and a second train arriving is a
collision. Leave this off and every district opens as a Depot, with two A/D tracks and
passengers working from Stage 1; the Depot upgrade cards are then left out of the deck</span>
<input id="ss-whistlestart" type="checkbox"></label>
<span class="set-hint" id="ss-whistlestart-hint"></span>
</div>
<div class="set-row" id="ss-tossloco-row">
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
Department slot. Turn this off and a train card can only ever be played onto the timetable.
@@ -902,7 +950,7 @@ ul.blocked li{padding:2px 0}
every load and nothing let go of it. This keeps your seat — the table waits for you — and
your token, and puts you back at the lobby, which lists every game this browser is in. -->
<button id="leavegame" hidden title="Go back to the lobby. Your seat is kept and the game waits for you — the lobby lists it under Games you are in, so you can come back or hand the browser to a different game.">Leave game</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<a class="home" href="./replays.html" style="font-size:12px" data-tip="Watch a recorded game, or open a save file. A save replays under the rules of the build that opens it, so one made on an earlier build may stop part-way — your file is never altered.">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
+12 -2
View File
@@ -25,7 +25,7 @@ import {
collectiveRevenueFloor,
houseRules,
} from '../engine/content.ts';
import type { ExtraStartRule, RevenueRules, StartingHand } from '../engine/content.ts';
import type { ExtraStartRule, RevenueRules, StartingHand, StartingOffice } from '../engine/content.ts';
import type { GameConfig, GameMode } from '../engine/state.ts';
export type PresetName = 'solitaire' | 'coop' | 'competitive' | 'cutthroat';
@@ -54,6 +54,8 @@ export type Settings = {
emergencyToolbox: boolean;
/** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */
discardTimetabled: boolean;
/** Ticked = everyone opens on a Whistle Post, the harder game. Unticked = a Depot. */
startWhistlePosts: boolean;
};
export const SETTING_KEYS: readonly (keyof Settings)[] = [
@@ -69,6 +71,7 @@ export const SETTING_KEYS: readonly (keyof Settings)[] = [
'employeeRotation',
'emergencyToolbox',
'discardTimetabled',
'startWhistlePosts',
];
export type Preset = {
@@ -103,6 +106,8 @@ const NO_OPTIONAL_RULES = {
* about how long a game runs, which is a dial the table already sets for itself.
*/
discardTimetabled: true,
// The default opening is a Depot, so the harder setting is off.
startWhistlePosts: false,
} as const;
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
@@ -227,6 +232,7 @@ export function settingsOf(config: GameConfig): Settings {
employeeRotation: config.optionalRules.employeeRotation,
emergencyToolbox: config.optionalRules.emergencyToolbox,
discardTimetabled: rules.discardTimetabled,
startWhistlePosts: rules.startingOffice === 'whistlePost',
};
}
@@ -274,7 +280,7 @@ export function configFromFrame(f: {
maxCollisionsPerDay: number;
maxCollisionsTotal: number;
optionalRules: GameConfig['optionalRules'];
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean };
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean; startingOffice?: StartingOffice };
}): GameConfig {
return {
mode: f.mode,
@@ -291,6 +297,9 @@ export function configFromFrame(f: {
// Carried like the rest: this path describes SOMEONE ELSE'S game to a joiner, so a setting
// dropped here shows them a rule the table is not playing (§6.2, Gitea#9).
discardTimetabled: f.houseRules.discardTimetabled,
// Spread rather than assigned: `exactOptionalPropertyTypes` refuses an explicit `undefined`,
// and a Frame from a server that predates this setting simply does not carry it.
...(f.houseRules.startingOffice ? { startingOffice: f.houseRules.startingOffice } : {}),
revenue: f.houseRules.revenue,
},
};
@@ -355,6 +364,7 @@ export function configFromSettings(
startingHand: settings.startingHand,
extraStart: settings.extraStart,
discardTimetabled: settings.discardTimetabled,
startingOffice: settings.startWhistlePosts ? 'whistlePost' : 'depot',
revenue: {
passengerPerCoach: settings.passengerPerCoach,
freightPerLoad: settings.freightPerLoad,
+4
View File
@@ -69,6 +69,10 @@ input[type=range]{flex:1;min-width:180px}
<p class="dim">A Station Master save is a small <code>.json</code> file &mdash; a seed and the list of
moves made. That is enough to rebuild the whole game, so a replay can be emailed like a text file.
Save one from inside a game with <b>Save replay</b>.</p>
<!-- #40 — the one thing about saves a player has to be told, wherever saves are handled. -->
<p class="dim">A save replays under the rules of the build that opens it, so one made on an
earlier build may stop part-way. The viewer says which move it stopped on, and your file is
never altered.</p>
<input type="file" id="file" accept=".json,application/json">
<div id="perr"></div>
</section>
+5 -1
View File
@@ -48,6 +48,7 @@ export const FIELDS: readonly Field[] = [
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' },
{ key: 'startWhistlePosts', kind: 'checkbox', id: 'whistlestart' },
];
/**
@@ -73,6 +74,7 @@ const FIELD_LABELS: Record<keyof Settings, string> = {
startingHand: 'Starting hand',
extraStart: 'An Extra may start at',
discardTimetabled: 'A Timetabled train may be discarded',
startWhistlePosts: 'Players start with Whistle Posts, not Depots',
passengerPerCoach: 'Passenger per coach',
freightPerLoad: 'Freight per load',
trainPerTransit: 'Train per transit',
@@ -116,7 +118,7 @@ export function rulesListHtml(config: GameConfig, players: number, days: number)
return (
head +
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart'])}</dl>` +
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart', 'startWhistlePosts'])}</dl>` +
`<h4>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
@@ -209,6 +211,7 @@ export function settingsForm(prefix: string): SettingsForm {
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
discardTimetabled: el<HTMLInputElement>('tossloco')?.checked === true,
startWhistlePosts: el<HTMLInputElement>('whistlestart')?.checked === true,
};
}
@@ -225,6 +228,7 @@ export function settingsForm(prefix: string): SettingsForm {
setChecked('rotation', values.employeeRotation);
setChecked('toolbox', values.emergencyToolbox);
setChecked('tossloco', values.discardTimetabled);
setChecked('whistlestart', values.startWhistlePosts);
}
function setNumber(id: string, value: number): void {
+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,
+124 -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,
@@ -798,6 +802,44 @@ describe('Freight Agent operations (§6.3)', () => {
);
});
it('returns a cleared car to the yard EMPTY, not still loaded', () => {
/**
* REGRESSION, v0.8.1.0. `inboundCleared` pushed `pooled(e.stock)` under a comment reading
* "a car back in a yard is back in the common supply, carrying nothing" — and `pooled` does not
* do that. It strips the `origin` stamp and keeps `loaded` DELIBERATELY, because a train can
* retire at a Division Point with freight still aboard (`advance.ts`). So every car the Freight
* Agent cleared arrived in the Classification Yard still carrying a load that had already been
* delivered and already paid for.
*
* It bites hardest on coaches: `passengersDetrained` takes `type === 'coach' && !loaded` out of
* the Division Yard, and §2.2 refills the Division Yard from Classification — so a cleared
* coach came back as stock that could not unload a passenger.
*/
const s = game();
const coord = withFacility(s);
const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
f.allows = { outbound: false, inbound: true };
f.inboundBox = [{ type: 'coach', loaded: true }];
const before = s.yards.classificationYard.length;
assert.ok(applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }).ok);
assert.ok(
applyIntent(s, 0, { type: 'freightAgent.clearInbound', at: coord, index: 0 }).ok,
'the Freight Agent could not clear the red box',
);
assert.equal(f.inboundBox.length, 0, 'the red box was not cleared');
assert.equal(s.yards.classificationYard.length, before + 1, 'the car did not reach the yard');
const arrived = s.yards.classificationYard[s.yards.classificationYard.length - 1]!;
assert.equal(arrived.type, 'coach', 'the wrong car reached the yard');
assert.equal(arrived.loaded, false, 'the cleared car arrived in the yard still loaded');
// And a coach that arrives empty is stock a Porter can actually use again.
assert.ok(
s.yards.classificationYard.some((c) => c.type === 'coach' && !c.loaded),
'no empty coach reached the common supply',
);
});
it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => {
// A player with no Facility cannot choose an option that has no possible follow-up.
const s = game();
@@ -1177,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(
@@ -1209,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 } };
@@ -1233,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
@@ -1350,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);
@@ -1370,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', () => {
@@ -1517,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',
@@ -1531,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);
@@ -1565,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'];
+226 -1
View File
@@ -15,8 +15,9 @@ 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';
const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8');
@@ -47,6 +48,12 @@ const KNOWN_UNREDUCED = [
'clearanceRequested',
'dispatchBonusUsed',
'expediteFault',
/**
* The Freight Agent chose to do nothing (§6.3 requires no action). Emitted by `freightAgent.end`
* beside the `phaseEnded` that ends the turn, and reduces to nothing itself — exactly the
* `switchingEnded` pattern below.
*/
'freightAgentIdled',
/**
* The New Train Phase's report that it could give a train nothing (playtest, 2026-09-16, the
* Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to
@@ -85,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',
];
@@ -161,3 +174,215 @@ describe('the intents are what reconstructs a game', () => {
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']);
});
});
/**
* A FREIGHT AGENT TURN, AS THE TABLE READS IT.
*
* Reported on Day 1 Stage 3 of v0.8.0.16, watching a bot: "one car moved to or from a facility —
* can we tell what car, what facility and whether it was to or from." Two separate faults sat
* behind that. The choice line ASSERTED a car had moved, which §6.3 does not require and the bot
* deliberately declines; and the three lines that do report the work named the grid coordinate
* rather than the industry standing on it.
*/
describe('a Freight Agent turn says what it did, to what, and where', () => {
const ctx = {
playerName: () => 'Bot 1',
facilityAt: (_p: number, c: { row: number; col: number }) =>
c.row === -1 && c.col === 1 ? 'the Freight House' : null,
};
it('announces the OPTION without claiming a car moved', () => {
const line = narrate({ type: 'localOpsOptionChosen', player: 0, option: 'freightAgent' } as never, ctx);
assert.ok(
!/one car moved/.test(line.text),
`the choice line still asserts an outcome: ${line.text}`,
);
// It must still say what the Freight Agent is FOR, or the option is a bare name.
assert.match(line.text, /Outbound|Inbound|jam/, `the choice line says nothing about the work: ${line.text}`);
});
it('says so when the Freight Agent deliberately does nothing', () => {
// The bot takes this route on purpose: unjamming a healthy box destroys a stocked load. Silence
// here is what made the turn read as a dropped turn.
const line = narrate({ type: 'freightAgentIdled', player: 0 } as never, ctx);
assert.match(line.text, /nothing worth doing/, `an idle Freight Agent is silent: ${line.text}`);
});
it('names the industry and the direction, not a coordinate', () => {
const at = { row: -1, col: 1 };
const stocked = narrate(
{ type: 'stockToOutbound', player: 0, at, stock: { type: 'boxcar', loaded: true } } as never,
ctx,
);
assert.match(stocked.text, /the Freight House/, `no industry named: ${stocked.text}`);
assert.ok(!/\(1, -1\)/.test(stocked.text), `still speaking in coordinates: ${stocked.text}`);
assert.match(stocked.text, /boxcar/, `the car is not named: ${stocked.text}`);
assert.match(stocked.text, /INTO/, `the direction is not stated: ${stocked.text}`);
const cleared = narrate(
{ type: 'inboundCleared', player: 0, at, stock: { type: 'hopper', loaded: true } } as never,
ctx,
);
assert.match(cleared.text, /the Freight House/, `no industry named: ${cleared.text}`);
assert.match(cleared.text, /OUT of/, `the direction is not stated: ${cleared.text}`);
const jam = narrate(
{ type: 'facilityUnjammed', player: 0, at, from: 'menAtWork', stock: { type: 'tank', loaded: true } } as never,
ctx,
);
assert.match(jam.text, /the Freight House/, `no industry named: ${jam.text}`);
/**
* Where there is no industry, the coordinate is still the honest fallback rather than "nowhere".
*
* AND IT IS SPELLED THE WAY THE ACTION MENU SPELLS IT — X,Y, east/west then north/south. The
* log printed the internal row/col order until 2026-09-21, so the same square read "(1,-1)" in
* the menu and "(-1,1)" in the log, side by side. `test/web.test.ts` pins the two against each
* other; this pins the order itself.
*/
const plain = narrate(
{ type: 'stockToOutbound', player: 0, at: { row: -2, col: 4 }, stock: { type: 'boxcar', loaded: true } } as never,
ctx,
);
assert.match(plain.text, /\(4,-2\)/, `the fallback coordinate is not in X,Y order: ${plain.text}`);
});
});
/**
* WHICH MAINLINE CARD, AND WHAT THE CARD IS CALLED.
*
* Reported 2026-09-22, playing ABS Signals: "In the history, it referred to it as Mainline card 7,
* but didn't give the actual card type, which was plains. It should specify both. Note there were
* two plains cards dealt in this hand." Both halves matter for that reason — the name informs, the
* slot is the only thing telling two Plains apart.
*/
describe('the log names the Mainline card an Enhancement was built on', () => {
const ctx = {
mainlineAt: (node: number) => (node === 7 ? 'Plains' : node === 3 ? 'Heavy Grade' : null),
enhancementName: (key: string) => (key === 'absSignals' ? 'ABS Signals' : null),
};
it('gives the slot AND the card type', () => {
const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx);
assert.match(line.text, /Mainline card 7/, `the slot is gone: ${line.text}`);
assert.match(line.text, /Plains/, `the card type is missing: ${line.text}`);
});
it('calls the card by its printed name, not a split key', () => {
const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx);
assert.match(line.text, /ABS Signals/, `not the printed name: ${line.text}`);
assert.ok(!/abs Signals/.test(line.text), `still splitting the key: ${line.text}`);
});
it('still says something useful when the card cannot be resolved', () => {
// No resolver at all — an engine test narrating events has no division to ask.
const bare = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 2 } as never, {});
assert.match(bare.text, /Mainline card 2/, `the slot must survive with no resolver: ${bare.text}`);
});
it('leaves a grid-square Enhancement naming its coordinate', () => {
const line = narrate(
{ type: 'enhancementPlaced', player: 0, key: 'smallYard', at: { row: -1, col: 2 } } as never,
{ enhancementName: (k: string) => (k === 'smallYard' ? 'Small Yard' : null) },
);
assert.match(line.text, /Small Yard/, `not the printed name: ${line.text}`);
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,
+25 -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,
@@ -59,6 +62,11 @@ const SAMPLES: GameEvent[] = [
{ type: 'deckReshuffled', order: ['c1', 'c2', 'c3', 'c4'], rngState: 7 },
{ type: 'departmentRefilled', slot: 0, cardId: 'c2' },
{ type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
// Sampled from the start rather than joining the unsampled 25: an idle Freight Agent is the ONE
// 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 },
@@ -123,7 +131,7 @@ describe('narration', () => {
/**
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
*
* `SAMPLES` exercises the TEXT of 30 of the 55 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
@@ -213,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'));
});
@@ -716,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`);
@@ -732,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,
+86 -9
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
@@ -230,7 +238,7 @@ describe('card catalogue (component 1)', () => {
* "Both" column loses its only argument.
*
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
* the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
* the Grocer's Warehouse only receives. `home-deck.md` prints both that way,
* and so does the modifier set — all three Refinery modifiers grant outbound.
*/
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
@@ -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'] });
+21 -3
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' });
}
@@ -562,8 +574,14 @@ describe('the history panel keeps the switching that matters', () => {
assert.ok(submit(game, { type: 'switch.move', trayId, to: plain, reverse: false }), 'the move was refused');
const moved = game.log.filter((l) => / moved the local crew/.test(l.text));
assert.equal(moved.length, 1, `expected one move line, got ${moved.length}`);
// The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
// checked here is that it names the square by what stands on it rather than by its coordinates.
assert.match(moved[0]!.text, /→ the Freight House|→ \(1,2\)/, `unexpected move line: ${moved[0]!.text}`);
/**
* The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
* checked here is that it names the square by what stands on it rather than by its coordinates.
*
* The coordinate branch is `(2,1)` for `{ row: 1, col: 2 }`: X,Y, east/west then north/south,
* which is how `view.ts` has always written a square and how `narrate.ts` writes one since
* 2026-09-21. It read `(1,2)` here while the log still printed the internal storage order.
*/
assert.match(moved[0]!.text, /→ the Freight House|→ \(2,1\)/, `unexpected move line: ${moved[0]!.text}`);
});
});
+423 -6
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 } from '../src/engine/content.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
@@ -310,6 +319,119 @@ describe('the action menu presents choices the way they are made', () => {
assert.deepEqual(shapes, ['en|ew', 'we|ws'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
});
it('offers ABS Signals on EVERY Mainline card, each one named', () => {
/**
* REGRESSION — reported from a table on Day 1 Stage 1 of v0.8.0.16, and the THIRD instance of
* one trap. The action list drops duplicate labels, and `describeIntent` for a card play named
* the grid placement but never `node` — so every Mainline card produced the identical label
* "play ABS Signals" and all but the lowest-index one were discarded before the menu saw them.
* The card's own tooltip says "any Mainline card" while exactly one was ever on offer.
*
* The engine was never wrong: `check` accepts any node whose kind is 'mainline', and
* `legalActions` filters by `check`. The whole failure was in the label.
*/
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
let absId: string | undefined;
for (const [id, card] of game.state.cards) {
const k = card.kind as { kind: string; key?: string };
if (k.kind === 'enhancement' && k.key === 'absSignals') { absId = id; break; }
}
assert.ok(absId, 'the deck has no ABS Signals card');
game.state.decks.hands.set(0, [absId]);
const mainlineNodes = game.state.division.nodes
.map((n, i) => ({ n, i }))
.filter(({ n }) => n.kind === 'mainline');
assert.ok(mainlineNodes.length > 1, 'this division has only one Mainline card — nothing to distinguish');
// The engine offers one per Mainline card ...
const offered = actionGroups(game).options.filter(
(o) => o.type === 'card.play' && o.cardId === absId && o.node !== undefined,
);
assert.equal(
offered.length,
mainlineNodes.length,
`the engine offers ${offered.length} placements for ${mainlineNodes.length} Mainline cards`,
);
// ... and every one of them must survive into the menu, which means distinct labels.
const labels = offered.map((o) => describeIntent(game.state, o));
assert.equal(
new Set(labels).size,
offered.length,
`the labels collapse, so the menu drops all but one: ${[...new Set(labels)].join(' / ')}`,
);
const spots = actionMenu(game)
.placeable.flatMap((g) => g.items)
.filter((it) => it.subjectKey === `card:${absId}`)
.flatMap((it) => it.spots);
assert.equal(
spots.length,
mainlineNodes.length,
`only ${spots.length} of ${mainlineNodes.length} Mainline cards can be chosen`,
);
/**
* And the card is called what the card face calls it. `prettyKey` rendered `absSignals` as
* "Abs Signals" on a button while the tooltip beside it said ABS — an acronym no key-splitter
* can recover, so the authored name in `ENHANCEMENT_CARDS` has to win.
*/
assert.ok(
labels.every((l) => l.includes('ABS Signals')),
`the card is not called by its printed name: ${labels[0]}`,
);
// Each spot names the card it would go on, so the choice is legible rather than positional.
for (const { n } of mainlineNodes) {
const name = mainlineProfile((n as { card: Parameters<typeof mainlineProfile>[0] }).card).name;
assert.ok(
spots.some((sp) => sp.label.includes(name)),
`no spot names the ${name}: ${spots.map((sp) => sp.label).join(' / ')}`,
);
}
});
it('spells a square the same way in the action menu and in the log', () => {
/**
* REPORTED 2026-09-21. `view.ts` wrote "(col,row)" — X,Y, east/west then north/south, with a
* comment saying so — and `narrate.ts` wrote "(row,col)", the internal storage order, with no
* comment at all. So the action menu offered a move to "(1,-1)" and the log then reported it at
* "(-1,1)", in two panels a player reads side by side.
*
* PINNED AGAINST EACH OTHER rather than against a literal: a test asserting one format would
* have passed all along on whichever file it was written against. This compares the two
* renderers on the same square, which is the property that was actually broken.
*/
const game = newGame(555);
const square = { row: -1, col: 2 };
const logged = describeIntent(game.state, {
type: 'switch.move',
trayId: [...game.state.trays.keys()][0]!,
to: square,
reverse: false,
});
const menu = describeIntent(game.state, {
type: 'card.play',
cardId: game.state.decks.hands.get(0)![0]!,
placement: square,
});
// Both must render the square, and render it identically.
const coord = /\((-?\d+,-?\d+)\)/;
const inLog = coord.exec(logged)?.[1];
const inMenu = coord.exec(menu)?.[1];
assert.ok(inLog, `the log line names no square: ${logged}`);
assert.ok(inMenu, `the menu line names no square: ${menu}`);
assert.equal(inMenu, inLog, 'the action menu and the log spell the same square differently');
// And the shared spelling is X,Y — east/west first, which is the order the map is drawn in.
assert.equal(inMenu, '2,-1', `not X,Y order: ${inMenu}`);
});
it('says how deep a Department pile is, so a discard can be aimed', () => {
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
@@ -1032,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
@@ -2457,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');
@@ -3003,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),
@@ -3010,6 +3136,156 @@ 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
* on-the-card indication. It's only when you look at the tooltip for trestle that you see that
* ABS exists." The same complaint the Heavy Grade wedge below answers, and it matters more
* here — ABS is what decides whether running a second train onto that card is safe.
*/
const s = createEngineGame({
id: 'div-abs',
seed: 7,
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'],
});
const before = divisionSvg(snapshot(s, [], null).division);
assert.ok(!before.includes('bs-abs'), 'a signal is drawn before ABS Signals was ever played');
const node = s.division.nodes.find((n) => n.kind === 'mainline');
assert.ok(node, 'this division has no Mainline card');
(node as { absSignals?: boolean }).absSignals = true;
const after = divisionSvg(snapshot(s, [], null).division);
assert.ok(after.includes('bs-abs-mast'), 'the card carrying ABS Signals draws no signal mast');
assert.ok(after.includes('bs-abs-lit'), 'the signal has no lit aspect');
// Exactly one card carries it, so the mark cannot be a row-wide decoration.
assert.equal((after.match(/bs-abs-mast/g) ?? []).length, 1, 'the signal is drawn on more than one card');
// And it stays in the tooltip too — the mark says THAT, the tip still says what it does.
assert.ok(after.includes('ABS Signals'), 'the tooltip stopped naming ABS Signals');
});
it('draws the Mainline modifiers on the card, not only in the tooltip', () => {
/**
* The other half of the ABS report (Jesse, 2026-09-21): "Brakeman, Airbrakes, Helpers and
* Realignment should also be drawn on the card, not just the tooltip."
*
* REALIGNMENT IS NOT IN THIS LIST ON PURPOSE. It never sits on a card — `reduce` takes the
* `became` branch and changes `node.card` outright — so a realigned card already announces
* itself by being a different card. Asserted below so the absence is a recorded finding rather
* than something that looks forgotten.
*/
const s = createEngineGame({
id: 'div-mods',
seed: 7,
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'],
});
const node = s.division.nodes.find((n) => n.kind === 'mainline');
assert.ok(node, 'this division has no Mainline card');
assert.ok(!divisionSvg(snapshot(s, [], null).division).includes('bs-mod'), 'a tag is drawn with no modifier on');
(node as { modifiers?: string[] }).modifiers = ['brakeman', 'airbrakes', 'helpers'];
const svg = divisionSvg(snapshot(s, [], null).division);
for (const tag of ['BRK', 'AIR', 'HLP']) {
assert.ok(svg.includes(tag), `the ${tag} modifier is not drawn on the card`);
}
// And the tooltip still names them in full — the tag says THAT, the tip says WHICH.
assert.match(svg, /Brakeman/, 'the tooltip stopped naming the modifiers');
// Realignment converts the card instead of sitting on it, so it must never produce a tag.
(node as { modifiers?: string[] }).modifiers = ['realignment'];
assert.ok(
!divisionSvg(snapshot(s, [], null).division).includes('bs-mod'),
'Realignment drew a tag, but it changes the card rather than standing on it',
);
});
it('draws which way a Heavy Grade climbs, instead of only saying it in the tooltip', () => {
/**
* REPORTED BY JESSE 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card
@@ -3489,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;
@@ -3752,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'],
@@ -5509,3 +5794,135 @@ describe('a Division Yard with no coaches is a reportable condition', () => {
assert.equal(waiting.loaded + waiting.empty, 2, 'the waiting coaches were miscounted');
});
});
// ---------------------------------------------------------------------------
/**
* The Quickstart is published beside the game, so a tester on the box can reach it.
*
* WHY THIS IS A TEST. The link on the splash page is a plain href to a file the BUILD copies out of
* `docs/`. Nothing else connects the two: rename the document, or move it, and the build quietly
* publishes nothing while the splash page keeps offering a link that 404s. Neither `tsc` nor any
* other test would notice — the whole failure lives between a file name and a string.
*/
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.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\.html"/, 'the splash page does not link the rendered guide');
});
it('brings the lobby doors back every time the lobby is shown', () => {
/**
* REPORTED 2026-09-22: "After leaving a game, I go back to the lobby. I see the games that I'm
* in, and I have the ability to rejoin or forget. But what if I want to join a different game
* or start a new one? How do I get back there?"
*
* `enterSeating` hides `#lb-choice-section`, and until v0.8.1.0 only the lobby's OWN leave
* paths put it back. Leaving a RUNNING game is a third route — `main.ts` calls `runLobby` again
* — so the section stayed hidden from the previous seating and the screen came back holding
* nothing but "Games you are in", with the two doors on the page at `display:none` and no
* control that could reveal them.
*
* Read out of the source, like the MIME table above: the fix is an ordering property of one
* function, and there is no DOM harness here that drives `runLobby` end to end. What this pins
* is that the reset exists inside `runLobby` and happens BEFORE the resume path re-enters
* seating — reversing those two would hand a resuming browser the wrong screen.
*/
const src = readFileSync(join(root, 'src/web/lobby.ts'), 'utf8');
const body = src.slice(src.indexOf('export function runLobby'));
assert.ok(body.length > 0, 'runLobby is gone');
const reset = body.indexOf("$('lb-choice-section').hidden = false");
assert.ok(reset > 0, 'runLobby never reveals the choice section, so leaving a game is a dead end');
const resume = body.indexOf('if (resume) enterSeating');
assert.ok(resume > 0, 'the resume path is gone');
assert.ok(reset < resume, 'the reset runs after the resume, which would show a seated browser the doors');
// And the seating section is put away with it, or the two would be on screen together.
const seating = body.indexOf("$('lb-seating-section').hidden = true");
assert.ok(seating > 0 && seating < resume, 'the seating section is not reset before resume');
});
it('publishes everything the guide links, so "Where to read more" is not five dead links', () => {
/**
* v0.8.0.16 published the Quickstart alone. Its §8 links five further documents by relative
* path, and every one of them 404'd on the package — verified against the running container,
* 5 of 6 paths missing. Publishing a guide without what it points at is the same broken-link
* failure as the test above, one hop further out, so it is pinned the same way: the links are
* read OUT OF THE GUIDE rather than listed here, or this test goes stale exactly as the
* references it guards did.
*/
const guide = readFileSync(join(dist, 'quickstart.md'), 'utf8');
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 >= 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`,
);
}
});
it('reaches the documentation from inside a game, in solitaire and multiplayer alike', () => {
/**
* Asked from a table, 2026-09-21: "how can we link the documentation so it can be reached from
* the gameplay, whether someone is playing solitaire or multiplayer?" The links live in the
* This Game card (Jesse's call) — and the point is that they need NO mode awareness, because
* both modes are the same page on the same origin. So this asserts the links exist and resolve,
* which is the whole of the mechanism.
*
* Read out of the built bundle rather than the source: what matters is what the shipped page
* offers, and a link that resolves in `src/` and not in `dist/` is the exact failure the two
* tests above exist to catch.
*/
const bundle = readFileSync(join(dist, 'web', 'main.js'), 'utf8');
const guide = bundle.slice(bundle.indexOf('GUIDE_DOCS'), bundle.indexOf('GUIDE_DOCS') + 4000);
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./-]+\.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`);
}
// A reference opened mid-turn must not take the game with it.
assert.ok(guide.includes('_blank'), 'the guide links would navigate away from a game in progress');
assert.ok(guide.includes('noopener'), 'a new-tab link without rel=noopener hands out a window handle');
});
it('is served as text rather than handed over as a download', () => {
/**
* The server's MIME fallback is `application/octet-stream`, which a browser downloads instead of
* displaying — so the link would hand a tester a file to save rather than a page to read. The
* table is read straight out of the source: asserting on a copy of it would pass while the real
* one was wrong.
*/
const http = readFileSync(join(root, 'src/server/http.ts'), 'utf8');
const table = http.slice(http.indexOf('const MIME'), http.indexOf('const HEARTBEAT_MS'));
assert.match(table, /'\.md':\s*'text\/plain/, 'a .md file would be served as a download');
});
});