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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1046 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
This commit is contained in:
Jesse.Markowitz
2026-09-23 07:07:21 -04:00
co-authored by Claude Opus 5
parent 517238a727
commit 3befc420da
66 changed files with 6828 additions and 3324 deletions
+184
View File
@@ -19,6 +19,190 @@ 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 ## 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 A second-digit bump, and a deliberate one. **0.8.1 had been reserved for the seatless display
+32 -30
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. 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 - **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 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`; about how finished it feels, and it is his. The number lives only in `package.json`;
@@ -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 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**. 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 - [x] **#42a** — **CONFIRMED at a table, 2026-09-23.** The solitaire setup screen's own fields were
the dealt game matches what was chosen. It took three attempts to become reachable at all — clicked through and the dealt game matched what was chosen.
reachable is not the same as correct. See **Reference · #42a**.
- [ ] **#40** — **An older save may not replay, and players are not told so anywhere they will see - [x] **#40** — **DONE, 2026-09-23.** One sentence, in the four places a player meets a save: the
it.** Not a v0.7.4 fact and not a bug: a save is re-played through the current rules, so any Quickstart's reporting section, a Rules FAQ entry ("Will an old save still replay?"), the
narrowing of what is legal can stop one. The rule is written down now (`README.md` § Design replay viewer's own page, and a tooltip on the **replays** link in the game — which had no
notes); what is still owed is a line **wherever a build is announced**. See **Reference · #40**. 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 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>`. 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 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. because the ruling was made on one game's evidence and the balance question behind it is open.
@@ -582,28 +606,6 @@ 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 - [ ] **#88** — `card-reference.md`'s industry table may still be stale beyond Grocer's Warehouse and
the Oil Refinery. See **Reference · #88**. the Oil Refinery. See **Reference · #88**.
- [ ] **#109** — **Render the published references instead of serving them as plain text.**
v0.8.0.17 publishes all five documents plus `rules/as-built.md`, links them from the splash
page and from the This Game card inside a running game, and serves them as `text/plain` — so a
tester reads their tables as rows of pipes and their links do not click. That was the
deliberate short version, to get the references in front of testers for this round rather than
leave them without any.
**Now SIX documents rather than one**, which raises the value and the cost together: the
Quickstart's §8 is a table of links, and a reader following one lands on another wall of
pipes.
**Copy the document, do not re-write it.** A hand-written HTML twin drifts from the Markdown on
the first edit, which is the whole argument of #15a. The step is a small Markdown-to-HTML
converter in `scripts/build-web.ts` writing `quickstart.html` beside the game, styled like the
splash page — headings, lists, tables, links and code spans are the whole of what the guide
uses. The `.md` MIME entry in `src/server/http.ts` and the two assertions in
`test/web.test.ts` (`the Quickstart guide reaches the site`) move to the rendered file with it.
**Cost:** an afternoon, most of it in the converter's table and list handling. No dependency —
a Markdown library would be the only runtime dependency this project has, and the guide uses a
small enough subset that it is not worth becoming the first.
--- ---
## Reference — measurements, rulings and rejected approaches ## Reference — measurements, rulings and rejected approaches
+34 -9
View File
@@ -1,8 +1,6 @@
# Station Master — Components and Markers # Station Master — Components and Markers
**Describes the game as built at v0.8.1.0** (2026-09-22). These references are kept current with **Version 0.8.2** · 2026-09-23
every release rather than versioned as editions, so there is no version in the filename: this file
is always the latest, and the build it describes is stated here.
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles, **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 hand state, timetable state and other markers are documented with their cards or in the
@@ -10,7 +8,7 @@ hand state, timetable state and other markers are documented with their cards or
The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in 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 `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 ## 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 | | Component | Count | Note |
| --- | ---: | --- | | --- | ---: | --- |
| Crew Trays | players + 3 | Engine and tray are one combined resource; there is no "engine without a tray". | | 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. | | Limits signs | 8 | "2N + spares", so relocating one is never a supply question. |
## The two yards ## The two yards
@@ -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 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. 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 It passes at the end of Stages 3, 6, 9 and 12 — every third Stage, at the Supervisor Shift. The
v0.8.0.13 the handover is **announced on screen and written into the history**: it is the one thing handover is **announced on screen and written into the history**: it is the one thing in the game
in the game that changes hands on the clock rather than because somebody did something, so nobody is that changes hands on the clock rather than because somebody did something, so nobody is watching
watching for it. Note that the Supervisor Shift refreshes every Laborer and Porter *every* Stage for it. Note that the Supervisor Shift refreshes every Laborer and Porter *every* Stage
while the Fedora moves only every third. while the Fedora moves only every third.
## D12 and seeded randomness ## 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. - 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 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.
+5 -5
View File
@@ -28,7 +28,6 @@ Written to be handed to somebody who is about to play, rather than to somebody b
| --- | --- | | --- | --- |
| [`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. | | [`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. | | [`rules.md`](rules.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. |
| [`home-deck.md`](home-deck.md) | How the Home Office deck is dealt, drawn and played out. | | [`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. | | [`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. | | [`components.md`](components.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
@@ -45,7 +44,7 @@ described — which read as though they documented a build five minor versions o
| --- | --- | | --- | --- |
| [`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.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/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/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/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. | | [`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. |
@@ -68,7 +67,7 @@ must do.
## Current status ## Current status
**v0.8.1.0.** 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 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 [`../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 what is open; this section is the shape of the project, not a running tally, because a
@@ -109,8 +108,9 @@ TypeScript natively, so there is no build step during development, which also me
only**: no `enum`, no parameter properties, no namespaces. only**: no `enum`, no parameter properties, no namespaces.
Running alongside, and independent of all of it: **print-and-play components.** 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 The generated tables in [`home-deck.md`](home-deck.md) and [`mainline-deck.md`](mainline-deck.md)
layout and art are the only remaining work before a table playtest — which answers the one question 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. simulation cannot, whether it is fun.
Run the harness with `node src/sim/harness.ts [games] [length]`. Run the harness with `node src/sim/harness.ts [games] [length]`.
+267 -28
View File
@@ -1,22 +1,18 @@
# Station Master — Home Deck # Station Master — Home Deck
**Describes the game as built at v0.8.1.0** (2026-09-22). These references are kept current with **Version 0.8.2** · 2026-09-23
every release rather than versioned as editions, so there is no version in the filename: this file
is always the latest, and the build it describes is stated here.
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the **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. 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 > **The card tables in this document are GENERATED from `src/engine/content.ts`** and checked by a
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with > test, so they cannot disagree with the game. `npm run build:cards` rebuilds them; do not edit a
> the game. Read it for every card's name, effect, placement and whether its printed effect actually > table by hand. The prose around them is how the deck WORKS; the tables are what is in it.
> 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 > **Card counts are not published.** Counts move with play balance, so a printed count answers a
> with play balance, so a document printing them is answering a question that has a different answer > 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 > 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 > the card at all — which is a fact about the design.
> and it was wrong for a month before anyone noticed.
## The piles ## The piles
@@ -41,7 +37,7 @@ 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 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 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 two separately shuffled piles, over the hand limit, so the first turn is spent choosing
which district you can afford to build. which district you can afford to build.
Drawing is one of the three Local Operations options — see [Rules](rules.md) Drawing is one of the three Local Operations options — see [Rules](rules.md)
@@ -56,16 +52,31 @@ Track cards are ordinary Home Office cards, not a separate personal supply.
through route, or it breaks the main. through route, or it breaks the main.
- Placing track **on a Limit sign** extends the Running Track and moves that sign outward. The sign - 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. 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 - **Nothing may be placed outside your Limits** — track, industries and Modifiers alike. Your
too. Your district ends at its sign. district ends at its sign.
- Curves and turnouts are printed left- or right-handed. A card may be turned 180° but never flipped - 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. 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 - 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 diverging arc. An **industry may be built over a straight** off the Running Track, on the same
is unavailable. 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 - A turnout may be **run through but not stopped on**: it is not Operational Rail, so a Move may not
end there. 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 ## Office cards
Every player begins at a **Whistle Post**, which is not drawn from the deck: one A/D track, no Every player begins at a **Whistle Post**, which is not drawn from the deck: one A/D track, no
@@ -78,12 +89,34 @@ 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. 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 ## Freight facilities
An industry is placed on a connected straight **stub off the Running Track** — never on the Running 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 itself, and never outside the Limits. A Facility carries its own rails, so placing one places
track. 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 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 duplicate industry, or both ends of a lockout pair — a producer and the consumer of the same
commodity cannot be built in one district. commodity cannot be built in one district.
@@ -92,22 +125,67 @@ commodity cannot be built in one district.
count. That distinction was a real bug: box count is how much WORK an industry can hold, not how 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. 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 ## Facility modifiers
A Modifier sits on an empty square among the **nine spots around its host Facility** — and, since A Modifier sits on an empty square **square against its host Facility — north, south, east or
v0.8.0.14, **inside your Limits**, like everything else. It may not stand in the Running Track row. west**. It may not go on a diagonal: touching at a corner is not touching. It must be **inside your
One of each kind per Office Area. 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 **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 second red box — somewhere for one more arriving load to be cleared to — and changes nothing about
how many cars may be spotted there. 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 **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 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. 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 <!-- BEGIN CARDS: modifiers -->
facility "only receives", which was wrong in both directions. 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 ## Train cards
@@ -121,9 +199,6 @@ house rule — `divisionPointsOnly`, `ownOffice`, or `anyOffice` (the default)
also available, because it is the one Mainline card with a yard. An Extra runs once and its card also available, because it is the one Mainline card with a yard. An Extra runs once and its card
goes to the Salvage Yard. 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 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 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. among its own cars. A Crew Tray holds four pieces, and a caboose counts toward the four.
@@ -131,14 +206,48 @@ among its own cars. A Crew Tray holds four pieces, and a caboose counts toward t
A train made up short of what its card calls for is reported as such, with what it wanted and why 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. 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 **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, carries the `empties only`, `reefers only`, `drop only` and `pick up empties only` restrictions,
every one of which the engine enforces. 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, Mainline modifiers and Maneuvers
- **Enhancements** are played into your district and change what a square does — the **Small Yard** - **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. `as-built.md` (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 marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the implementation
knows. knows.
- **ABS Signals is the exception, and it matters.** It is dealt as an Enhancement but is **not - **ABS Signals is the exception, and it matters.** It is dealt as an Enhancement but is **not
@@ -151,9 +260,139 @@ every one of which the engine enforces.
Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
implement. 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 ## Opponent-directed cards — not dealt
The **Action** and **Space-use** categories are opponent-directed and are **excluded from every 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 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: 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. 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.
+45 -23
View File
@@ -1,22 +1,19 @@
# Station Master — Mainline Deck # Station Master — Mainline Deck
**Describes the game as built at v0.8.1.0** (2026-09-22). These references are kept current with **Version 0.8.2** · 2026-09-23
every release rather than versioned as editions, so there is no version in the filename: this file
is always the latest, and the build it describes is stated here.
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a **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. 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 > **The card table in this document is GENERATED from `src/engine/content.ts`** and checked by a
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with > test, so it cannot disagree with the game. `npm run build:cards` rebuilds it; do not edit it by
> the game. This document explains how the deck is used; that one is the table of record. Where the > hand. The prose explains how the deck is used; the table is the record of what is in it.
> two ever differ, as-built is right.
## How Mainline cards work ## How Mainline cards work
At setup the game lays one Mainline card between each neighbouring pair of Offices and one beyond 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. 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 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 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. in the deck: `buildDivision` lays them itself.
@@ -27,7 +24,7 @@ A card does not belong to either neighbouring Office. It is shared Division.
A card is divided into **regions**, and a train advances **one region per Stage**. Crossing time is 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.** 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 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 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: start region and nothing else does:
@@ -73,23 +70,45 @@ The PDF art labels the Interchange "Yard". This reference uses **Interchange** t
it apart from the Division Yard, the Classification Yard, the Yard Office and the Small Yard — five it apart from the Division Yard, the Classification Yard, the Yard Office and the Small Yard — five
different things. different things.
**Region counts and entry points per card are in [`rules/as-built.md`](rules/as-built.md).** **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, and what it does NOT do
The Interchange prints a car-sorting capability. **It is not implemented, and never has been.** The 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 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 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. 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 Re-ordering a consist is done at a **Small Yard** in an Office Area, for one switching Move. See
Rules §4.3. 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 ## Heavy Grade modifiers
Home Office cards, played onto a Mainline card during a player's Draw option. Home Office cards, played onto a Mainline card during a player's Draw option.
@@ -99,11 +118,17 @@ Home Office cards, played onto a Mainline card during a player's Draw option.
| Brakeman | Heavy Grade only. A **downhill** train starts one region further on. | | 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. | | 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. | | 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. | | 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 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. 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 — 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 **ABS Signals is dealt from the Home Office deck as an Enhancement, and it is the one Enhancement
@@ -135,9 +160,6 @@ around decides which modifiers are worth anything and which direction of traffic
the whole game. Handing that to one of two neighbours advantages them over the other, and neither the whole game. Handing that to one of two neighbours advantages them over the other, and neither
has a fair claim to it. has a fair claim to it.
**Re-opened and closed again on 2026-08-23**, when giving the choice to the Superintendent was So the orientation is **rolled from the game's seed**. That is deterministic, roughly even
considered and rejected. Jesse's call: v0.5.0's ruling stands. Rolling from the seed is (51/49 east/west), identical in solitaire and multiplayer, and keeps setup non-interactive: the game
deterministic, roughly even (51/49 east/west over 400 games), identical in solitaire and has no setup phase, so asking would mean interrupting play before the first Local Operations.
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).
+54 -42
View File
@@ -1,6 +1,8 @@
# Station Master — Quickstart # Station Master — Quickstart
**For a tester who has never played. Describes the game as built at v0.8.1.0** (2026-09-22). **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 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. of confusion. The deeper references are listed at the end.
@@ -32,21 +34,26 @@ and do not wait.**
## 2. How you win ## 2. How you win
The game runs a set number of **Days** — five by default. Each Day is **12 Stages**, which you can A game runs for a set number of **Days**, chosen when the game is created. Each Day is **12
think of as two-hour clock periods from midnight. 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 Clear the floor, and how you win depends on the game type:
three players over five Days that is 45. **Miss it and everybody loses**, however well you
personally did. This is the number to watch. - **Co-op** — clearing the floor is the win, together.
2. **Co-op:** meeting the floor is the win, together. - **Competitive** — clearing the floor puts the game on, and the **highest individual Revenue**
3. **Competitive:** meeting the floor puts the game on, and the **highest individual Revenue** wins. wins. Everyone is still working towards the same floor first.
4. **Solitaire:** meet the floor by yourself. - **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 **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 collective loss: the railroad has been declared unsafe. The limits are configurable, and can be
the game. switched off entirely.
**Falling short offers another Day** rather than just ending, so a game that misses the floor can be **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. 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 | | A passenger getting off at your platform | 1 |
| Completing an outbound freight load | 1 | | Completing an outbound freight load | 1 |
| Completing an inbound freight unload | 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 Every rate is set when the game is dealt and can be changed. As they usually stand, **your score
almost entirely from working cars in your own district** — trains passing through pay nothing by comes almost entirely from working cars in your own district** — trains passing through pay nothing
themselves. by themselves.
--- ---
@@ -100,7 +107,13 @@ Stage not spent switching.
## 4. The screen ## 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 - **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 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. outside the phases that change it, unless you pin it open.
- **History.** What has happened, most recent first. - **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 - **Your Move** — the buttons. If it is not your turn this is empty, and the board tells you who is
acting. acting.
@@ -121,23 +134,20 @@ Right column:
- **Blocked — why nothing is moving.** *Read this panel.* When something will not work, this is - **Blocked — why nothing is moving.** *Read this panel.* When something will not work, this is
where the game explains why, in rules terms. where the game explains why, in rules terms.
- **Facilities** — the load pipelines at each industry. - **Facilities** — the load pipelines at each industry.
- **This Game** — the seed or your seat, the game code, the rules this game was dealt under, and
Across the top: your Revenue, the target, the Day and Stage, collisions, and the game code. links to this guide and the other references.
**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.
--- ---
## 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. 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**. 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 2. **Stage 1 — Draw.** You start on a **Depot** with almost nothing else. Play a track card or two to
to extend your Running Track, and get a **Depot** down as soon as one appears: it is the upgrade extend your Running Track. A Depot already works passengers and has two A/D tracks, so a second
that makes you a passenger facility and gives you a second A/D track. 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 3. **Build one industry** on a stub off the main — not on the Running Track itself, which the game
will not allow. will not allow.
4. **Play a train card** when you get one. It rolls onto the timetable and then runs at that Stage 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 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. 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 ## 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 - **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 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 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. - **Expedited trains leave the same Stage they arrived**, after the Cargo phase. Ordinary ones wait.
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.
--- ---
## 7. What to report ## 7. Issues / Suggestions
Most useful, in order: Please do report anything that looks like a bug or an area to be improved.
1. **What you expected versus what happened**, with the Day and Stage. "Day 2 Stage 9, train 5 had - **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". 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 - **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. made, so it replays exactly and the problem 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 - **Anything the screen did not explain.** If you had to guess a rule, that is worth saying even
the game was right. when the game was right.
4. **Anything you went looking for and could not find.** - **Anything you went looking for and could not find.**
Bugs go to the tracker; anything unclear in this guide is also worth saying. **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. Where to read more ## 8. Documentation / References
| For | Read | | For | Read |
| --- | --- | | --- | --- |
| The rules in full, with the FAQ | [Rules](rules.md) | | The rules in full, with the FAQ | [Rules](rules.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](home-deck.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) | | The Mainline cards and what they do to a train | [Mainline deck](mainline-deck.md) |
| Rolling stock, yards, trays, the Fedora | [Components](components.md) | | Rolling stock, yards, trays, the Fedora | [Components](components.md) |
+42 -27
View File
@@ -1,12 +1,10 @@
# Station Master — Rules # Station Master — Rules
**Describes the game as built at v0.8.1.0** (2026-09-22). These references are kept current with **Version 0.8.2** · 2026-09-23
every release rather than versioned as editions, so there is no version in the filename: this file
is always the latest, and the build it describes is stated here.
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable **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. 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. `src/engine/content.ts` and is the table of record.
**New to the game? Start with the [Quickstart](quickstart.md).** **New to the game? Start with the [Quickstart](quickstart.md).**
@@ -30,7 +28,7 @@ This book is divided as follows:
The companion references are the [Quickstart](quickstart.md) for a new player, The companion references are the [Quickstart](quickstart.md) for a new player,
[Mainline deck](mainline-deck.md), [Home deck](home-deck.md), [Mainline deck](mainline-deck.md), [Home deck](home-deck.md),
[components](components.md), and the generated per-card table [components](components.md), and the generated per-card table
[`rules/as-built.md`](rules/as-built.md). [Home deck](home-deck.md) and [Mainline deck](mainline-deck.md).
## 2. Definitions ## 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. 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 **Length is a free `days` count**, not a preset — any number of Days may be set. The default is 5.
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.
**How a game ends and who wins:** **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. **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 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 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 whether the result may leave the Office or would be held there. Flying Switch spends one Move to
@@ -211,18 +207,14 @@ roll a tail cut into a connected Freight Facility.
> draw actually empties the pile; a pile with cards buried under the one taken is not refilled, or > 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. > the Departments would grow without limit and drain the deck into themselves.
> >
> This surprised a player at the table (2026-09-22), which is why it is written down here: the rule
> was implemented from the prototype rules and had never reached this document.
**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. **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.
> **A car cleared from a red Inbound box comes back empty.** Whatever was in it has arrived — the > **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 > 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 > paid on arrival, not on clearing. What returns to the Classification Yard is the *car*, in the
> common supply and carrying nothing. Before v0.8.1.0 it returned still marked loaded, which put > common supply and carrying nothing.
> coaches into the yards that could never be used to unload another passenger.
Implementation note, still true at v0.8.1.0: `card.discard` is accepted during Local Operations 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 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. implementation quirk rather than a fourth published turn option.
@@ -294,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 > could board or detrain anywhere for the rest of the game, while trains whose cards call for coaches
> were made up empty. > were made up empty.
> >
> **This is the rules working as printed and the ruling is that it stands** (Jesse, 2026-09-17, the > **This is the rules working as printed: running out of cars is part of the game.** The yard panel
> same ruling Gitea#2 got: running out is part of the game). What changed is that the game now says > warns while the shortage lasts, and a train made up short reports what it wanted and why none is
> it — the yard panel warns while the shortage lasts, and a train made up short reports why. Whether > coming.
> the ratchet should be broken is open as TODO #108, to be decided on a second game's evidence.
### 4.7 Freight work ### 4.7 Freight work
@@ -334,7 +325,7 @@ score has to come principally from passenger and freight work.
### 6.1 What the engine supports ### 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 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 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: 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:
@@ -423,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? ### 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 **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 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. the choice to one neighbour would advantage them permanently. See the Mainline deck reference.
### Can I use an Interchange to reorder a train? ### 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 **No.** Its printed car-sorting is not implemented. What the Interchange offers is the one Mainline
description was corrected on 2026-09-20 to stop advertising it. What the Interchange actually offers card with a yard, so an **Extra may be made up and started there**. Re-ordering a consist is done at
is the one Mainline card with a yard, so an **Extra may be made up and started there**. Re-ordering a a **Small Yard** in a district.
consist is done at a **Small Yard** in a district.
### Can I play attack cards on another player? ### Can I play attack cards on another player?
@@ -442,8 +432,8 @@ is rejected.
### Is multiplayer playable? ### Is multiplayer playable?
**Yes.** Lobby, game codes, seating, bots, an authoritative server that survives restarts, per-seat **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 reconnection and a replayed view of everyone else's turns — see §3.5 and §6. The opponent-directed
opponent-directed cards remain unimplemented in every mode, so there are still no card attacks. cards are unimplemented in every mode, so there are no card attacks.
### Why did my passenger train arrive with no coaches? ### Why did my passenger train arrive with no coaches?
@@ -456,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 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* 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". 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
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.1.0", "version": "0.8.2",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "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 * EACH TABLE LANDS UNDER THE SECTION IT BELONGS TO, between a marker pair the deck documents carry:
* 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.
* *
* So there was no current reference at all, and `content.ts` spent several releases pointing at * <!-- BEGIN CARDS: track --> …generated… <!-- END CARDS: track -->
* `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.
* *
* A HAND-WRITTEN REPLACEMENT WOULD HAVE DRIFTED THE SAME WAY, and for the same reason: nothing * Everything around the markers is hand-written and is never touched. Only the Mainline card table
* fails when a table falls behind a constant. So the reference is emitted from the same exported * goes to `mainline-deck.md`; every other table belongs to the Home Office deck.
* 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.
* *
* `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 { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -70,40 +61,17 @@ const trainRow = (t: TrainProfile): string =>
`| ${t.isExtra ? 'X' : ''}${t.number} | ${t.name} | ${t.speed} | ` + `| ${t.isExtra ? 'X' : ''}${t.number} | ${t.name} | ${t.speed} | ` +
`${t.direction === 'playerChoice' ? "player's choice" : t.direction} | ${consistOf(t.consist)} | ${rulesOf(t.rules)} |`; `${t.direction === 'playerChoice' ? "player's choice" : t.direction} | ${consistOf(t.consist)} | ${rulesOf(t.rules)} |`;
const lines: string[] = []; /** Generated blocks, keyed by the marker name the deck documents wrap them in. */
const w = (s = ''): void => void lines.push(s); 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'); section('trains');
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();
w('### Timetabled'); w('### Timetabled');
w(); w();
w('| # | Class | Speed | Runs | Consist | Printed rules |'); w('| # | Class | Speed | Runs | Consist | Printed rules |');
@@ -116,16 +84,7 @@ w('| # | Class | Speed | Runs | Consist | Printed rules |');
w('| ---: | --- | --- | --- | --- | --- |'); w('| ---: | --- | --- | --- | --- | --- |');
for (const t of EXTRA_TRAINS) w(trainRow(t)); for (const t of EXTRA_TRAINS) w(trainRow(t));
w(); w();
w('---'); section('mainline');
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();
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |'); w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |');
w('| --- | ---: | ---: | --- | :---: | :---: |'); w('| --- | ---: | ---: | --- | :---: | :---: |');
for (const m of MAINLINE_PROFILES) { 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(); w();
for (const m of MAINLINE_PROFILES) w(`- **${m.name}** — ${mainlineDescription(m.kind)}`); for (const m of MAINLINE_PROFILES) w(`- **${m.name}** — ${mainlineDescription(m.kind)}`);
w(); w();
w('---'); section('office');
w();
w('## Office cards');
w();
w('Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in'); 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('order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**');
w('to Porters rather than one more.'); 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(`Whistle Posts are a fixed supply of ${WHISTLE_POST_SUPPLY} outside the deck, and Limits signs a`);
w(`supply of ${LIMITS_SUPPLY}.`); w(`supply of ${LIMITS_SUPPLY}.`);
w(); w();
w('---'); section('facilities');
w();
w('## Freight facilities');
w();
w('Each lists the car types it works, which way its traffic flows, and the industries it may not sit'); 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('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'); 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(`| ${f.name} | ${f.carTypes.join(', ')} | ${f.flow} | ${f.baseOut} | ${f.baseIn} | ${f.baseLoaders} | ${lo} |`);
} }
w(); w();
w('---'); section('modifiers');
w();
w('## Modifier cards');
w();
w('Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger'); 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('Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which');
w('is not one.'); 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(`| ${m.name} | ${hosts} | ${m.addOut || '—'} | ${m.addIn || '—'} | ${m.addLoaders || '—'} | ${m.addPorters || '—'} |`);
} }
w(); w();
w('---'); section('track');
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();
w('| Track | Geometry | Hand | Operational rail | Move cost | Dealt |'); w('| Track | Geometry | Hand | Operational rail | Move cost | Dealt |');
w('| --- | --- | --- | :---: | ---: | :---: |'); w('| --- | --- | --- | :---: | ---: | :---: |');
for (const t of TRACK_CARDS) { for (const t of TRACK_CARDS) {
@@ -212,11 +151,7 @@ for (const t of TRACK_CARDS) {
w(); w();
w('A row marked "no" is a shape the engine understands but the deck does not currently print.'); w('A row marked "no" is a shape the engine understands but the deck does not currently print.');
w(); w();
w('---'); section('enhancements');
w();
w('## Enhancements');
w();
w('The column that only the implementation can fill in: **whether the printed effect actually'); 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('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'); 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(`| ${card?.name ?? r.key} | ${r.placement} | ${needs} | **${r.effect}** |`);
} }
w(); w();
w('---'); section('opponent');
w();
w('## Opponent-directed cards, and what answers them');
w();
w('**None of these is dealt in any deck today.** A card that can only be played at another player'); 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('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'); 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 ?? '—'} |`); for (const c of MAINLINE_MODIFIER_CARDS) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
w(); 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}`);
}
+47 -31
View File
@@ -14,6 +14,9 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync,
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; 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)), '..'); const root = join(dirname(fileURLToPath(import.meta.url)), '..');
/** /**
* Overridable so a test can point the build at an isolated directory instead of the shared * Overridable so a test can point the build at an isolated directory instead of the shared
@@ -189,41 +192,46 @@ if (existsSync(imageSrc)) {
} }
/** /**
* The player-facing documentation, published beside the game so a tester can reach it from the box. * The player-facing documentation, RENDERED and published beside the game.
* *
* COPIED, NOT RE-WRITTEN. The Markdown in `docs/` is the one copy; a hand-written HTML twin would * MARKDOWN IS STILL THE ONE COPY. `docs/*.md` is what is written and reviewed; this turns it into
* drift from it on the first edit, which is the whole lesson of TODO #15a and of the 2026-09-20 * a page at build time. A hand-written HTML twin would drift from it on the first edit, which is
* documentation pass that found four references a month out of date. * the whole lesson of TODO #15a and of the documentation pass that found four references a month
* out of date.
* *
* THE WHOLE SET, NOT ONLY THE QUICKSTART. v0.8.0.16 published the guide alone, and the guide's own * WHY RENDER AT ALL. They were served as `text/plain`, which is honest and unreadable: a card
* §8 "Where to read more" links five further documents by relative path — so every one of them * reference is mostly tables, and as plain text a table is rows of pipes. That was TODO #109, taken
* 404'd on the package (verified on the box: 5 of 6 paths missing). Publishing the guide without * deliberately as the short version to get the references in front of testers for one round.
* what it points at is the same broken-link failure the test below was written to catch, one hop
* further out. The names are kept exactly as the guide writes them, because those links are what
* has to resolve.
* *
* SERVED AS PLAIN TEXT for now, which is honest rather than good: tables render as pipes and the * NO MARKDOWN LIBRARY. `scripts/markdown.ts` covers the subset these five documents use, and this
* links do not click. Rendering them into styled pages needs a small Markdown converter and is * project has no runtime dependencies at all — one would be a poor first.
* filed as TODO #109 — this is the version that gets the references in front of testers for this
* round rather than leaving them without any.
* *
* PUBLISHED UNDER THEIR OWN NAMES, which since v0.8.0.17 carry no version: the documents are kept * THE `.md` IS PUBLISHED TOO, beside the page. It costs nothing, it is what a reader who wants the
* current with every release rather than published as editions, so `docs/rules.md` is served as * source or a diff actually wants, and it keeps every link that was handed out while the documents
* `rules.md` and the splash page and the This Game card link it by that name. Four of them were * were served as text working rather than 404ing.
* `StationMaster-<name>-v0.4.5.md` until then — the prototype edition they were first written
* against, never the version they described.
*/ */
const GUIDE_DOCS: readonly string[] = [ const GUIDE_DOCS: readonly string[] = DOC_PAGES.map((d) => `${d.slug}.md`);
'quickstart.md',
'rules.md', /** `rules.md` → `rules.html`, so a link between documents lands on the rendered page. */
'home-deck.md', const docLink = (href: string): string =>
'mainline-deck.md', /^https?:/.test(href) || href.startsWith('#') ? href : href.replace(/\.md(#|$)/, '.html$1');
'components.md',
// Generated by `build:cards` and checked in; a test fails when it disagrees with the code, which /**
// is why it is the one reference that has never drifted. Its `rules/` directory is preserved * The title and version line, lifted out of the Markdown body.
// because that is the path the Quickstart links it by. *
'rules/as-built.md', * 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) { for (const rel of GUIDE_DOCS) {
const src = join(root, 'docs', rel); const src = join(root, 'docs', rel);
@@ -233,9 +241,17 @@ for (const rel of GUIDE_DOCS) {
console.error(`WARNING: docs/${rel} is missing — a published link will 404`); console.error(`WARNING: docs/${rel} is missing — a published link will 404`);
continue; continue;
} }
const md = readFileSync(src, 'utf8');
const out = join(dist, rel); const out = join(dist, rel);
mkdirSync(dirname(out), { recursive: true }); mkdirSync(dirname(out), { recursive: true });
copyFileSync(src, out); 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. // A tiny note for whoever unzips this later and wonders what it needs.
+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 };
}
+95 -4
View File
@@ -1101,9 +1101,20 @@ function evaluateClearance(
const node = s.division.nodes[targetIndex]; const node = s.division.nodes[targetIndex];
if (!node || node.kind !== 'mainline') return 'clear'; 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); 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. * §8.1 asks about the next SUBDIVISION, not the next card.
@@ -1138,9 +1149,32 @@ function evaluateClearance(
const occupants: { tray: TrayId; onCard: number }[] = []; const occupants: { tray: TrayId; onCard: number }[] = [];
for (const i of subdivision) { for (const i of subdivision) {
const n = s.division.nodes[i]; const n = s.division.nodes[i];
if (!n || n.kind !== 'mainline') continue;
if (behind(i)) continue; if (behind(i)) continue;
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 }); 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 // 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. // way to pass the order, so the train simply holds.
if (spendDispatchBonus(s, tray, otherTray, events) > 0) continue; 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'; return 'blocked';
} }
@@ -1495,12 +1547,51 @@ function arriveAtOffice(
return 'moved'; 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) { if (area.heldAtLimits.length > 0 && area.heldAtLimits[0] !== id) {
const first = area.heldAtLimits.shift()!; const first = area.heldAtLimits.shift()!;
area.adOccupancy.push(first); area.adOccupancy.push(first);
const held = s.trays.get(first); const held = s.trays.get(first);
if (held) held.position = { at: 'grid', seat, coord: area.officeCoord }; 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); area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);
+155 -12
View File
@@ -352,6 +352,35 @@ function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCo
return null; 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. * 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: * 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. * 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'; if (rules.pickUpEmptiesOnly && fresh.some(carriesLoad)) return 'EMPTIES_ONLY';
const freight = fresh.filter(isFreight).length; const freight = fresh.filter(isFreight).length;
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE'; 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. // 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.timetable[s.clock.stage - 1] !== i.trainNumber) return 'NO_SUCH_TRAY';
if (s.freeTrays.length === 0) 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; return null;
} }
@@ -1430,6 +1485,13 @@ function checkPlay(
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED'; if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
const proto = protoCard(card.kind, variant); const proto = protoCard(card.kind, variant);
if (!proto) return 'NO_PLACEMENT'; 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'; return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
} }
case 'modifier': { case 'modifier': {
@@ -1462,8 +1524,25 @@ function checkPlay(
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS'; if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses. // 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'; 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'; return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
} }
case 'enhancement': { case 'enhancement': {
@@ -1644,6 +1723,45 @@ export function movesFor(
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b); 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()] }; return { to: [...to.values()], blocked: [...blocked.values()] };
} }
@@ -2049,7 +2167,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
} }
case 'newTrain.secondSection': 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': case 'mainline.clearance':
return [ return [
@@ -2621,6 +2747,8 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'secondSectionOrdered': case 'secondSectionOrdered':
s.pendingSecondSections.push(e.trainNumber); 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; break;
case 'trainScheduled': case 'trainScheduled':
@@ -2946,20 +3074,27 @@ function adjacentFacilityCoord(
coord: GridCoord, coord: GridCoord,
modifier?: ModifierKind, modifier?: ModifierKind,
): GridCoord | null { ): 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 * NORTH, SOUTH, EAST OR WEST — NOT THE DIAGONALS (Jesse's ruling, 2026-09-23).
// guard here rejected the three above outright. *
* 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; const hosts = modifier ? modifierProfile(modifier).hosts : null;
for (let dr = -1; dr <= 1; dr++) { const ORTHOGONAL = [
for (let dc = -1; dc <= 1; dc++) { { dr: -1, dc: 0 },
if (dr === 0 && dc === 0) continue; { 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 c = { row: coord.row + dr, col: coord.col + dc };
const f = area.grid.get(coordKey(c))?.facility; const f = area.grid.get(coordKey(c))?.facility;
if (!f) continue; if (!f) continue;
if (hosts && !hosts.includes(f.subtype)) continue; if (hosts && !hosts.includes(f.subtype)) continue;
return c; return c;
} }
}
return null; return null;
} }
@@ -3075,6 +3210,14 @@ function emptyCard(): TrackCard {
} }
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */ /** 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 { 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.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
s.decks.salvageYard.push(cardId); s.decks.salvageYard.push(cardId);
+48 -1
View File
@@ -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 * 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 * 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 * 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 * 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 * `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 * `card-reference.md`, which describes the v0.4.5 deck and carries a banner saying not to use its
@@ -1224,6 +1224,8 @@ export type HouseRules = {
startingHand: StartingHand; startingHand: StartingHand;
revenue: RevenueRules; revenue: RevenueRules;
extraStart: ExtraStartRule; 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.) * §6.2 — MAY A TIMETABLED TRAIN BE THROWN AWAY? (Gitea#9, superseding Gitea#6.)
* *
@@ -1244,12 +1246,22 @@ export type HouseRules = {
discardTimetabled: boolean; 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()`. */ /** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
export type HouseRuleOverrides = { export type HouseRuleOverrides = {
startingHand?: StartingHand; startingHand?: StartingHand;
revenue?: Partial<RevenueRules>; revenue?: Partial<RevenueRules>;
extraStart?: ExtraStartRule; extraStart?: ExtraStartRule;
discardTimetabled?: boolean; discardTimetabled?: boolean;
startingOffice?: StartingOffice;
}; };
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */ /** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
@@ -1267,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 — // 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. // which has no setting and simply allows it — playing the same game.
discardTimetabled: true, 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',
}; };
/** /**
@@ -1286,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` // 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. // is what they were played under, and a replay that discards a Timetabled train needs it.
discardTimetabled: true, 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. */ /** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
@@ -1306,9 +1328,31 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
}, },
extraStart: given.extraStart ?? d.extraStart, extraStart: given.extraStart ?? d.extraStart,
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled, 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. */ /** What the dialog calls each option, in the order it offers them. */
export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string }[] = [ export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string }[] = [
{ value: 'threeRandom', label: 'Three random cards' }, { value: 'threeRandom', label: 'Three random cards' },
@@ -1403,6 +1447,9 @@ export function deckComposition(): { category: string; count: number }[] {
{ category: 'mainlineModifier', count: sum(MAINLINE_MODIFIER_CARDS) }, { category: 'mainlineModifier', count: sum(MAINLINE_MODIFIER_CARDS) },
{ category: 'maneuver', count: sum(MANEUVER_CARDS) }, { category: 'maneuver', count: sum(MANEUVER_CARDS) },
{ category: 'action', count: sum(ACTION_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 },
]; ];
} }
+16 -1
View File
@@ -126,6 +126,21 @@ export type GameEvent =
* Reduces to nothing, like `switchingEnded` above: it reports a choice the state already holds. * Reduces to nothing, like `switchingEnded` above: it reports a choice the state already holds.
*/ */
| { type: 'freightAgentIdled'; player: PlayerIndex } | { 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 } | { 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 * §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
@@ -185,7 +200,7 @@ export type GameEvent =
*/ */
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number } | { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number }
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: 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: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
| { type: 'trainHeld'; trainNumber: number; reason: string } | { type: 'trainHeld'; trainNumber: number; reason: string }
/** A train whose card pays for standing still (X18 Circus) collected on it. */ /** 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' | 'INBOUND_BOX_FULL'
| 'NO_EMPTY_COACH_IN_YARD' | 'NO_EMPTY_COACH_IN_YARD'
| 'NOT_A_CONTROL_POINT' | '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' | 'NO_EXTRA_PENDING'
/** §7 gives an Extra to the player who played the card; another seat may not place it for them. */ /** §7 gives an Extra to the player who played the card; another seat may not place it for them. */
| 'NOT_YOUR_EXTRA' | 'NOT_YOUR_EXTRA'
+12 -1
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. * 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 isTurnout = kind?.kind === 'track' && kind.geometry === 'turnout';
const isFacility = kind?.kind === 'freightFacility';
const targets = onMainline const targets = onMainline
? [] ? []
: kind?.kind === 'enhancement' : kind?.kind === 'enhancement'
? attachments ? attachments
: isTurnout : isTurnout || isFacility
? dedupe([...placements, ...attachments]) ? dedupe([...placements, ...attachments])
: placements; : placements;
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest // Orientation is chosen on placement, and a printed card turns but never flips, so the widest
@@ -375,8 +382,12 @@ function newTrainCandidates(s: GameState, player: PlayerIndex): Intent[] {
} }
const due = s.timetable[s.clock.stage - 1]; const due = s.timetable[s.clock.stage - 1];
if (due !== null && due !== undefined) { if (due !== null && due !== undefined) {
// 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 }); out.push({ type: 'newTrain.secondSection', trainNumber: due });
} }
}
/** /**
* WHERE A PENDING EXTRA MAY START (§7, Jesse's ruling) — every candidate offered, with `check` * WHERE A PENDING EXTRA MAY START (§7, Jesse's ruling) — every candidate offered, with `check`
* doing the filtering, so "is this a Control Point" and "does the house rule allow it" have one * doing the filtering, so "is this a Control Point" and "does the house rule allow it" have one
+36 -6
View File
@@ -23,11 +23,13 @@ import {
TRACK_CARDS, TRACK_CARDS,
ROLLING_STOCK_SUPPLY, ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY, STAGES_PER_DAY,
SECOND_SECTION,
TIMETABLED_TRAINS, TIMETABLED_TRAINS,
crewTrayCount, crewTrayCount,
mainlineCardCount, mainlineCardCount,
officeProfile, officeProfile,
} from './content.ts'; } from './content.ts';
import type { StartingOffice } from './content.ts';
import type { Rng } from './rng.ts'; import type { Rng } from './rng.ts';
import { createRng } from './rng.ts'; import { createRng } from './rng.ts';
import type { 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. */ /** 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. * 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 TIMETABLED_TRAINS) push({ kind: 'timetabledTrain', number: t.number });
for (const t of EXTRA_TRAINS) push({ kind: 'extraTrain', 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) { 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 (let i = 0; i < o.copiesInDeck; i++) push({ kind: 'office', tier: o.tier });
} }
for (const f of FREIGHT_PROFILES) { 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 (const m of MODIFIER_PROFILES) {
for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind }); 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) { if (opponentCardsInDeck) {
for (const c of SPACE_USE_CARDS) { for (const c of SPACE_USE_CARDS) {
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key }); 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 * branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
* drawn turnout cards only. * drawn turnout cards only.
*/ */
function buildOfficeArea(seat: SeatIndex): OfficeArea { function buildOfficeArea(seat: SeatIndex, tier: StartingOffice): OfficeArea {
const row = 0; const row = 0;
const officeCoord = { row, col: 0 }; const officeCoord = { row, col: 0 };
const limitsWest = { row, col: -1 }; const limitsWest = { row, col: -1 };
@@ -158,7 +185,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
baseOperationalRail: true, baseOperationalRail: true,
standing: [], standing: [],
standingWest: 0, standingWest: 0,
facility: buildPassengerFacility('whistlePost'), facility: buildPassengerFacility(tier),
modifiers: [], modifiers: [],
enhancements: [], enhancements: [],
}; };
@@ -180,7 +207,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
return { return {
seat, seat,
tier: 'whistlePost', tier,
grid, grid,
officeCoord, officeCoord,
runningRow: row, runningRow: row,
@@ -285,6 +312,9 @@ export function createGame(opts: SetupOptions): GameState {
const rng = createRng(seed); const rng = createRng(seed);
const playerCount = playerNames.length; 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 })); 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 // 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. // seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else.
const officeAreas = new Map<SeatIndex, OfficeArea>(); 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. // §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. // Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
const divisionRolls = players.map(() => rng.d12()); const divisionRolls = players.map(() => rng.d12());
@@ -339,7 +369,7 @@ export function createGame(opts: SetupOptions): GameState {
*/ */
const rules = houseRules(config); const rules = houseRules(config);
const deal = OPENING_DEALS[rules.startingHand]; 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>(); const cards = new Map<CardId, Card>();
for (const c of deck) cards.set(c.id, c); 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: 'timetabledTrain'; number: number }
| { kind: 'extraTrain'; number: number } | { kind: 'extraTrain'; number: number }
| { kind: 'office'; tier: OfficeTier } | { 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: 'freightFacility'; facility: FreightKind }
| { kind: 'modifier'; modifier: ModifierKind } | { kind: 'modifier'; modifier: ModifierKind }
/** Handedness is printed on the card: it is the diagonal the 45° leg lies on. */ /** 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 * and only the obstructions are worth listing in the "why nothing is moving" panel, where every
* turnout in the district would otherwise appear. * 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 }; export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
/** /**
+25 -5
View File
@@ -18,6 +18,7 @@
* checked, before `submit` is ever called — see `intent()` below. * checked, before `submit` is ever called — see `intent()` below.
*/ */
import { withSavedOpening } from '../engine/content.ts';
import { check } from '../engine/apply.ts'; import { check } from '../engine/apply.ts';
import { legalActions } from '../engine/legal.ts'; import { legalActions } from '../engine/legal.ts';
import type { Intent } from '../engine/intents.ts'; import type { Intent } from '../engine/intents.ts';
@@ -212,10 +213,25 @@ function buildSession(
return snapshot(game.state, [], null, null, null, false, seat); return snapshot(game.state, [], null, null, null, false, seat);
} }
function linesSince(seat: PlayerIndex): { text: string; tone: string }[] { /**
const already = sentLines.get(seat) ?? 0; * WHAT THIS SEAT HAS NOT BEEN SENT YET, bookmarked by SEQUENCE rather than by position.
sentLines.set(seat, game.log.length); *
return game.log.slice(already); * 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 { 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 type ResumeFailure = { stoppedAt: number; of: number; intent: string; code: string };
export function tryResumeSession(saved: SavedGame): { ok: true; session: GameSession } | { ok: false; failure: ResumeFailure } { 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) { if (stopped) {
return { return {
ok: false, ok: false,
+15 -1
View File
@@ -1259,7 +1259,17 @@ export function officeSvg(
const tx = W / 2 - tw / 2; const tx = W / 2 - tw / 2;
const facingWord = t.facing === 'e' ? 'east' : 'west'; const facingWord = t.facing === 'e' ? 'east' : 'west';
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', '); 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}` : ''), `${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"/>`; )}"><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>`; out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
@@ -1470,6 +1480,10 @@ text.bs-mod{fill:#c8a04a}
/* A signal standing on a Mainline card carrying ABS Signals. Green is otherwise unused on the /* 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 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. */ 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-mast{stroke:#9aa3b0;stroke-width:1.6}
.bs-abs-lit{fill:#4fae6a;stroke:#2c6b40;stroke-width:0.8} .bs-abs-lit{fill:#4fae6a;stroke:#2c6b40;stroke-width:0.8}
.bs-abs-dark{fill:#2a3038;stroke:#59626f;stroke-width:0.8} .bs-abs-dark{fill:#2a3038;stroke:#59626f;stroke-width:0.8}
+39 -5
View File
@@ -441,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`, 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': 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 { return {
tone: 'good', tone: 'good',
text: text:
`Train ${e.trainNumber} stood still for a whole Stage at ${e.where} and earned a point — ` + `CIRCUS SET-UP — Train ${e.trainNumber} stood still for a whole Stage at ${e.where}, ` +
'its card pays for the stop, not for the run (circus set-up)', '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': case 'trainHeld':
return { return {
@@ -535,6 +542,21 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`, text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
where: e.at, where: e.at,
}; };
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': case 'freightAgentIdled':
return { return {
tone: 'quiet', tone: 'quiet',
@@ -701,10 +723,22 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
}; };
// -- consequences // -- 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 return e.delta < 0
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` } ? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${owner}${e.delta} Revenue (now ${e.total})` }
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` }; : { tone: 'good', text: `${owner}+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
}
case 'phaseEnded': case 'phaseEnded':
return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` }; 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 * screen. `Frame` has carried `superintendent` all along and the standalone replay printed it; the
* live game never did. * 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 }[] = [ const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
{ {
key: 'localOps', 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>` + `<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
`<span class="dim">${esc(f.clock)}</span></div>` + `<span class="dim">${esc(f.clock)}</span></div>` +
`<div class="tc-now">phase <b>${esc(f.phase)}</b></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 // 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 // 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 // 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-asks{color:#a99ac4;font-style:italic}
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0; .tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
border-radius:11px;padding:1px 9px;font-size:12px} 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 /* 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 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. */ that changes every turn, while this changes four times a Day. */
+29 -1
View File
@@ -33,6 +33,7 @@ import {
MANEUVER_CARDS, MANEUVER_CARDS,
MODIFIER_PROFILES, MODIFIER_PROFILES,
REALIGNMENTS, REALIGNMENTS,
SECOND_SECTION,
OFFICE_ORDER, OFFICE_ORDER,
SPACE_USE_CARDS, SPACE_USE_CARDS,
STAGES_PER_SHIFT, STAGES_PER_SHIFT,
@@ -1923,6 +1924,8 @@ 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 // 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. // 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)}`; return `${k.hand === 'none' ? '' : `${k.hand}-hand `}${geometryLabel(k.geometry)}`;
case 'secondSection':
return SECOND_SECTION.name;
case 'spaceUse': case 'spaceUse':
case 'enhancement': case 'enhancement':
case 'mainlineModifier': case 'mainlineModifier':
@@ -2045,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}`; 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': { case 'track': {
// Track is the largest category in the deck, so a player holds it constantly — and what it // 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. // can be joined to is decided by the hand, which is not something the name alone conveys.
@@ -2088,7 +2099,24 @@ export function cardDescription(s: GameState, id: string): string {
: rule?.effect === 'dormantSolo' : rule?.effect === 'dormantSolo'
? ' · never fires in solitaire — it answers an opponent card the solo deck omits' ? ' · 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}`;
} }
} }
} }
+72 -13
View File
@@ -44,6 +44,7 @@ import {
DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL, DEFAULT_MAX_COLLISIONS_TOTAL,
LEGACY_HOUSE_RULES, LEGACY_HOUSE_RULES,
withSavedOpening,
collectiveRevenueFloor, collectiveRevenueFloor,
houseRules, houseRules,
industryProfile, industryProfile,
@@ -229,8 +230,19 @@ export type Game = {
seed: number; seed: number;
/** Every intent submitted, in order — the save file. */ /** Every intent submitted, in order — the save file. */
history: Intent[]; 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. * True when the hand is OVER the limit, so the turn cannot end until it is played down.
* *
@@ -313,13 +325,30 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
*/ */
export const SOLO_PLAYER = 'Solitaire'; 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 { export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] }); 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() }; 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 // A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over. // first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' }); pushLine(game, 'Game Begins', 'start');
game.log.push({ text: `Solitaire · one player · seed ${seed}`, tone: 'quiet' }); pushLine(game, `Solitaire · one player · seed ${seed}`, 'quiet');
drain(game); drain(game);
return game; return game;
} }
@@ -335,7 +364,7 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game { export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames }); 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() }; 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). * NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
* *
@@ -350,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 * 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. * 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); drain(game);
return game; return game;
} }
@@ -1011,7 +1040,21 @@ function trainCardTitle(number: number, isExtra: boolean): string | null {
const calls = parts.length > 0 ? parts.join(' + ') : 'no cars at all'; const calls = parts.length > 0 ? parts.join(' + ') : 'no cars at all';
const note = p.rules.note ? ` — ${p.rules.note}` : ''; const note = p.rules.note ? ` — ${p.rules.note}` : '';
const name = `${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}”`; 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}`;
} }
/** /**
@@ -1148,7 +1191,7 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
const result = applyIntent(game.state, actor, intent); const result = applyIntent(game.state, actor, intent);
if (!result.ok) { 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; return false;
} }
game.history.push(intent); game.history.push(intent);
@@ -1395,7 +1438,16 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
*/ */
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled']; const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
const ruling = RULINGS.includes(e.type) && who !== null; 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 const text = ruling
? `Superintendent Player ${who} ${uncapitalise(said)}` ? `Superintendent Player ${who} ${uncapitalise(said)}`
: mine : mine
@@ -1403,7 +1455,7 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
: said; : said;
// `trace` is a tone the history panel does not draw — see `inHistory`. The line exists so the // `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. // 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)); game.cues.push(...cuesFor(events));
@@ -1433,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}.`; 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);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1465,7 +1524,7 @@ export function toSave(game: Game): Save {
* in force then — never the current defaults. * in force then — never the current defaults.
*/ */
function configFor(save: Save, config: GameConfig): GameConfig { function configFor(save: Save, config: GameConfig): GameConfig {
return { ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES }; return withSavedOpening({ ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES });
} }
/** /**
+1 -1
View File
@@ -102,7 +102,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
</div> </div>
<p class="newhere">New to Station Master? <p class="newhere">New to Station Master?
<a href="./quickstart.md">Read the Quickstart guide</a> &mdash; what the game is, how you win, <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 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> read, and it will save you an hour of guessing.</p>
+13 -6
View File
@@ -582,10 +582,18 @@ function renderTurnChart(f: Frame): void {
// chip that can never change is a chip to read past. // chip that can never change is a chip to read past.
const superName = const superName =
f.players.length > 1 ? (f.players.find((p) => p.index === table.superintendent)?.name ?? null) : null; 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( $('turnchart').innerHTML = turnChartHtml(
replaying ? { ...f, ...table, awaiting: null } : f, replaying ? { ...f, ...table, awaiting: null } : f,
actorName, actorName,
superName, superName,
yours,
); );
} }
@@ -678,12 +686,11 @@ function renderGameCard(f: Frame): void {
* nobody sees, the same reasoning the folded body above already follows. * nobody sees, the same reasoning the folded body above already follows.
*/ */
const GUIDE_DOCS: readonly { href: string; label: string; what: string }[] = [ const GUIDE_DOCS: readonly { href: string; label: string; what: string }[] = [
{ href: './quickstart.md', label: 'Quickstart', what: 'What the game is and a first twenty minutes — for anyone who has not played.' }, { href: './quickstart.html', label: 'Quickstart', what: 'What the game is and a first twenty minutes — for anyone who has not played.' },
{ href: './rules.md', label: 'Rules', what: 'The rules in full, with the FAQ.' }, { href: './rules.html', label: 'Rules', what: 'The rules in full, with the FAQ.' },
{ href: './rules/as-built.md', label: 'Every card', what: 'Generated from the code, so it cannot drift from what the game actually does.' }, { href: './home-deck.html', label: 'Home deck', what: 'How the Home Office deck is dealt and played.' },
{ href: './home-deck.md', 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: './mainline-deck.md', 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.' },
{ href: './components.md', label: 'Components', what: 'Rolling stock, yards, trays, the Fedora.' },
]; ];
const GUIDE_HTML = const GUIDE_HTML =
+21 -1
View File
@@ -441,6 +441,7 @@ ul.blocked li{padding:2px 0}
<h2>Join a game</h2> <h2>Join a game</h2>
<p class="ng-note">Ask whoever created the game for its code. You will see the whole rule set <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> 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> <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> <button id="lb-look" type="button">Look up game</button>
<p class="lb-error" id="lb-join-err" role="alert"></p> <p class="lb-error" id="lb-join-err" role="alert"></p>
@@ -462,6 +463,7 @@ ul.blocked li{padding:2px 0}
<section id="lb-create-panel" hidden> <section id="lb-create-panel" hidden>
<h2>Create a new game</h2> <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">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 <!-- 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. --> for what is really two short lists: what game this is, and what its rules are. -->
@@ -628,6 +630,15 @@ ul.blocked li{padding:2px 0}
<input id="lb-toolbox" type="checkbox"></label> <input id="lb-toolbox" type="checkbox"></label>
<span class="set-hint" id="lb-toolbox-hint"></span> <span class="set-hint" id="lb-toolbox-hint"></span>
</div> </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"> <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 <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 Department slot, where a rival may pick it up. Turn this off and a train card can only ever
@@ -853,6 +864,15 @@ ul.blocked li{padding:2px 0}
<input id="ss-toolbox" type="checkbox"></label> <input id="ss-toolbox" type="checkbox"></label>
<span class="set-hint" id="ss-toolbox-hint"></span> <span class="set-hint" id="ss-toolbox-hint"></span>
</div> </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"> <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 <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. Department slot. Turn this off and a train card can only ever be played onto the timetable.
@@ -930,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 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. --> 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> <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> <span class="dim build" title="what is actually deployed">__BUILD__</span>
</header> </header>
+12 -2
View File
@@ -25,7 +25,7 @@ import {
collectiveRevenueFloor, collectiveRevenueFloor,
houseRules, houseRules,
} from '../engine/content.ts'; } 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'; import type { GameConfig, GameMode } from '../engine/state.ts';
export type PresetName = 'solitaire' | 'coop' | 'competitive' | 'cutthroat'; export type PresetName = 'solitaire' | 'coop' | 'competitive' | 'cutthroat';
@@ -54,6 +54,8 @@ export type Settings = {
emergencyToolbox: boolean; emergencyToolbox: boolean;
/** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */ /** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */
discardTimetabled: boolean; discardTimetabled: boolean;
/** Ticked = everyone opens on a Whistle Post, the harder game. Unticked = a Depot. */
startWhistlePosts: boolean;
}; };
export const SETTING_KEYS: readonly (keyof Settings)[] = [ export const SETTING_KEYS: readonly (keyof Settings)[] = [
@@ -69,6 +71,7 @@ export const SETTING_KEYS: readonly (keyof Settings)[] = [
'employeeRotation', 'employeeRotation',
'emergencyToolbox', 'emergencyToolbox',
'discardTimetabled', 'discardTimetabled',
'startWhistlePosts',
]; ];
export type Preset = { 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. * about how long a game runs, which is a dial the table already sets for itself.
*/ */
discardTimetabled: true, discardTimetabled: true,
// The default opening is a Depot, so the harder setting is off.
startWhistlePosts: false,
} as const; } as const;
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a /** 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, employeeRotation: config.optionalRules.employeeRotation,
emergencyToolbox: config.optionalRules.emergencyToolbox, emergencyToolbox: config.optionalRules.emergencyToolbox,
discardTimetabled: rules.discardTimetabled, discardTimetabled: rules.discardTimetabled,
startWhistlePosts: rules.startingOffice === 'whistlePost',
}; };
} }
@@ -274,7 +280,7 @@ export function configFromFrame(f: {
maxCollisionsPerDay: number; maxCollisionsPerDay: number;
maxCollisionsTotal: number; maxCollisionsTotal: number;
optionalRules: GameConfig['optionalRules']; optionalRules: GameConfig['optionalRules'];
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean }; houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean; startingOffice?: StartingOffice };
}): GameConfig { }): GameConfig {
return { return {
mode: f.mode, 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 // 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). // dropped here shows them a rule the table is not playing (§6.2, Gitea#9).
discardTimetabled: f.houseRules.discardTimetabled, 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, revenue: f.houseRules.revenue,
}, },
}; };
@@ -355,6 +364,7 @@ export function configFromSettings(
startingHand: settings.startingHand, startingHand: settings.startingHand,
extraStart: settings.extraStart, extraStart: settings.extraStart,
discardTimetabled: settings.discardTimetabled, discardTimetabled: settings.discardTimetabled,
startingOffice: settings.startWhistlePosts ? 'whistlePost' : 'depot',
revenue: { revenue: {
passengerPerCoach: settings.passengerPerCoach, passengerPerCoach: settings.passengerPerCoach,
freightPerLoad: settings.freightPerLoad, 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 <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. 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> 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"> <input type="file" id="file" accept=".json,application/json">
<div id="perr"></div> <div id="perr"></div>
</section> </section>
+5 -1
View File
@@ -48,6 +48,7 @@ export const FIELDS: readonly Field[] = [
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' }, { key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' }, { key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' }, { 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', startingHand: 'Starting hand',
extraStart: 'An Extra may start at', extraStart: 'An Extra may start at',
discardTimetabled: 'A Timetabled train may be discarded', discardTimetabled: 'A Timetabled train may be discarded',
startWhistlePosts: 'Players start with Whistle Posts, not Depots',
passengerPerCoach: 'Passenger per coach', passengerPerCoach: 'Passenger per coach',
freightPerLoad: 'Freight per load', freightPerLoad: 'Freight per load',
trainPerTransit: 'Train per transit', trainPerTransit: 'Train per transit',
@@ -116,7 +118,7 @@ export function rulesListHtml(config: GameConfig, players: number, days: number)
return ( return (
head + 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>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` + `<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</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, employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true, emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
discardTimetabled: el<HTMLInputElement>('tossloco')?.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('rotation', values.employeeRotation);
setChecked('toolbox', values.emergencyToolbox); setChecked('toolbox', values.emergencyToolbox);
setChecked('tossloco', values.discardTimetabled); setChecked('tossloco', values.discardTimetabled);
setChecked('whistlestart', values.startWhistlePosts);
} }
function setNumber(id: string, value: number): void { function setNumber(id: string, value: number): void {
+4
View File
@@ -23,6 +23,10 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
+86 -71
View File
@@ -23,6 +23,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
@@ -1215,6 +1219,7 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
assert.ok(waiting, 'no Waiting Area card in the deck'); assert.ok(waiting, 'no Waiting Area card in the deck');
const [cardId] = waiting!; const [cardId] = waiting!;
s.decks.hands.set(0, [cardId]); 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' }); applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const spots = legalActions(s, 0).filter( const spots = legalActions(s, 0).filter(
@@ -1247,6 +1252,7 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
assert.ok(copies.length >= 2, 'the deck should hold more than one Waiting Area'); assert.ok(copies.length >= 2, 'the deck should hold more than one Waiting Area');
s.decks.hands.set(0, [copies[0]!]); s.decks.hands.set(0, [copies[0]!]);
openOffice(s);
const first = legalActions(s, 0).find( const first = legalActions(s, 0).find(
(i) => i.type === 'card.play' && i.cardId === copies[0] && i.placement !== undefined, (i) => i.type === 'card.play' && i.cardId === copies[0] && i.placement !== undefined,
) as { type: 'card.play'; cardId: string; placement: { row: number; col: number } }; ) as { type: 'card.play'; cardId: string; placement: { row: number; col: number } };
@@ -1271,6 +1277,25 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Raise a player's Office to a tier that IS a Passenger Facility.
*
* A Waiting Area, Restaurant or Hotel may not be played at a Whistle Post (2026-09-23), and these
* fixtures name the Whistle Post opening, so a test about those cards has to open the Office first.
* Mirrors the `officeUpgraded` reducer: the tier, and the passenger flow that comes with it.
*/
function openOffice(s: GameState, player = 0 as never, tier: 'depot' | 'station' | 'terminal' = 'depot'): void {
const area = areaOf(s, player);
const to = officeProfile(tier);
area.tier = tier;
const card = area.grid.get(coordKey(area.officeCoord));
if (card?.facility) {
card.facility.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
card.facility.capacity = { outbound: to.passengerOut, inbound: to.passengerIn };
card.facility.porters = to.porters;
}
}
describe('the Limits bound the district, and the nine spots reach round a Facility', () => { 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 * A district whose Running Track has been extended one square east, so the sign stands at col 2
@@ -1388,12 +1413,15 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
); );
}); });
it('offers a Modifier the DIAGONAL spots around its host, not just the four orthogonal ones', () => { it('refuses a Modifier on a diagonal, and offers only the four orthogonal spots', () => {
/** /**
* REPORTED by Jesse: a Modifier could not be placed to the south-east of his industry. §9 places * JESSE'S RULING, 2026-09-23, REVERSING HIS OWN EARLIER REPORT. This asserted the opposite: he
* one "adjacent to a Facility, on any of the nine nearby spots" and `check` has always accepted * had reported that a Modifier could not be placed to the south-east of his industry, and §9's
* all eight neighbours — it was `placementCandidates` that walked north, south, east and west * "any of the nine nearby spots" was read as all eight neighbours. A Modifier must sit SQUARE
* only, so a diagonal square with no orthogonal neighbour was legal and never offered. * 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 s = game();
const under = district(s); const under = district(s);
@@ -1408,13 +1436,25 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined) .filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
.map((i) => coordKey((i as { placement: GridCoord }).placement)); .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); const southEast = at(under.row - 1, under.col + 1);
assert.ok( assert.ok(
offered.includes(coordKey(southEast)), !offered.includes(coordKey(southEast)),
`the south-east spot (${southEast.row}, ${southEast.col}) is legal but was never offered — offered: ${offered.join(' ')}`, `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', () => { it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
@@ -1555,9 +1595,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
// Forced open so this tests the freight/passenger distinction rather than the `allows` gating — // 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. // 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 officeCard = area.grid.get(coordKey(area.officeCoord))!;
const office = officeCard.facility!; 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( const waiting = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea', ([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
@@ -1569,29 +1611,27 @@ describe("a Modifier grants only what its host's flow can use", () => {
assert.ok(spot, 'a Waiting Area has nowhere legal beside the Office'); assert.ok(spot, 'a Waiting Area has nowhere legal beside the Office');
assert.ok(applyIntent(s, 0, spot!).ok); assert.ok(applyIntent(s, 0, spot!).ok);
assert.equal(office.capacity.outbound, 1, 'the passenger slot should still be granted'); // Relative, so the assertion says what the Modifier is worth rather than what a Depot prints.
assert.equal(office.porters, 1, 'the porter should still be granted'); 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( assert.equal(
carsOn(officeCard), officeCard.standing, carsOn(officeCard), officeCard.standing,
'a Modifier gave a Passenger Facility an industry track to spot cars on', '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 * JESSE'S RULING, 2026-09-23, REVERSING the 2026-09-17 call that let these stand dormant.
* shows one green / one red box. I expected two, because Restaurant increases outbound by one."
* *
* `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility, so the +1 * `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility — it allows
* outbound is genuinely unusable while the Office is a Whistle Post — suppressing it is right, * neither direction — so the card's +1 outbound was discarded on the spot and only its porter
* and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied * landed. Dormant was defensible while the panel explained itself, but a card that can be played
* only the difference between two tiers and knew nothing about what had been discarded. * 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 * THE RECOVERY PATH IN `officeUpgraded` IS LEFT IN PLACE and is now unreachable by play: it
* now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the * restores a grant suppressed at a Whistle Post, and no such grant can be created any more. It
* suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across * is kept because it is correct, and relaxing this rule would need it back.
* that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently
* and tests nothing about the upgrade path.
*/ */
const s = game(); const s = game();
const area = areaOf(s, 0); const area = areaOf(s, 0);
@@ -1603,57 +1643,32 @@ describe("a Modifier grants only what its host's flow can use", () => {
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'restaurant', ([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'restaurant',
)!; )!;
s.decks.hands.set(0, [restaurant[0]]); s.decks.hands.set(0, [restaurant[0]]);
assert.ok(applyIntent(s, 0, {
type: 'card.play', const beside = { row: area.officeCoord.row - 1, col: area.officeCoord.col };
cardId: restaurant[0], assert.equal(
placement: { row: area.officeCoord.row - 1, col: area.officeCoord.col }, check(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }),
}).ok, 'the Restaurant could not be played beside the Office'); '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!; const f = office.facility!;
assert.equal(f.capacity.outbound, 0, 'a Whistle Post gained a passenger slot it cannot have'); assert.equal(f.capacity.outbound, officeProfile('depot').passengerOut + 1, 'the slot did not land');
assert.equal(f.porters, 1, 'the porter has no direction gate and should have landed'); assert.equal(f.porters, officeProfile('depot').porters + 1, 'the porter did not land');
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',
);
});
}); });
// ---------------------------------------------------------------------------
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 { function withSiding(s: GameState): GridCoord {
const area = areaOf(s, 0); const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({ const plain = (geometry: object): TrackCard => ({
+37 -13
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 * 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. * 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 * 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 * 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. * 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'; import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.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', () => { 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 }); execFileSync(process.execPath, [join(root, 'scripts/build-card-reference.ts')], { cwd: root });
const after = readFileSync(doc, 'utf8'); DOCS.forEach((d, i) => {
assert.equal( assert.equal(
after, readFileSync(d, 'utf8'),
before, before[i],
'the checked-in card reference is stale — run `npm run build:cards` and commit the result', `${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', () => { 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 // 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. // 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, /Crack Limited/);
assert.match(md, /\| 3 \| Express \|/); assert.match(md, /\| 3 \| Express \|/);
assert.ok(!/Mail-Express/.test(md), 'the superseded v0.4.5 train names are back'); 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'); 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', () => { it('says the tables are generated, so nobody edits them by hand', () => {
const md = readFileSync(doc, 'utf8'); for (const d of DOCS) {
assert.match(md, /Generated from `src\/engine\/content\.ts`/); const md = readFileSync(d, 'utf8');
assert.match(md, /Do not edit by/); 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 { describe, it } from 'node:test';
import assert from 'node:assert/strict'; 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 { legalActions } from '../src/engine/legal.ts';
import { describeIntent } from '../src/sim/view.ts'; import { describeIntent } from '../src/sim/view.ts';
import { badlyMadeUp } from '../src/engine/advance.ts'; import { badlyMadeUp } from '../src/engine/advance.ts';
@@ -26,6 +26,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: 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, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, 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 { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts'; import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts';
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.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 { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey, decisionActor, subdivisions, turnOf } from '../src/engine/state.ts'; import { coordKey, decisionActor, subdivisions, turnOf } from '../src/engine/state.ts';
@@ -23,6 +25,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}; };
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] }); 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'); 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', () => { it('still collides without an Interlocking', () => {
const s = game(); const s = game();
areaOf(s, 0).adOccupancy = ['blocker']; areaOf(s, 0).adOccupancy = ['blocker'];
+104 -1
View File
@@ -15,7 +15,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; 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 { actionGroups, currentActor } from '../src/web/game.ts';
import { narrate } from '../src/sim/narrate.ts'; import { narrate } from '../src/sim/narrate.ts';
@@ -92,6 +92,12 @@ const KNOWN_UNREDUCED = [
'trainHeld', 'trainHeld',
'trainHighballed', 'trainHighballed',
'trainMadeUp', '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', 'trainStoodStill',
'trainsDestroyed', 'trainsDestroyed',
]; ];
@@ -283,3 +289,100 @@ describe('the log names the Mainline card an Enhancement was built on', () => {
assert.match(line.text, /\(2,-1\)/, `the square is gone or in the wrong order: ${line.text}`); 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, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
...over, ...over,
}); });
+4
View File
@@ -15,6 +15,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
+4
View File
@@ -34,6 +34,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
+92
View File
@@ -38,6 +38,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}; };
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] }); 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', () => { describe('a turnout may be laid on top of a card already down', () => {
/** Lays a straight inside the Limits and returns where it went. */ /** 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 layStraight = (s: GameState): GridCoord => {
const area = areaOf(s, 0); const area = areaOf(s, 0);
const target = { row: area.runningRow, col: area.limitsEast.col }; 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'); 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', () => { it('refuses to swap the track out from under a car, or out from under an Interlocking', () => {
const s = game(); const s = game();
const area = areaOf(s, 0); 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, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}; };
+4
View File
@@ -26,6 +26,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
+4
View File
@@ -29,6 +29,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
+21 -8
View File
@@ -22,6 +22,7 @@ import { developerBot, playGame } from '../src/sim/bot.ts';
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts'; import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts'; import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
import { summarize } from '../src/sim/stats.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 // 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. // 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, maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL, maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
@@ -63,6 +66,7 @@ const SAMPLES: GameEvent[] = [
// line whose whole job is to be read when nothing happened, so an empty or fallback sentence // 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. // would reproduce the silence it exists to fix.
{ type: 'freightAgentIdled', player: 0 }, { 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: '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: '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 }, { type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
@@ -127,7 +131,7 @@ describe('narration', () => {
/** /**
* THE KNOWN GAP, PINNED SO IT CANNOT GROW. * THE KNOWN GAP, PINNED SO IT CANNOT GROW.
* *
* `SAMPLES` exercises the TEXT of 31 of the 56 declared events; the other 25 have a narration * `SAMPLES` exercises the TEXT of 32 of the 57 declared events; the other 25 have a narration
* case (checked above) but no sample, so nothing proves their sentence is any good. Found * 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 * 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 * other, so it could only ever assert that the sample list had 30 distinct entries, and the one
@@ -217,9 +221,12 @@ describe('impediments', () => {
}); });
it('warns when every A/D track is occupied', () => { 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'] }); 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); const found = impediments(s, 0);
assert.ok(found.some((b) => /A\/D/.test(b.why) && b.severity === 'risk')); assert.ok(found.some((b) => /A\/D/.test(b.why) && b.severity === 'risk'));
}); });
@@ -720,8 +727,14 @@ describe('the replay behaves like the game it is replaying', () => {
* TWENTY-FOUR, not twelve, and the difference is instructive: on this stride the first game that * 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 * 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 … * 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)); 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`); assert.ok(withCues.length > 20, `only ${withCues.length} frames carry a cue`);
@@ -736,10 +749,10 @@ describe('the replay behaves like the game it is replaying', () => {
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently'); assert.ok(kinds.has('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('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'); 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 // Collisions are rarer still now that every Office opens as a Depot — see the note above. This
// guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current // is the one cue the pool is not guaranteed to contain on every stride. If it starts failing
// stride and seed count. If this starts failing after either changes, widen the pool rather than // after the stride, the seed count or the opening changes, widen the pool rather than deleting
// deleting the assertion — §10 is the one event a player most needs to hear. // the assertion — §10 is the one event a player most needs to hear.
assert.ok(kinds.has('crash'), 'a collision never made a sound'); 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 // 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, maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL, maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}; };
}; };
+4
View File
@@ -24,6 +24,10 @@ const competitive: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
+4
View File
@@ -15,6 +15,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
+4
View File
@@ -16,6 +16,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
+85 -8
View File
@@ -7,7 +7,7 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import type { CarType } from '../src/engine/content.ts'; 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 { import {
DECK_SIZE, DECK_SIZE,
EXTRA_TRAINS, EXTRA_TRAINS,
@@ -27,6 +27,7 @@ import {
nextOfficeTier, nextOfficeTier,
officeProfile, officeProfile,
} from '../src/engine/content.ts'; } from '../src/engine/content.ts';
import { coordKey } from '../src/engine/state.ts';
import { createRng } from '../src/engine/rng.ts'; import { createRng } from '../src/engine/rng.ts';
import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts'; import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts';
import type { StartingHand } from '../src/engine/content.ts'; import type { StartingHand } from '../src/engine/content.ts';
@@ -40,6 +41,10 @@ const solitaireConfig: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
@@ -55,7 +60,8 @@ const gameDealtWith = (startingHand: StartingHand, seed = 1234) =>
createGame({ createGame({
id: 'g1', id: 'g1',
seed, 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'], 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 // 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. // 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, // 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, // 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. // 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 // "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. // 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. // 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); assert.equal(buildDeck().length, SOLITAIRE_DECK_SIZE);
}); });
@@ -104,6 +110,8 @@ describe('card catalogue (component 1)', () => {
industry: 9, industry: 9,
modifier: 23, modifier: 23,
train: 22, train: 22,
// Q9 — dealt since 2026-09-23, which is what makes `newTrain.secondSection` cost something.
secondSection: 1,
spaceUse: 11, spaceUse: 11,
// 6 — the dispatching ladder and Facing Point Locks are dealt 0 copies (see // 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 // 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 // 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 // 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. // 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 // 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 // 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 // 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. // 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); assert.equal(DEFENCE_ONLY_COPIES, 2);
for (const c of DEFENCE_ONLY_CARDS) { for (const c of DEFENCE_ONLY_CARDS) {
assert.ok(c.answers, `${c.name} is held back without saying what it answers`); 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', () => { 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. // 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 // This asked for a THIRD of the deck until Gitea#14, which was only ever a rule of thumb. It
@@ -616,3 +624,72 @@ describe('game setup (component 2)', () => {
); );
}); });
}); });
/**
* WHICH OFFICE EVERY PLAYER OPENS ON.
*
* Jesse's call, 2026-09-23: a Whistle Post has one A/D track and is not a Passenger Facility, so the
* opening of every game was spent unable to work a passenger and one arrival away from a collision.
* The default is a Depot now; the Whistle Post opening stays as the harder setting.
*/
describe('the starting Office, and the deck that goes with it', () => {
const withRules = (houseRules: Record<string, unknown>) =>
createGame({
id: 'so', seed: 7,
config: { ...solitaireConfig, mode: 'competitive', houseRules } as never,
playerNames: ['A', 'B'],
});
const officeCards = (g: ReturnType<typeof withRules>, tier: string): number =>
[...g.cards.values()].filter((c) => c.kind.kind === 'office' && (c.kind as { tier: string }).tier === tier).length;
it('deals Depots by default, and leaves the Depot upgrades out of the deck', () => {
// A Depot card at a table that already has Depots is a dead draw: `check` refuses it, because an
// upgrade must be to the NEXT tier. Station and Terminal are still upgrades and stay in.
const g = createGame({
id: 'd', seed: 7,
config: { ...solitaireConfig, mode: 'competitive', houseRules: {} } as never,
playerNames: ['A', 'B'],
});
assert.deepEqual([...g.officeAreas.values()].map((a) => a.tier), ['depot', 'depot']);
assert.equal(officeCards(g, 'depot'), 0, 'Depot upgrades are still in the deck');
assert.ok(officeCards(g, 'station') > 0, 'Station upgrades were dropped too');
assert.ok(officeCards(g, 'terminal') > 0, 'Terminal upgrades were dropped too');
});
it('deals Whistle Posts when the table asks for the harder game, Depot cards and all', () => {
const g = withRules({ startingOffice: 'whistlePost' });
assert.deepEqual([...g.officeAreas.values()].map((a) => a.tier), ['whistlePost', 'whistlePost']);
assert.ok(officeCards(g, 'depot') > 0, 'the Depot upgrade is missing from a Whistle Post game');
});
it('gives a Depot two A/D tracks and a working passenger facility from Stage 1', () => {
// This is the whole of why the default moved: one A/D track is what made an arrival a collision,
// and a Whistle Post earns nothing from a passenger however well the district is built.
const g = createGame({
id: 'p', seed: 7, config: { ...solitaireConfig, houseRules: {} } as never, playerNames: ['A'],
});
const area = g.officeAreas.get(0 as never)!;
assert.equal(officeProfile(area.tier).adTracks, 2, 'a Depot should have two A/D tracks');
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
assert.equal(f.allows.outbound, true, 'a Depot should board passengers from the start');
assert.equal(f.allows.inbound, true, 'a Depot should detrain passengers from the start');
assert.ok(f.porters > 0, 'a Depot should have a Porter');
});
it('replays a save written before the setting as the Whistle Post game it was', () => {
/**
* The one house rule that changes how a game is DEALT rather than how it plays, so replaying it
* under the wrong opening is a different railroad from intent one — silently. `withSavedOpening`
* fills it for a save that names other rules and cannot name this one.
*/
const saved: { houseRules: { startingHand: 'sixRandom'; startingOffice?: 'depot' | 'whistlePost' } } =
{ houseRules: { startingHand: 'sixRandom' } };
assert.equal(withSavedOpening(saved).houseRules.startingOffice, 'whistlePost');
// A config that names it is left exactly as it is, in both directions.
assert.equal(withSavedOpening({ houseRules: { startingOffice: 'depot' as const } }).houseRules.startingOffice, 'depot');
// And a config with no house rules at all is a fresh game, not an old save.
assert.deepEqual(withSavedOpening({}), {});
});
});
+11 -1
View File
@@ -27,6 +27,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: 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 // 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. // 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`); assert.ok(lays.length > 3, `the bot laid only ${lays.length} pieces`);
}); });
+4
View File
@@ -25,6 +25,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: false, employeeRotation: false,
+11 -1
View File
@@ -41,6 +41,10 @@ const config = (): GameConfig => {
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY, maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL, maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false, 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 }, 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', () => { it('never changes the game it plans for, and every plan replays to the position it promised', () => {
let checked = 0; let checked = 0;
let withSteps = 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 s = createGame({ id: `plan-${seed}`, seed, config: config(), playerNames: ['bot'] });
const r = playGame(s, developerBot, pump, 50_000, undefined, (st) => { const r = playGame(s, developerBot, pump, 50_000, undefined, (st) => {
const p = actingPlayer(st); const p = actingPlayer(st);
+14 -9
View File
@@ -32,6 +32,10 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
...over, ...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', () => { 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 // 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 // lost IS the score the engine kept. Seed 9 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 // 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. // 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 // It was seed 42 until v0.8.0.10, and seed 44 until 0.8.2. Each time the SEED moved, not the
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and // assertion: 42's collision went away with the Gitea#26 ruling, and 44's deal changed when the
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion. // Second Section card joined the deck (Q9) and reshuffled everything. This is the fixture shape
for (const seed of [1, 7, 44]) { // 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 { state } = playKeepingEvents(seed);
const me = state.tally.byPlayer[0]!; const me = state.tally.byPlayer[0]!;
assert.equal( 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`, `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]!; 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.revenueGained > 0, 'seed 9 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.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', () => { 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, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}; };
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] }); const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
+12
View File
@@ -32,6 +32,10 @@ const config: GameConfig = {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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: { optionalRules: {
reducedVisibility: false, reducedVisibility: false,
employeeRotation: 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, direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
} as never); } as never);
if (node?.kind === 'mainline') { 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' }); node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
} }
+116 -13
View File
@@ -20,11 +20,11 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts'; import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.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 type { DivisionView } from '../src/sim/view.ts';
import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile } 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 { 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 { fieldSelectors } from '../src/web/settings-form.ts';
import { record, renderHtml } from '../src/sim/replay.ts'; import { record, renderHtml } from '../src/sim/replay.ts';
import type { Frame } from '../src/sim/view.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) * other test in this file built it. `npm run test` directly (skipping `npm test`'s `pretest` hook)
* will not have run it. * 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'); const dist = join(root, 'dist');
/** /**
* A directory the actual "run the build command" test below builds into, kept separate from the * A directory the actual "run the build command" test below builds into, kept separate from the
@@ -1145,7 +1154,7 @@ describe('the page explains itself', () => {
// A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential // 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 // (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
// action that did nothing. // 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')!); 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 // 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 // happened to deal, which made it luck: the moment deck composition changed it dealt no upgrade
@@ -2570,7 +2579,7 @@ describe('the static build', () => {
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that // 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 // 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. // 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 area = game.state.officeAreas.get(0)!;
const cell = view(game).cells.find((c) => c.kind === 'office')!; const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track'); assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
@@ -3116,6 +3125,10 @@ describe('the Division map shows the whole route', () => {
maxCollisionsPerDay: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}, },
playerNames: ['A', 'B', 'C', 'D'].slice(0, players), playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
@@ -3123,6 +3136,72 @@ describe('the Division map shows the whole route', () => {
return divisionSvg(snapshot(s, [], null).division); 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', () => { 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 * REPORTED FROM A TABLE, Day 1 Stage 1 of v0.8.0.16: "when played on the trestle, there was no
@@ -3136,6 +3215,10 @@ describe('the Division map shows the whole route', () => {
config: { config: {
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}, },
playerNames: ['A', 'B'], playerNames: ['A', 'B'],
@@ -3173,6 +3256,10 @@ describe('the Division map shows the whole route', () => {
config: { config: {
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}, },
playerNames: ['A', 'B', 'C', 'D'], playerNames: ['A', 'B', 'C', 'D'],
@@ -3678,7 +3765,12 @@ describe('the sounds fire on the events they name', () => {
// The model names WHAT happened and the page decides what it sounds like. Getting this wrong is // 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 // 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. // 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> = {}; const cues: Record<string, number> = {};
let stageBoundaries = 0; let stageBoundaries = 0;
let dayBoundaries = 0; let dayBoundaries = 0;
@@ -3941,6 +4033,10 @@ describe('the Day rolling over says so (Gitea#10)', () => {
maxCollisionsPerDay: 3, maxCollisionsPerDay: 3,
maxCollisionsTotal: 10, maxCollisionsTotal: 10,
pvpCardsAllowed: false, 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 }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
}, },
playerNames: ['Joe', 'Bot 1'], playerNames: ['Joe', 'Bot 1'],
@@ -5713,17 +5809,19 @@ describe('the Quickstart guide reaches the site', () => {
it('is published into dist and linked from the splash page', () => { it('is published into dist and linked from the splash page', () => {
const guide = join(dist, 'quickstart.md'); const guide = join(dist, 'quickstart.md');
assert.ok(existsSync(guide), 'the build did not publish 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'); const text = readFileSync(guide, 'utf8');
assert.match(text, /^# Station Master — Quickstart/, 'quickstart.md is not the guide'); 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( assert.match(
text, text.split('\n').slice(0, 4).join('\n'),
/Describes the game as built at v/, /\*\*Version \d+\.\d+/,
'the guide does not say which build it describes', 'the guide does not carry its version at the top',
); );
const splash = readFileSync(join(dist, 'index.html'), 'utf8'); const splash = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide'); assert.match(splash, /href="\.\/quickstart\.html"/, 'the splash page does not link the rendered guide');
}); });
it('brings the lobby doors back every time the lobby is shown', () => { it('brings the lobby doors back every time the lobby is shown', () => {
@@ -5769,17 +5867,22 @@ describe('the Quickstart guide reaches the site', () => {
* references it guards did. * references it guards did.
*/ */
const guide = readFileSync(join(dist, 'quickstart.md'), 'utf8'); const guide = readFileSync(join(dist, 'quickstart.md'), 'utf8');
const section = guide.slice(guide.indexOf('## 8. Where to read more')); const section = guide.slice(guide.indexOf('## 8. Documentation / References'));
assert.ok(section.length > 0, 'the guide no longer has a "Where to read more" section'); 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. // Markdown links, minus anchors and absolute URLs — what a reader can actually click.
const targets = [...section.matchAll(/\]\(([^)#][^)]*)\)/g)] const targets = [...section.matchAll(/\]\(([^)#][^)]*)\)/g)]
.map((m) => m[1]!.replace(/^`|`$/g, '')) .map((m) => m[1]!.replace(/^`|`$/g, ''))
.filter((t) => !/^https?:/.test(t)); .filter((t) => !/^https?:/.test(t));
assert.ok(targets.length >= 4, `only ${targets.length} references parsed out of the guide`); assert.ok(targets.length >= 3, `only ${targets.length} references parsed out of the guide`);
for (const t of targets) { for (const t of targets) {
assert.ok(existsSync(join(dist, t)), `the guide links ${t}, which the build does not publish`); assert.ok(existsSync(join(dist, t)), `the guide links ${t}, which the build does not publish`);
// And the rendered page it becomes, since that is what a reader actually follows.
assert.ok(
existsSync(join(dist, t.replace(/\.md$/, '.html'))),
`the guide links ${t}, whose rendered page the build does not publish`,
);
} }
}); });
@@ -5800,7 +5903,7 @@ describe('the Quickstart guide reaches the site', () => {
assert.ok(bundle.includes('GUIDE_DOCS') || bundle.includes('quickstart.md'), 'the bundle has no guide links'); 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. // Every document offered in-game must be a file the build published.
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.md)["'`]/g)].map((m) => m[1]!); const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.html)["'`]/g)].map((m) => m[1]!);
assert.ok(hrefs.length >= 5, `only ${hrefs.length} in-game guide links found`); assert.ok(hrefs.length >= 5, `only ${hrefs.length} in-game guide links found`);
for (const h of hrefs) { for (const h of hrefs) {
assert.ok(existsSync(join(dist, h.replace(/^\.\//, ''))), `the game links ${h}, which is not published`); assert.ok(existsSync(join(dist, h.replace(/^\.\//, ''))), `the game links ${h}, which is not published`);