Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b90c0413d2 | ||
|
|
dc31555625 | ||
|
|
f308a2d94d | ||
|
|
a6657241de | ||
|
|
ad277fb994 |
+495
-3
@@ -19,6 +19,498 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.8.0.17 — 2026-09-21
|
||||||
|
|
||||||
|
Four things a table found on Day 1 of v0.8.0.16, all of them the same shape: the game knew
|
||||||
|
something and the screen did not say it.
|
||||||
|
|
||||||
|
### ABS Signals could only ever be played on one Mainline card
|
||||||
|
|
||||||
|
Its tooltip says "any Mainline card". Exactly one was ever on offer — the Trestle, on the board it
|
||||||
|
was reported from.
|
||||||
|
|
||||||
|
**The engine was never wrong.** `check` accepts any node whose kind is `mainline`, and
|
||||||
|
`legalActions` filters by `check`, so every Mainline card was enumerated and legal. The whole
|
||||||
|
failure was in the LABEL: `describeIntent` named `i.placement` and never `i.node`, so all of them
|
||||||
|
described themselves as plain "play ABS Signals" — and the action list drops duplicate labels, which
|
||||||
|
discarded every one but the lowest-index node before the menu saw it.
|
||||||
|
|
||||||
|
**This is the third time that trap has been sprung**, and the file documents the other two three
|
||||||
|
lines apart: a turnout's two rotations produced one label each until the rotation was named, and
|
||||||
|
three Department discards collapsed into one button until the pile was named. The fix is the same
|
||||||
|
one both times: name the thing that distinguishes them. Pinned by a test that counts the Mainline
|
||||||
|
cards in the division and requires a distinct, named spot for each.
|
||||||
|
|
||||||
|
The card is also called what the card face calls it. `prettyKey` rendered `absSignals` as "Abs
|
||||||
|
Signals" on a button while the tooltip beside it said ABS — an acronym no key-splitter can recover —
|
||||||
|
so the authored names in the content tables now win. Three of those names were transcribed in
|
||||||
|
sentence case and were corrected rather than adopted: the repository says "Yard Office" 36 times
|
||||||
|
against "Yard office" twice, and a lookup that imports its own source's typos is the drift it exists
|
||||||
|
to prevent.
|
||||||
|
|
||||||
|
### Nothing on a Mainline card showed what was standing on it
|
||||||
|
|
||||||
|
Played, ABS left no mark. You found out it existed by hovering the card — which is the complaint the
|
||||||
|
Heavy Grade wedge answered in v0.8.0.7, and it matters more here, because ABS is what decides
|
||||||
|
whether running a second train onto that card is safe.
|
||||||
|
|
||||||
|
A card carrying it now draws a **signal mast with a lit lamp** at its top-right corner. A signal is
|
||||||
|
the literal object, and unlike a text badge it needs no room for words, which is what lets it sit
|
||||||
|
clear of a name as long as "Uncontrolled Siding" on a 152px cell.
|
||||||
|
|
||||||
|
The Mainline modifiers had the same defect and are drawn too, as **BRK**, **AIR** and **HLP** beside
|
||||||
|
the card's name. Tags rather than names only because the measurements leave no choice — "Brakeman ·
|
||||||
|
Airbrakes · Helpers" is thirty characters where about eleven fit — and the tooltip has always spelled
|
||||||
|
them out. **Realignment is deliberately not among them:** it never sits on a card, because `reduce`
|
||||||
|
takes the `became` branch and changes `node.card` outright, so a realigned Trestle simply IS an
|
||||||
|
Uncontrolled Siding afterwards. Asserted in the test, so the absence reads as a finding rather than
|
||||||
|
an omission.
|
||||||
|
|
||||||
|
### A Freight Agent turn said a car moved when none had
|
||||||
|
|
||||||
|
"Chose FREIGHT AGENT work — one car moved to or from a facility", and then nothing. Three faults
|
||||||
|
behind one line.
|
||||||
|
|
||||||
|
The line **asserted an outcome**. §6.3 requires no action at all, and the bot takes that route
|
||||||
|
deliberately — unjamming a healthy box destroys a load that cost a whole Local Operations action to
|
||||||
|
stock, so an idle Stage is strictly better. It now says what the Freight Agent MAY do.
|
||||||
|
|
||||||
|
**An idle Agent was silent.** A new `freightAgentIdled` event says so, and gives the reason. It
|
||||||
|
reduces to nothing, exactly like `switchingEnded`: it reports a choice the state already holds.
|
||||||
|
|
||||||
|
**The work named a coordinate, not the industry.** A `place` helper has existed for this since the
|
||||||
|
switching lines were moved to it, and its own comment makes the argument — "(-1,1)" is the grid's
|
||||||
|
notation and means nothing at a table where people are looking at cards. These three lines were
|
||||||
|
missed. A Freight Agent turn now reads "loaded a loaded boxcar INTO the green Outbound box at the
|
||||||
|
Freight House", with the direction in capitals because to-or-from was the question asked.
|
||||||
|
|
||||||
|
### The log and the action menu spelled the same square differently
|
||||||
|
|
||||||
|
`view.ts` wrote `(col,row)` — X,Y, east/west then north/south — with a comment saying why.
|
||||||
|
`narrate.ts` wrote `(row,col)`, the internal storage order, with no comment at all. So the menu
|
||||||
|
offered a move to "(1,-1)" and the log then reported it at "(-1,1)", in two panels read side by
|
||||||
|
side. The log follows the map now.
|
||||||
|
|
||||||
|
Pinned by a test that renders one square through BOTH describers and compares them to each other
|
||||||
|
rather than to a literal — a test written against either file alone would have passed all along.
|
||||||
|
|
||||||
|
### The documentation is reachable from inside a game, and all of it is published
|
||||||
|
|
||||||
|
**v0.8.0.16 published the Quickstart and nothing it points at.** Its §8 "Where to read more" links
|
||||||
|
five further documents by relative path, and every one of them 404'd on the package — verified
|
||||||
|
against the running container, five of six paths missing. The whole table was dead. The build
|
||||||
|
publishes the full set now, and the test reads the links OUT OF the guide rather than listing them,
|
||||||
|
so it cannot go stale the way the references themselves did.
|
||||||
|
|
||||||
|
**The guide is linked from the This Game card** (Jesse's call), which is where reference already
|
||||||
|
lives — the seed, the seat, the house rules — rather than from the header, which is the line that
|
||||||
|
must not wrap. It needs no mode awareness: solitaire and multiplayer are the same page on the same
|
||||||
|
origin, so one relative link resolves in both, on the public site and on a StartOS box alike. Every
|
||||||
|
link opens in a new tab, because a player reading the rules mid-turn must not lose the game behind
|
||||||
|
them.
|
||||||
|
|
||||||
|
### The references dropped the version from their names
|
||||||
|
|
||||||
|
`StationMaster-Rules-v0.4.5.md` and three like it described **v0.8.0.16** and had done since the
|
||||||
|
v0.8.0.15 audit. The `v0.4.5` was the prototype rules edition they were first written against, kept
|
||||||
|
in the filename only because thirty-six citations pointed at it — and it read, to anyone opening the
|
||||||
|
published guide, as documentation five minor versions out of date.
|
||||||
|
|
||||||
|
They are `quickstart.md`, `rules.md`, `home-deck.md`, `mainline-deck.md` and `components.md` now,
|
||||||
|
with every citation rewritten. **These are kept current with each release rather than published as
|
||||||
|
editions**, so the name is always the latest and the build each describes is stated at the top.
|
||||||
|
|
||||||
|
Two errors surfaced while checking them against this release, which is the argument for doing it:
|
||||||
|
`home-deck.md` listed ABS Signals among the Enhancements "played into your district" that "change
|
||||||
|
what a square does" — it does neither, and that miscategorisation is this release's bug written
|
||||||
|
down. And `mainline-deck.md`, which lists everything that may be played onto a Mainline card, never
|
||||||
|
mentioned ABS Signals at all. Both corrected.
|
||||||
|
|
||||||
|
## 0.8.0.16 — 2026-09-20
|
||||||
|
|
||||||
|
The Quickstart put where a tester can actually reach it, the last place that still told the old
|
||||||
|
story about the Interchange, and the release that carries v0.8.0.15's corrected card text into a
|
||||||
|
package.
|
||||||
|
|
||||||
|
### The Quickstart is published beside the game
|
||||||
|
|
||||||
|
v0.8.0.15 wrote a Quickstart for a tester who has never played, and then left it in `docs/`, where
|
||||||
|
a tester does not look — reachable only by someone who already has the repository. Nobody being
|
||||||
|
handed the box has it.
|
||||||
|
|
||||||
|
`build-web.ts` now copies `docs/quickstart.md` into `dist/quickstart.md`, and the
|
||||||
|
splash page offers it under the three doors: *New to Station Master? Read the Quickstart guide.*
|
||||||
|
**Not a fourth door** — reading the guide is not a way to play, and giving it equal weight in that
|
||||||
|
grid would say it is.
|
||||||
|
|
||||||
|
**Copied, never re-written.** The Markdown document stays the one copy. A hand-written HTML twin
|
||||||
|
would drift from it on the first edit, which is exactly the failure #15a was raised about and
|
||||||
|
exactly what the v0.8.0.15 pass spent its time undoing: four references a month and two minor
|
||||||
|
versions out of date.
|
||||||
|
|
||||||
|
**Served as plain text, which is honest rather than good.** Tables render as rows of pipes and the
|
||||||
|
links do not click. Rendering it into a styled page wants a small Markdown converter and is filed
|
||||||
|
as **TODO #109**; this is the fifteen-minute version that gets the guide in front of testers for
|
||||||
|
this round instead of leaving them without one.
|
||||||
|
|
||||||
|
Two things had to be true and neither is checked by `tsc`, so both are tests:
|
||||||
|
|
||||||
|
- **The link has to find the file.** The href on the splash page and the filename the build writes
|
||||||
|
are two strings with nothing connecting them — rename the document and the build quietly
|
||||||
|
publishes nothing while the page keeps offering a link that 404s. The test asserts the guide is
|
||||||
|
in `dist`, is the guide, and is the file the splash page names. The build also warns loudly
|
||||||
|
rather than silently skipping a missing document.
|
||||||
|
- **A `.md` file must not arrive as a download.** The server's MIME fallback is
|
||||||
|
`application/octet-stream`, which a browser saves instead of displaying, so the link would hand a
|
||||||
|
tester a file rather than a page. `'.md': 'text/plain; charset=utf-8'` was added to the table in
|
||||||
|
`http.ts`, and the test reads that table out of the source rather than asserting on a copy of it,
|
||||||
|
which would pass while the real one was wrong.
|
||||||
|
|
||||||
|
### `sortsCars` says what it gates, not what the card prints
|
||||||
|
|
||||||
|
Asked directly after v0.8.0.15 — does everything now agree? — and the audit turned up one place that
|
||||||
|
did not. The field's own doc comment read:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Interchange: "Sort cars in new order". */
|
||||||
|
sortsCars: boolean;
|
||||||
|
```
|
||||||
|
|
||||||
|
which names the printed text as though it were the flag's meaning. Nothing reads it to permit a
|
||||||
|
sort. Its two readers — `resolveExtraStart` in `apply.ts` and the enumeration in `legal.ts` — both
|
||||||
|
ask the same question, which is whether this is the one Mainline card with a Yard Limit and
|
||||||
|
therefore the one an Extra may be made up and started on.
|
||||||
|
|
||||||
|
**Comment only, and worth the bump because of where it is.** This is what a developer reads before
|
||||||
|
using the flag, and it is the most likely source of the sentence v0.8.0.15 had to correct — the one
|
||||||
|
telling players, on the board, that cars could be sorted at an Interchange. The name is kept for its
|
||||||
|
link to the card face, and the comment now says outright that the name is not the meaning.
|
||||||
|
|
||||||
|
Everything else already agreed, and was checked rather than assumed: `mainlineDescription`, the
|
||||||
|
generated `rules/as-built.md`, the Mainline deck reference and the Rules FAQ all say the printed
|
||||||
|
sorting is unimplemented and that a consist is re-ordered at a Small Yard. `rules/implications.md`
|
||||||
|
is the deliberate exception and was already correct — it transcribes what the card face prints and
|
||||||
|
then records that the concept is "still unimplemented".
|
||||||
|
|
||||||
|
### The reference stamps, and what was filed rather than done
|
||||||
|
|
||||||
|
The five documents v0.8.0.15 stamped — the Quickstart, the Rules, Components, the Home deck and the
|
||||||
|
Mainline deck — plus `docs/design.md` now read **v0.8.0.16**, and the Rules book's implementation
|
||||||
|
note about `card.discard` says the same. They describe this build because the audit above re-checked
|
||||||
|
them against it, not because the number was swept forward: the point of the stamp is that it was
|
||||||
|
earned, and a stamp bumped without a reading is worth less than none.
|
||||||
|
|
||||||
|
**TODO #109** holds the part deliberately not done — rendering the guide into a styled page instead
|
||||||
|
of serving it as plain text. `build-web.ts`'s comment names that number rather than gesturing at
|
||||||
|
"the next step", so the file and the worklist cannot drift apart the way the references just did.
|
||||||
|
|
||||||
|
### Why this is a release at all
|
||||||
|
|
||||||
|
One player-visible change, the link to the guide, and one that only a developer reads. The
|
||||||
|
Interchange correction a player actually sees on the board is v0.8.0.15's; this is the version the
|
||||||
|
wrapper bundles, so that correction reaches the box rather than only the repository — and a tag
|
||||||
|
with an uncommitted comment sitting on top of it is not a thing to package.
|
||||||
|
|
||||||
|
## 0.8.0.15 — 2026-09-20
|
||||||
|
|
||||||
|
The reference documentation brought up to the game as it actually runs, ahead of the next round of
|
||||||
|
testing, plus a **Quickstart** to hand a tester who has never played. One live bug fell out of the
|
||||||
|
pass.
|
||||||
|
|
||||||
|
### The Interchange advertised an action the game does not have
|
||||||
|
|
||||||
|
`mainlineDescription` told players "Cars may be sorted into any new order here." It is the printed
|
||||||
|
capability and **it has never been implemented** — nothing reads `sortsCars` to permit a sort. Its
|
||||||
|
one live use is identifying the card an Extra may be made up on, because the Interchange is the
|
||||||
|
Mainline card with a yard.
|
||||||
|
|
||||||
|
That sentence was not only in the documentation. `view.ts` renders this text as a Mainline card's
|
||||||
|
`what`, so it is what a player reads **on the board**, and the generated card reference printed a
|
||||||
|
"Sorts cars: yes" column beside it. A card advertising a button that does not exist sends a player
|
||||||
|
hunting for it and then concluding the game is broken. The description now says what the card does,
|
||||||
|
the generated column is headed "Extra may start", and the Rules FAQ answer says the card used to
|
||||||
|
claim otherwise.
|
||||||
|
|
||||||
|
### The documents
|
||||||
|
|
||||||
|
Four hand-written references had not been touched since **v0.6.2** and were a month and two minor
|
||||||
|
versions stale. Each now carries the build it describes at the top. The `v0.4.5` in their filenames
|
||||||
|
is the prototype rules edition they were first written against, and the names are kept deliberately:
|
||||||
|
`src/`, `CHANGELOG.md` and `docs/rules/` all cite them, and several of those citations are historical
|
||||||
|
records of what a document said at the time.
|
||||||
|
|
||||||
|
- **`quickstart.md` — NEW.** What the game is, how you win, the shape of a Stage, what
|
||||||
|
is on the screen, a first twenty minutes, the things that surprise new players, and what to report.
|
||||||
|
Written for somebody about to play rather than somebody building it.
|
||||||
|
- **Rules.** §3.4 replaced outright — the `firstToTarget` / `highestAfterDays` victory model and the
|
||||||
|
`target`-bearing length presets have not existed since 2026-08; it is a free `days` count and a
|
||||||
|
combined floor of `3 × players × days`, with extended play. §3.5 and §6 said there was no lobby, no
|
||||||
|
server and no multiplayer; there is. All three optional rules are implemented now, and "Sister
|
||||||
|
Trains" never existed. Crossing time is regions, not mph. Extras are started where the player
|
||||||
|
chooses. Added: the coach ratchet, the Small Yard's nose sorting, the make-up shortfall report, and
|
||||||
|
the screen's names for the last two phases, which differ from the rules' names.
|
||||||
|
- **Home deck.** Restructured around how the deck is *used* — piles, hand limit, reshuffle,
|
||||||
|
placement, upgrades, what a modifier grant means — and defers every per-card table to
|
||||||
|
`rules/as-built.md`. The counts table came out under TODO #15a, Jesse's own ruling: counts move
|
||||||
|
with balance, and that table had been wrong for a month. Also corrected: industry track length
|
||||||
|
(four cars like any card, not the box count), modifier grants, and `emptiesOnly`, which is enforced.
|
||||||
|
- **Mainline deck.** Said cards were dealt "with replacement" in its opening paragraph and corrected
|
||||||
|
itself four sections later; priced crossings in mph; and listed the Uncontrolled Siding as a
|
||||||
|
passing card, which it is not — it puts an arriving train in the siding a region behind.
|
||||||
|
- **Components.** Verified against `ROLLING_STOCK_SUPPLY`; the table was right. Added the Crew Tray,
|
||||||
|
Whistle Post and Limits supplies, the coach ratchet, and what the Fedora actually carries.
|
||||||
|
- **`docs/design.md`**, the index everything starts from, said **v0.4.3**, "what is not: the server",
|
||||||
|
and 493 tests. It now lists the player-facing references — which it never has — so the Quickstart
|
||||||
|
is findable, and marks `card-reference.md` superseded rather than presenting it as the card spec.
|
||||||
|
|
||||||
|
**`rules/as-built.md` needed no correction beyond the Interchange**, which is the point of generating
|
||||||
|
it: `npm run build:cards` rewrites it from `content.ts` and `test/card-reference.test.ts` fails if
|
||||||
|
the checked-in file disagrees. Everything hand-maintained around it had drifted; it had not.
|
||||||
|
|
||||||
|
## 0.8.0.14 — 2026-09-17
|
||||||
|
|
||||||
|
Six reports from the Day 2-3 playtest of v0.8.0.13. Two were the same shortage seen from opposite
|
||||||
|
ends, one was a rule working as designed that Jesse has now reversed, and three were the screen
|
||||||
|
saying too little or too much.
|
||||||
|
|
||||||
|
**THIS RELEASE STRANDS GAMES IN PROGRESS, INCLUDING THE ONE ON THE BOX.** Modifiers are now bounded
|
||||||
|
by the Limits, which makes a once-legal move illegal — so a save holding one is refused at that move.
|
||||||
|
Measured on the playtest save: `whistle-6945.day3.stage10` stops at intent 528 of 539, eleven moves
|
||||||
|
from the end, on `card.play c55 → (-2,4)`. The file is untouched and v0.8.0.13 still finishes it.
|
||||||
|
|
||||||
|
### Every coach in the game was stuck in the Classification Yard
|
||||||
|
|
||||||
|
Two reports, one cause. The Sparrow (trains 5/6) calls for three coaches and nothing else, and ran
|
||||||
|
the length of the Division empty; Tom had a loaded coach standing at his Depot and could find no way
|
||||||
|
to unload the passengers. Both are §2.2 and §9.2 acting exactly as printed, and the combination is a
|
||||||
|
one-way ratchet: boarding discards the emptied coach into the **Classification** Yard, detraining
|
||||||
|
draws a fresh empty **out of the Division Yard**, and Classification comes back only when the
|
||||||
|
Division Yard runs bare.
|
||||||
|
|
||||||
|
Measured across the save, because the shape of it is the point: sixteen coaches in the Division Yard
|
||||||
|
at setup, **zero from Day 2 Stage 8 onward**, fifteen piled up in Classification — while the Division
|
||||||
|
Yard sat at 46-47 freight cars and stopped draining, so the refill was never going to fire. Every
|
||||||
|
passenger operation in the game was over, and four of the twelve timetabled trains carry nothing but
|
||||||
|
coaches.
|
||||||
|
|
||||||
|
**Jesse's ruling: the rule stands, the game says so loudly.** The same ruling Gitea#2 got, and the
|
||||||
|
same one 0.8.0.13 gave the blocked Depot panel. Three places say it now:
|
||||||
|
|
||||||
|
- **A train made up short reports it** (`makeUpShort`). `trainNeedingCars` returns null both when a
|
||||||
|
consist is full and when the yard holds nothing it will take — the same answer for "done" and
|
||||||
|
"cannot be done" — so the phase moved on in silence, and the only trace was a MADE UP line
|
||||||
|
promising "now taking cars" with nothing after it. It now names what the card wanted, how many are
|
||||||
|
waiting in Classification, and how far the Division Yard is from bare.
|
||||||
|
- **The yard panel warns while the condition lasts**, rather than only at the moment a train goes
|
||||||
|
short.
|
||||||
|
- **The Depot's blocked panel already said it**, and still does — it was correct all along, off to
|
||||||
|
the side, and read by nobody who had gone looking in the wrong place.
|
||||||
|
|
||||||
|
### A modifier may no longer stand outside the Limits
|
||||||
|
|
||||||
|
Reported as a bug and it was working as designed — the design being a call of Jesse's, recorded in
|
||||||
|
`apply.ts`: a Modifier is not track, so a Facility standing at the limit kept all nine of its spots,
|
||||||
|
because bounding the card looked like it would make it unplayable exactly where a district ends.
|
||||||
|
**Reversed.** What decided it is what the board shows — Transmission Lines at (-2,4) with the sign at
|
||||||
|
column 3 reads as building outside your own territory, and §8.1 and §10 both reason about what lies
|
||||||
|
inside a player's Limits.
|
||||||
|
|
||||||
|
The feared case was checked on the move that prompted the change rather than argued away: the Power
|
||||||
|
Plant was at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were both free, legal and
|
||||||
|
inside. Six of the nine spots survive a Facility at the limit, and the sign moves outward as the
|
||||||
|
Running Track grows (§2.1, Gap 4a).
|
||||||
|
|
||||||
|
### The history panel was full of coordinates
|
||||||
|
|
||||||
|
"Does switching show up in history at all?" — it all did. A six-Move turn wrote a line per move,
|
||||||
|
every one of them a pair of grid coordinates, plus one per mandatory coupling. **Switching is still
|
||||||
|
logged in full**; what changed is what the panel DRAWS: the line saying somebody switched, the first
|
||||||
|
move of the turn, work at an **industry**, the Small Yard sort, and a closing summary.
|
||||||
|
|
||||||
|
**The suppressed lines are still written, marked `trace`, and that is not a detail.** Dropping these
|
||||||
|
events was the first attempt and the step-queue suite caught it: `dwellForStep` gives a step no dwell
|
||||||
|
when it produced no narration, so a switching move with no line became a silent step and the board
|
||||||
|
stopped replaying switching altogether — the exact thing v0.8.0 was built to let a table watch. The
|
||||||
|
tone is the seam. The caption keeps its text, the panel filters the tone.
|
||||||
|
|
||||||
|
**The last move rides in the closing line rather than being kept in place**, and it has to: nothing
|
||||||
|
knows a move was the last until the turn is over, and by then the line has been written and streamed
|
||||||
|
to every client (`server/session.ts` § `linesSince`), so it cannot be revised. `switchingEnded` says
|
||||||
|
what the turn cost and where the crew was left — "finished switching — 3 of 6 Moves used, leaving
|
||||||
|
Train 10 at the Small Yard".
|
||||||
|
|
||||||
|
Places are named rather than pointed at: `at the Freight House` and `the Small Yard` instead of
|
||||||
|
`(1,1)`. Two smaller things fell out of reading these lines properly. Every move line ended with
|
||||||
|
"The crew chip on the grid carries the whole train with it" — six times a turn, when the opener
|
||||||
|
already says it once, and it pushed the useful half of the line out of the caption row. And the move
|
||||||
|
count said "of 6" with the 6 hardcoded, which is simply wrong on a Reduced-Visibility night Stage
|
||||||
|
where a turn gets five; the turn now carries its own allowance.
|
||||||
|
|
||||||
|
### Making up a train says which train
|
||||||
|
|
||||||
|
"I did not see anything in the history about making up train 10." It was all there — a MADE UP line
|
||||||
|
and one line per car — and every one of those lines read "the train being made up", so a player
|
||||||
|
looking back for train 10 found nothing under that name. `carPlacedOnTrain` and `carPassed` now carry
|
||||||
|
the number, the way `trainArrived` carries its owner. Also "a empty tank" is now "an empty tank".
|
||||||
|
|
||||||
|
### The Small Yard offered permutations, not trains
|
||||||
|
|
||||||
|
The menu read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person. The
|
||||||
|
option Jesse wanted was the **first of the five** and he could not tell which one it was. Each option
|
||||||
|
now reads as the train it would build: `re-order to engine · loaded hopper · empty tank · caboose ·
|
||||||
|
loaded boxcar (front to back)`.
|
||||||
|
|
||||||
|
One of the five re-ordered nothing at all and would still have spent a Move — bringing the LAST car
|
||||||
|
to the end is the identity — and a two-car train's "full reversal" duplicated its only real option.
|
||||||
|
Both are filtered by the resulting ORDER rather than by the case that produced them, so a new
|
||||||
|
generator cannot bring either back. No typed-order box: `check` already accepts any permutation, so
|
||||||
|
one is buildable, and Jesse declined it as more to go wrong for a labelling problem.
|
||||||
|
|
||||||
|
### A Small Yard may now put cars ahead of the engine
|
||||||
|
|
||||||
|
Jesse's own open question, discussed and then built. **Two sources disagreed and the design notes
|
||||||
|
won.** The v0.4.5 card text says a train there may "reorder its entire consist **and put the engine
|
||||||
|
at the nose**", which is what `consistSorted` did unconditionally. `implications.md`, drawn from the
|
||||||
|
design source, says a train "may sort itself into any order, **including cars ahead of the engine**"
|
||||||
|
— and records that the Small Yard is the answer the switching puzzle was designed to have.
|
||||||
|
|
||||||
|
`switch.sortConsist` carries an optional `engineAt`, absent meaning the nose, so every save written
|
||||||
|
before this replays to exactly the train it built. **The menu did not multiply.** Offering every car
|
||||||
|
order at every engine position takes a four-car consist from four options to twenty, which is the
|
||||||
|
unreadability that started this; instead the engine is a separate short list offered against the
|
||||||
|
consist as it stands — four re-orders and four engine positions, eight readable options. A player
|
||||||
|
who wants both spends two Moves, the same price the yard charges for any second sort.
|
||||||
|
|
||||||
|
**§8.2 needed no new code, and the reason is worth stating because the obvious guess is wrong.**
|
||||||
|
`badlyMadeUp` is deliberately direction-free: a train with its whole consist ahead of the engine is a
|
||||||
|
PUSHING train and fit to run. What it refuses is a broken-backed train — the engine buried among its
|
||||||
|
own cars — and a caboose anywhere but the end away from the engine. So the hold Jesse asked for was
|
||||||
|
already there, and the button now warns before the Move is spent by asking that same predicate rather
|
||||||
|
than keeping a copy of it.
|
||||||
|
|
||||||
|
That warning immediately earned itself: **every one of the eight options on train 10 is refused by
|
||||||
|
§8.2**, including the arrangement asked for at the table, because that train carries a caboose and
|
||||||
|
each sort moves it off the rear. The menu says so now instead of spending a Move to find out. It is
|
||||||
|
also the correct answer rather than a gap — that train is ALREADY made up, so every sort on offer
|
||||||
|
would break it — and the labels distinguish the two cases outright: `MADE UP, ready to leave` or
|
||||||
|
`HELD at the Office: <why>`.
|
||||||
|
|
||||||
|
**A made-up order is always on the menu for a train that needs one**, which is worth stating because
|
||||||
|
it looks as though it might not be. A yard sort serves two errands — pulling one car out to an end to
|
||||||
|
be spotted, and putting the train back together to leave — and the curated orders are written for the
|
||||||
|
first. They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
|
||||||
|
so bringing the CABOOSE to the tail is always among them. An explicit "make it up to leave" option
|
||||||
|
was written and then deleted, because it produced exactly that order and was dropped by the dedupe
|
||||||
|
every time.
|
||||||
|
|
||||||
|
### West to east, the way the board draws it
|
||||||
|
|
||||||
|
"Front to back" is not a direction a table can read: which end is the front depends on which way the
|
||||||
|
train is pointed, and the board has drawn the crew strip **west on the left** since v0.8.0 —
|
||||||
|
reversing the consist for an east-facing train so its nose lands at the east end, with the engine as
|
||||||
|
a ◀ or ▶ arrow. The sort labels now read the same way, so the button and the picture describe the
|
||||||
|
same train and "ahead of" and "behind" the engine are read off the strip rather than asserted:
|
||||||
|
|
||||||
|
```
|
||||||
|
re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶ · HELD at the Office: the caboose must be at the rear of the train
|
||||||
|
put the whole consist ahead of the engine — west to east: ENGINE ▶ · caboose · empty tank · loaded hopper · loaded boxcar
|
||||||
|
```
|
||||||
|
|
||||||
|
`badlyMadeUp`'s "1 car(s) ahead of it" is a real plural now; it reaches a player through the held
|
||||||
|
train's reason as well as through these labels.
|
||||||
|
|
||||||
|
### A role says what it is for
|
||||||
|
|
||||||
|
Tom reached for the Freight Agent to unload passengers, which it has never done — detraining is a
|
||||||
|
**Porter's** action in the Cargo phase. Both halves were working and neither was visible. The
|
||||||
|
Freight Agent, Porter and Laborer groups now carry a sentence saying what the role does, where the
|
||||||
|
role is chosen, rather than leaving a player who picked the wrong one to discover it by finding
|
||||||
|
nothing there.
|
||||||
|
|
||||||
|
## 0.8.0.13 — 2026-09-16
|
||||||
|
|
||||||
|
Nine reports from the Day 1–2 playtest of v0.8.0.12. One was a real bug that cost a car, one was a
|
||||||
|
rule working correctly with nothing on screen to say so, and the rest are things the table could not
|
||||||
|
see.
|
||||||
|
|
||||||
|
### The Division Yard no longer takes a click while your board is behind
|
||||||
|
|
||||||
|
The worst of the batch, because it moved a car. `renderActions` puts the action list away while the
|
||||||
|
queue is catching up — a move offered against a position that has already moved on is a move made
|
||||||
|
blind — but the make-up wiring sat OUTSIDE that guard, so the yard chips stayed lit and clickable.
|
||||||
|
Reported exactly as it happens: a bot was adding the last car, the board lagged, a chip was clicked,
|
||||||
|
a coach left the Division Yard and the train still ended up with three cars. The click had submitted
|
||||||
|
a real intent against a board that was several moves stale.
|
||||||
|
|
||||||
|
Both halves are fixed. The chips are wired only when the queue is idle, and the yard COUNTS are now
|
||||||
|
drawn from the board on screen rather than the live game — they were the one panel still reporting a
|
||||||
|
future the player had not been shown, which is what "the yards may not be in sync with the turns
|
||||||
|
behind" was describing.
|
||||||
|
|
||||||
|
### Every seat has a button, in map order
|
||||||
|
|
||||||
|
The Office Area picker had a button per opponent and none for yourself, so the one player who could
|
||||||
|
not reach their own district was the player waiting on everybody else — watching the board follow
|
||||||
|
whoever was acting, with no way back. Your own seat is now in the row, and `watchedDistrict` returns
|
||||||
|
your board for it rather than falling through to the actor, which is the same bug seen from the other
|
||||||
|
side.
|
||||||
|
|
||||||
|
The buttons are ordered by SEAT — west to east, exactly as the Division map draws it — instead of by
|
||||||
|
player index, which is the order people joined. Seat is not player index and must not be assumed to
|
||||||
|
be: under Employee Rotation the seating moves, and because the row is sorted from the Frame's own
|
||||||
|
`seat` on every render, the buttons rotate with the players rather than having to be told.
|
||||||
|
|
||||||
|
### The Fedora passing is said out loud
|
||||||
|
|
||||||
|
§5 moves the Superintendent at the end of Stages 3, 6, 9 and 12, and the log never mentioned it. The
|
||||||
|
rotation was riding on `actorChanged` — turn bookkeeping, fired every time the cursor moves, which
|
||||||
|
`record()` drops as noise — so the one moment that event carried something a player needed went past
|
||||||
|
in silence, with only "Supervisor Shift" in the history to hint at it.
|
||||||
|
|
||||||
|
It is its own event now (`superintendentChanged`), narrated in the log and announced on screen the
|
||||||
|
way a completed run already is. The phase keeps its name: the Supervisor Shift refreshes every
|
||||||
|
Laborer and Porter EVERY Stage, and the Fedora moves only every third — naming the phase after the
|
||||||
|
rarer event would mislead about the common one.
|
||||||
|
|
||||||
|
### A collision says whose Office it was and who paid for it
|
||||||
|
|
||||||
|
The line named the wreck and the reason and stopped there: the 5-point penalty rides in a separate
|
||||||
|
`revenueChanged`, so a player had to add two log entries together to learn who had just lost five
|
||||||
|
Revenue, and "COLLISION at the Office" never said whose. The faulting seat is in the event — and for
|
||||||
|
anything inside a district that seat IS the district's owner — so the line now reads "COLLISION at
|
||||||
|
Tom's Office … Tom loses 5 Revenue — it happened in their district." A Mainline collision is phrased
|
||||||
|
differently because §10 makes it the Superintendent's, which is a different kind of fault.
|
||||||
|
|
||||||
|
### Passengers: the rule stands, the silence goes
|
||||||
|
|
||||||
|
Reported as a bug and it is not one, which took a replay of the save to establish. The Depot in
|
||||||
|
question had a Restaurant and a Hotel beside it and `cap{out:3}` — capacity was never the problem,
|
||||||
|
and the modifiers grant exactly what they print. What stopped a second passenger was §6.3: stocking
|
||||||
|
takes a LOADED car of the facility's type out of the Division Yard, and there was not a loaded coach
|
||||||
|
in it. Six were sitting in the Classification Yard, which §2.2 returns only when the Division Yard
|
||||||
|
runs bare, and it was holding sixty-odd cars.
|
||||||
|
|
||||||
|
Jesse's ruling is the same one Gitea#2 got: the shortage stays, because running out is part of the
|
||||||
|
game. So the blocked panel now says it — room for N more passengers, no loaded coach in the Division
|
||||||
|
Yard, and how many are waiting in Classification — instead of the action simply being absent from the
|
||||||
|
menu with no reason given.
|
||||||
|
|
||||||
|
### Smaller
|
||||||
|
|
||||||
|
"Working left" is now "working eastward" in the make-up panel and the New Train tip. It was always the
|
||||||
|
same rule — `playerLeftOf` is increasing seat index — but "left" describes a table nobody is looking
|
||||||
|
at, while the map runs west to east, so at a real three-player game the second car went to the player
|
||||||
|
sitting EAST and the text read as wrong. The history panel keeps 90 lines instead of 60, in the same
|
||||||
|
230px box: more to scroll back through, no more screen taken, and the newest line stays where the eye
|
||||||
|
already is.
|
||||||
|
|
||||||
|
### Note for the packaging repo
|
||||||
|
|
||||||
|
`git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is NOT empty this time: `events.ts` declares
|
||||||
|
`superintendentChanged` and `advance.ts` emits it. Both are additive — `check()`, `legal.ts` and
|
||||||
|
every predicate are untouched, and events are derived by replaying a save rather than stored — so no
|
||||||
|
once-legal move became illegal and games in progress resume.
|
||||||
|
|
||||||
## 0.8.0.12 — 2026-09-16
|
## 0.8.0.12 — 2026-09-16
|
||||||
|
|
||||||
A player who has lost their browser storage can be put back in their seat (Gitea#33). No rule
|
A player who has lost their browser storage can be put back in their seat (Gitea#33). No rule
|
||||||
@@ -3124,7 +3616,7 @@ that same occupant list.
|
|||||||
|
|
||||||
`buildDivision` drew uniformly from the nine card TYPES **with replacement**, so a Division could be
|
`buildDivision` drew uniformly from the nine card TYPES **with replacement**, so a Division could be
|
||||||
dealt two Interchanges or two Tunnels, and Plains — printed twice in the deck — carried the same
|
dealt two Interchanges or two Tunnels, and Plains — printed twice in the deck — carried the same
|
||||||
weight as cards printed once. `docs/StationMaster-Mainline-Deck-v0.4.5.md` had flagged the mismatch
|
weight as cards printed once. `docs/mainline-deck.md` had flagged the mismatch
|
||||||
as needing correction; "an Extra may start at the Interchange if one is on the board" is what forced
|
as needing correction; "an Extra may start at the Interchange if one is on the board" is what forced
|
||||||
it, since that only reads as a rule if the board holds at most one. Now dealt from `MAINLINE_DECK`
|
it, since that only reads as a rule if the board holds at most one. Now dealt from `MAINLINE_DECK`
|
||||||
without replacement, verified over 1600 deals across 1–4 players.
|
without replacement, verified over 1600 deals across 1–4 players.
|
||||||
@@ -3161,7 +3653,7 @@ cannot see is a board with nothing to click and no reason given.
|
|||||||
|
|
||||||
**1/2 Crack Limited 3 coaches → 2. 5/6 The Sparrow 2 → 3.** A change to the cards, not a
|
**1/2 Crack Limited 3 coaches → 2. 5/6 The Sparrow 2 → 3.** A change to the cards, not a
|
||||||
transcription fix, so `Trains3.pdf` and the transcription in `implications.md` §5 keep the original
|
transcription fix, so `Trains3.pdf` and the transcription in `implications.md` §5 keep the original
|
||||||
numbers with a footnote; `content.ts` and `StationMaster-Home-Deck-v0.4.5.md` carry what the game
|
numbers with a footnote; `content.ts` and `home-deck.md` carry what the game
|
||||||
plays. Both consists remain inside the four-car Crew Tray limit.
|
plays. Both consists remain inside the four-car Crew Tray limit.
|
||||||
|
|
||||||
A test had to follow: `multiplayer.test.ts` used Train 1 *because* it had three cars, to exercise a
|
A test had to follow: `multiplayer.test.ts` used Train 1 *because* it had three cars, to exercise a
|
||||||
@@ -3295,7 +3787,7 @@ before v0.4.9 — 6 copies, one slot each direction — so §9.3 names it, and t
|
|||||||
The card set says the same thing without needing the rules text. All three Refinery modifiers —
|
The card set says the same thing without needing the rules text. All three Refinery modifiers —
|
||||||
Pipelines, Oil Depot, Viscosity Breakers — grant **+1 outbound**; a two-way Refinery would be the only
|
Pipelines, Oil Depot, Viscosity Breakers — grant **+1 outbound**; a two-way Refinery would be the only
|
||||||
industry in the game with no card able to raise one of its two directions.
|
industry in the game with no card able to raise one of its two directions.
|
||||||
`StationMaster-Home-Deck-v0.4.5.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
|
`home-deck.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
|
||||||
Warehouse · Inbound · 0 out / 1 in".
|
Warehouse · Inbound · 0 out / 1 in".
|
||||||
|
|
||||||
So the Refinery ships and the Grocer's receives, and the **Freight House is the one two-way industry**
|
So the Refinery ships and the Grocer's receives, and the **Freight House is the one two-way industry**
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ deliberately no longer names one: it went stale for six releases.
|
|||||||
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
|
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
|
||||||
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
|
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
|
||||||
where its automatic phases finally get a visible beat.
|
where its automatic phases finally get a visible beat.
|
||||||
|
|
||||||
|
**The caption and the history panel are not the same list**, since 2026-09-17. Switching is logged
|
||||||
|
in full; a move from the middle of a turn writes its line as tone `trace`, so the step still
|
||||||
|
carries it — the board captions the move and earns its dwell, and `dwellForStep` pays nothing for a
|
||||||
|
step that said nothing — while the history panel filters the tone out. What the panel draws is the
|
||||||
|
line saying somebody switched, the FIRST move, work at an **industry**, the Small Yard sort, and a
|
||||||
|
closing summary. The last move rides in that summary rather than being kept in place: nothing knows
|
||||||
|
a move was the last until the turn is over, by which time the line has been written and streamed to
|
||||||
|
every client, so it cannot be revised.
|
||||||
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
|
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
|
||||||
the page is proven not to throw, but nobody has watched a bot switch on screen.
|
the page is proven not to throw, but nobody has watched a bot switch on screen.
|
||||||
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
|||||||
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
|
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
|
||||||
4. **The screen** — #44 #81 #33 #36
|
4. **The screen** — #44 #81 #33 #36
|
||||||
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
|
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
|
||||||
6. **Rules** — #12 #80 #82 #83 #85
|
6. **Rules** — #12 #80 #82 #83 #85 #108
|
||||||
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
|
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
|
||||||
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
|
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
|
||||||
9. **Code health and housekeeping** — #46 #45 #84 #87
|
9. **Code health and housekeeping** — #46 #45 #84 #87
|
||||||
@@ -407,6 +407,42 @@ need RAR or Jesse rather than code.**
|
|||||||
- [ ] **#85** — The 0.4.9 playtest line is behind on a rules ruling, and that was checked rather than
|
- [ ] **#85** — The 0.4.9 playtest line is behind on a rules ruling, and that was checked rather than
|
||||||
assumed. See **Reference · #85**.
|
assumed. See **Reference · #85**.
|
||||||
|
|
||||||
|
- [x] **#107** — **May a Small Yard put cars on the NOSE of the engine? YES** — raised by Jesse
|
||||||
|
2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says
|
||||||
|
a train there reorders "and puts the engine at the nose", `implications.md` says "any order,
|
||||||
|
INCLUDING cars ahead of the engine". The design notes won.
|
||||||
|
|
||||||
|
`switch.sortConsist` gained an optional `engineAt` (absent = the nose, so older saves replay
|
||||||
|
unchanged). The menu did NOT multiply: the engine is a separate short list offered against the
|
||||||
|
consist as it stands, so a four-car train has eight options rather than twenty, and a player
|
||||||
|
wanting both a re-order and an engine move spends two Moves. §8.2 needed no new code —
|
||||||
|
`badlyMadeUp` already holds a broken-backed train, and is deliberately direction-free, so a
|
||||||
|
PUSHING train (whole consist ahead of the engine) is fit to run. The button warns first, by
|
||||||
|
asking that predicate rather than copying it.
|
||||||
|
|
||||||
|
Labels read WEST TO EAST with the engine drawn as the board's own ◀ / ▶ arrow, because "front
|
||||||
|
to back" depends on which way the train points and the board has reversed east-facing consists
|
||||||
|
since v0.8.0. Each says `MADE UP, ready to leave` or `HELD at the Office: <why>`.
|
||||||
|
|
||||||
|
- [ ] **#108** — **The coach ratchet: every coach ends up in the Classification Yard and never comes
|
||||||
|
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.
|
||||||
|
|
||||||
|
§9.2 boarding discards the emptied coach into **Classification**; detraining draws a fresh
|
||||||
|
empty **out of the Division Yard**; §2.2 returns Classification only when the Division Yard runs
|
||||||
|
bare. Coaches therefore move one way only. **Measured over `whistle-6945` (3 Days, 539
|
||||||
|
intents):** 16 coaches in the Division Yard at setup, **0 from Day 2 Stage 8 to the end**, 15
|
||||||
|
in Classification — while the Division Yard held steady at 46-47 freight cars, so the refill
|
||||||
|
could not fire. From that point no passenger can board or detrain anywhere on the board, and
|
||||||
|
four of the twelve timetabled trains (1/2 Crack Limited, 5/6 Sparrow) carry nothing but
|
||||||
|
coaches.
|
||||||
|
|
||||||
|
The two changes that would break the ratchet were put up and declined for now: sending the
|
||||||
|
emptied coach back to the **Division** Yard instead of Classification (a one-line change to the
|
||||||
|
boarding reducer), or amending §2.2 to refill when the Division Yard holds no car of a NEEDED
|
||||||
|
type rather than only when bare. **Revisit with a second game's data** — one game cannot tell a
|
||||||
|
rule from a seed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Play balance
|
## Play balance
|
||||||
@@ -546,6 +582,28 @@ 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
|
||||||
@@ -1956,7 +2014,7 @@ source Start-position art for the ten card types before it can begin.
|
|||||||
|
|
||||||
2026-08-22, immediately after Gitea#7 changed the coach counts on four train cards and the
|
2026-08-22, immediately after Gitea#7 changed the coach counts on four train cards and the
|
||||||
answer to "where do we keep track of that?" turned out to be **five places of three different
|
answer to "where do we keep track of that?" turned out to be **five places of three different
|
||||||
vintages**: `src/engine/content.ts` (the truth), `docs/StationMaster-Home-Deck-v0.4.5.md` (a
|
vintages**: `src/engine/content.ts` (the truth), `docs/home-deck.md` (a
|
||||||
readable per-card table, a version-stamped snapshot), `docs/rules/implications.md` §5 (the
|
readable per-card table, a version-stamped snapshot), `docs/rules/implications.md` §5 (the
|
||||||
transcription of `Trains3.pdf`, deliberately frozen at what the design SAYS),
|
transcription of `Trains3.pdf`, deliberately frozen at what the design SAYS),
|
||||||
`docs/Trains3.pdf` (the artwork), and `docs/rules/card-reference.md` (an invented placeholder
|
`docs/Trains3.pdf` (the artwork), and `docs/rules/card-reference.md` (an invented placeholder
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
# Station Master — Home Deck
|
|
||||||
|
|
||||||
**Rules implementation reference: v0.4.5**
|
|
||||||
**Scope:** every card associated with the Home Office deck, including cards catalogued in the source but deliberately excluded from the dealt deck.
|
|
||||||
|
|
||||||
## Dealt card catalogue
|
|
||||||
|
|
||||||
The live Home Office deck contains **213 cards** in every currently supported mode. The rules for its setup, drawing, discarding, reshuffling, and hand limit are in the rules book, section 4.2.
|
|
||||||
|
|
||||||
| Active category | Cards |
|
|
||||||
| --- | ---: |
|
|
||||||
| Track | 96 |
|
|
||||||
| Office upgrades | 14 |
|
|
||||||
| Freight facilities | 27 |
|
|
||||||
| Facility modifiers | 23 |
|
|
||||||
| Train cards | 22 |
|
|
||||||
| Enhancements | 18 |
|
|
||||||
| Mainline modifiers | 7 |
|
|
||||||
| Maneuvers | 6 |
|
|
||||||
| **Total dealt** | **213** |
|
|
||||||
|
|
||||||
The catalogue also contains 12 space-use cards and 10 action cards. All 22 are excluded from every v0.4.5 dealt deck because their opponent-directed play rules are not implemented. They are listed at the end of this document for completeness.
|
|
||||||
|
|
||||||
## Track cards — 96
|
|
||||||
|
|
||||||
Track cards are ordinary Home Office cards, not a separate personal supply. A placed card must connect to existing rail. The Running Track is the horizontal row from Limit to Limit; cards there must carry an east–west through route. Placing track on a Limit extends the Running Track and moves that Limit outward.
|
|
||||||
|
|
||||||
| Card | Copies | Implemented placement facts |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| Straight track | 32 | East–west Operational Rail. |
|
|
||||||
| Curved track, right | 16 | A 45° curve; can rotate 180°, but cannot flip. Right-hand geometry is fixed to the `ne_sw` diagonal. |
|
|
||||||
| Curved track, left | 16 | A 45° curve; can rotate 180°, but cannot flip. Left-hand geometry is fixed to the `nw_se` diagonal. |
|
|
||||||
| Turnout, right | 16 | East–west through route plus one 45° branch. It may be passed through but is not Operational Rail, so a train cannot end a Move on it. |
|
|
||||||
| Turnout, left | 16 | Same operational rules; opposite fixed diagonal. |
|
|
||||||
| Sharp curved track, right | 0 | Catalogued but not dealt. |
|
|
||||||
| Sharp curved track, left | 0 | Catalogued but not dealt. |
|
|
||||||
|
|
||||||
A turnout may upgrade an existing straight, or a curve whose arc is exactly the turnout’s diverging arc. An upgrade is forbidden if the existing card holds standing cars or an enhancement. All other occupied squares are unavailable.
|
|
||||||
|
|
||||||
## Offices — 14
|
|
||||||
|
|
||||||
Every player begins at a Whistle Post, which is not drawn from the deck. Office cards are upgrades and must be played in sequence; they upgrade the existing Office rather than replacing its card or attached track.
|
|
||||||
|
|
||||||
| Card | Copies | A/D tracks | Porters | Passenger outbound/inbound slots | Other effect |
|
|
||||||
| --- | ---: | ---: | ---: | ---: | --- |
|
|
||||||
| Depot | 8 | 2 | 1 | 1 / 1 | Becomes a Control Point and Passenger Facility. |
|
|
||||||
| Station | 4 | 3 | 2 | 2 / 2 | Upgrade Depot only; Control Point and Passenger Facility. |
|
|
||||||
| Terminal | 2 | 4 | 3 | 3 / 3 | Upgrade Station only; Control Point and Passenger Facility. |
|
|
||||||
|
|
||||||
The Whistle Post has one A/D track, no porters, and no passenger slots. An Office upgrade preserves modifiers already applied to it.
|
|
||||||
|
|
||||||
## Freight facilities — 27
|
|
||||||
|
|
||||||
An industry may be placed only on a connected straight stub off the Running Track. Each facility begins with one Laborer and a three-box MEN | AT | WORK freight pipeline. Its industry track has capacity equal to its base outbound plus inbound capacity, with a minimum of one car.
|
|
||||||
|
|
||||||
No Office Area may contain a duplicate industry, or both ends of a listed lockout pair.
|
|
||||||
|
|
||||||
| Card | Copies | Cars handled | Flow | Base boxes | Lockout in same Office Area |
|
|
||||||
| --- | ---: | --- | --- | --- | --- |
|
|
||||||
| Freight House | 6 | Boxcar | Outbound and inbound | 1 out / 1 in | Freight House; Grocer’s Warehouse |
|
|
||||||
| Mine Tipple | 6 | Hopper | Outbound | 1 out / 0 in | Power Plant |
|
|
||||||
| Refinery | 3 | Tank car | Outbound | 1 out / 0 in | Power Plant |
|
|
||||||
| Power Plant | 6 | Hopper or tank car | Inbound | 0 out / 1 in | Mine Tipple; Refinery |
|
|
||||||
| Packing Sheds | 3 | Reefer | Outbound | 1 out / 0 in | Grocer’s Warehouse |
|
|
||||||
| Grocer’s Warehouse | 3 | Boxcar or reefer | Inbound | 0 out / 1 in | Packing Sheds; Freight House |
|
|
||||||
|
|
||||||
## Facility modifiers — 23
|
|
||||||
|
|
||||||
A modifier occupies an empty square adjacent to an eligible facility. It is unique by modifier type within an Office Area. The implementation attaches it permanently to the first eligible adjacent facility found; it does not implement a per-Stage choice when one modifier touches more than one possible facility.
|
|
||||||
|
|
||||||
`+ out` adds green outbound capacity only where the host can load; `+ in` adds red inbound capacity only where the host can unload. A capacity increase at a freight facility also lengthens its industry track by the usable number of added slots. Worker increases always apply.
|
|
||||||
|
|
||||||
| Modifier | Copies | Eligible host | Actual grant |
|
|
||||||
| --- | ---: | --- | --- |
|
|
||||||
| Waiting Area | 3 | Any Office | +1 outbound passenger slot; +1 Porter |
|
|
||||||
| Restaurant | 2 | Any Office | +1 outbound passenger slot; +1 Porter |
|
|
||||||
| Hotel | 1 | Any Office | +1 outbound passenger slot; +1 Porter |
|
|
||||||
| Truck Dock | 2 | Freight House, Packing Sheds, Grocer’s Warehouse | +1 **inbound** slot; no Laborer |
|
|
||||||
| Railroad Express Agency | 1 | Freight House | +1 outbound slot; +1 Laborer |
|
|
||||||
| Forklifts | 2 | Freight House, Packing Sheds | +1 outbound slot; +1 Laborer |
|
|
||||||
| Prep Plant | 1 | Mine Tipple | +1 outbound slot; +1 Laborer |
|
|
||||||
| Coal Piles | 1 | Mine Tipple | +1 outbound slot; +1 Laborer |
|
|
||||||
| Conveyor Belts | 1 | Mine Tipple | +1 outbound slot; +1 Laborer |
|
|
||||||
| Pipelines | 1 | Refinery | +1 outbound slot; +1 Laborer |
|
|
||||||
| Oil Depot | 1 | Refinery | +1 outbound slot; +1 Laborer |
|
|
||||||
| Viscosity Breakers | 1 | Refinery | +1 outbound slot; +1 Laborer |
|
|
||||||
| Transmission Lines | 1 | Power Plant | +1 Laborer |
|
|
||||||
| Rotary Dumps | 1 | Power Plant | +1 Laborer |
|
|
||||||
| Steam Turbines | 1 | Power Plant | +1 Laborer |
|
|
||||||
| Ice House | 2 | Packing Sheds or Grocer’s Warehouse | +1 outbound slot; +1 Laborer |
|
|
||||||
| Local Small Groceries | 1 | Grocer’s Warehouse | +1 Laborer |
|
|
||||||
|
|
||||||
A bonus beside a facility that cannot use its direction is not usable and does not create a slot or lengthen the track — an outbound bonus beside an inbound-only facility, or the Truck Dock's inbound bonus beside the outbound-only Packing Sheds, which leaves that card with no effect at all. Likewise, passenger modifiers beside a Whistle Post add Porters but do not create an outbound slot until the Office becomes a Passenger Facility.
|
|
||||||
|
|
||||||
## Train cards — 22
|
|
||||||
|
|
||||||
Playing a timetabled train card rolls the seeded D12 and places its number in the first open timetable slot at or after the result, wrapping around the 12-slot chart. It then runs at that Stage every Day. An Extra is queued and made up when a Crew Tray becomes available; v0.4.5 automatically launches Extras eastbound from the Western Division Point.
|
|
||||||
|
|
||||||
The listed consist is a maximum, not a minimum: a train may depart with fewer cars, but must not exceed the listed categories, put a car behind a caboose, or leave with the engine buried among cars. A Crew Tray holds no more than four rolling-stock cars.
|
|
||||||
|
|
||||||
> **Changed 2026-08-22 (Gitea#7), Jesse's call:** the coach counts on **1/2 Crack Limited** and
|
|
||||||
> **5/6 The Sparrow** were swapped — the Limited drops from three coaches to two, the Sparrow rises
|
|
||||||
> from two to three. This is a change to the CARDS, not a correction to this table: `Trains3.pdf` and
|
|
||||||
> the transcription in [`rules/implications.md`](rules/implications.md) §5 still show the original
|
|
||||||
> numbers, and are right about what the printed cards said. `src/engine/content.ts` and this table
|
|
||||||
> carry what the game plays.
|
|
||||||
|
|
||||||
| Train | Speed | Direction | Listed maximum consist | Implemented special rule |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| 1/2 Crack Limited | Fast | 1 west / 2 east | **2 coaches** | No switching; passenger work only at Terminals; expedited. |
|
|
||||||
| 3/4 Express | Fast | 3 west / 4 east | 2 freight | May exchange at most one freight car at each grid location during its switching turn; expedited. |
|
|
||||||
| 5/6 The Sparrow | Fast | 5 west / 6 east | **3 coaches** | No switching; expedited. |
|
|
||||||
| 7/8 Local | Slow | 7 west / 8 east | 1 freight, 1 coach | Its coach may not be set out during switching. |
|
|
||||||
| 9/10 Heavy Freight | Slow | 9 west / 10 east | 3 freight, 1 caboose | — |
|
|
||||||
| 11/12 Drag Freight | Slow | 11 west / 12 east | 2 freight, 1 caboose | — |
|
|
||||||
| X13 Appleseed Extra | Slow | Player choice printed; v0.4.5 launches east | 3 empty freight, 1 caboose | May drop cars but cannot pick up. The engine enforces no pickup, but does not enforce the printed empties-only consist restriction at make-up. |
|
|
||||||
| X14 Fruit Growers Express | Fast | Player choice printed; v0.4.5 launches east | 2 reefers, 1 caboose | Expedited. The code enforces reefers-only; the printed extra loaded-reefer pickup is not a separate rule. |
|
|
||||||
| X15 Yard Xfer | Slow | Player choice printed; v0.4.5 launches east | 2 freight, 1 caboose | — |
|
|
||||||
| X16 Light Engine Move | Fast | Player choice printed; v0.4.5 launches east | No cars | No switching. |
|
|
||||||
| X17 Campaign Train | Fast | Player choice printed; v0.4.5 launches east | 1 coach | No switching. First Office arrival lays over for speeches; later arrivals are expedited. |
|
|
||||||
| X18 Circus Train | Slow | Player choice printed; v0.4.5 launches east | 2 freight, 1 coach, 1 caboose | No switching. The first Mainline Phase in which it remains stopped awards its current Office’s player 1 Revenue. |
|
|
||||||
| X19 Military Train | Slow | Player choice printed; v0.4.5 launches east | 1 freight, 2 coaches | No switching; no passenger work; expedited. |
|
|
||||||
| X20 Director’s Private Car | Slow | Player choice printed; v0.4.5 launches east | 2 freight, 1 coach | No passenger work. |
|
|
||||||
| X21 Freight Extra | Slow | Player choice printed; v0.4.5 launches east | 3 freight, 1 caboose | — |
|
|
||||||
| X22 Pee-Dee | Slow | Player choice printed; v0.4.5 launches east | 1 caboose | May pick up empty cars only. |
|
|
||||||
|
|
||||||
## Enhancements — 18
|
|
||||||
|
|
||||||
| Card | Copies | Placement | v0.4.5 behavior |
|
|
||||||
| --- | ---: | --- | --- |
|
|
||||||
| Interlocking | 2 | Bare Running Track straight | When the Office is full, an inbound train is held at the Limits instead of colliding. |
|
|
||||||
| Facing Point Locks | 2 | Any card; requires an Interlocking somewhere in the district | Blocks Derail. Derail is unavailable in v0.4.5, so this remains dormant. |
|
|
||||||
| Yard Office | 1 | Bare Secondary Track straight | A coachless inbound train is diverted to this track instead of occupying an A/D track. |
|
|
||||||
| Small Yard | 1 | Bare Secondary Track straight | A train may spend one switching Move here to reorder its entire consist and put the engine at the nose. |
|
|
||||||
| Water Column | 2 | Bare Running Track straight | Removes a Watertower. Watertower cards are unavailable, so this remains dormant. |
|
|
||||||
| Overpass | 1 | Any card | No implemented effect. |
|
|
||||||
| Telegraph | 3 | Bare Running Track straight | Once per Day, the Superintendent may add 4 to an opposing train’s number when that makes a facing clearance safe. |
|
|
||||||
| Telephone | 2 | On a Telegraph | Same dispatch mechanism, +8 once per Day. |
|
|
||||||
| Radio | 2 | On a Telephone | Same dispatch mechanism, +12 once per Day. |
|
|
||||||
| ABS Signals | 2 | Any Mainline card | Prevents rear-end collisions and holds a following train short. |
|
|
||||||
|
|
||||||
An enhancement requiring a bare straight cannot share that straight with another such enhancement. Telephone and Radio are the explicit stackable chain.
|
|
||||||
|
|
||||||
## Maneuvers — 6
|
|
||||||
|
|
||||||
| Card | Copies | Behavior |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| Red Flags | 5 | May be played at any time on a train stopped on a Mainline card. An approaching following train is held instead of moving into it. |
|
|
||||||
| Flying Switch | 1 | During the owner’s switching option, spend one Move to roll a tail-end cut into a track-connected freight facility without moving the engine there. |
|
|
||||||
| Poling | 0 | Catalogued but not dealt; no rule is implemented. |
|
|
||||||
|
|
||||||
## Catalogued but not dealt — 22 opponent-directed cards
|
|
||||||
|
|
||||||
### Space-use cards — 12
|
|
||||||
|
|
||||||
Bean House (1), Flop House (1), Watertower (1), Hobo Jungle (1), Section House (1), City Blocks (4), Engine Shops (1), Tenderloin District (1), and Engineer Cemetery (1) are excluded. Their source descriptions say they consume table space; Hobo Jungle additionally describes vandalism looting a passing boxcar. No placement or effect is available in v0.4.5.
|
|
||||||
|
|
||||||
### Action cards — 10
|
|
||||||
|
|
||||||
Derail (2), Broken Coupler (1), Railroad Crossing (1), Per Diem Inventory (1), Demurrage Charge (1), Customer Complaints (1), Vandalism (1), Hotbox (1), and Outlawed (1) are excluded. Their printed target/effect text is catalogued in the code, but `card.play` rejects the categories as not implemented. Consequently, no card can currently be played on another player.
|
|
||||||
|
|
||||||
There are also two **Facing Point Locks** listed among Mainline modifiers. They are treated as the same grid enhancement as Facing Point Locks above, require an Interlocking, and are included in the active 213-card total.
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# Station Master — Mainline Deck
|
|
||||||
|
|
||||||
**Rules implementation reference: v0.4.5**
|
|
||||||
**Scope:** the tarot-sized Mainline cards placed between Offices. This is an implementation reference, not a transcription of earlier prototype rules.
|
|
||||||
|
|
||||||
## How Mainline cards work
|
|
||||||
|
|
||||||
At setup the game places one randomly selected Mainline card between each neighbouring pair of Offices and one beyond each end Office, between it and a Division Point. Thus, a game with *N* players has *N + 1* Mainline cards. The implementation selects types with replacement; it does not deal them from a shuffled finite deck.
|
|
||||||
|
|
||||||
A train crossing a Mainline card spends Stages, rather than moving through its printed cells. A 60 mph card costs one Stage and a 30 mph card costs two. A Slow train adds one Stage to every crossing. On Hilly terrain, any train carrying at least one coach uses the passenger rate; a train carrying no coach uses the freight rate. No crossing can take less than one Stage.
|
|
||||||
|
|
||||||
The card does not itself determine which player owns the adjacent Office. It is part of the shared Division.
|
|
||||||
|
|
||||||
## Physical card inventory in `Mainline Cards.pdf`
|
|
||||||
|
|
||||||
The supplied PDF has **ten** tarot-sized terrain cards. Plains appears twice; the other terrain types appear once each. It also includes two Division Point cards.
|
|
||||||
|
|
||||||
| Physical card | Copies in the PDF |
|
|
||||||
| --- | ---: |
|
|
||||||
| Plains | 2 |
|
|
||||||
| Curves | 1 |
|
|
||||||
| Hilly | 1 |
|
|
||||||
| Heavy Grade | 1 |
|
|
||||||
| Double Track | 1 |
|
|
||||||
| Uncontrolled Siding | 1 |
|
|
||||||
| Tunnel | 1 |
|
|
||||||
| Trestle | 1 |
|
|
||||||
| Interchange | 1 |
|
|
||||||
| East Division Point | 1 |
|
|
||||||
| West Division Point | 1 |
|
|
||||||
|
|
||||||
The PDF art labels this card “Yard”; this reference uses the implementation’s correct name, **Interchange**, to distinguish it from the Division Yard, Classification Yard, Yard Office, and Small Yard.
|
|
||||||
|
|
||||||
## Implemented Mainline card reference
|
|
||||||
|
|
||||||
| Card | Implemented crossing time and feature |
|
|
||||||
| --- | --- |
|
|
||||||
| Plains | 60 mph; one Stage for Fast, two for Slow. The implementation has one Plains *type* rather than the PDF’s two physical copies. |
|
|
||||||
| Curves | 30 mph; two Stages for Fast, three for Slow. |
|
|
||||||
| Hilly | Passenger train: 60 mph. Freight-only train: 30 mph. Add one Stage if Slow. |
|
|
||||||
| Heavy Grade | Starts at 30 mph; two Stages for Fast, three for Slow. The card prints “Player sets orientation”; **the game deliberately overrides that and rolls the uphill direction from the seed** — settled in v0.5.0 and re-confirmed 2026-08-23, see the note below. Grade modifiers can reduce the time, to a minimum of one Stage. |
|
|
||||||
| Double Track | 60 mph. Printed capability: trains may pass. The traffic-resolution rule is in Rules §4.5. |
|
|
||||||
| Uncontrolled Siding | 60 mph. Printed capability: trains may pass. The traffic-resolution rule is in Rules §4.5. |
|
|
||||||
| Tunnel | 30 mph. |
|
|
||||||
| Trestle | 60 mph. |
|
|
||||||
| Interchange | 60 mph. A train may be reordered there only through the card’s printed “sort cars” concept; the current engine does **not** provide a Mainline sorting action for it. |
|
|
||||||
|
|
||||||
### PDF/code mismatch — CORRECTED
|
|
||||||
|
|
||||||
**Was:** `src/engine/content.ts` defines nine `MAINLINE_PROFILES` types: one Plains entry plus the eight other terrain types above. `setup.ts` selected uniformly from that nine-type list, **with replacement**. The second Plains card shown in `Mainline Cards.pdf` was therefore not represented as a duplicate card or as extra Plains weight in setup — and, worse than a weighting error, a Division could be dealt two Interchanges, two Tunnels or two Trestles, none of which the deck contains.
|
|
||||||
|
|
||||||
**Now:** `MAINLINE_DECK` in `content.ts` is the inventory table above — ten drawable cards, Plains twice and the other eight once each — and `buildDivision` deals from it without replacement. The two Division Point cards are not in that deck: they are the fixed ends of the Division, laid by `buildDivision` itself rather than drawn.
|
|
||||||
|
|
||||||
The Interchange is what forced the correction. §7 lets an Extra be started at the Interchange "if one is on the board" (see `docs/rules/implications.md`, §7), which only reads as a rule if the board can hold at most one.
|
|
||||||
|
|
||||||
The executable state represents East and West Division Points as fixed end nodes, not as card records. They are functionally present at the ends of the Division, but are not represented as the two PDF cards in the deck/state model.
|
|
||||||
|
|
||||||
## Heavy Grade modifiers
|
|
||||||
|
|
||||||
These cards come from the Home Office deck and are played onto a Mainline card during a player’s Draw option.
|
|
||||||
|
|
||||||
| Card | Copies | Placement and actual effect |
|
|
||||||
| --- | ---: | --- |
|
|
||||||
| Brakeman | 1 | Heavy Grade only. Reduces a downhill crossing by one Stage. |
|
|
||||||
| Airbrakes | 1 | Heavy Grade only, and Brakeman must already be on that card. Reduces a downhill crossing by one additional Stage. |
|
|
||||||
| Helpers | 1 | Heavy Grade only. Reduces an uphill crossing by one Stage. |
|
|
||||||
| Realignment | 2 | May be played only onto an unoccupied Mainline card. Changes Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, or Trestle → Uncontrolled Siding. It cannot be played on any other card type. |
|
|
||||||
|
|
||||||
For the grade cards, “uphill” should be the direction selected by the player when the card is placed. In v0.4.5 it is the seeded `gradeUp` direction because setup has no player-choice step.
|
|
||||||
|
|
||||||
## What is not implemented
|
|
||||||
|
|
||||||
- There is no player choice or physical placement interaction for Mainline cards; setup deals them automatically from the seeded random stream. There **is** a finite draw pile as of v0.6.2 — the deck above, dealt without replacement, so no Division can hold two of a card printed once.
|
|
||||||
- Interchange is catalogued as a “sort cars” card, but v0.4.5 has no operation that reorders a train on the Interchange. The Small Yard in an Office Area is the implemented sorting mechanism.
|
|
||||||
- Interchange now has one player-facing use: an Extra Train may be **started** there, made up in its yard and highballing onto the Mainline when the Subdivision is clear (§7, v0.6.2). Car sorting remains unimplemented.
|
|
||||||
|
|
||||||
## Heavy Grade orientation is settled, not missing
|
|
||||||
|
|
||||||
Heavy Grade orientation is rolled from the seed rather than chosen by a player. **This is a decision, not a gap, and it is not awaiting a player-selection step.**
|
|
||||||
|
|
||||||
The card prints “(Up)” and “Player sets orientation”, which assumes the card has an owner. This one does not: `buildDivision` lays the Division as `DP · Mainline · Office · Mainline · … · DP`, so a Heavy Grade always sits **between two districts**, or beyond an end Division Point next to one — never inside a single player’s own district.
|
|
||||||
|
|
||||||
Orientation is not cosmetic: Brakeman and Airbrakes each take a Stage off a train running **downhill**, Helpers takes one off a train running **uphill**, and odd-numbered trains run west while even run east. Turning the card around therefore decides which of those modifier cards are worth anything and which direction of traffic is favoured — permanently, for the whole game. Handing that to one of the two neighbours advantages them over the other, and no player has a fair claim to it.
|
|
||||||
|
|
||||||
**Re-opened and closed again on 2026-08-23**, when the option of giving the choice to the Superintendent was considered and rejected. Jesse’s call: v0.5.0’s ruling stands. Rolling from the seed is deterministic, roughly even (51/49 east/west over 400 games), identical for solitaire and multiplayer, and keeps setup non-interactive — the game has no setup phase, so the question would have to interrupt play before the first Local Operations, in the minority of games that deal the card at all (20% at one player, rising to 50% at four).
|
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
# Station Master — Components and Markers
|
# Station Master — Components and Markers
|
||||||
|
|
||||||
**Rules implementation reference: v0.4.5**
|
**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
|
||||||
**Scope:** non-card physical components and supplies modeled by the v0.4.5 game. Card-created facilities, workers, deck piles, hand state, timetable state, and other markers are documented with their cards or in the rules book.
|
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,
|
||||||
|
hand state, timetable state and other markers are documented with their cards or in the
|
||||||
|
[rules book](rules.md).
|
||||||
|
|
||||||
|
The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in
|
||||||
|
`src/engine/content.ts`. They are physical inventory rather than deck tuning, which is why they are
|
||||||
|
printed here at all — per-category CARD counts are deliberately not published anywhere (TODO #15a).
|
||||||
|
|
||||||
## Rolling stock
|
## Rolling stock
|
||||||
|
|
||||||
@@ -19,6 +28,14 @@ Rolling stock has a type and a load state. In the interface, coloured cars are l
|
|||||||
|
|
||||||
The engine is not rolling stock and does not count against the four-car Crew Tray limit.
|
The engine is not rolling stock and does not count against the four-car Crew Tray limit.
|
||||||
|
|
||||||
|
## Other supplies
|
||||||
|
|
||||||
|
| Component | Count | Note |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| Crew Trays | players + 3 | Engine and tray are one combined resource; there is no "engine without a tray". |
|
||||||
|
| Whistle Post cards | 4 | Every player starts on one; it is not drawn from the deck. |
|
||||||
|
| Limits signs | 8 | "2N + spares", so relocating one is never a supply question. |
|
||||||
|
|
||||||
## The two yards
|
## The two yards
|
||||||
|
|
||||||
### Division Yard
|
### Division Yard
|
||||||
@@ -34,7 +51,16 @@ When a train completes a run or is destroyed, its caboose returns to the Divisio
|
|||||||
|
|
||||||
### Classification Yard
|
### Classification Yard
|
||||||
|
|
||||||
The Classification Yard collects used rolling stock: cars displaced by boarding passengers, cars cleared from inbound red boxes, cars removed by unjamming a facility, ordinary cars from completed or destroyed trains, and empty cars replaced by a completed outbound freight load. It is not a player-selectable source. It returns to service only when the Division Yard is empty.
|
The Classification Yard collects used rolling stock: cars displaced by boarding passengers, cars
|
||||||
|
cleared from inbound red boxes, cars removed by unjamming a facility, ordinary cars from completed
|
||||||
|
or destroyed trains, and empty cars replaced by a completed outbound freight load. It is not a
|
||||||
|
player-selectable source. It returns to service only when the Division Yard is empty.
|
||||||
|
|
||||||
|
> **This is a one-way ratchet for COACHES, and it decides games.** Boarding sends an emptied coach
|
||||||
|
> here; detraining takes a fresh empty out of the *Division* Yard. Nothing returns a coach to the
|
||||||
|
> Division Yard except the bare-yard refill — and a yard kept topped up with freight cars returning
|
||||||
|
> from industries may never run bare. Measured over one three-Day game, every coach was here by the
|
||||||
|
> middle of Day 2 and stayed. See [Rules](rules.md) §4.6.
|
||||||
|
|
||||||
## Crew Trays and trains
|
## Crew Trays and trains
|
||||||
|
|
||||||
@@ -44,7 +70,16 @@ Within a tray, the engine can pull, push, or be between cars while switching. To
|
|||||||
|
|
||||||
## Fedora
|
## Fedora
|
||||||
|
|
||||||
The **Fedora** is the physical marker for the Superintendent. The player with it resolves following-train clearance decisions and takes the 5-Revenue penalty for a Mainline collision caused by an unsafe clearance. The initial holder is the first player tied for the highest Superintendent setup D12 roll. The Fedora passes to the next seat to the left at each third-stage shift change.
|
The **Fedora** is the physical marker for the Superintendent. The player with it resolves
|
||||||
|
following-train clearance decisions, takes the Yard Office and Red Flag questions, and is the player
|
||||||
|
every round round the table starts with. A Mainline collision is their fault by §10, and costs 5
|
||||||
|
Revenue. The initial holder is the first player tied for the highest Superintendent setup D12 roll.
|
||||||
|
|
||||||
|
It passes at the end of Stages 3, 6, 9 and 12 — every third Stage, at the Supervisor Shift. Since
|
||||||
|
v0.8.0.13 the handover is **announced on screen and written into the history**: it is the one thing
|
||||||
|
in the game that changes hands on the clock rather than because somebody did something, so nobody is
|
||||||
|
watching for it. Note that the Supervisor Shift refreshes every Laborer and Porter *every* Stage
|
||||||
|
while the Fedora moves only every third.
|
||||||
|
|
||||||
## D12 and seeded randomness
|
## D12 and seeded randomness
|
||||||
|
|
||||||
+37
-21
@@ -20,13 +20,32 @@ These stay as-is. Everything below is derived from them.
|
|||||||
> placeholder for exactly this material, and the balance measurements in Gap 12 were taken against a
|
> placeholder for exactly this material, and the balance measurements in Gap 12 were taken against a
|
||||||
> ruleset that does not match the design.
|
> ruleset that does not match the design.
|
||||||
|
|
||||||
|
## For players and testers
|
||||||
|
|
||||||
|
Written to be handed to somebody who is about to play, rather than to somebody building the game.
|
||||||
|
|
||||||
|
| Document | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| [`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/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. |
|
||||||
|
| [`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. |
|
||||||
|
|
||||||
|
**None of these carry a version in the filename**, and that is deliberate (Jesse, 2026-09-21): they
|
||||||
|
are kept current with every release rather than published as editions, so the name is always the
|
||||||
|
latest and each says at the top which build it describes. Four of them were stamped `v0.4.5` until
|
||||||
|
v0.8.0.17 — the prototype rules edition they were first written against, never the version they
|
||||||
|
described — which read as though they documented a build five minor versions old.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
| Document | What it is |
|
| Document | What it is |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [`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) | What is printed on every card, plus the economy summary. The spec an engine or a print-and-play layout consumes. |
|
| [`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/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. |
|
||||||
@@ -49,23 +68,20 @@ must do.
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
**v0.4.3.** Rules formalized, card faces specified, architecture documented, and the game playable
|
**v0.8.0.17.** Rules formalized, card faces specified, architecture documented, and the game
|
||||||
solitaire in a browser. See [`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and
|
playable **solitaire and multiplayer** in a browser against an authoritative server. See
|
||||||
[`../TODO.md`](../TODO.md) for what is open; this section is the shape of the project, not a
|
[`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and [`../TODO.md`](../TODO.md) for
|
||||||
running tally, because a hand-maintained tally is what drifted last time.
|
what is open; this section is the shape of the project, not a running tally, because a
|
||||||
|
hand-maintained tally is what drifted last time.
|
||||||
|
|
||||||
**What is built.** The rules engine, the developer bot, the balance harness, the replay viewer and
|
**What is built.** The rules engine, the developer bot, the balance harness, the replay viewer, the
|
||||||
the playable page — components 1–7, 17 and 18 of
|
playable page — and the server: lobby, game codes, seating, bots, per-seat reconnection, persistence
|
||||||
[`architecture/components.md`](architecture/components.md). A game can be saved, shared, replayed and
|
by replaying the intent history, and an ordered replay of other players' turns on each player's own
|
||||||
stepped back through. **493 tests.**
|
screen. It ships as a StartOS package. **999 fast tests and 35 simulation tests.**
|
||||||
|
|
||||||
**What is not.** The server. Phases 0 and 1 of
|
**What is not.** The 22 opponent-directed cards — the Action and Space-use categories — are held out
|
||||||
[`architecture/multiplayer.md`](architecture/multiplayer.md) landed in v0.4.0 — seat and player are
|
of every dealt deck until they have an implementation, along with the two defensive cards whose only
|
||||||
separate, turn state is per player, and the page talks to a `Session` rather than to the engine, so a
|
purpose is to answer them. Real audio: everything the game plays is synthesised from oscillators.
|
||||||
`RemoteSession` drops in without the page changing. Phase 2 onward is **deliberately held** until the
|
|
||||||
two provisional rules introduced in v0.3.0 have been played at a table: changing a rule after the wire
|
|
||||||
format is live costs far more than changing it before. Also unbuilt: the 22 opponent-directed cards
|
|
||||||
and real audio.
|
|
||||||
|
|
||||||
**Balance is not where it should be, and no conclusion should be read from the revenue numbers yet.**
|
**Balance is not where it should be, and no conclusion should be read from the revenue numbers yet.**
|
||||||
The rebalance pass is deliberately deferred until the rules stop moving — card counts, industry counts
|
The rebalance pass is deliberately deferred until the rules stop moving — card counts, industry counts
|
||||||
@@ -85,17 +101,17 @@ on that. [`architecture/protocol.md`](architecture/protocol.md) §3 has the reas
|
|||||||
`test/events.test.ts` pins it.
|
`test/events.test.ts` pins it.
|
||||||
|
|
||||||
**The economy, in one line:** Local Operations actions are the main currency — one per Stage, twelve
|
**The economy, in one line:** Local Operations actions are the main currency — one per Stage, twelve
|
||||||
per Day — but **inbound work bypasses them**, which is where the game's variance comes from. See
|
per Day — but **inbound work bypasses them**, which is where the game's variance comes from.
|
||||||
`card-reference.md` §7.
|
|
||||||
|
|
||||||
**Stack: TypeScript**, chosen so the engine runs in both the server and the browser — one
|
**Stack: TypeScript**, chosen so the engine runs in both the server and the browser — one
|
||||||
implementation of the movement rules, and instant affordances without a round-trip. Node 22 runs
|
implementation of the movement rules, and instant affordances without a round-trip. Node 22 runs
|
||||||
TypeScript natively, so there is no build step during development, which also means **erasable syntax
|
TypeScript natively, so there is no build step during development, which also means **erasable syntax
|
||||||
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.** `card-reference.md`
|
Running alongside, and independent of all of it: **print-and-play components.**
|
||||||
specifies every card face, so layout and art are the only remaining work before a table playtest —
|
[`rules/as-built.md`](rules/as-built.md) carries every card face as the game actually deals it, so
|
||||||
which answers the one question simulation cannot, whether it is fun.
|
layout and art are the only remaining work before a table playtest — which answers the one question
|
||||||
|
simulation cannot, whether it is fun.
|
||||||
|
|
||||||
Run the harness with `node src/sim/harness.ts [games] [length]`.
|
Run the harness with `node src/sim/harness.ts [games] [length]`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Station Master — Home Deck
|
||||||
|
|
||||||
|
**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
|
||||||
|
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
|
||||||
|
rules are for playing each kind of card out of it.
|
||||||
|
|
||||||
|
> **Per-card facts live in [`rules/as-built.md`](rules/as-built.md)**, which is GENERATED from
|
||||||
|
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with
|
||||||
|
> the game. Read it for every card's name, effect, placement and whether its printed effect actually
|
||||||
|
> resolves yet. This document is how the deck WORKS; that one is what is in it.
|
||||||
|
>
|
||||||
|
> **No card counts appear here, deliberately** (TODO #15a, Jesse's call 2026-08-22): counts move
|
||||||
|
> with play balance, so a document printing them is answering a question that has a different answer
|
||||||
|
> after the next retune. Where a count matters it is rendered as a yes/no — whether the deck deals
|
||||||
|
> the card at all — which is a fact about the design. This page used to print a full counts table
|
||||||
|
> and it was wrong for a month before anyone noticed.
|
||||||
|
|
||||||
|
## The piles
|
||||||
|
|
||||||
|
- **Home Office deck** — face down. The pile a Draw comes from.
|
||||||
|
- **Three Departments** — face-up discard piles. A discard goes onto one, which is precisely so a
|
||||||
|
rival may take it; a Draw may take the top card of a Department instead of the deck.
|
||||||
|
- **Salvage Yard** — where a played-out card ends up. An Extra's card goes here after its run.
|
||||||
|
|
||||||
|
When the Home Office deck runs out it is rebuilt from the Salvage Yard and **all three Departments
|
||||||
|
in full**, reshuffled from the seeded stream. A **spent timetabled train** is not collected — its
|
||||||
|
number is on the timetable and it cannot run twice — but a *discarded* train was never played and
|
||||||
|
is still runnable, so it comes back.
|
||||||
|
|
||||||
|
## Hand and turn
|
||||||
|
|
||||||
|
The hand limit is **three**, or four while you hold a Red Flag. You may not end a turn over the
|
||||||
|
limit: play a card or discard one to a Department. Some cards cannot be discarded at all — an Extra
|
||||||
|
never can, and a timetabled train cannot when the `discardTimetabled` house rule is off — so a hand
|
||||||
|
of nothing but those has exactly one way forward, which is to play one.
|
||||||
|
|
||||||
|
The opening deal is a house-rule choice made when the game is dealt. The default (`threeRandom`) is
|
||||||
|
three cards from one shuffled deck; `threeTrackThreeOther` deals three track and three others from
|
||||||
|
two separately shuffled piles, deliberately over the hand limit, so the first turn is spent choosing
|
||||||
|
which district you can afford to build.
|
||||||
|
|
||||||
|
Drawing is one of the three Local Operations options — see [Rules](rules.md)
|
||||||
|
§4.2. Taking the option lets you draw **and** play or discard within the same turn.
|
||||||
|
|
||||||
|
## Track cards
|
||||||
|
|
||||||
|
Track cards are ordinary Home Office cards, not a separate personal supply.
|
||||||
|
|
||||||
|
- A placed card must **connect to existing rail**: at least one neighbour must join it.
|
||||||
|
- The **Running Track** is the row from Limit to Limit. A card placed there must carry an east–west
|
||||||
|
through route, or it breaks the main.
|
||||||
|
- Placing track **on a Limit sign** extends the Running Track and moves that sign outward. The sign
|
||||||
|
is a physical card, so it moves rather than being left stranded mid-track.
|
||||||
|
- **Nothing may be placed outside your Limits** — track, industries and, since v0.8.0.14, Modifiers
|
||||||
|
too. Your district ends at its sign.
|
||||||
|
- Curves and turnouts are printed left- or right-handed. A card may be turned 180° but never flipped
|
||||||
|
over, so its 45° leg never changes diagonal.
|
||||||
|
- A **turnout may upgrade** an existing straight, or a curve whose arc is exactly the turnout's
|
||||||
|
diverging arc. Not if the card holds standing cars or an enhancement. Every other occupied square
|
||||||
|
is unavailable.
|
||||||
|
- A turnout may be **run through but not stopped on**: it is not Operational Rail, so a Move may not
|
||||||
|
end there.
|
||||||
|
|
||||||
|
## Office cards
|
||||||
|
|
||||||
|
Every player begins at a **Whistle Post**, which is not drawn from the deck: one A/D track, no
|
||||||
|
Porters, no passenger slots, and not a Control Point.
|
||||||
|
|
||||||
|
Office cards are **upgrades in strict sequence** — Whistle Post → Depot → Station → Terminal — and
|
||||||
|
each upgrades the Office in place rather than replacing its card or its attached track. Modifiers
|
||||||
|
already beside it are preserved. Each tier adds an A/D track, a Porter, and an outbound and inbound
|
||||||
|
passenger slot; a Depot and above is a Control Point and a Passenger Facility.
|
||||||
|
|
||||||
|
An upgrade takes no placement: the Office is where it already is.
|
||||||
|
|
||||||
|
## Freight facilities
|
||||||
|
|
||||||
|
An industry is placed on a connected straight **stub off the Running Track** — never on the Running
|
||||||
|
Track itself, and never outside the Limits. A Facility carries its own rails, so placing one places
|
||||||
|
track.
|
||||||
|
|
||||||
|
Each begins with one Laborer and a three-box **MEN | AT | WORK** pipeline. No Office Area may hold a
|
||||||
|
duplicate industry, or both ends of a lockout pair — a producer and the consumer of the same
|
||||||
|
commodity cannot be built in one district.
|
||||||
|
|
||||||
|
**An industry track holds four cars, like any other card.** It is *not* sized by the industry's box
|
||||||
|
count. That distinction was a real bug: box count is how much WORK an industry can hold, not how
|
||||||
|
much RAIL it has, and conflating the two invented a printed siding no industry card carries.
|
||||||
|
|
||||||
|
## Facility modifiers
|
||||||
|
|
||||||
|
A Modifier sits on an empty square among the **nine spots around its host Facility** — and, since
|
||||||
|
v0.8.0.14, **inside your Limits**, like everything else. It may not stand in the Running Track row.
|
||||||
|
One of each kind per Office Area.
|
||||||
|
|
||||||
|
**A Modifier adds a BOX, never room for a car.** A Truck Dock beside a Grocer's Warehouse gives it a
|
||||||
|
second red box — somewhere for one more arriving load to be cleared to — and changes nothing about
|
||||||
|
how many cars may be spotted there.
|
||||||
|
|
||||||
|
**A grant the host cannot use does nothing**, and the game says so rather than pretending. An
|
||||||
|
outbound bonus beside an inbound-only facility, or the Truck Dock's inbound bonus beside the
|
||||||
|
outbound-only Packing Sheds, is discarded — the latter leaving that card with no effect at all.
|
||||||
|
Passenger modifiers beside a **Whistle Post** add Porters but create no outbound slot until the
|
||||||
|
Office becomes a Passenger Facility; the panel reports that as DORMANT rather than claiming the
|
||||||
|
facility "only receives", which was wrong in both directions.
|
||||||
|
|
||||||
|
## Train cards
|
||||||
|
|
||||||
|
Playing a **timetabled** train rolls the seeded D12 and puts its number in the first open timetable
|
||||||
|
slot at or after the result, wrapping around the 12-slot chart. It then runs at that Stage **every
|
||||||
|
Day**.
|
||||||
|
|
||||||
|
Playing an **Extra** queues it; it is made up when a Crew Tray comes free, and **the player who
|
||||||
|
played the card chooses where it starts and loads it as they choose** (§7). Where it may start is a
|
||||||
|
house rule — `divisionPointsOnly`, `ownOffice`, or `anyOffice` (the default) — and the Interchange is
|
||||||
|
also available, because it is the one Mainline card with a yard. An Extra runs once and its card
|
||||||
|
goes to the Salvage Yard.
|
||||||
|
|
||||||
|
> The v0.4.5 behaviour of launching every Extra eastbound from the Western Division Point was
|
||||||
|
> replaced in v0.6.2. The number no longer decides an Extra's direction; the start does.
|
||||||
|
|
||||||
|
The listed consist is a **maximum, not a minimum**. A train may depart with fewer cars, but not with
|
||||||
|
more, not of the wrong category, not with a car behind the caboose, and not with the engine buried
|
||||||
|
among its own cars. A Crew Tray holds four pieces, and a caboose counts toward the four.
|
||||||
|
|
||||||
|
A train made up short of what its card calls for is reported as such, with what it wanted and why
|
||||||
|
the yard could not supply it — see Rules §4.4.
|
||||||
|
|
||||||
|
**Per-train consists and printed rules: [`rules/as-built.md`](rules/as-built.md) § Trains.** It
|
||||||
|
carries the `empties only`, `reefers only`, `drop only` and `pick up empties only` restrictions,
|
||||||
|
every one of which the engine enforces.
|
||||||
|
|
||||||
|
## Enhancements, Mainline modifiers and Maneuvers
|
||||||
|
|
||||||
|
- **Enhancements** are played into your district and change what a square does — the **Small Yard**
|
||||||
|
(re-order a consist for one Move), **Interlocking**, **Yard Office** and the rest. `as-built.md`
|
||||||
|
marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the implementation
|
||||||
|
knows.
|
||||||
|
- **ABS Signals is the exception, and it matters.** It is dealt as an Enhancement but is **not
|
||||||
|
played into your district**: it goes onto a **Mainline card** — any one of them — and stops
|
||||||
|
trains there rear-ending each other. It takes no square and is not part of anybody's Office
|
||||||
|
Area. See [Mainline deck](mainline-deck.md).
|
||||||
|
- **Mainline modifiers** are played onto a Mainline card: the Heavy Grade helpers and Realignment.
|
||||||
|
See [Mainline deck](mainline-deck.md).
|
||||||
|
- **Maneuvers** are held and spent: **Red Flags** and **Flying Switch** have their own actions.
|
||||||
|
Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
|
||||||
|
implement.
|
||||||
|
|
||||||
|
## Opponent-directed cards — not dealt
|
||||||
|
|
||||||
|
The **Action** and **Space-use** categories are opponent-directed and are **excluded from every
|
||||||
|
dealt deck**, because their play rules are not implemented. They are catalogued in
|
||||||
|
`as-built.md` so the composition is on record, and `check` refuses to play one. This is deliberate:
|
||||||
|
silently accepting them would make a card look playable while doing nothing.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Station Master — Mainline Deck
|
||||||
|
|
||||||
|
**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
|
||||||
|
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
|
||||||
|
card does to a train crossing it, and the Home Deck cards played onto one.
|
||||||
|
|
||||||
|
> **Per-card numbers live in [`rules/as-built.md`](rules/as-built.md)**, which is GENERATED from
|
||||||
|
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with
|
||||||
|
> the game. This document explains how the deck is used; that one is the table of record. Where the
|
||||||
|
> two ever differ, as-built is right.
|
||||||
|
|
||||||
|
## How Mainline cards work
|
||||||
|
|
||||||
|
At setup the game lays one Mainline card between each neighbouring pair of Offices and one beyond
|
||||||
|
each end Office, between it and a Division Point — so a game with *N* players uses **N + 1** cards.
|
||||||
|
They are **dealt from a finite deck without replacement** (since v0.6.2), so no Division can hold
|
||||||
|
two of a card printed once. The two Division Points are the fixed ends of the Division and are not
|
||||||
|
in the deck: `buildDivision` lays them itself.
|
||||||
|
|
||||||
|
A card does not belong to either neighbouring Office. It is shared Division.
|
||||||
|
|
||||||
|
### Crossing time is REGIONS, not mph
|
||||||
|
|
||||||
|
A card is divided into **regions**, and a train advances **one region per Stage**. Crossing time is
|
||||||
|
therefore `regions − startRegion`, and nothing else. **The printed mph is scenery.**
|
||||||
|
|
||||||
|
This is the part most likely to be remembered wrong, because it used to work the other way: mph set
|
||||||
|
the cost and a Slow train added a Stage to *every* card. It does not. Four things move a train's
|
||||||
|
start region and nothing else does:
|
||||||
|
|
||||||
|
1. **The card's own back region.** The Uncontrolled Siding and the Interchange print a back region
|
||||||
|
that is not part of the road, so a train running through begins past it.
|
||||||
|
2. **A card that prints a Fast and a Slow start — and only Hilly does.** A fast train starts one
|
||||||
|
region along and crosses in 1 Stage; a slow one takes 2. **No other card reads a train's
|
||||||
|
Fast/Slow rating at all.**
|
||||||
|
3. **The permanent Heavy Grade modifiers**, each moving a train one region up the hill.
|
||||||
|
4. **Occupancy**: arriving to find the card occupied can put a train in the siding, a region behind.
|
||||||
|
|
||||||
|
No crossing ever takes less than one Stage.
|
||||||
|
|
||||||
|
### Traffic
|
||||||
|
|
||||||
|
Only **Double Track** lets two trains stand on one card, so a following train is not held behind a
|
||||||
|
slower one. Every other card holds one train at a time. The **Uncontrolled Siding** is not a passing
|
||||||
|
card: arriving to find a train already there puts you in the siding a region behind it — you do not
|
||||||
|
run into it, and it costs you the extra Stage instead.
|
||||||
|
|
||||||
|
Whether a following train may enter an occupied card at all is the Superintendent's ruling (§8.1,
|
||||||
|
Rules §4.5). Getting it wrong is what causes collisions.
|
||||||
|
|
||||||
|
## The deck
|
||||||
|
|
||||||
|
Ten drawable cards — Plains twice, the other eight once each — plus the two Division Point cards,
|
||||||
|
which are not drawn.
|
||||||
|
|
||||||
|
| Card | Copies |
|
||||||
|
| --- | ---: |
|
||||||
|
| Plains | 2 |
|
||||||
|
| Curves | 1 |
|
||||||
|
| Hilly | 1 |
|
||||||
|
| Heavy Grade | 1 |
|
||||||
|
| Double Track | 1 |
|
||||||
|
| Uncontrolled Siding | 1 |
|
||||||
|
| Tunnel | 1 |
|
||||||
|
| Trestle | 1 |
|
||||||
|
| Interchange | 1 |
|
||||||
|
| East / West Division Point | 1 each, not dealt |
|
||||||
|
|
||||||
|
The PDF art labels the Interchange "Yard". This reference uses **Interchange** throughout, to keep
|
||||||
|
it apart from the Division Yard, the Classification Yard, the Yard Office and the Small Yard — five
|
||||||
|
different things.
|
||||||
|
|
||||||
|
**Region counts and entry points per card are in [`rules/as-built.md`](rules/as-built.md).**
|
||||||
|
|
||||||
|
## The Interchange, and what it does NOT do
|
||||||
|
|
||||||
|
The Interchange prints a car-sorting capability. **It is not implemented, and never has been.** The
|
||||||
|
one thing the card's `sortsCars` flag actually gates is that an **Extra Train may be made up and
|
||||||
|
started here** — it is the Mainline card with a yard, which is why §7 allows it (v0.6.2). An Extra
|
||||||
|
starting here begins in the back region and takes the extra Stage.
|
||||||
|
|
||||||
|
Re-ordering a consist is done at a **Small Yard** in an Office Area, for one switching Move. See
|
||||||
|
Rules §4.3.
|
||||||
|
|
||||||
|
> **Corrected 2026-09-20.** Until this pass the card said "Cars may be sorted into any new order
|
||||||
|
> here" — on the board, in the tooltip a player reads, and in the generated reference. A card
|
||||||
|
> advertising an action the game will not offer sends a player hunting for a button that does not
|
||||||
|
> exist. The description now says what the card does.
|
||||||
|
|
||||||
|
## Heavy Grade modifiers
|
||||||
|
|
||||||
|
Home Office cards, played onto a Mainline card during a player's Draw option.
|
||||||
|
|
||||||
|
| Card | Placement and effect |
|
||||||
|
| --- | --- |
|
||||||
|
| Brakeman | Heavy Grade only. A **downhill** train starts one region further on. |
|
||||||
|
| Airbrakes | Heavy Grade only, and **Brakeman must already be on that card**. A downhill train starts one region further again. |
|
||||||
|
| Helpers | Heavy Grade only. An **uphill** train starts one region further on. |
|
||||||
|
| Realignment | Only onto an **unoccupied** Mainline card. Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, Trestle → Uncontrolled Siding. No other card may be realigned. |
|
||||||
|
|
||||||
|
A Heavy Grade is 3 regions, so it is 3 Stages to climb and 3 to run down before help. Modifiers
|
||||||
|
never reduce a crossing below one Stage.
|
||||||
|
|
||||||
|
### ABS Signals — an Enhancement, but it lives out here
|
||||||
|
|
||||||
|
**ABS Signals is dealt from the Home Office deck as an Enhancement, and it is the one Enhancement
|
||||||
|
that is not played into a district.** It goes onto a **Mainline card — any of them**, not only a
|
||||||
|
grade — and takes no square in anybody's Office Area.
|
||||||
|
|
||||||
|
What it does: trains on that card **will not rear-end each other**. A following train is held short
|
||||||
|
of the one ahead instead of running into it, so the Superintendent has no §8.1 judgment to make and
|
||||||
|
no collision is scored. It is the only thing besides a Red Flag that prevents a rear-end collision
|
||||||
|
out on the Mainline, and unlike a Red Flag it stays on the card for the rest of the game.
|
||||||
|
|
||||||
|
On the board a card carrying it draws a **signal mast with a lit lamp** at its top-right corner; the
|
||||||
|
three grade helpers draw as **BRK**, **AIR** and **HLP** beside the card's name. Realignment draws
|
||||||
|
nothing, because a realigned card simply becomes the card it was converted into.
|
||||||
|
|
||||||
|
## Heavy Grade orientation is settled, not missing
|
||||||
|
|
||||||
|
Heavy Grade orientation is **rolled from the seed**, not chosen by a player. **This is a decision,
|
||||||
|
not a gap.**
|
||||||
|
|
||||||
|
The card prints "(Up)" and "Player sets orientation", which assumes the card has an owner. This one
|
||||||
|
does not: the Division is laid `DP · Mainline · Office · Mainline · … · DP`, so a Heavy Grade always
|
||||||
|
sits **between two districts**, or beyond an end Division Point beside one — never inside a single
|
||||||
|
player's district.
|
||||||
|
|
||||||
|
Orientation is not cosmetic. Brakeman and Airbrakes help a train running **downhill**, Helpers helps
|
||||||
|
one running **uphill**, and odd-numbered trains run west while even run east. Turning the card
|
||||||
|
around decides which modifiers are worth anything and which direction of traffic is favoured, for
|
||||||
|
the whole game. Handing that to one of two neighbours advantages them over the other, and neither
|
||||||
|
has a fair claim to it.
|
||||||
|
|
||||||
|
**Re-opened and closed again on 2026-08-23**, when giving the choice to the Superintendent was
|
||||||
|
considered and rejected. Jesse's call: v0.5.0's ruling stands. Rolling from the seed is
|
||||||
|
deterministic, roughly even (51/49 east/west over 400 games), identical in solitaire and
|
||||||
|
multiplayer, and keeps setup non-interactive — the game has no setup phase, so the question would
|
||||||
|
have to interrupt play before the first Local Operations, in the minority of games that deal the
|
||||||
|
card at all (20% at one player, rising to 50% at four).
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Station Master — Quickstart
|
||||||
|
|
||||||
|
**For a tester who has never played. Describes the game as built at v0.8.0.17** (2026-09-21).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What the game is
|
||||||
|
|
||||||
|
You are a railroad **Office** — a town — on a shared east–west main line called the **Division**.
|
||||||
|
Everyone's Office sits in a row along it, west to east, with **Mainline cards** between them.
|
||||||
|
|
||||||
|
Two jobs run at once:
|
||||||
|
|
||||||
|
**Your own job, in your district.** Build track. Build industries and a passenger platform. Shunt
|
||||||
|
cars around with a switching crew to get the right car to the right place, so that freight can be
|
||||||
|
loaded and unloaded and passengers can get on and off. Every one of those completed pieces of work
|
||||||
|
pays **Revenue**, which is the score.
|
||||||
|
|
||||||
|
**The shared job, out on the Division.** Scheduled trains run across everybody's territory on a
|
||||||
|
timetable. They arrive at your Office, and you work them. When two trains want the same stretch of
|
||||||
|
track, the player wearing the **Superintendent's Fedora** rules on whether the second may follow the
|
||||||
|
first. Rule wrong and they collide, which costs 5 Revenue and counts against a limit that can end
|
||||||
|
the game.
|
||||||
|
|
||||||
|
The tension the game is built around: **the useful work is local and slow, and the trains are shared
|
||||||
|
and do not wait.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. How you win
|
||||||
|
|
||||||
|
The game runs a set number of **Days** — five by default. Each Day is **12 Stages**, which you can
|
||||||
|
think of as two-hour clock periods from midnight.
|
||||||
|
|
||||||
|
At the end of the last Day:
|
||||||
|
|
||||||
|
1. **The table's combined Revenue is checked first**, against a floor of **3 × players × Days**. At
|
||||||
|
three players over five Days that is 45. **Miss it and everybody loses**, however well you
|
||||||
|
personally did. This is the number to watch.
|
||||||
|
2. **Co-op:** meeting the floor is the win, together.
|
||||||
|
3. **Competitive:** meeting the floor puts the game on, and the **highest individual Revenue** wins.
|
||||||
|
4. **Solitaire:** meet the floor by yourself.
|
||||||
|
|
||||||
|
**Collisions can end it early and badly.** Breaching the collision limit stops play at once in a
|
||||||
|
collective loss — the railroad has been declared unsafe. Default limits are 3 in one Day and 5 in
|
||||||
|
the game.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
### Where Revenue comes from
|
||||||
|
|
||||||
|
| Work | Pays |
|
||||||
|
| --- | --- |
|
||||||
|
| A passenger boarding at your platform | 1 |
|
||||||
|
| A passenger getting off at your platform | 1 |
|
||||||
|
| Completing an outbound freight load | 1 |
|
||||||
|
| Completing an inbound freight unload | 1 |
|
||||||
|
| A train completing its run across the whole Division | 0 by default, to every player |
|
||||||
|
|
||||||
|
Those rates are set when the game is dealt and can be changed. The default means **your score comes
|
||||||
|
almost entirely from working cars in your own district** — trains passing through pay nothing by
|
||||||
|
themselves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The shape of a Stage
|
||||||
|
|
||||||
|
Every Stage runs five phases in this order. Only two of them are your turn.
|
||||||
|
|
||||||
|
| # | Phase | What happens |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | **Local Operations** | **Your turn.** Choose ONE of three things (below). Each player in turn, starting with the Superintendent and working eastward. |
|
||||||
|
| 2 | **New Train** | Trains due this Stage are made up from the Division Yard. The table takes turns adding one car each. |
|
||||||
|
| 3 | **Mainline** | Automatic. Trains move, lowest number first. This is where clearance rulings and collisions happen. |
|
||||||
|
| 4 | **Cargo** | **Your turn.** Your Laborers and Porters do their work — the loading, unloading, boarding and detraining that actually pays. |
|
||||||
|
| 5 | **Supervisor Shift** | Automatic. Laborers and Porters refresh; expedited trains depart. Every third Stage the Fedora passes. |
|
||||||
|
|
||||||
|
### Local Operations: you get exactly one of these
|
||||||
|
|
||||||
|
- **Switch** — take a crew and shunt. **Six Moves** (five at night under Reduced Visibility). This is
|
||||||
|
how cars physically get from the yard to an industry and back. Cars you run over are coupled up
|
||||||
|
automatically, so plan the route.
|
||||||
|
- **Draw** — take a card, then play and/or discard. This is how your district gets built: track,
|
||||||
|
industries, Office upgrades, modifiers, train cards.
|
||||||
|
- **Freight Agent** — one clerical act: stock a green outbound box, clear a red inbound box, or
|
||||||
|
unjam a facility.
|
||||||
|
|
||||||
|
**You cannot do two of them in one Stage.** Choosing is most of the game. A Stage spent drawing is a
|
||||||
|
Stage not spent switching.
|
||||||
|
|
||||||
|
> Passengers are **not** the Freight Agent's job. They board and get off in the **Cargo** phase, with
|
||||||
|
> a **Porter**. Reaching for the wrong role and finding nothing there is the single most common new
|
||||||
|
> player mistake, so each role now says on screen what it is for.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The screen
|
||||||
|
|
||||||
|
Left column, top to bottom:
|
||||||
|
|
||||||
|
- **The Division — west to east.** The shared main line: every Office and the Mainline cards between
|
||||||
|
them, with trains drawn where they are. **West is always on the left**, and a train's engine is
|
||||||
|
drawn as an arrow (◀ or ▶) showing which way it points.
|
||||||
|
- **Your Office Area.** Your own grid of track cards. This is where switching happens. It folds away
|
||||||
|
outside the phases that change it, unless you pin it open.
|
||||||
|
- **History.** What has happened, most recent first.
|
||||||
|
|
||||||
|
Right column:
|
||||||
|
|
||||||
|
- **Your Move** — the buttons. If it is not your turn this is empty, and the board tells you who is
|
||||||
|
acting.
|
||||||
|
- **Cards in My Hand**, and the **Department decks** — three face-up discard piles anyone may draw
|
||||||
|
the top of.
|
||||||
|
- **The Yards.** The **Division Yard** is the live supply of cars. The **Classification Yard** is
|
||||||
|
where used cars go, and it comes back **only when the Division Yard runs completely bare**.
|
||||||
|
- **Timetable** — who is due out and when.
|
||||||
|
- **Blocked — why nothing is moving.** *Read this panel.* When something will not work, this is
|
||||||
|
where the game explains why, in rules terms.
|
||||||
|
- **Facilities** — the load pipelines at each industry.
|
||||||
|
|
||||||
|
Across the top: your Revenue, the target, the Day and Stage, collisions, and the game code.
|
||||||
|
|
||||||
|
**When other players are acting**, their turns are replayed on your board a step at a time rather
|
||||||
|
than arriving already rearranged, with a `[N behind]` counter, **Pause** and **Skip**. Your own moves
|
||||||
|
are not replayed at you — they are already on your screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Your first twenty minutes
|
||||||
|
|
||||||
|
Play a **solitaire** game first. It needs no server and nobody else, and it is the same rules.
|
||||||
|
|
||||||
|
1. Open the site, choose **Play solitaire**, accept the defaults, **Deal**.
|
||||||
|
2. **Stage 1 — Draw.** You start on a **Whistle Post** with almost nothing. Play a track card or two
|
||||||
|
to extend your Running Track, and get a **Depot** down as soon as one appears: it is the upgrade
|
||||||
|
that makes you a passenger facility and gives you a second A/D track.
|
||||||
|
3. **Build one industry** on a stub off the main — not on the Running Track itself, which the game
|
||||||
|
will not allow.
|
||||||
|
4. **Play a train card** when you get one. It rolls onto the timetable and then runs at that Stage
|
||||||
|
**every Day**.
|
||||||
|
5. **When a train arrives at your Office**, switch cars to it or from it, and do the paying work in
|
||||||
|
the **Cargo** phase.
|
||||||
|
6. Watch the **Blocked** panel whenever you are stuck. It is usually one missing thing: no empty car
|
||||||
|
spotted, no loaded car in the yard, a full box, a locked industry track.
|
||||||
|
|
||||||
|
Then play a Day or two of multiplayer with bots filling the other seats, to see the table take turns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Things that surprise new players
|
||||||
|
|
||||||
|
- **A turnout cannot be stopped on.** You may run through it; you may not end a Move there.
|
||||||
|
- **Coupling is mandatory.** Run over a standing car and you take it, whether or not you wanted it.
|
||||||
|
- **An industry track locks while a load is on its MEN | AT | WORK boxes.** No train can enter,
|
||||||
|
cross or work there until it clears.
|
||||||
|
- **A modifier adds a BOX, never room for a car.** It raises how much work an industry can hold, not
|
||||||
|
how much rail it has.
|
||||||
|
- **Nothing may be built outside your Limits** — track, industries and modifiers alike. Your district
|
||||||
|
ends at its sign, and the sign moves outward as your Running Track grows.
|
||||||
|
- **A train must be made up to leave.** Engine at one end; if it has a caboose, the caboose at the
|
||||||
|
far end. A train shunted out of shape sits at your Office until you fix it — a **Small Yard** will
|
||||||
|
re-order a consist for one Move, and each option tells you whether the result can leave.
|
||||||
|
- **Passengers may dry up completely.** Coaches move one way — boarding sends the emptied coach to
|
||||||
|
the Classification Yard, and it only comes back when the Division Yard is bare, which may never
|
||||||
|
happen. When it does, the yard panel warns you and passenger trains are made up empty. **This is
|
||||||
|
the rules working as designed**, not a bug; report how it felt, not that it happened.
|
||||||
|
- **Expedited trains leave the same Stage they arrived**, after the Cargo phase. Ordinary ones wait.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. What to report
|
||||||
|
|
||||||
|
Most useful, in order:
|
||||||
|
|
||||||
|
1. **What you expected versus what happened**, with the Day and Stage. "Day 2 Stage 9, train 5 had
|
||||||
|
no coaches" is worth more than "passengers seem broken".
|
||||||
|
2. **Save the game** (the **Save replay** button) and send the file. A save is the seed and the moves
|
||||||
|
made, so it replays exactly and the bug can be looked at directly.
|
||||||
|
3. **Anything the screen did not explain.** If you had to guess a rule, that is a finding even when
|
||||||
|
the game was right.
|
||||||
|
4. **Anything you went looking for and could not find.**
|
||||||
|
|
||||||
|
Bugs go to the tracker; anything unclear in this guide is also worth saying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Where to read more
|
||||||
|
|
||||||
|
| For | Read |
|
||||||
|
| --- | --- |
|
||||||
|
| 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) |
|
||||||
|
| The Mainline cards and what they do to a train | [Mainline deck](mainline-deck.md) |
|
||||||
|
| Rolling stock, yards, trays, the Fedora | [Components](components.md) |
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
# Station Master — Rules
|
# Station Master — Rules
|
||||||
|
|
||||||
**First-draft rules reference for v0.4.5**
|
**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
|
||||||
**Authority:** observed v0.4.5 code paths and tests. Where a card face, prototype document, and executable behavior differ, this document reports executable behavior and marks unimplemented material.
|
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
|
||||||
|
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
|
||||||
|
`src/engine/content.ts` and is the table of record.
|
||||||
|
|
||||||
|
**New to the game? Start with the [Quickstart](quickstart.md).**
|
||||||
|
|
||||||
## 1. Overview and background
|
## 1. Overview and background
|
||||||
|
|
||||||
@@ -19,11 +27,14 @@ This book is divided as follows:
|
|||||||
6. the implemented multiplayer/engine status; and
|
6. the implemented multiplayer/engine status; and
|
||||||
7. FAQs and implementation limits.
|
7. FAQs and implementation limits.
|
||||||
|
|
||||||
The companion references are [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md), [Home deck](StationMaster-Home-Deck-v0.4.5.md), and [components](StationMaster-Components-v0.4.5.md).
|
The companion references are the [Quickstart](quickstart.md) for a new player,
|
||||||
|
[Mainline deck](mainline-deck.md), [Home deck](home-deck.md),
|
||||||
|
[components](components.md), and the generated per-card table
|
||||||
|
[`rules/as-built.md`](rules/as-built.md).
|
||||||
|
|
||||||
## 2. Definitions
|
## 2. Definitions
|
||||||
|
|
||||||
| Term | Meaning in v0.4.5 |
|
| Term | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| A/D track | An Office arrival/departure capacity. A Whistle Post has 1; Depot, Station, and Terminal have 2, 3, and 4. |
|
| A/D track | An Office arrival/departure capacity. A Whistle Post has 1; Depot, Station, and Terminal have 2, 3, and 4. |
|
||||||
| Card location | A square in an Office Area grid. A train may end a switching Move only on Operational Rail. |
|
| Card location | A square in an Office Area grid. A train may end a switching Move only on Operational Rail. |
|
||||||
@@ -43,7 +54,7 @@ The companion references are [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.
|
|||||||
| Operational Rail | A card on which a train may finish a Move. Turnouts are pass-through only; a locked industry is not usable at all. |
|
| Operational Rail | A card on which a train may finish a Move. Turnouts are pass-through only; a locked industry is not usable at all. |
|
||||||
| Running Track | The east–west track between an Office Area’s Limits, including its Office. |
|
| Running Track | The east–west track between an Office Area’s Limits, including its Office. |
|
||||||
| Secondary Track | All local rail inside the Limits that is not Running Track. |
|
| Secondary Track | All local rail inside the Limits that is not Running Track. |
|
||||||
| Stage | One of twelve turns in a Day. Its phases are Local Operations, New Train, Mainline, Load/Unload, and Shift Change. |
|
| Stage | One of twelve turns in a Day. Its phases are Local Operations, New Train, Mainline, Load/Unload and Shift Change. **On screen the last two are labelled "Cargo" and "Supervisor Shift"** — same phases, the names the interface uses. |
|
||||||
| Subdivision | Mainline between Division Points or Control Points. Clearance checks look through the whole next Subdivision. |
|
| Subdivision | Mainline between Division Points or Control Points. Clearance checks look through the whole next Subdivision. |
|
||||||
| Superintendent | The player with the Fedora. The role decides same-direction clearances and rotates every three Stages. |
|
| Superintendent | The player with the Fedora. The role decides same-direction clearances and rotates every three Stages. |
|
||||||
| Timetabled train | A numbered train card scheduled to one of the 12 Stage slots, then due at that slot each Day. Odd numbers go west; even numbers go east. |
|
| Timetabled train | A numbered train card scheduled to one of the 12 Stage slots, then due at that slot each Day. Odd numbers go west; even numbers go east. |
|
||||||
@@ -52,11 +63,18 @@ The companion references are [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.
|
|||||||
|
|
||||||
### 3.1 Starting a new game in the shipped client
|
### 3.1 Starting a new game in the shipped client
|
||||||
|
|
||||||
The v0.4.5 browser page creates a **one-player solitaire Standard game**. Select **New game**, then choose:
|
The front page has three doors: **Play multiplayer**, **Play solitaire**, and **Browse replays**.
|
||||||
|
|
||||||
|
**Solitaire** runs entirely in your own browser and needs nothing from the server. Select **New
|
||||||
|
game**, then choose:
|
||||||
|
|
||||||
1. a numeric seed, or leave it blank for a fresh browser-generated seed;
|
1. a numeric seed, or leave it blank for a fresh browser-generated seed;
|
||||||
2. a starting hand;
|
2. a starting hand;
|
||||||
3. passenger, freight, and train-transit Revenue rates.
|
3. passenger, freight, and train-transit Revenue rates;
|
||||||
|
4. the number of Days, and the optional rules.
|
||||||
|
|
||||||
|
**Multiplayer** goes to the lobby — see §3.5. It needs the server, because the game is authoritative
|
||||||
|
there rather than in any one browser.
|
||||||
|
|
||||||
The browser writes those choices into the URL. A particular game is defined by the **seed plus these house rules**, not the seed alone.
|
The browser writes those choices into the URL. A particular game is defined by the **seed plus these house rules**, not the seed alone.
|
||||||
|
|
||||||
@@ -87,7 +105,7 @@ In a multi-player engine game, every player receives two seeded D12 rolls:
|
|||||||
- The **division roll** orders seats from low west to high east; equal results put the lower player index farther east.
|
- The **division roll** orders seats from low west to high east; equal results put the lower player index farther east.
|
||||||
- The **Superintendent roll** gives the initial Fedora to the first player tied for highest.
|
- The **Superintendent roll** gives the initial Fedora to the first player tied for highest.
|
||||||
|
|
||||||
The opening deal starts at the Superintendent’s seat and proceeds left (eastward in the engine’s seat ordering).
|
The opening deal starts at the Superintendent’s seat and proceeds **eastward** — increasing seat index, which is how the Division map draws the table.
|
||||||
|
|
||||||
### 3.3 Saving, resuming, and replaying solitaire
|
### 3.3 Saving, resuming, and replaying solitaire
|
||||||
|
|
||||||
@@ -97,25 +115,61 @@ The browser also stores the current local game and resumes it automatically when
|
|||||||
|
|
||||||
**Undo is solitaire-only.** It removes the final accepted intent and rebuilds the game from the earlier history. Random outcomes are not rerolled: replaying the same action consumes the same seeded result. Undo can therefore change the player’s decision after seeing an outcome, but cannot fish for a different timetable die roll.
|
**Undo is solitaire-only.** It removes the final accepted intent and rebuilds the game from the earlier history. Random outcomes are not rerolled: replaying the same action consumes the same seeded result. Undo can therefore change the player’s decision after seeing an outcome, but cannot fish for a different timetable die roll.
|
||||||
|
|
||||||
### 3.4 Engine game modes and endings
|
### 3.4 Game modes and endings
|
||||||
|
|
||||||
The engine defines Solitaire, Competitive, and Co-op modes. The browser exposes only Solitaire.
|
Three modes: **Solitaire**, **Competitive** and **Co-op**. All three are playable.
|
||||||
|
|
||||||
| Length | Target | Days |
|
**Length is a free `days` count**, not a preset. The old `short`/`standard`/`campaign` presets
|
||||||
| --- | ---: | ---: |
|
carried a `target` and were dropped in 2026-08; they survive only as a convenience argument for the
|
||||||
| Short | 10 | 3 |
|
simulation tooling, resolving to 3, 5 and 10 Days. The default is 5.
|
||||||
| Standard | 20 | 5 |
|
|
||||||
| Campaign | 45 | 10 |
|
|
||||||
|
|
||||||
With `firstToTarget`, competitive mode ends when any individual reaches the target; co-op uses target × player count and total Revenue. With `highestAfterDays`, competitive mode requires the table’s combined Revenue to reach `3 × players × days`; otherwise everyone loses. If that floor is met, the highest individual score wins. Solitaire and co-op win only if their score reaches their target at the end of the length.
|
**How a game ends and who wins:**
|
||||||
|
|
||||||
Competitive mode also ends in a collective loss after three collisions in one Day.
|
1. **The timetable runs out** at the end of the last Day. Then:
|
||||||
|
2. **The combined Revenue floor** is checked first — `3 × players × days`. Fall short and
|
||||||
|
**everybody loses**, whatever anyone individually scored.
|
||||||
|
3. **Co-op** wins as a table if the floor is met.
|
||||||
|
4. **Competitive** is won by the **highest individual Revenue** once the floor is met.
|
||||||
|
5. **Collisions end it early.** Breaching the per-Day or total collision limit ends play at once in a
|
||||||
|
collective loss — the railroad has been declared unsafe.
|
||||||
|
|
||||||
The configuration contains four optional-rule flags. Only two have engine effects: **Reduced Visibility** gives five rather than six switching Moves in Stages 1, 2, 3, 11, and 12; **Emergency Toolbox** initially sets the Red Flags hand-limit status, allowing four cards. Sister Trains and Employee Rotation are represented in configuration/state design but are not executed by v0.4.5.
|
**Extended play (Gitea#11).** Both days-based endings — running out of timetable, and closing short
|
||||||
|
of the Revenue floor — offer **another Day**, because they are the same event seen twice: the last
|
||||||
|
Day ended, and this is what the books say. A collision ending is **not** extendable, and neither is a
|
||||||
|
collision breach during an extended Day. The official result is frozen when the timetable first ran
|
||||||
|
out, so a railroad declared unsafe on Day 9 does not retract who won on Day 5.
|
||||||
|
|
||||||
### 3.5 Multiplayer setup status
|
**Three optional rules, all implemented:**
|
||||||
|
|
||||||
There is no multiplayer lobby, room creation flow, remote server, invitation flow, or network session in v0.4.5. The engine can be called with multiple player names (and rejects a Solitaire configuration with more than one), but the delivered page always calls it for one local player. See section 6.
|
| Rule | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| Reduced Visibility | Five switching Moves instead of six, in Stages 1, 2, 3, 11 and 12 — the night Stages. |
|
||||||
|
| Employee Rotation | Every player moves one chair at the end of each Day. Revenue and the Fedora travel with the player; the Office Areas stay with the seats. |
|
||||||
|
| Emergency Toolbox | Starts every player holding the Red Flags status, so the hand limit opens at four. |
|
||||||
|
|
||||||
|
(There is no "Sister Trains" flag. It appeared in an earlier draft of this document and never in the
|
||||||
|
configuration.)
|
||||||
|
|
||||||
|
### 3.5 Multiplayer setup
|
||||||
|
|
||||||
|
Multiplayer is delivered and is what this package is for. The flow:
|
||||||
|
|
||||||
|
1. **Create or join.** The host creates a game and gets a **game code**; everyone else joins with
|
||||||
|
that code. Seats fill as people arrive, and any seat left empty can be **filled with a bot**.
|
||||||
|
2. **Start.** Once the host starts, that same page is where every player plays their turns and
|
||||||
|
watches the table. There is nothing else to open.
|
||||||
|
3. **The server is authoritative.** The game lives on the server, not in a browser: it survives a
|
||||||
|
page reload, a browser restart and a service update, replaying its intent history to get back to
|
||||||
|
where it was.
|
||||||
|
4. **Your seat is a token in YOUR browser**, scoped to the origin you joined at. A reload finds it
|
||||||
|
and puts you straight back. Clearing site data, a private window, or a different browser does not:
|
||||||
|
the seat is still yours and still on the server, but that browser can no longer prove it is you.
|
||||||
|
An administrator can mint a **single-use recovery link** (StartOS action **Restore a Seat**) that
|
||||||
|
trades a code for the token and expires in 30 minutes.
|
||||||
|
5. **Watching the table.** Other players' turns arrive as an ordered replay rather than as a board
|
||||||
|
that has silently rearranged itself, with a `[N behind]` counter, Pause and Skip.
|
||||||
|
|
||||||
|
Solitaire needs none of this and runs with the page alone.
|
||||||
|
|
||||||
## 4. Basic game mechanics
|
## 4. Basic game mechanics
|
||||||
|
|
||||||
@@ -124,13 +178,16 @@ There is no multiplayer lobby, room creation flow, remote server, invitation flo
|
|||||||
Each of 12 Stages follows this sequence:
|
Each of 12 Stages follows this sequence:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. Local Operations — each player, starting with the Superintendent and proceeding left
|
1. Local Operations — each player, starting with the Superintendent and proceeding eastward
|
||||||
2. New Train — make up due timetabled trains, then queued second sections and Extras
|
2. New Train — make up due timetabled trains, then queued second sections and Extras
|
||||||
3. Mainline — automatic train movement in numeric order
|
3. Mainline — automatic train movement in numeric order
|
||||||
4. Load/Unload — each player, starting with the Superintendent and proceeding left
|
4. Load/Unload — each player, same order. On screen: "Cargo"
|
||||||
5. Shift Change — expedited departures, clocks/workers, and possibly the Fedora
|
5. Shift Change — expedited departures, workers. On screen: "Supervisor Shift"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**"Proceeding left" is seat order, west to east**, which is how the Division map draws it — the
|
||||||
|
screen says "eastward" for that reason, because a table has no shared left.
|
||||||
|
|
||||||
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. At Day end, dispatch-device use resets, collision count resets, the Day and Stage roll over, and victory is checked.
|
At a Shift Change, Laborers and Porters reset. The Fedora moves after Stages 3, 6, 9, and 12. At Day end, dispatch-device use resets, collision count resets, the Day and Stage roll over, and victory is checked.
|
||||||
|
|
||||||
### 4.2 Local Operations: choose one option
|
### 4.2 Local Operations: choose one option
|
||||||
@@ -139,13 +196,19 @@ 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 can reorder a consist for one Move. Flying Switch spends one Move to roll a tail cut into a connected Freight Facility.
|
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
|
||||||
|
of the engine** — which is how a cut is set up to be shoved into a facing industry. Each option on
|
||||||
|
the menu shows the train it would build, laid out west to east as the board draws it, and says
|
||||||
|
whether the result may leave the Office or would be held there. Flying Switch spends one Move to
|
||||||
|
roll a tail cut into a connected Freight Facility.
|
||||||
|
|
||||||
**Draw.** Take one card from the face-down Home Office or the exposed top of one Department pile. During this option, play eligible cards and/or discard cards to Department piles, then finish at the hand limit. Track, facilities, offices, modifiers, enhancements, and train cards have the placement or scheduling rules in the deck references. Mainline modifiers are played from this option as well.
|
**Draw.** Take one card from the face-down Home Office or the exposed top of one Department pile. During this option, play eligible cards and/or discard cards to Department piles, then finish at the hand limit. Track, facilities, offices, modifiers, enhancements, and train cards have the placement or scheduling rules in the deck references. Mainline modifiers are played from this option as well.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
Implementation note: `card.discard` is accepted by the v0.4.5 engine during Local Operations without checking that Draw was chosen. This unusual implementation behavior is not a separate published turn option.
|
Implementation note, still true at v0.8.0.17: `card.discard` is accepted during Local Operations
|
||||||
|
without checking that the Draw option was chosen — unlike `card.play`, which does check. This is an
|
||||||
|
implementation quirk rather than a fourth published turn option.
|
||||||
|
|
||||||
### 4.3 Track, switching, and local safety
|
### 4.3 Track, switching, and local safety
|
||||||
|
|
||||||
@@ -159,18 +222,36 @@ Train-card restrictions also apply while switching. No-switching trains cannot m
|
|||||||
|
|
||||||
At the Stage shown on the timetable, the engine makes up the matching timetabled train if a Crew Tray is free. It starts at the Division Point appropriate to its direction. A train may receive matching loaded or empty cars from the Division Yard until its listed maximum consist is reached or no suitable car remains. It is permitted to leave under-strength.
|
At the Stage shown on the timetable, the engine makes up the matching timetabled train if a Crew Tray is free. It starts at the Division Point appropriate to its direction. A train may receive matching loaded or empty cars from the Division Yard until its listed maximum consist is reached or no suitable car remains. It is permitted to leave under-strength.
|
||||||
|
|
||||||
A Second Section order on the due train creates another identical timetabled train behind it when a free tray is available. A played Extra is made up after timetabled trains and Second Sections when a tray is free; v0.4.5 automatically sends every Extra east from the Western Division Point.
|
A Second Section order on the due train creates another identical timetabled train behind it when a
|
||||||
|
free tray is available. A played Extra is made up after timetabled trains and Second Sections when a
|
||||||
|
tray is free, and **the player who played the card chooses where it starts** — either Division
|
||||||
|
Point, the Interchange, or an Office, according to the `extraStart` house rule — and loads it as they
|
||||||
|
choose rather than going round the table.
|
||||||
|
|
||||||
|
A timetabled train's consist is built by the table: **starting with the Superintendent and working
|
||||||
|
eastward, each player adds ONE car**, going round again until the train is full or the Division Yard
|
||||||
|
holds nothing it can take.
|
||||||
|
|
||||||
|
**When the yard can supply nothing, the game says so.** The round is skipped — there is no point
|
||||||
|
asking for a car that cannot be given — and the train is reported as made up short, naming what its
|
||||||
|
card wanted, how many such cars are waiting in the Classification Yard, and how far the Division Yard
|
||||||
|
is from bare. See §4.6 for why that happens to coaches in particular.
|
||||||
|
|
||||||
### 4.5 Mainline movement and Office arrival
|
### 4.5 Mainline movement and Office arrival
|
||||||
|
|
||||||
Mainline movement is automatic and processes lower train numbers first; a timetabled train outranks an Extra with the same number. A train at a Division Point, at an Office A/D track, or already crossing a Mainline card attempts its applicable movement.
|
Mainline movement is automatic and processes lower train numbers first; a timetabled train outranks an Extra with the same number. A train at a Division Point, at an Office A/D track, or already crossing a Mainline card attempts its applicable movement.
|
||||||
|
|
||||||
Crossing time comes from the Mainline card and train speed. On a normal card, a train must check the entire next Subdivision before entering it:
|
**Crossing time is the card's REGIONS**, one per Stage — not its printed mph, which is scenery, and
|
||||||
|
not the train's Fast/Slow rating, which only **Hilly** reads. See the
|
||||||
|
[Mainline deck](mainline-deck.md) reference for what moves a train's entry
|
||||||
|
point. On a normal card, a train must check the entire next Subdivision before entering it:
|
||||||
|
|
||||||
- an opposing train normally blocks entry;
|
- an opposing train normally blocks entry;
|
||||||
- a following same-direction train asks the Superintendent to allow or deny clearance;
|
- a following same-direction train asks the Superintendent to allow or deny clearance;
|
||||||
- Red Flags or ABS Signals hold the follower automatically; and
|
- Red Flags or ABS Signals hold the follower automatically; and
|
||||||
- passing cards allow entry without this occupancy check.
|
- **Double Track** — the one card two trains may stand on — allows entry without that check. The
|
||||||
|
**Uncontrolled Siding** is not a passing card: a train arriving to find it occupied takes the
|
||||||
|
siding a region behind, which costs it the extra Stage instead of a collision.
|
||||||
|
|
||||||
An Office arrival normally takes a free A/D track. If the Office is full, the inbound train collides and the local Office player loses 5 Revenue; Interlocking instead holds it at the Limits. A coachless inbound train may divert to a Yard Office. Cars fouling the Running Track at the Office also cause a collision.
|
An Office arrival normally takes a free A/D track. If the Office is full, the inbound train collides and the local Office player loses 5 Revenue; Interlocking instead holds it at the Limits. A coachless inbound train may divert to a Yard Office. Cars fouling the Running Track at the Office also cause a collision.
|
||||||
|
|
||||||
@@ -185,7 +266,22 @@ Passenger work occurs during Load/Unload, at a Depot, Station, or Terminal. Each
|
|||||||
- **Board:** replace an empty coach on an eligible train at the Office with a loaded coach from a green outbound box. The removed empty coach goes to the Classification Yard. Earn configured passenger Revenue.
|
- **Board:** replace an empty coach on an eligible train at the Office with a loaded coach from a green outbound box. The removed empty coach goes to the Classification Yard. Earn configured passenger Revenue.
|
||||||
- **Detrain:** replace a loaded coach on an eligible train with an empty coach from the Division Yard, placing the loaded coach into an available red inbound box. Earn configured passenger Revenue.
|
- **Detrain:** replace a loaded coach on an eligible train with an empty coach from the Division Yard, placing the loaded coach into an available red inbound box. Earn configured passenger Revenue.
|
||||||
|
|
||||||
Crack Limited trains permit passenger work at Terminals only. Military Train and Director’s Private Car permit no passenger work. A Whistle Post has no Porters.
|
Crack Limited trains permit passenger work at Terminals only. Military Train and Director's Private
|
||||||
|
Car permit no passenger work. A Whistle Post has no Porters.
|
||||||
|
|
||||||
|
> **Coaches travel one way, and it is worth knowing before you plan around passengers.** Boarding
|
||||||
|
> sends the emptied coach to the **Classification** Yard; detraining draws a fresh empty out of the
|
||||||
|
> **Division** Yard; and §2.2 returns the Classification Yard only when the Division Yard runs
|
||||||
|
> completely bare. Measured over one three-Day game: sixteen coaches in the Division Yard at setup,
|
||||||
|
> **none from Day 2 Stage 8 onward**, fifteen piled in Classification while the Division Yard held
|
||||||
|
> steady at 46–47 freight cars and stopped draining — so the refill never fired and no passenger
|
||||||
|
> could board or detrain anywhere for the rest of the game, while trains whose cards call for coaches
|
||||||
|
> were made up empty.
|
||||||
|
>
|
||||||
|
> **This is the rules working as printed and the ruling is that it stands** (Jesse, 2026-09-17, the
|
||||||
|
> same ruling Gitea#2 got: running out is part of the game). What changed is that the game now says
|
||||||
|
> it — the yard panel warns while the shortage lasts, and a train made up short reports why. Whether
|
||||||
|
> the ratchet should be broken is open as TODO #108, to be decided on a second game's evidence.
|
||||||
|
|
||||||
### 4.7 Freight work
|
### 4.7 Freight work
|
||||||
|
|
||||||
@@ -197,43 +293,77 @@ For an **inbound unload**, a matching loaded car must be spotted at an inbound-c
|
|||||||
|
|
||||||
## 5. Solitaire
|
## 5. Solitaire
|
||||||
|
|
||||||
The implemented game is solitaire: one named player, one Office Area, local browser execution, Standard length, highest-after-days victory, and the default house rules unless changed in New Game. The game has no AI opponent. “Multiplayer” gameplay does not occur locally by simulating other players.
|
Solitaire is one named player and one Office Area, running entirely in the browser with no server.
|
||||||
|
It is **not** the only implemented game any more — see §6 — but it is the one that needs nothing but
|
||||||
|
the page.
|
||||||
|
|
||||||
Solitaire-specific features are:
|
Solitaire-specific features are:
|
||||||
|
|
||||||
- a local browser save, automatic resume, and JSON download/load;
|
- a local browser save, automatic resume, and JSON download/load;
|
||||||
- unlimited step-by-step Undo back through accepted action history; and
|
- **unlimited step-by-step Undo** back through accepted action history — multiplayer has none,
|
||||||
|
because a shared game cannot be rewound under the other players; and
|
||||||
- a seed/rules URL suitable for sharing or reproducing a game.
|
- a seed/rules URL suitable for sharing or reproducing a game.
|
||||||
|
|
||||||
|
Automatic phases get a visible beat in solitaire too, so the board plays its own moves out rather
|
||||||
|
than jumping.
|
||||||
|
|
||||||
The active deck removes all 22 opponent-directed cards. Therefore, the defensive cards whose only purpose is to answer them (Facing Point Locks and Water Column) can be placed but have no opportunity to fire; Overpass has no effect at all. The game still includes shared-rail mechanics such as Mainline clearance, but with one player no other player can occupy the Division.
|
The active deck removes all 22 opponent-directed cards. Therefore, the defensive cards whose only purpose is to answer them (Facing Point Locks and Water Column) can be placed but have no opportunity to fire; Overpass has no effect at all. The game still includes shared-rail mechanics such as Mainline clearance, but with one player no other player can occupy the Division.
|
||||||
|
|
||||||
To win the default game, finish Day 5 with at least 20 Revenue. A result below 20 is a loss. Train-transit Revenue defaults to zero, so the default score must principally come from passenger and freight work.
|
To win the default one-player game, finish Day 5 having met the combined Revenue floor —
|
||||||
|
`3 × players × days`, which at one player over five Days is **15**. Below it is a loss, and the game
|
||||||
|
offers you another Day rather than simply ending. Train-transit Revenue defaults to zero, so the
|
||||||
|
score has to come principally from passenger and freight work.
|
||||||
|
|
||||||
## 6. Multiplayer
|
## 6. Multiplayer
|
||||||
|
|
||||||
### 6.1 What the engine supports
|
### 6.1 What the engine supports
|
||||||
|
|
||||||
The engine has player, seat, score, Office Area, Director/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 left, and models the following multiplayer-specific outcomes:
|
The rules engine has always supported multiple named players; since v0.7 the lobby, server and
|
||||||
|
client around it are delivered too, so this section now describes a game people actually play. The
|
||||||
|
engine has player, seat, score, Office Area, Division, phase-order, co-op and competitive-mode data
|
||||||
|
for multiple named players. It deals each player a hand, creates one Office Area per seat, starts acting order at the Superintendent and proceeds eastward, and models the following multiplayer-specific outcomes:
|
||||||
|
|
||||||
- the D12 seating and Superintendent rolls described in section 3;
|
- the D12 seating and Superintendent rolls described in section 3;
|
||||||
- individual Revenue in competitive play and shared total Revenue in co-op;
|
- individual Revenue in competitive play and shared total Revenue in co-op;
|
||||||
- a competitive collective loss after three collisions in one Day;
|
- a collective loss on breaching the collision limits;
|
||||||
- a collective Revenue floor for competitive highest-after-days games; and
|
- the combined Revenue floor, `3 × players × days`; and
|
||||||
- train-transit Revenue awarded to every player, if that revenue setting is nonzero.
|
- train-transit Revenue awarded to every player, if that revenue setting is nonzero.
|
||||||
|
|
||||||
The engine’s setup checks only that there is at least one player and that Solitaire has exactly one player. It does not enforce a maximum player count, although the test and design material exercise two through four players.
|
The engine’s setup checks only that there is at least one player and that Solitaire has exactly one player. It does not enforce a maximum player count, although the test and design material exercise two through four players.
|
||||||
|
|
||||||
### 6.2 What is not delivered in v0.4.5
|
### 6.2 What IS delivered
|
||||||
|
|
||||||
There is no implemented multiplayer game setup for end users: no server, lobby, invitation, room code, player join flow, authoritative remote state, or remote Session. The browser page creates a local single-player session only. Accordingly, there is no supported procedure for resuming a multiplayer game, and no multiplayer Undo.
|
Everything in §3.5: a lobby with game codes, seating, bots filling empty chairs, an authoritative
|
||||||
|
server that survives restarts and updates by replaying its intent history, per-seat reconnection, an
|
||||||
|
administrator's single-use seat-recovery link, and an ordered replay of other players' turns on each
|
||||||
|
player's own screen.
|
||||||
|
|
||||||
The 12 space-use cards and 10 action cards are not dealt in competitive or co-op either. The engine rejects attempts to play either category. Thus, **no card can currently be played on another player**. This includes Derail, Broken Coupler, Railroad Crossing, score-penalty cards, Vandalism, Hotbox, Outlawed, and all table-space cards. Facing Point Locks and Water Column are implemented only as dormant defences for these unavailable effects.
|
The package also exposes administrative **actions** on StartOS — list games in progress, get the
|
||||||
|
join secret, manage a game, restore a seat — documented in the wrapper repository rather than here.
|
||||||
|
|
||||||
### 6.3 Difference from solitaire, if a multi-player engine session is created
|
### 6.3 What is NOT delivered
|
||||||
|
|
||||||
Players have separate local districts, hands, and scores, but they share the Home Office deck, Department piles, yards, timetable, Mainline, and traffic consequences. Turn order is sequential; one current actor acts at a time. The Superintendent role is attached to a player while Offices are attached to fixed seats. Employee Rotation is not active, so players do not actually change seats in v0.4.5.
|
**No card may be played at another player.** The 12 space-use and 10 action cards are excluded from
|
||||||
|
every dealt deck in every mode, and `check` rejects playing one. That includes Derail, Broken
|
||||||
|
Coupler, Railroad Crossing, the score-penalty cards, Vandalism, Hotbox, Outlawed and all
|
||||||
|
table-space cards. Facing Point Locks and Water Column exist only as dormant defences against
|
||||||
|
effects nothing can currently cause, and Overpass has no effect at all.
|
||||||
|
|
||||||
The deck is still 213 cards and still excludes opponent-directed content. Therefore multi-player engine mode changes shared traffic, scores, turns, and win/loss evaluation—not card attacks or a remote user experience.
|
**No multiplayer Undo.** A shared game cannot be rewound under the other players.
|
||||||
|
|
||||||
|
### 6.4 How multiplayer differs from solitaire
|
||||||
|
|
||||||
|
Players have separate districts, hands and scores, and share the Home Office deck, the Department
|
||||||
|
piles, the yards, the timetable, the Mainline and every traffic consequence. One player acts at a
|
||||||
|
time.
|
||||||
|
|
||||||
|
**A seat is not a player**, and the distinction is load-bearing. Offices belong to seats; Revenue,
|
||||||
|
hands, the Fedora and identity belong to players. With **Employee Rotation** on, players move one
|
||||||
|
chair at the end of each Day and take their Revenue and the Fedora with them, while the districts
|
||||||
|
stay where they are.
|
||||||
|
|
||||||
|
So multiplayer changes shared traffic, scores, turn order and how the game is won or lost — not card
|
||||||
|
attacks, which do not exist in any mode.
|
||||||
|
|
||||||
## 7. Frequently asked questions
|
## 7. Frequently asked questions
|
||||||
|
|
||||||
@@ -271,20 +401,42 @@ It may be on Secondary Track rather than the Office, or be badly made up: its en
|
|||||||
|
|
||||||
### Why did an expedited train leave after passenger/freight work?
|
### Why did an expedited train leave after passenger/freight work?
|
||||||
|
|
||||||
Expedite means it departs in the Stage it arrived, but v0.4.5 waits until Shift Change so it remains present for that Stage’s Load/Unload phase.
|
Expedite means it departs in the Stage it arrived, but the departure waits until Shift Change so the
|
||||||
|
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?
|
||||||
|
|
||||||
Not in v0.4.5. The engine launches Extras eastbound from the Western Division Point. Heavy Grade orientation is seeded automatically at setup.
|
**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
|
||||||
|
a decision rather than a gap — the card sits between two districts and belongs to neither, so handing
|
||||||
|
the choice to one neighbour would advantage them permanently. See the Mainline deck reference.
|
||||||
|
|
||||||
### Can I use an Interchange to reorder a train?
|
### Can I use an Interchange to reorder a train?
|
||||||
|
|
||||||
No. The card is catalogued with a sorting concept, but there is no implemented Interchange sorting action. Small Yard is the available local sorting mechanism.
|
**No, and the card used to claim otherwise.** Its printed car-sorting has never been implemented; the
|
||||||
|
description was corrected on 2026-09-20 to stop advertising it. What the Interchange actually offers
|
||||||
|
is the one Mainline card with a yard, so an **Extra may be made up and started there**. Re-ordering a
|
||||||
|
consist is done at a **Small Yard** in a district.
|
||||||
|
|
||||||
### Can I play attack cards on another player?
|
### Can I play attack cards on another player?
|
||||||
|
|
||||||
No. All action and space-use cards are excluded from every dealt deck in v0.4.5 and their play is rejected.
|
No. All action and space-use cards are excluded from every dealt deck in every mode, and playing one
|
||||||
|
is rejected.
|
||||||
|
|
||||||
### Is multiplayer playable?
|
### Is multiplayer playable?
|
||||||
|
|
||||||
No. Multi-player state and rules-engine support exist, but the lobby, server, remote client, and opponent-directed card mechanics are not implemented.
|
**Yes.** Lobby, game codes, seating, bots, an authoritative server that survives restarts, per-seat
|
||||||
|
reconnection and a replayed view of everyone else's turns are all delivered — see §3.5 and §6. The
|
||||||
|
opponent-directed cards remain unimplemented in every mode, so there are still no card attacks.
|
||||||
|
|
||||||
|
### Why did my passenger train arrive with no coaches?
|
||||||
|
|
||||||
|
Almost certainly the coach ratchet in §4.6: every coach has ended up in the Classification Yard,
|
||||||
|
which comes back only when the Division Yard runs completely bare. The yard panel warns when this
|
||||||
|
has happened, and a train made up short says so in the log.
|
||||||
|
|
||||||
|
### Why is every option in the Small Yard marked "HELD at the Office"?
|
||||||
|
|
||||||
|
Because that train is **already made up**, so every re-order on offer would break it — most often by
|
||||||
|
moving the caboose off the rear, which §8.2 will not let a train depart with. If the train is *not*
|
||||||
|
currently fit to run, at least one option will be marked "MADE UP, ready to leave".
|
||||||
@@ -69,7 +69,7 @@ card prints are what it costs to cross. Where a train *enters* is what the rules
|
|||||||
train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than
|
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.
|
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 | Sorts cars |
|
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |
|
||||||
| --- | ---: | ---: | --- | :---: | :---: |
|
| --- | ---: | ---: | --- | :---: | :---: |
|
||||||
| Plains | 1 | 0 | — | — | — |
|
| Plains | 1 | 0 | — | — | — |
|
||||||
| Curves | 2 | 0 | — | — | — |
|
| Curves | 2 | 0 | — | — | — |
|
||||||
@@ -92,7 +92,7 @@ the Division and are not dealt. What each card does, in the words the game uses
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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. · Cars may be sorted into any new order here.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -193,9 +193,9 @@ cannot carry this column, which is the argument for generating the page rather t
|
|||||||
| --- | --- | --- | :---: |
|
| --- | --- | --- | :---: |
|
||||||
| Interlocking | runningTrackStraight | — | **live** |
|
| Interlocking | runningTrackStraight | — | **live** |
|
||||||
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
|
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
|
||||||
| Yard office | secondaryTrackStraight | — | **live** |
|
| Yard Office | secondaryTrackStraight | — | **live** |
|
||||||
| Small yard | secondaryTrackStraight | — | **live** |
|
| Small Yard | secondaryTrackStraight | — | **live** |
|
||||||
| Water column | runningTrackStraight | — | **dormantSolo** |
|
| Water Column | runningTrackStraight | — | **dormantSolo** |
|
||||||
| Overpass | onCard | — | **unbuilt** |
|
| Overpass | onCard | — | **unbuilt** |
|
||||||
| Telegraph | runningTrackStraight | — | **live** |
|
| Telegraph | runningTrackStraight | — | **live** |
|
||||||
| Telephone | onCard | telegraph on the same card | **live** |
|
| Telephone | onCard | telegraph on the same card | **live** |
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ read "Both" for both of them, and that was the *other half* of the same mistaken
|
|||||||
House" named those two, §9.3 had to be describing them, so they had to be two-way. Once the Freight
|
House" named those two, §9.3 had to be describing them, so they had to be two-way. Once the Freight
|
||||||
House is its own card the argument evaporates, and playtesting settled it — "Grocer's Warehouse
|
House is its own card the argument evaporates, and playtesting settled it — "Grocer's Warehouse
|
||||||
should be receive only, does not ship anything out"; "Refinery: only ships out tanks, does not
|
should be receive only, does not ship anything out"; "Refinery: only ships out tanks, does not
|
||||||
receive anything" (Jesse). `StationMaster-Home-Deck-v0.4.5.md` prints both that way, and the modifier
|
receive anything" (Jesse). `home-deck.md` prints both that way, and the modifier
|
||||||
set agrees: all three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1
|
set agrees: all three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1
|
||||||
outbound**, which would be an odd card set for a facility that receives half the time.
|
outbound**, which would be an odd card set for a facility that receives half the time.
|
||||||
<!-- TODO v0.5.0: Mine Tipple, Produce Shed and Power Plant above (3/3/4, 2/2/3, 3/3/4) were NOT
|
<!-- TODO v0.5.0: Mine Tipple, Produce Shed and Power Plant above (3/3/4, 2/2/3, 3/3/4) were NOT
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ asked a third time:
|
|||||||
rising to 50% at four players. A pre-game interrupt for a rule four games in five never see.
|
rising to 50% at four players. A pre-game interrupt for a rule four games in five never see.
|
||||||
|
|
||||||
The docs were the actual defect. `README.md` still listed it among three open rules questions (all
|
The docs were the actual defect. `README.md` still listed it among three open rules questions (all
|
||||||
three closed in v0.5.0) and `StationMaster-Mainline-Deck-v0.4.5.md` still said "the implementation
|
three closed in v0.5.0) and `mainline-deck.md` still said "the implementation
|
||||||
needs a player-selection step to match the card". Both now say settled, and why.
|
needs a player-selection step to match the card". Both now say settled, and why.
|
||||||
|
|
||||||
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
|
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
|
||||||
@@ -555,7 +555,7 @@ Hotel) are what grow them.
|
|||||||
now carries **2** coaches and The Sparrow **3**. The table above is left as `Trains3.pdf` prints it,
|
now carries **2** coaches and The Sparrow **3**. The table above is left as `Trains3.pdf` prints it,
|
||||||
because that is what this section is for — what the design SAYS. What the game plays is
|
because that is what this section is for — what the design SAYS. What the game plays is
|
||||||
`src/engine/content.ts`, with the per-card table in
|
`src/engine/content.ts`, with the per-card table in
|
||||||
[`../StationMaster-Home-Deck-v0.4.5.md`](../StationMaster-Home-Deck-v0.4.5.md).
|
[`../home-deck.md`](../home-deck.md).
|
||||||
|
|
||||||
### Extras (X13–X22) — ten distinct trains, not four generic ones
|
### Extras (X13–X22) — ten distinct trains, not four generic ones
|
||||||
|
|
||||||
@@ -910,7 +910,7 @@ any setting** — being a place an Extra can start is part of what upgrading buy
|
|||||||
- **The Mainline cards were rolled, not dealt.** `buildDivision` drew uniformly from the nine card
|
- **The Mainline cards were rolled, not dealt.** `buildDivision` drew uniformly from the nine card
|
||||||
TYPES **with replacement**, so a Division could be dealt two Interchanges or two Tunnels, and
|
TYPES **with replacement**, so a Division could be dealt two Interchanges or two Tunnels, and
|
||||||
Plains — printed twice in the deck — carried the same weight as cards printed once.
|
Plains — printed twice in the deck — carried the same weight as cards printed once.
|
||||||
`docs/StationMaster-Mainline-Deck-v0.4.5.md` had already flagged the mismatch as needing
|
`docs/mainline-deck.md` had already flagged the mismatch as needing
|
||||||
correction; "an Extra may start at the Interchange if one is on the board" is what forced it, since
|
correction; "an Extra may start at the Interchange if one is on the board" is what forced it, since
|
||||||
that only reads as a rule if the board holds at most one. Now dealt from the printed ten-card deck
|
that only reads as a rule if the board holds at most one. Now dealt from the printed ten-card deck
|
||||||
without replacement.
|
without replacement.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.8.0.12",
|
"version": "0.8.0.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ w('card prints are what it costs to cross. Where a train *enters* is what the ru
|
|||||||
w('train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than');
|
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('part of the road all change the entry point rather than the card\'s length.');
|
||||||
w();
|
w();
|
||||||
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |');
|
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) {
|
||||||
const ss = m.speedStarts ? `${m.speedStarts.fast} / ${m.speedStarts.slow}` : '—';
|
const ss = m.speedStarts ? `${m.speedStarts.fast} / ${m.speedStarts.slow}` : '—';
|
||||||
|
|||||||
@@ -188,6 +188,56 @@ if (existsSync(imageSrc)) {
|
|||||||
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
|
for (const f of readdirSync(imageSrc)) copyFileSync(join(imageSrc, f), join(imageOut, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player-facing documentation, published beside the game so a tester can reach it from the box.
|
||||||
|
*
|
||||||
|
* COPIED, NOT RE-WRITTEN. The Markdown in `docs/` is the one copy; a hand-written HTML twin would
|
||||||
|
* drift from it on the first edit, which is the whole lesson of TODO #15a and of the 2026-09-20
|
||||||
|
* 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
|
||||||
|
* §8 "Where to read more" links five further documents by relative path — so every one of them
|
||||||
|
* 404'd on the package (verified on the box: 5 of 6 paths missing). Publishing the guide without
|
||||||
|
* 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
|
||||||
|
* links do not click. Rendering them into styled pages needs a small Markdown converter and is
|
||||||
|
* 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
|
||||||
|
* current with every release rather than published as editions, so `docs/rules.md` is served as
|
||||||
|
* `rules.md` and the splash page and the This Game card link it by that name. Four of them were
|
||||||
|
* `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[] = [
|
||||||
|
'quickstart.md',
|
||||||
|
'rules.md',
|
||||||
|
'home-deck.md',
|
||||||
|
'mainline-deck.md',
|
||||||
|
'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
|
||||||
|
// because that is the path the Quickstart links it by.
|
||||||
|
'rules/as-built.md',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const rel of GUIDE_DOCS) {
|
||||||
|
const src = join(root, 'docs', rel);
|
||||||
|
if (!existsSync(src)) {
|
||||||
|
// Loud rather than silent: a missing document is a broken link on a page already published, and
|
||||||
|
// the build is the only place that can still notice.
|
||||||
|
console.error(`WARNING: docs/${rel} is missing — a published link will 404`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const out = join(dist, rel);
|
||||||
|
mkdirSync(dirname(out), { recursive: true });
|
||||||
|
copyFileSync(src, out);
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(dist, 'README.txt'),
|
join(dist, 'README.txt'),
|
||||||
|
|||||||
+50
-3
@@ -31,12 +31,13 @@ import {
|
|||||||
houseRules,
|
houseRules,
|
||||||
officeProfile,
|
officeProfile,
|
||||||
mainlineProfile,
|
mainlineProfile,
|
||||||
|
consistSize,
|
||||||
} from './content.ts';
|
} from './content.ts';
|
||||||
import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
|
import type { CarType, Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||||
import type { GameEvent } from './events.ts';
|
import type { GameEvent } from './events.ts';
|
||||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||||
// the legality test cannot disagree about which train is being assembled.
|
// the legality test cannot disagree about which train is being assembled.
|
||||||
import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts';
|
import { acceptsCar, areaAtSeat, areaOf, isBeingMadeUp, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||||
import { legalActions } from './legal.ts';
|
import { legalActions } from './legal.ts';
|
||||||
import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||||
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts';
|
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||||
@@ -393,6 +394,45 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
return { events, needsInput: true };
|
return { events, needsInput: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAY SO WHEN A TRAIN GOT NOTHING, before the round is over and the train runs (playtest,
|
||||||
|
* 2026-09-16: "train 5, the sparrow, has no coaches, which seems strange").
|
||||||
|
*
|
||||||
|
* `trainNeedingCars` returns null both when every consist is full and when the Division Yard holds
|
||||||
|
* nothing a short train will take — the same answer for "done" and for "cannot be done" — so the
|
||||||
|
* phase moved on in silence and the only trace was a MADE UP line promising "now taking cars". The
|
||||||
|
* Sparrow calls for three coaches and left empty twice in one game.
|
||||||
|
*
|
||||||
|
* REPORTED HERE RATHER THAN AT THE MADE-UP MOMENT, because a train made up early in the round can
|
||||||
|
* still be filled by a later placement; only once the round has nothing left to offer is the
|
||||||
|
* shortfall a fact. This is reached exactly once per Stage — the next line enters the Mainline
|
||||||
|
* Phase — so the report cannot repeat.
|
||||||
|
*/
|
||||||
|
for (const tray of s.trays.values()) {
|
||||||
|
if (!isBeingMadeUp(tray) || tray.trainNumber === null) continue;
|
||||||
|
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||||
|
if (!profile) continue;
|
||||||
|
const category = (t: CarType): 'freight' | 'coach' | 'caboose' =>
|
||||||
|
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||||||
|
// What the card still wants: asked of `acceptsCar` per category, so a full category and a
|
||||||
|
// category barred by the card's own rules answer the same way here as they do to a player.
|
||||||
|
const missing = (['freight', 'coach', 'caboose'] as const).filter((cat) => {
|
||||||
|
const sample: CarType = cat === 'coach' ? 'coach' : cat === 'caboose' ? 'caboose' : 'boxcar';
|
||||||
|
return acceptsCar(tray, sample);
|
||||||
|
});
|
||||||
|
if (missing.length === 0) continue;
|
||||||
|
events.push({
|
||||||
|
type: 'makeUpShort',
|
||||||
|
trainNumber: tray.trainNumber,
|
||||||
|
isExtra: tray.trainIsExtra,
|
||||||
|
placed: tray.consist.length,
|
||||||
|
wanted: consistSize(profile.consist),
|
||||||
|
missing: [...missing],
|
||||||
|
waiting: s.yards.classificationYard.filter((c) => missing.includes(category(c.type))).length,
|
||||||
|
divisionYardHolds: s.yards.divisionYard.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
|
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,7 +584,7 @@ export function badlyMadeUp(tray: CrewTray): string | null {
|
|||||||
const pulling = tray.engineAt === 0;
|
const pulling = tray.engineAt === 0;
|
||||||
const pushing = tray.engineAt === n;
|
const pushing = tray.engineAt === n;
|
||||||
if (!pulling && !pushing) {
|
if (!pulling && !pushing) {
|
||||||
return `not made up — the engine is buried in the train, ${tray.engineAt} car(s) ahead of it`;
|
return `not made up — the engine is buried in the train, ${tray.engineAt} car${tray.engineAt === 1 ? '' : 's'} ahead of it`;
|
||||||
}
|
}
|
||||||
const caboose = tray.consist.findIndex((c) => c.type === 'caboose');
|
const caboose = tray.consist.findIndex((c) => c.type === 'caboose');
|
||||||
if (caboose === -1) return null;
|
if (caboose === -1) return null;
|
||||||
@@ -1656,6 +1696,13 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
|
||||||
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
|
||||||
events.push({ type: 'actorChanged', player: s.clock.superintendent });
|
events.push({ type: 'actorChanged', player: s.clock.superintendent });
|
||||||
|
/**
|
||||||
|
* SAID OUT LOUD, as well as recorded. `actorChanged` is turn bookkeeping and the log discards it,
|
||||||
|
* so this — the one time in three Stages that it means the Fedora moved — had no line anywhere
|
||||||
|
* (playtest, 2026-09-16). Emitted alongside rather than instead: `actorChanged` still carries the
|
||||||
|
* cursor, and anything reading it keeps working.
|
||||||
|
*/
|
||||||
|
events.push({ type: 'superintendentChanged', player: s.clock.superintendent, stage: s.clock.stage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
|
||||||
|
|||||||
+108
-17
@@ -974,6 +974,16 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
const seen = new Set(i.order);
|
const seen = new Set(i.order);
|
||||||
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
||||||
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
||||||
|
/**
|
||||||
|
* The engine may finish anywhere in the train, including with cars ahead of it (Jesse,
|
||||||
|
* 2026-09-17). `engineAt` indexes the SORTED consist, so `consist.length` is legal and means
|
||||||
|
* the engine on the tail with everything ahead of it — the shoving case a Small Yard exists to
|
||||||
|
* set up. Refused outside that range rather than clamped: a clamp would silently build a
|
||||||
|
* different train from the one the player asked for.
|
||||||
|
*/
|
||||||
|
if (i.engineAt !== undefined && (i.engineAt < 0 || i.engineAt > tray.consist.length)) {
|
||||||
|
return 'CONSIST_ORDER';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1426,17 +1436,30 @@ function checkPlay(
|
|||||||
if (!placement) return 'NO_PLACEMENT';
|
if (!placement) return 'NO_PLACEMENT';
|
||||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||||
/**
|
/**
|
||||||
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
|
* NEITHER ON THE RUNNING TRACK ROW NOR OUTSIDE THE LIMITS — Jesse's call, both halves, the
|
||||||
* halves.
|
* second REVERSED on 2026-09-17 after a Day 3 playtest.
|
||||||
*
|
*
|
||||||
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
|
* It used to read the other way: a Modifier is not track (§9), so unlike a siding it could
|
||||||
* standing at the limit has three of its nine spots out there, and refusing them would make
|
* hang outside the Limits, because a Facility standing at the limit has three of its nine
|
||||||
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
|
* spots out there and refusing them would make the card unplayable exactly where the district
|
||||||
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
|
* ends. What that argument missed is what the board then shows — Transmission Lines at (-2,4)
|
||||||
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
|
* with the sign at column 3 — which reads as building outside your own territory, and §8.1 and
|
||||||
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
|
* §10 both reason about what lies inside a player's Limits.
|
||||||
|
*
|
||||||
|
* THE UNPLAYABLE CASE WAS CHECKED ON THE REPORTED MOVE, not assumed away: the Power Plant was
|
||||||
|
* at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were free, legal and inside. Six
|
||||||
|
* of the nine spots survive a Facility at the limit, and the sign moves outward as the Running
|
||||||
|
* Track grows (§2.1, Gap 4a), so the ground arrives with the district.
|
||||||
|
*
|
||||||
|
* The Running Track row stays barred for its own reason: inside the Limits that row is always
|
||||||
|
* full, so it bit only beyond the sign, where a Modifier would block the sign from moving
|
||||||
|
* outward with nothing on screen to warn you. That ground is now out of bounds anyway, which
|
||||||
|
* makes this the narrower rule rather than a redundant one — the row is barred INSIDE the
|
||||||
|
* Limits too, where a square can fall vacant if the main is rebuilt around it.
|
||||||
*/
|
*/
|
||||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||||
|
// §2.1 — a district's cards belong inside its own sign, Modifiers included since 2026-09-17.
|
||||||
|
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
|
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||||
@@ -1680,6 +1703,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
from,
|
from,
|
||||||
to: i.to,
|
to: i.to,
|
||||||
movesRemaining: turnOf(s, player).movesRemaining - 1,
|
movesRemaining: turnOf(s, player).movesRemaining - 1,
|
||||||
|
// "3 of 6" was written with the 6 hardcoded in the narrator, which is wrong on a night
|
||||||
|
// Stage under Reduced Visibility, where a turn gets five. The turn knows; the event carries.
|
||||||
|
movesAllowed: turnOf(s, player).movesAllowed,
|
||||||
/**
|
/**
|
||||||
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
|
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
|
||||||
*
|
*
|
||||||
@@ -1775,15 +1801,45 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
at: here,
|
at: here,
|
||||||
before: tray.consist.map((c) => ({ ...c })),
|
before: tray.consist.map((c) => ({ ...c })),
|
||||||
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
||||||
|
// Absent means the nose, which is what every sort did before 2026-09-17 — so an older save
|
||||||
|
// replays to exactly the train it built.
|
||||||
|
engineAt: i.engineAt ?? 0,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'switch.end':
|
/**
|
||||||
|
* The closing summary comes BEFORE `phaseEnded`, so the history reads as the turn ending rather
|
||||||
|
* than as a postscript to it. Split out of the shared case below for that one line.
|
||||||
|
*/
|
||||||
|
case 'switch.end': {
|
||||||
|
const turn = turnOf(s, player);
|
||||||
|
const ended: GameEvent = {
|
||||||
|
type: 'switchingEnded',
|
||||||
|
player,
|
||||||
|
movesUsed: turn.movesAllowed - turn.movesRemaining,
|
||||||
|
movesAllowed: turn.movesAllowed,
|
||||||
|
...(turn.lastMove ? { lastMove: turn.lastMove } : {}),
|
||||||
|
};
|
||||||
|
return [ended, { type: 'phaseEnded', player, phase: 'localOps' }];
|
||||||
|
}
|
||||||
|
|
||||||
case 'draw.end':
|
case 'draw.end':
|
||||||
case 'freightAgent.end':
|
|
||||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||||
|
|
||||||
|
case 'freightAgent.end':
|
||||||
|
/**
|
||||||
|
* An idle Freight Agent SAYS SO. §6.3 requires no action and choosing to take none is a real
|
||||||
|
* decision — see `freightAgentIdled`. Without the line the log announced the option and then
|
||||||
|
* fell silent, which reads as the game having dropped the turn.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
...(turnOf(s, player).freightAgentUsed
|
||||||
|
? []
|
||||||
|
: [{ type: 'freightAgentIdled', player } as const]),
|
||||||
|
{ type: 'phaseEnded', player, phase: 'localOps' },
|
||||||
|
];
|
||||||
|
|
||||||
case 'draw.fromHomeOffice': {
|
case 'draw.fromHomeOffice': {
|
||||||
const events: GameEvent[] = [
|
const events: GameEvent[] = [
|
||||||
{
|
{
|
||||||
@@ -1956,18 +2012,41 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'newTrain.placeCar':
|
/**
|
||||||
|
* THE TRAIN'S NUMBER RIDES ALONG (playtest, 2026-09-16: "I did not see anything in the history
|
||||||
|
* about making up train 10 and how each person added each car to it").
|
||||||
|
*
|
||||||
|
* It was all there — a MADE UP line and one line per car — but every one of those lines read
|
||||||
|
* "the train being made up", so a player scanning the history for train 10 found nothing under
|
||||||
|
* that name. The tray id is no use to a reader and the narrator has no state to look it up in,
|
||||||
|
* so the number travels with the event, exactly as `owner` does on `trainArrived`.
|
||||||
|
*/
|
||||||
|
case 'newTrain.placeCar': {
|
||||||
|
const placeTray = s.trays.get(i.trayId);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: 'carPlacedOnTrain',
|
type: 'carPlacedOnTrain',
|
||||||
player,
|
player,
|
||||||
trayId: i.trayId,
|
trayId: i.trayId,
|
||||||
stock: { type: i.carType, loaded: i.loaded },
|
stock: { type: i.carType, loaded: i.loaded },
|
||||||
|
trainNumber: placeTray?.trainNumber ?? null,
|
||||||
|
isExtra: placeTray?.trainIsExtra ?? false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
case 'newTrain.passCar':
|
case 'newTrain.passCar': {
|
||||||
return [{ type: 'carPassed', player, trayId: i.trayId }];
|
const passTray = s.trays.get(i.trayId);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'carPassed',
|
||||||
|
player,
|
||||||
|
trayId: i.trayId,
|
||||||
|
trainNumber: passTray?.trainNumber ?? null,
|
||||||
|
isExtra: passTray?.trainIsExtra ?? false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
case 'newTrain.secondSection':
|
case 'newTrain.secondSection':
|
||||||
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
|
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
|
||||||
@@ -2141,7 +2220,10 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
}
|
}
|
||||||
// Only the player sitting in this district can be switching this tray, so the Moves come off
|
// Only the player sitting in this district can be switching this tray, so the Moves come off
|
||||||
// their turn. The event carries no player of its own.
|
// their turn. The event carries no player of its own.
|
||||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
const mover = turnOf(s, playerAtSeat(s, seat));
|
||||||
|
mover.movesRemaining = e.movesRemaining;
|
||||||
|
// Where the crew was left, for the line that closes the turn — see `switchingEnded`.
|
||||||
|
mover.lastMove = { trayId: e.trayId, to: e.to };
|
||||||
|
|
||||||
const area = areaAtSeat(s, seat);
|
const area = areaAtSeat(s, seat);
|
||||||
|
|
||||||
@@ -2219,9 +2301,18 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
case 'consistSorted': {
|
case 'consistSorted': {
|
||||||
const tray = s.trays.get(e.trayId)!;
|
const tray = s.trays.get(e.trayId)!;
|
||||||
tray.consist = e.after.map((c) => ({ ...c }));
|
tray.consist = e.after.map((c) => ({ ...c }));
|
||||||
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
|
/**
|
||||||
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
|
* WHERE THE SORT PUT THE ENGINE — 0 on every sort before 2026-09-17, and on most of them
|
||||||
tray.engineAt = 0;
|
* since, because putting the engine back on the nose is what a Small Yard is usually for:
|
||||||
|
* §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||||
|
*
|
||||||
|
* It is no longer forced. The design source (`implications.md`) has always said a train here
|
||||||
|
* "may sort itself into any order, INCLUDING cars ahead of the engine", against a v0.4.5 card
|
||||||
|
* text that says the engine ends at the nose; Jesse settled it for the source. A numbered
|
||||||
|
* train left nose-loaded is held at the Office by §8.2 until it sorts again — see
|
||||||
|
* `departureRefusal`.
|
||||||
|
*/
|
||||||
|
tray.engineAt = e.engineAt;
|
||||||
// "Spends one move in the yard" — the sort costs a Move.
|
// "Spends one move in the yard" — the sort costs a Move.
|
||||||
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
|
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
|
||||||
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
|
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
|
||||||
|
|||||||
+50
-8
@@ -289,7 +289,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
|||||||
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
|
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
|
||||||
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
|
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
|
||||||
* the game with no way to raise the direction it is supposed to use half its capacity on.
|
* the game with no way to raise the direction it is supposed to use half its capacity on.
|
||||||
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
|
* `home-deck.md` prints it "Outbound, 1 out / 0 in".
|
||||||
*/
|
*/
|
||||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
|
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
|
||||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
|
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
|
||||||
@@ -297,7 +297,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
|||||||
/**
|
/**
|
||||||
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
|
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
|
||||||
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
|
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
|
||||||
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
|
* not ship anything out". `home-deck.md` prints it "Inbound, 0 out / 1 in".
|
||||||
*
|
*
|
||||||
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
|
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
|
||||||
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
|
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
|
||||||
@@ -589,7 +589,20 @@ export type MainlineProfile = {
|
|||||||
speedStarts?: { fast: number; slow: number };
|
speedStarts?: { fast: number; slow: number };
|
||||||
/** Double Track: "Trains may pass". */
|
/** Double Track: "Trains may pass". */
|
||||||
trainsMayPass: boolean;
|
trainsMayPass: boolean;
|
||||||
/** Interchange: "Sort cars in new order". */
|
/**
|
||||||
|
* Interchange only. The card prints "Sort cars in new order" — **and that is not what this flag
|
||||||
|
* does**, which is why it is worth spelling out where the field is declared.
|
||||||
|
*
|
||||||
|
* The printed sorting has never been implemented: nothing reads this to permit a sort, and a
|
||||||
|
* consist is re-ordered at a Small Yard in a district (`switch.sortConsist`). What this actually
|
||||||
|
* marks is the one Mainline card with a Yard Limit, and therefore the one an Extra may be made up
|
||||||
|
* and started on (`apply.ts` § resolveExtraStart, `legal.ts`).
|
||||||
|
*
|
||||||
|
* Named for the printed text, and kept that way deliberately — renaming it would lose the link to
|
||||||
|
* the card face — but the name has already misled once: `mainlineDescription` grew a sentence
|
||||||
|
* telling players cars could be sorted here, which reached the board and the generated card
|
||||||
|
* reference before it was caught on 2026-09-20.
|
||||||
|
*/
|
||||||
sortsCars: boolean;
|
sortsCars: boolean;
|
||||||
/** Named entry points printed on the card; some are unlocked by modifier cards. */
|
/** Named entry points printed on the card; some are unlocked by modifier cards. */
|
||||||
entryPoints: readonly string[];
|
entryPoints: readonly string[];
|
||||||
@@ -655,7 +668,7 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
|
|||||||
* used it as one: `buildDivision` drew uniformly from those types with replacement, which made two
|
* used it as one: `buildDivision` drew uniformly from those types with replacement, which made two
|
||||||
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
|
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
|
||||||
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
|
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
|
||||||
* inventory, transcribed in `docs/StationMaster-Mainline-Deck-v0.4.5.md`, which had already flagged
|
* inventory, transcribed in `docs/mainline-deck.md`, which had already flagged
|
||||||
* the mismatch as needing correction.
|
* the mismatch as needing correction.
|
||||||
*
|
*
|
||||||
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
|
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
|
||||||
@@ -805,7 +818,25 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
|
|||||||
parts.push('One train at a time — anything following has to wait for it to clear.');
|
parts.push('One train at a time — anything following has to wait for it to clear.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p.sortsCars) parts.push('Cars may be sorted into any new order here.');
|
/**
|
||||||
|
* WHAT THE INTERCHANGE ACTUALLY DOES, which is not what it prints.
|
||||||
|
*
|
||||||
|
* This said "Cars may be sorted into any new order here." — the printed capability, shown to
|
||||||
|
* players on the board (`view.ts` renders this as a Mainline card's `what`) and printed in the
|
||||||
|
* generated card reference. It is not implemented and never has been: nothing reads `sortsCars`
|
||||||
|
* to permit a sort. Its one live use is identifying the card an Extra may be made up on, because
|
||||||
|
* the Interchange is the Mainline card with a yard (`apply.ts` § resolveExtraStart).
|
||||||
|
*
|
||||||
|
* Found while bringing the reference documents up to date, 2026-09-20. A card that advertises an
|
||||||
|
* action the game will not offer is worse than one that says nothing — a player goes looking for
|
||||||
|
* a button that does not exist and concludes the game is broken.
|
||||||
|
*/
|
||||||
|
if (p.sortsCars) {
|
||||||
|
parts.push(
|
||||||
|
'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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,14 +1008,25 @@ export function enhancementRule(key: string): EnhancementRule | null {
|
|||||||
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
|
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE NAMES HERE ARE WHAT A PLAYER READS, since v0.8.0.17 — `cardName` takes them in preference to
|
||||||
|
* `prettyKey`'s guess, which cannot recover an acronym (it rendered `absSignals` as "Abs Signals"
|
||||||
|
* beside a tooltip saying ABS).
|
||||||
|
*
|
||||||
|
* So their CASE matters, and three were transcribed in sentence case while the rules, the panels
|
||||||
|
* and every other line of this repository treat them as proper terms: "Yard Office" outnumbered
|
||||||
|
* "Yard office" 36 to 2, "Small Yard" 36 to 2, "Water Column" 10 to 2. Corrected here rather than
|
||||||
|
* special-cased in `cardName`, because this table is the source and a lookup that second-guesses
|
||||||
|
* its own source is the drift it exists to prevent.
|
||||||
|
*/
|
||||||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||||||
{ key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
{ key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||||
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an
|
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an
|
||||||
// Event held out until built, so at zero it defends against nothing that can be dealt anyway.
|
// Event held out until built, so at zero it defends against nothing that can be dealt anyway.
|
||||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
|
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
|
||||||
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
|
{ key: 'yardOffice', name: 'Yard Office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
|
||||||
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
|
{ key: 'smallYard', name: 'Small Yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
|
||||||
{ key: 'waterColumn', name: 'Water column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
|
{ key: 'waterColumn', name: 'Water Column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
|
||||||
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
|
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
|
||||||
/**
|
/**
|
||||||
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
|
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
|
||||||
|
|||||||
+84
-4
@@ -28,6 +28,15 @@ export type GameEvent =
|
|||||||
| { type: 'stageBegan'; day: number; stage: number }
|
| { type: 'stageBegan'; day: number; stage: number }
|
||||||
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
/** Employee Rotation (Appendix B) — every player has moved one chair left for the new Day. */
|
||||||
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
| { type: 'seatsRotated'; day: number; seating: PlayerIndex[] }
|
||||||
|
/**
|
||||||
|
* §5 — the Fedora passed, at the end of Stage 3, 6, 9 or 12.
|
||||||
|
*
|
||||||
|
* ITS OWN EVENT RATHER THAN THE `actorChanged` THIS USED TO RIDE ON. That one is turn bookkeeping,
|
||||||
|
* fired every time the cursor moves, and `record()` drops it on the floor as noise — so the one
|
||||||
|
* moment it carried that a player actually needed to see went past in silence. Reported from the
|
||||||
|
* table (2026-09-16): the Supervisor Shift appears in the history and the handover never does.
|
||||||
|
*/
|
||||||
|
| { type: 'superintendentChanged'; player: PlayerIndex; stage: number }
|
||||||
| { type: 'phaseBegan'; phase: string }
|
| { type: 'phaseBegan'; phase: string }
|
||||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||||
// -- local operations
|
// -- local operations
|
||||||
@@ -37,7 +46,7 @@ export type GameEvent =
|
|||||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||||
* choice the player made invisible in their own log.
|
* choice the player made invisible in their own log.
|
||||||
*/
|
*/
|
||||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; movesAllowed: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||||
| {
|
| {
|
||||||
type: 'carsCoupled';
|
type: 'carsCoupled';
|
||||||
player: PlayerIndex;
|
player: PlayerIndex;
|
||||||
@@ -73,7 +82,50 @@ export type GameEvent =
|
|||||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||||
}
|
}
|
||||||
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||||
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
| {
|
||||||
|
type: 'consistSorted';
|
||||||
|
player: PlayerIndex;
|
||||||
|
trayId: TrayId;
|
||||||
|
at: GridCoord;
|
||||||
|
before: RollingStock[];
|
||||||
|
after: RollingStock[];
|
||||||
|
/**
|
||||||
|
* Where the engine ends up in `after`, counted as an index into it — 0 is the nose.
|
||||||
|
*
|
||||||
|
* The Small Yard used to put the engine back on the front unconditionally, which is what the
|
||||||
|
* v0.4.5 card text says ("reorder its entire consist and put the engine at the nose").
|
||||||
|
* `implications.md` records the design source saying the opposite — "may sort itself into any
|
||||||
|
* order, INCLUDING cars ahead of the engine" — and Jesse settled it that way on 2026-09-17.
|
||||||
|
*/
|
||||||
|
engineAt: number;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A player finished their switching turn: what it cost, and where the crew was left.
|
||||||
|
*
|
||||||
|
* The history panel keeps a switching turn's FIRST move and drops the ones in the middle, so the
|
||||||
|
* closing line is where "and it ended up here" has to come from. It cannot be recovered by
|
||||||
|
* revealing the last `trayMoved` after the fact: the log streams to clients as it is written
|
||||||
|
* (`server/session.ts` § linesSince), and nobody knows a move was the last one until the turn is
|
||||||
|
* already over and that line has been sent.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
type: 'switchingEnded';
|
||||||
|
player: PlayerIndex;
|
||||||
|
movesUsed: number;
|
||||||
|
movesAllowed: number;
|
||||||
|
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* THE FREIGHT AGENT WAS CHOSEN AND DID NOTHING, which is a decision rather than an absence.
|
||||||
|
*
|
||||||
|
* §6.3 requires no action, and the bot deliberately takes that route — unjamming a healthy box
|
||||||
|
* destroys a load that cost a whole Local Operations action to stock, so an idle Stage is
|
||||||
|
* strictly better. Reported from a table on Day 1 Stage 3 of v0.8.0.16: the log announced
|
||||||
|
* FREIGHT AGENT work and then said nothing at all, so the turn read as a bug.
|
||||||
|
*
|
||||||
|
* Reduces to nothing, like `switchingEnded` above: it reports a choice the state already holds.
|
||||||
|
*/
|
||||||
|
| { type: 'freightAgentIdled'; player: PlayerIndex }
|
||||||
| { 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
|
||||||
@@ -186,8 +238,36 @@ export type GameEvent =
|
|||||||
at: ExtraStart;
|
at: ExtraStart;
|
||||||
direction: Direction;
|
direction: Direction;
|
||||||
}
|
}
|
||||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock; trainNumber: number | null; isExtra: boolean }
|
||||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId; trainNumber: number | null; isExtra: boolean }
|
||||||
|
/**
|
||||||
|
* A train was made up and the round could give it NOTHING THE CARD CALLS FOR — the Division Yard
|
||||||
|
* holds no car of a category it still wants (§7, §8.2 "may depart with fewer").
|
||||||
|
*
|
||||||
|
* ITS OWN EVENT BECAUSE THE SILENCE WAS THE BUG (playtest, 2026-09-16: "train 5, the sparrow, has
|
||||||
|
* no coaches, which seems strange"). `trainNeedingCars` returns null in exactly this case, so the
|
||||||
|
* phase never stops, nobody is asked for a car, and the only trace was a MADE UP line promising
|
||||||
|
* "now taking cars" with nothing after it. The train then ran the whole Division empty.
|
||||||
|
*
|
||||||
|
* CARRIES WHY, not just that. The shortage is a standing condition rather than a moment — §2.2
|
||||||
|
* returns the Classification Yard only when the Division Yard runs bare — so the counts that
|
||||||
|
* explain it have to travel with the event: what is still wanted, how many such cars are waiting
|
||||||
|
* in Classification, and how far the Division Yard is from empty.
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
type: 'makeUpShort';
|
||||||
|
trainNumber: number;
|
||||||
|
isExtra: boolean;
|
||||||
|
/** How many cars it got, out of what the card calls for. */
|
||||||
|
placed: number;
|
||||||
|
wanted: number;
|
||||||
|
/** The categories the card still wants and the Division Yard cannot supply. */
|
||||||
|
missing: ('freight' | 'coach' | 'caboose')[];
|
||||||
|
/** Cars of those categories sitting in the Classification Yard. */
|
||||||
|
waiting: number;
|
||||||
|
/** §2.2 — Classification comes back only when this reaches zero. */
|
||||||
|
divisionYardHolds: number;
|
||||||
|
}
|
||||||
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||||
|
|||||||
+13
-1
@@ -47,7 +47,19 @@ export type Intent =
|
|||||||
* order, including cars in front of the engine". This is the designed answer to §A.3's
|
* order, including cars in front of the engine". This is the designed answer to §A.3's
|
||||||
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
|
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
|
||||||
*/
|
*/
|
||||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
|
/**
|
||||||
|
* §Enhancements, Small Yard — one Move to re-make a train standing on the yard.
|
||||||
|
*
|
||||||
|
* `order` is a permutation of the current consist, nose first. `engineAt` is where the LOCOMOTIVE
|
||||||
|
* ends up in it: 0 puts it back on the front, which is what the v0.4.5 card text describes and
|
||||||
|
* what this action did unconditionally until 2026-09-17. `implications.md` records the design
|
||||||
|
* source saying a train here "may sort itself into any order, including cars ahead of the engine",
|
||||||
|
* and Jesse ruled that way — so it is a number now, and a train left nose-loaded is one §8.2 will
|
||||||
|
* not let out of the Office until it is sorted again.
|
||||||
|
*
|
||||||
|
* Optional, defaulting to 0, so every save written before this replays exactly as it did.
|
||||||
|
*/
|
||||||
|
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[]; engineAt?: number }
|
||||||
| { type: 'switch.end' }
|
| { type: 'switch.end' }
|
||||||
// -- draw (§6.2)
|
// -- draw (§6.2)
|
||||||
| { type: 'draw.fromHomeOffice' }
|
| { type: 'draw.fromHomeOffice' }
|
||||||
|
|||||||
+68
-6
@@ -91,17 +91,79 @@ function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||||
}
|
}
|
||||||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
/**
|
||||||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
* Small Yard: enumerating every permutation would explode, so offer the useful ones — bringing
|
||||||
// so a UI may submit an arbitrary permutation.
|
* each car to the droppable end, plus a full reversal. `check` validates any order, so a UI may
|
||||||
|
* submit an arbitrary permutation.
|
||||||
|
*
|
||||||
|
* NOTHING THAT RE-ORDERS NOTHING. Bringing the LAST car to the end is the identity, and a
|
||||||
|
* two-car train's reversal repeats its only real option — so the menu carried a move that spent
|
||||||
|
* one of six Moves to leave the train exactly as it was, beside a duplicate of the move next to
|
||||||
|
* it. Both were invisible while the labels were index lists (playtest, 2026-09-17); both are
|
||||||
|
* plainly wrong once the label reads as a train. Filtered by the ORDER rather than by the case
|
||||||
|
* that produced it, so a new generator cannot reintroduce either.
|
||||||
|
*/
|
||||||
const n = tray.consist.length;
|
const n = tray.consist.length;
|
||||||
|
const identity = [...Array(n).keys()];
|
||||||
if (n > 1) {
|
if (n > 1) {
|
||||||
|
const orders: number[][] = [];
|
||||||
for (let k = 0; k < n; k++) {
|
for (let k = 0; k < n; k++) {
|
||||||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
const order = identity.filter((x) => x !== k);
|
||||||
order.push(k);
|
order.push(k);
|
||||||
out.push({ type: 'switch.sortConsist', trayId, order });
|
orders.push(order);
|
||||||
|
}
|
||||||
|
orders.push([...identity].reverse());
|
||||||
|
// Nothing that re-orders nothing: the current train is the one thing on offer that costs a
|
||||||
|
// Move and changes the board not at all. Keyed by (order, engine position) together, since
|
||||||
|
// since 2026-09-17 the same car order at a different engine position is a different train.
|
||||||
|
const seen = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||||
|
const offer = (order: number[], engineAt: number): void => {
|
||||||
|
const key = `${order.join(',')}|${engineAt}`;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ type: 'switch.sortConsist', trayId, order, engineAt });
|
||||||
|
};
|
||||||
|
// The car orders, each leaving the engine on the nose — the Small Yard's ordinary use.
|
||||||
|
for (const order of orders) offer(order, 0);
|
||||||
|
/**
|
||||||
|
* A MADE-UP ORDER IS ALWAYS AMONG THESE, which is worth saying because it looks as though it
|
||||||
|
* might not be (Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the
|
||||||
|
* back").
|
||||||
|
*
|
||||||
|
* A yard sort serves two errands — pulling one car out to an end so it can be spotted, and
|
||||||
|
* putting the train back together to leave — and the orders above are written for the first.
|
||||||
|
* They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
|
||||||
|
* so bringing the CABOOSE to the tail is always one of them, and with the engine on the nose
|
||||||
|
* that is a train §8.2 will let out of the Office.
|
||||||
|
*
|
||||||
|
* An explicit "make it up to leave" option was written here and deleted: it produced exactly
|
||||||
|
* the k-is-the-caboose order and was dropped by the dedupe every time. The one case where no
|
||||||
|
* made-up order appears is a train that is ALREADY made up, where such an option would be the
|
||||||
|
* identity — and the labels say which is which, so a player can see that every offer would
|
||||||
|
* break a train that is currently fit to run.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* WHERE THE ENGINE GOES, as its own short list rather than multiplied through the one above
|
||||||
|
* (Jesse's call, 2026-09-17: "a separate engine control").
|
||||||
|
*
|
||||||
|
* Offering every car order at every engine position is the honest enumeration and it is
|
||||||
|
* unreadable: a four-car consist would go from four options to twenty, which is the labelling
|
||||||
|
* problem that prompted all of this. So the engine positions are offered against the consist AS
|
||||||
|
* IT STANDS — pick an order, or pick where the engine sits, each one Move. A player who wants
|
||||||
|
* both spends two, which is the same price the yard charges for any second sort.
|
||||||
|
*
|
||||||
|
* OFFERED FOR A ONE-CAR TRAIN TOO, unlike the car orders: a single car ahead of the engine or
|
||||||
|
* behind it is exactly the difference between shoving it into a facing industry and pulling it.
|
||||||
|
*/
|
||||||
|
if (n >= 1) {
|
||||||
|
const seenEngine = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||||
|
for (let k = 0; k <= n; k++) {
|
||||||
|
const key = `${identity.join(',')}|${k}`;
|
||||||
|
if (seenEngine.has(key)) continue;
|
||||||
|
seenEngine.add(key);
|
||||||
|
out.push({ type: 'switch.sortConsist', trayId, order: identity, engineAt: k });
|
||||||
}
|
}
|
||||||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||||
|
|||||||
+1
-1
@@ -230,7 +230,7 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
|||||||
* `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so
|
* `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so
|
||||||
* a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the
|
* a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the
|
||||||
* deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory
|
* deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory
|
||||||
* (`docs/StationMaster-Mainline-Deck-v0.4.5.md`, which flagged this as needing correction), and the
|
* (`docs/mainline-deck.md`, which flagged this as needing correction), and the
|
||||||
* deal is now a deal: take cards out of it and do not put them back.
|
* deal is now a deal: take cards out of it and do not put them back.
|
||||||
*
|
*
|
||||||
* The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is
|
* The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is
|
||||||
|
|||||||
@@ -875,6 +875,25 @@ export type FinalReport = {
|
|||||||
export type TurnState = {
|
export type TurnState = {
|
||||||
option: 'switch' | 'draw' | 'freightAgent' | null;
|
option: 'switch' | 'draw' | 'freightAgent' | null;
|
||||||
movesRemaining: number;
|
movesRemaining: number;
|
||||||
|
/**
|
||||||
|
* What `movesRemaining` started at this Stage — six, or five under Reduced Visibility at night.
|
||||||
|
*
|
||||||
|
* CARRIED RATHER THAN ASSUMED. Every reader of `movesRemaining` that wanted to say "3 of 6" had
|
||||||
|
* hardcoded the 6, which is simply wrong on a night Stage, and the only other way to recover it is
|
||||||
|
* to re-derive `movesForStage` outside the phase driver that owns it. It also makes "is this the
|
||||||
|
* FIRST move of the turn?" a comparison rather than a guess, which is what the history panel needs
|
||||||
|
* to keep the opening move of a switching turn and drop the ones in the middle.
|
||||||
|
*/
|
||||||
|
movesAllowed: number;
|
||||||
|
/**
|
||||||
|
* The last square this player's crew moved to this Stage, and which crew it was.
|
||||||
|
*
|
||||||
|
* Switching ends with a summary line, and "where did the train end up" is the half of it a player
|
||||||
|
* actually wants. It cannot be recovered from the log: the line naming the last move is written
|
||||||
|
* before anyone knows it was the last, and the log streams to clients as it is written, so a line
|
||||||
|
* already sent cannot be revised afterwards.
|
||||||
|
*/
|
||||||
|
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||||
drawnThisTurn: boolean;
|
drawnThisTurn: boolean;
|
||||||
freightAgentUsed: boolean;
|
freightAgentUsed: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -1057,6 +1076,7 @@ export function freshTurn(moves: number): TurnState {
|
|||||||
return {
|
return {
|
||||||
option: null,
|
option: null,
|
||||||
movesRemaining: moves,
|
movesRemaining: moves,
|
||||||
|
movesAllowed: moves,
|
||||||
drawnThisTurn: false,
|
drawnThisTurn: false,
|
||||||
freightAgentUsed: false,
|
freightAgentUsed: false,
|
||||||
freightWorked: {},
|
freightWorked: {},
|
||||||
|
|||||||
+13
-4
@@ -684,10 +684,19 @@ export function carriesThroughTrack(card: TrackCard): boolean {
|
|||||||
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
|
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
|
||||||
* neighbourhood, are usable from the first Stage.
|
* neighbourhood, are usable from the first Stage.
|
||||||
*
|
*
|
||||||
* MODIFIERS ARE NOT SUBJECT TO THIS, and are not track: §9 places one on any of the nine spots
|
* MODIFIERS ARE SUBJECT TO THIS TOO, since 2026-09-17 — REVERSING an earlier call of Jesse's that
|
||||||
* around a Facility, and a Facility standing at the limit has three of its nine outside them.
|
* exempted them. The exemption reasoned that §9 places a Modifier on any of the nine spots around a
|
||||||
* Jesse's call. `check` bars them from the Running Track ROW instead, which is the ground the main
|
* Facility, so a Facility standing at the limit has three of its nine outside them and bounding the
|
||||||
* grows onto.
|
* card would make it unplayable exactly where a district ends. Play showed the cost of that the
|
||||||
|
* other way round: a Transmission Lines card went down at (-2,4) with the sign at column 3, which
|
||||||
|
* reads at the table as building outside your own territory, and §8.1 and §10 both reason about
|
||||||
|
* what is inside a player's Limits.
|
||||||
|
*
|
||||||
|
* THE FEARED CASE DID NOT ARISE, and was measured on the move that prompted the change rather than
|
||||||
|
* argued: the Power Plant sat at (-1,3) against a sign at 3, and (-2,2) and (-2,3) were both free,
|
||||||
|
* legal and inside. A Facility at the limit keeps six of its nine spots, and the Limits move outward
|
||||||
|
* as the Running Track grows (§2.1, Gap 4a), so the ground for a Modifier arrives with the district.
|
||||||
|
* `check` bars them from the Running Track ROW as well, which is the ground the main grows onto.
|
||||||
*/
|
*/
|
||||||
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
|
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
|
||||||
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
|
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ const MIME: Record<string, string> = {
|
|||||||
'.json': 'application/json; charset=utf-8',
|
'.json': 'application/json; charset=utf-8',
|
||||||
'.png': 'image/png',
|
'.png': 'image/png',
|
||||||
'.svg': 'image/svg+xml',
|
'.svg': 'image/svg+xml',
|
||||||
|
/**
|
||||||
|
* The Quickstart guide, published by `build-web.ts` as `quickstart.md`.
|
||||||
|
*
|
||||||
|
* text/plain ON PURPOSE. The fallback below is `application/octet-stream`, which makes a browser
|
||||||
|
* DOWNLOAD the file instead of showing it — so without this line the splash page's "read the
|
||||||
|
* guide" link hands a tester a file to save rather than a page to read.
|
||||||
|
*/
|
||||||
|
'.md': 'text/plain; charset=utf-8',
|
||||||
};
|
};
|
||||||
|
|
||||||
const HEARTBEAT_MS = 20_000;
|
const HEARTBEAT_MS = 20_000;
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
||||||
* anything outside its own body.
|
* anything outside its own body.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The Mainline modifiers, as the three letters drawn on a card.
|
||||||
|
*
|
||||||
|
* DECLARED INSIDE THIS FUNCTION, like every constant around it, because `sim/replay.ts` emits
|
||||||
|
* `divisionSvg.toString()` into the replay page — a module-scope const it closed over would be
|
||||||
|
* undefined there, and the page threw exactly that way before this was moved.
|
||||||
|
*
|
||||||
|
* Keyed by the name the VIEW builds (`prettyKey`'d in `view.ts`), because that is what arrives
|
||||||
|
* here — not the engine's key, which this file never sees. A modifier with no tag draws nothing
|
||||||
|
* rather than a raw key: `flatMap` over a missing entry yields none, so a card added upstream is
|
||||||
|
* silently unmarked instead of printing "rotaryDumps" at a player.
|
||||||
|
*
|
||||||
|
* ABS Signals is deliberately absent: it has a signal mast of its own.
|
||||||
|
*/
|
||||||
|
const MOD_TAGS: Record<string, string> = { Brakeman: 'BRK', Airbrakes: 'AIR', Helpers: 'HLP' };
|
||||||
|
|
||||||
const CW = { dp: 118, ml: 152, run: 78 };
|
const CW = { dp: 118, ml: 152, run: 78 };
|
||||||
/**
|
/**
|
||||||
* TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row.
|
* TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row.
|
||||||
@@ -144,6 +160,10 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
tip: string;
|
tip: string;
|
||||||
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
|
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
|
||||||
flash?: boolean;
|
flash?: boolean;
|
||||||
|
/** ABS Signals standing on this Mainline card — drawn as a signal mast, not only described. */
|
||||||
|
abs?: boolean;
|
||||||
|
/** The Mainline modifiers on this card, as short tags — see `MOD_TAGS`. */
|
||||||
|
mods?: string[];
|
||||||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||||||
seat: number | null;
|
seat: number | null;
|
||||||
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||||||
@@ -291,6 +311,10 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
// words with no gameplay attached — reported exactly that way.
|
// words with no gameplay attached — reported exactly that way.
|
||||||
(n.what ? `\n\n${n.what}` : ''),
|
(n.what ? `\n\n${n.what}` : ''),
|
||||||
seat: null,
|
seat: null,
|
||||||
|
// Attachments are drawn, not only described: `modifiers` carries ABS Signals alongside the
|
||||||
|
// Mainline modifiers, and the name is the one the view already built for the tooltip.
|
||||||
|
abs: !dp && n.modifiers.includes('ABS Signals'),
|
||||||
|
mods: dp ? [] : n.modifiers.flatMap((m) => MOD_TAGS[m] ?? []),
|
||||||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||||
regions: dp ? 1 : (n.regions ?? 0),
|
regions: dp ? 1 : (n.regions ?? 0),
|
||||||
gradeUp: dp ? null : (n.gradeUp ?? null),
|
gradeUp: dp ? null : (n.gradeUp ?? null),
|
||||||
@@ -416,6 +440,57 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
out += `</g>`;
|
out += `</g>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ABS SIGNALS, DRAWN RATHER THAN ONLY SAID — reported from a table on Day 1 Stage 1 of
|
||||||
|
* v0.8.0.16: "when played on the trestle, there was no on-the-card indication; it's only when
|
||||||
|
* you look at the tooltip for trestle that you see that ABS exists."
|
||||||
|
*
|
||||||
|
* Exactly the complaint the grade wedge below answers, and the card it protects is the one a
|
||||||
|
* player is deciding whether to run a second train onto. A signal is the literal object — ABS
|
||||||
|
* is Automatic Block Signals — so the mark is a mast with a lit lamp rather than a badge
|
||||||
|
* reading "ABS", and it needs no room for text, which is what lets it sit clear of a name as
|
||||||
|
* long as "Uncontrolled Siding" on a 152px card.
|
||||||
|
*
|
||||||
|
* TOP-RIGHT, above the rail and clear of the chip registers: the flag owns the cell ends at
|
||||||
|
* rail height and the grade wedge owns the bottom-right, so this is the corner left.
|
||||||
|
*/
|
||||||
|
if (c.abs) {
|
||||||
|
const mx = c.x + c.w - 10;
|
||||||
|
const top = c.y + 5;
|
||||||
|
out += `<g class="bs-abs">`;
|
||||||
|
out += `<line class="bs-abs-mast" x1="${mx}" y1="${top}" x2="${mx}" y2="${top + 15}"/>`;
|
||||||
|
// Two lamps, the upper one lit: a signal showing an aspect, not a bare post.
|
||||||
|
out += `<circle class="bs-abs-lit" cx="${mx}" cy="${top + 3}" r="2.6"/>`;
|
||||||
|
out += `<circle class="bs-abs-dark" cx="${mx}" cy="${top + 9}" r="2.6"/>`;
|
||||||
|
out += `</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE MAINLINE MODIFIERS, DRAWN RATHER THAN ONLY SAID (Jesse, 2026-09-21) — the other half of
|
||||||
|
* the ABS report above: "Brakeman, Airbrakes, Helpers and Realignment should also be drawn on
|
||||||
|
* the card, not just the tooltip."
|
||||||
|
*
|
||||||
|
* ONLY THREE OF THOSE FOUR CAN EVER BE HERE. Realignment does not sit on a card — `reduce`
|
||||||
|
* takes the `became` branch and CHANGES `node.card`, so a realigned Trestle simply is an
|
||||||
|
* Uncontrolled Siding afterwards and the card face already says so. The other three are
|
||||||
|
* `gradeOnly`, so in practice this is a Heavy Grade's row.
|
||||||
|
*
|
||||||
|
* TAGS, NOT NAMES, and only because the measurements leave no choice: "Brakeman · Airbrakes ·
|
||||||
|
* Helpers" is 30 characters where about eleven fit beside a card name on a 152px cell. Three
|
||||||
|
* letters is the most that fits while still mapping to one card each, and the tooltip — which
|
||||||
|
* has always named them in full — is what expands it. The mark says THAT there is one, which
|
||||||
|
* is the half that was missing.
|
||||||
|
*
|
||||||
|
* Right-aligned on the name row: the only band on the cell that is clear, with the chip
|
||||||
|
* registers starting at `CHIP_Y` below and the capacity line and grade wedge at the foot.
|
||||||
|
* Shifted left of the signal when a card carries both.
|
||||||
|
*/
|
||||||
|
if (c.mods && c.mods.length > 0) {
|
||||||
|
out +=
|
||||||
|
`<text class="bs-mod" text-anchor="end" x="${c.x + c.w - (c.abs ? 20 : 7)}" y="${c.y + 14}">` +
|
||||||
|
`${esc(c.mods.join('·'))}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
|
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
|
||||||
*
|
*
|
||||||
@@ -1392,6 +1467,12 @@ export const BOARD_CSS = `
|
|||||||
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
||||||
.bs-mod{font:10px ui-monospace,monospace}
|
.bs-mod{font:10px ui-monospace,monospace}
|
||||||
text.bs-mod{fill:#c8a04a}
|
text.bs-mod{fill:#c8a04a}
|
||||||
|
/* A signal standing on a Mainline card carrying ABS Signals. Green is otherwise unused on the
|
||||||
|
Division row apart from the Division Point's dashed border, so a lit lamp does not compete with
|
||||||
|
the amber "it is happening here" or the red flag for meaning. */
|
||||||
|
.bs-abs-mast{stroke:#9aa3b0;stroke-width:1.6}
|
||||||
|
.bs-abs-lit{fill:#4fae6a;stroke:#2c6b40;stroke-width:0.8}
|
||||||
|
.bs-abs-dark{fill:#2a3038;stroke:#59626f;stroke-width:0.8}
|
||||||
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
||||||
.bs-enh-spent{fill:#5b6b7d;text-decoration:line-through}
|
.bs-enh-spent{fill:#5b6b7d;text-decoration:line-through}
|
||||||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||||||
|
|||||||
+214
-17
@@ -20,6 +20,8 @@ import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
|||||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||||
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
||||||
import type { GameEvent } from '../engine/events.ts';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
|
import { badlyMadeUp } from '../engine/advance.ts';
|
||||||
|
import type { CrewTray } from '../engine/state.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Small formatters
|
// Small formatters
|
||||||
@@ -50,12 +52,38 @@ export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
|
|||||||
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
|
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "a loaded boxcar", "an empty tank" — the article the word actually takes.
|
||||||
|
*
|
||||||
|
* The make-up line hard-coded "a" and produced "a empty tank" at the table. Vowel-initial is the
|
||||||
|
* whole rule here: every car word is ordinary English ('empty', 'loaded', and the car types), so
|
||||||
|
* there is no 'an hour' case to special-case and inventing one would be the more fragile choice.
|
||||||
|
*/
|
||||||
|
export function indefinite(label: string): string {
|
||||||
|
return `${/^[aeiou]/i.test(label) ? 'an' : 'a'} ${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Train 10" / "Extra X18" — one spelling of a train's name for every line that mentions one. */
|
||||||
|
export function trainLabel(trainNumber: number | null, isExtra: boolean): string {
|
||||||
|
if (trainNumber === null) return 'the local crew';
|
||||||
|
return isExtra ? `Extra X${trainNumber}` : `Train ${trainNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function carsLabel(cars: RollingStock[]): string {
|
export function carsLabel(cars: RollingStock[]): string {
|
||||||
if (cars.length === 0) return 'nothing';
|
if (cars.length === 0) return 'nothing';
|
||||||
return cars.map(carLabel).join(', ');
|
return cars.map(carLabel).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
const at = (c: GridCoord): string => `(${c.row},${c.col})`;
|
/**
|
||||||
|
* X,Y — EAST/WEST THEN NORTH/SOUTH, exactly as `view.ts` writes it, and NOT the internal row/col
|
||||||
|
* storage order.
|
||||||
|
*
|
||||||
|
* These two disagreed until 2026-09-21: the action menu said "(1,-1)" and the log said "(-1,1)" for
|
||||||
|
* the same square, side by side on the same screen. `view.ts` carried the comment explaining why
|
||||||
|
* the display order is X,Y; this one had no comment at all and was simply the storage order
|
||||||
|
* reaching the page. Jesse's call — the log and the action menu spell a square the same way.
|
||||||
|
*/
|
||||||
|
const at = (c: GridCoord): string => `(${c.col},${c.row})`;
|
||||||
|
|
||||||
const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const;
|
const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const;
|
||||||
const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`;
|
const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`;
|
||||||
@@ -108,11 +136,22 @@ export type NarrateContext = {
|
|||||||
* events has no roster, and "Player 2" is a truthful fallback rather than a broken one.
|
* events has no roster, and "Player 2" is a truthful fallback rather than a broken one.
|
||||||
*/
|
*/
|
||||||
playerName?: (player: PlayerIndex) => string;
|
playerName?: (player: PlayerIndex) => string;
|
||||||
|
/**
|
||||||
|
* Names the Facility standing on one of a player's squares, or null where there is none.
|
||||||
|
*
|
||||||
|
* Switching lines used to give the bare coordinate — "Set out a loaded hopper at (-1,1)" — which
|
||||||
|
* is the grid's own notation and means nothing at a table where people are looking at cards. The
|
||||||
|
* industry is the whole point of the move, so it is what the line should say.
|
||||||
|
*/
|
||||||
|
facilityAt?: (player: PlayerIndex, at: GridCoord) => string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||||
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
||||||
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
|
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
|
||||||
|
// The industry on a square when there is one, and the coordinate when there is not — a crew works
|
||||||
|
// plain track too, and "at nowhere" would be worse than the notation.
|
||||||
|
const place = (player: PlayerIndex, c: GridCoord): string => ctx.facilityAt?.(player, c) ?? at(c);
|
||||||
|
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
// -- clock
|
// -- clock
|
||||||
@@ -127,6 +166,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
|
.map((p) => ctx.playerName?.(p) ?? `Player ${p + 1}`)
|
||||||
.join(' → ')}`,
|
.join(' → ')}`,
|
||||||
};
|
};
|
||||||
|
case 'superintendentChanged':
|
||||||
|
/**
|
||||||
|
* The Fedora is the only thing in the game that changes hands on a clock rather than because
|
||||||
|
* somebody did something, so it is the one handover nobody at the table watches happen.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
tone: 'clock',
|
||||||
|
text:
|
||||||
|
`SUPERINTENDENT — the Fedora passes to ${ctx.playerName?.(e.player) ?? 'the next player'} ` +
|
||||||
|
`at the end of Stage ${e.stage}. They rule on clearances, take the Yard Office and Red Flag ` +
|
||||||
|
`questions, and every round that goes round the table now starts with them.`,
|
||||||
|
};
|
||||||
case 'phaseBegan':
|
case 'phaseBegan':
|
||||||
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
// Its own tone, not `quiet`. A phase marker sat in the same grey as the events inside it, so
|
||||||
// the log read as one undifferentiated column and you could not see where a phase began.
|
// the log read as one undifferentiated column and you could not see where a phase began.
|
||||||
@@ -146,7 +197,15 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
? 'Chose to SWITCH — six Moves to shunt cars around the yard. Watch the crew chip on the grid: it carries its consist with it, and cars it passes over are coupled automatically.'
|
? 'Chose to SWITCH — six Moves to shunt cars around the yard. Watch the crew chip on the grid: it carries its consist with it, and cars it passes over are coupled automatically.'
|
||||||
: e.option === 'draw'
|
: e.option === 'draw'
|
||||||
? 'Chose to DRAW a card'
|
? 'Chose to DRAW a card'
|
||||||
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
|
/**
|
||||||
|
* SAYS WHAT MAY BE DONE, NOT WHAT WAS. This read "one car moved to or from a
|
||||||
|
* facility" — an assertion — and §6.3 requires no action at all, so when the Freight
|
||||||
|
* Agent went idle the log claimed a car had moved and then fell silent about which.
|
||||||
|
* Reported from a table on Day 1 Stage 3 of v0.8.0.16. The work itself is narrated by
|
||||||
|
* `stockToOutbound`, `inboundCleared` and `facilityUnjammed`, each naming the car and
|
||||||
|
* the industry; an idle Agent is narrated by `freightAgentIdled`.
|
||||||
|
*/
|
||||||
|
: 'Chose FREIGHT AGENT work — may stock a green Outbound box, clear a red Inbound one, or free a jam',
|
||||||
};
|
};
|
||||||
case 'trayMoved':
|
case 'trayMoved':
|
||||||
// `via` rides on the event only when there was another legal route to the same square
|
// `via` rides on the event only when there was another legal route to the same square
|
||||||
@@ -155,7 +214,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
return {
|
return {
|
||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
where: e.to,
|
where: e.to,
|
||||||
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
// NO TUTORIAL TAIL. "The crew chip on the grid carries the whole train with it" was appended
|
||||||
|
// to EVERY move — six times a turn, and the opener (`localOpsOptionChosen`) already says it
|
||||||
|
// once. It also pushed the useful half of the line out of the caption row, which shows one
|
||||||
|
// step at a time and is the place a player reads a move as it happens.
|
||||||
|
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${place(e.player, e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of ${e.movesAllowed} Moves left`,
|
||||||
};
|
};
|
||||||
case 'carsCoupled': {
|
case 'carsCoupled': {
|
||||||
/**
|
/**
|
||||||
@@ -167,22 +230,59 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
const own = e.recoupled?.stock.length ?? 0;
|
const own = e.recoupled?.stock.length ?? 0;
|
||||||
const found = e.stock.length - own;
|
const found = e.stock.length - own;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (own > 0) parts.push(`picked its own ${carsLabel(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
|
// `a loaded tank` for one, a bare list for several — "took loaded tank" reads as a telegram.
|
||||||
if (found > 0) parts.push(`coupled ${carsLabel(e.stock.slice(own))} standing on the line`);
|
const some = (cars: RollingStock[]): string =>
|
||||||
|
cars.length === 1 ? indefinite(carLabel(cars[0]!)) : carsLabel(cars);
|
||||||
|
if (own > 0) parts.push(`picked its own ${some(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
|
||||||
|
if (found > 0) parts.push(`took ${some(e.stock.slice(own))} standing there`);
|
||||||
return {
|
return {
|
||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
where: e.at,
|
where: e.at,
|
||||||
text:
|
text:
|
||||||
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
// "1 car(s)" was the plural of a machine. The count is already implied by the cars named
|
||||||
|
// in `parts`, so the sentence leads with where and which end instead.
|
||||||
|
`Coupled at ${place(e.player, e.at)}, ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||||
` — ${parts.join(', and ')}`,
|
` — ${parts.join(', and ')}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'consistSorted':
|
case 'consistSorted': {
|
||||||
|
/**
|
||||||
|
* WHERE THE ENGINE ENDED UP, and whether the train can still run.
|
||||||
|
*
|
||||||
|
* ASKED OF `badlyMadeUp` RATHER THAN RE-DECIDED HERE, which matters because the obvious guess
|
||||||
|
* is wrong: §8.2 is enforced direction-free, so a train with its WHOLE consist ahead of the
|
||||||
|
* engine is a pushing train and perfectly fit to leave. What it may not be is broken-backed,
|
||||||
|
* with the engine buried among its own cars. A copy of that rule in the narrator would have
|
||||||
|
* told a player their pushing train was stranded when it was not.
|
||||||
|
*/
|
||||||
|
const ahead = e.engineAt;
|
||||||
|
const unfit = badlyMadeUp({ consist: e.after, engineAt: e.engineAt } as CrewTray);
|
||||||
|
const where =
|
||||||
|
ahead === 0
|
||||||
|
? 'so the right car is now on the end and can be spotted'
|
||||||
|
: unfit === null
|
||||||
|
? `with the whole consist AHEAD of the engine — it runs as a pushing train`
|
||||||
|
: `with ${ahead} car${ahead === 1 ? '' : 's'} ahead of the engine — ${unfit}, so it is held at the Office until it is sorted again (§8.2)`;
|
||||||
return {
|
return {
|
||||||
tone: 'good',
|
tone: 'good',
|
||||||
where: e.at,
|
where: e.at,
|
||||||
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], ${where}`,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
case 'switchingEnded': {
|
||||||
|
/**
|
||||||
|
* The line that closes a switching turn, and the only one the history keeps from the middle of
|
||||||
|
* it: what it cost, and where the crew was left standing.
|
||||||
|
*/
|
||||||
|
const used = `${e.movesUsed} of ${e.movesAllowed} Move${e.movesAllowed === 1 ? '' : 's'} used`;
|
||||||
|
if (e.movesUsed === 0) return { tone: 'quiet', text: 'Finished switching without moving a car' };
|
||||||
|
if (!e.lastMove) return { tone: 'plain', text: `Finished switching — ${used}` };
|
||||||
|
return {
|
||||||
|
tone: 'plain',
|
||||||
|
where: e.lastMove.to,
|
||||||
|
text: `Finished switching — ${used}, leaving ${train(e.lastMove.trayId)} at ${place(e.player, e.lastMove.to)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'carsDropped':
|
case 'carsDropped':
|
||||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||||
// the train may pull away from cars set out behind it and must couple back up to cars set out
|
// the train may pull away from cars set out behind it and must couple back up to cars set out
|
||||||
@@ -191,7 +291,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
where: e.at,
|
where: e.at,
|
||||||
text:
|
text:
|
||||||
`Set out ${carsLabel(e.stock)} at ${at(e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
|
`Set out ${carsLabel(e.stock)} at ${place(e.player, e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
// -- cards
|
// -- cards
|
||||||
@@ -383,23 +483,39 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// -- freight agent
|
// -- freight agent
|
||||||
|
/**
|
||||||
|
* THE INDUSTRY, NOT THE COORDINATE — `place` over `at`, for the reason its own comment gives:
|
||||||
|
* "(-1,1)" is the grid's notation and means nothing at a table where people are looking at
|
||||||
|
* cards. The switching lines were moved to it and these three were missed, so a Freight Agent
|
||||||
|
* turn was the one place the log still spoke in coordinates. Asked directly from a table on
|
||||||
|
* Day 1 Stage 3 of v0.8.0.16: "can we tell what car, what facility and whether it was to or
|
||||||
|
* from." The car and the direction were already here; the facility was not.
|
||||||
|
*/
|
||||||
case 'stockToOutbound':
|
case 'stockToOutbound':
|
||||||
return {
|
return {
|
||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
where: e.at,
|
where: e.at,
|
||||||
text: `Freight Agent put a ${carLabel(e.stock)} into the green Outbound box at ${at(e.at)}`,
|
text: `Freight Agent loaded a ${carLabel(e.stock)} INTO the green Outbound box at ${place(e.player, e.at)}`,
|
||||||
};
|
};
|
||||||
case 'inboundCleared':
|
case 'inboundCleared':
|
||||||
return {
|
return {
|
||||||
tone: 'plain',
|
tone: 'plain',
|
||||||
where: e.at,
|
where: e.at,
|
||||||
text: `Freight Agent cleared a ${carLabel(e.stock)} from the red Inbound box at ${at(e.at)}`,
|
text: `Freight Agent cleared a ${carLabel(e.stock)} OUT of the red Inbound box at ${place(e.player, e.at)}`,
|
||||||
};
|
};
|
||||||
case 'facilityUnjammed':
|
case 'facilityUnjammed':
|
||||||
return {
|
return {
|
||||||
tone: 'bad',
|
tone: 'bad',
|
||||||
|
text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
|
||||||
where: e.at,
|
where: e.at,
|
||||||
text: `UNJAMMED ${at(e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
|
};
|
||||||
|
case 'freightAgentIdled':
|
||||||
|
return {
|
||||||
|
tone: 'quiet',
|
||||||
|
text:
|
||||||
|
'Freight Agent found nothing worth doing — no green box could be stocked, no red box needed ' +
|
||||||
|
'clearing, and no load was jammed. §6.3 requires no action, and unjamming a healthy box ' +
|
||||||
|
'would destroy a load that cost a whole action to stock.',
|
||||||
};
|
};
|
||||||
|
|
||||||
// -- trains
|
// -- trains
|
||||||
@@ -415,9 +531,39 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'carPlacedOnTrain':
|
case 'carPlacedOnTrain':
|
||||||
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
|
// NAMES THE TRAIN. "the train being made up" was true and useless: a player looking back for
|
||||||
|
// what happened to train 10 found four lines that never said 10 (playtest, 2026-09-16).
|
||||||
|
return {
|
||||||
|
tone: 'plain',
|
||||||
|
text: `Added ${indefinite(carLabel(e.stock))} to ${trainLabel(e.trainNumber, e.isExtra)}`,
|
||||||
|
};
|
||||||
case 'carPassed':
|
case 'carPassed':
|
||||||
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
|
return {
|
||||||
|
tone: 'quiet',
|
||||||
|
text: `Passed on ${trainLabel(e.trainNumber, e.isExtra)} — no suitable car in the Division Yard`,
|
||||||
|
};
|
||||||
|
case 'makeUpShort': {
|
||||||
|
// What it wanted, in the words the card uses, so the line can be checked against the card.
|
||||||
|
const names: Record<'freight' | 'coach' | 'caboose', string> = {
|
||||||
|
freight: 'freight car',
|
||||||
|
coach: 'coach',
|
||||||
|
caboose: 'caboose',
|
||||||
|
};
|
||||||
|
const wants = e.missing.map((m) => names[m]).join(' or ');
|
||||||
|
const got = e.placed === 0 ? 'NO CARS AT ALL' : `only ${e.placed} of the ${e.wanted} its card calls for`;
|
||||||
|
// §2.2 is the whole explanation and it is not guessable from the board: the cars are visible
|
||||||
|
// in the Classification Yard, and why they will not come back is not.
|
||||||
|
const why =
|
||||||
|
e.waiting > 0
|
||||||
|
? ` ${e.waiting} sit in the Classification Yard, which comes back only when the Division Yard is bare — and it still holds ${e.divisionYardHolds} cars.`
|
||||||
|
: ' There are none in the Classification Yard either.';
|
||||||
|
return {
|
||||||
|
tone: 'bad',
|
||||||
|
text:
|
||||||
|
`${trainLabel(e.trainNumber, e.isExtra).toUpperCase()} WAS MADE UP WITH ${got} — the ` +
|
||||||
|
`Division Yard holds no ${wants} it can take, so nobody was asked for one.${why}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
case 'dispatchBonusUsed':
|
case 'dispatchBonusUsed':
|
||||||
return {
|
return {
|
||||||
tone: 'good',
|
tone: 'good',
|
||||||
@@ -435,12 +581,31 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
const wrecked = e.trains
|
const wrecked = e.trains
|
||||||
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
.map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
|
||||||
.join(' and ');
|
.join(' and ');
|
||||||
|
/**
|
||||||
|
* WHOSE OFFICE, AND WHO PAYS (playtest, 2026-09-16: "it doesn't say who suffers the revenue
|
||||||
|
* loss… we need to know which player received the penalty and why").
|
||||||
|
*
|
||||||
|
* `player` is the seat at fault, and for everything that happens inside a district that is the
|
||||||
|
* district's owner — so it names the place as well as the payer. A Mainline collision is the
|
||||||
|
* Superintendent's by rule (§10), which is a different sentence: it happened on open road, not
|
||||||
|
* in anybody's Office. The 5 points ride in a separate `revenueChanged`, which is why the line
|
||||||
|
* never mentioned them; a player should not have to add two log entries together.
|
||||||
|
*/
|
||||||
|
const who = ctx.playerName?.(e.player) ?? null;
|
||||||
|
const mainline = e.where === 'the Mainline';
|
||||||
|
const place = who === null || mainline ? e.where : `${who}'s ${e.where.replace(/^the /, '')}`;
|
||||||
|
const cost =
|
||||||
|
who === null
|
||||||
|
? ' 5 Revenue is lost.'
|
||||||
|
: mainline
|
||||||
|
? ` ${who} loses 5 Revenue: §10 makes a Mainline collision the Superintendent's fault.`
|
||||||
|
: ` ${who} loses 5 Revenue — it happened in their district.`;
|
||||||
return {
|
return {
|
||||||
tone: 'bad',
|
tone: 'bad',
|
||||||
text:
|
text:
|
||||||
`COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
|
`COLLISION at ${place} — ${wrecked} destroyed: ${why}.${cost} Engines and cabooses go back ` +
|
||||||
`Yard, all other cars to the Classification Yard. A Timetabled train card returns ` +
|
`to the Division Yard, all other cars to the Classification Yard. A Timetabled train card ` +
|
||||||
`to its slot and runs again next Day; an Extra is gone for good.`,
|
`returns to its slot and runs again next Day; an Extra is gone for good.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,6 +825,38 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
|||||||
* that actually refused rather than a second guess at it.
|
* that actually refused rather than a second guess at it.
|
||||||
*/
|
*/
|
||||||
if (f.kind === 'passenger') {
|
if (f.kind === 'passenger') {
|
||||||
|
/**
|
||||||
|
* NOBODY TO PUT ON THE PLATFORM, AND NO WAY TO SEE WHY (playtest, 2026-09-16).
|
||||||
|
*
|
||||||
|
* "He would like to have two passengers waiting in his depot… but the only option he had was
|
||||||
|
* bringing a tank load into the refinery." His Depot had a Restaurant and a Hotel beside it and
|
||||||
|
* three outbound slots — capacity was never the problem. §6.3 stocking takes a LOADED car of
|
||||||
|
* the facility's type out of the Division Yard, and there was not a loaded coach in it: six
|
||||||
|
* were sitting in Classification, which §2.2 returns only when the Division Yard runs bare.
|
||||||
|
*
|
||||||
|
* Jesse's ruling (2026-09-16) is the same one Gitea#2 got: the shortage stays, because running
|
||||||
|
* out is part of the game. What must not stay is the silence — an action with no legal target
|
||||||
|
* is simply absent from the menu, so the player is left to guess whether they misunderstood the
|
||||||
|
* rules or the game is broken.
|
||||||
|
*/
|
||||||
|
if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) {
|
||||||
|
const loadedCoaches = s.yards.divisionYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||||
|
if (loadedCoaches === 0) {
|
||||||
|
const waiting = s.yards.classificationYard.filter((c) => c.type === 'coach' && c.loaded).length;
|
||||||
|
out.push({
|
||||||
|
where: `${name} ${key}`,
|
||||||
|
why:
|
||||||
|
`room for ${f.capacity.outbound - f.outboundBox.length} more passenger` +
|
||||||
|
`${f.capacity.outbound - f.outboundBox.length === 1 ? '' : 's'} to wait, but no loaded ` +
|
||||||
|
`coach in the Division Yard for the Freight Agent to bring over` +
|
||||||
|
(waiting > 0
|
||||||
|
? ` — ${waiting} ${waiting === 1 ? 'is' : 'are'} in the Classification Yard, which comes ` +
|
||||||
|
'back only when the Division Yard is bare'
|
||||||
|
: ''),
|
||||||
|
severity: 'waiting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (portersLeft(f) > 0) {
|
if (portersLeft(f) > 0) {
|
||||||
const coord = uncoordKey(key);
|
const coord = uncoordKey(key);
|
||||||
// Passengers standing on the platform with nothing carrying them away.
|
// Passengers standing on the platform with nothing carrying them away.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
|||||||
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
// Says WHO builds, which is the question this phase actually raises at a table: the round
|
||||||
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
// starts with the Superintendent and works left, one car each, repeating (§7, Gap 9) — not
|
||||||
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
// with whoever played the card. An Extra is the exception: its player loads it as they choose.
|
||||||
tip: 'Timetabled trains for this Stage are built: starting with the Superintendent and working left, each player adds ONE car, going round again until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
tip: 'Timetabled trains for this Stage are built: each player adds ONE car at a time, starting with the Superintendent and working eastward, repeating until the consist is full or the Division Yard has nothing suitable. New timetabled trains are rolled onto the timetable. Held trains are built. An Extra is loaded by the player who played it.',
|
||||||
// a locomotive being made up
|
// a locomotive being made up
|
||||||
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
icon: '<rect class="ic" x="2" y="6" width="9" height="7" rx="1"/><path class="ic" d="M11 9h4v4h-4"/><circle class="icf" cx="5" cy="15" r="1.5"/><circle class="icf" cx="13" cy="15" r="1.5"/>',
|
||||||
},
|
},
|
||||||
|
|||||||
+100
-5
@@ -10,7 +10,7 @@
|
|||||||
* drift into two different pictures of the same board.
|
* drift into two different pictures of the same board.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isExpedited, regionOfTransit } from '../engine/advance.ts';
|
import { badlyMadeUp, isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||||||
import {
|
import {
|
||||||
areaAtSeat,
|
areaAtSeat,
|
||||||
areaOf,
|
areaOf,
|
||||||
@@ -953,9 +953,22 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`)
|
? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`)
|
||||||
: undefined;
|
: undefined;
|
||||||
const upgrade = over?.geometry.kind === 'track';
|
const upgrade = over?.geometry.kind === 'track';
|
||||||
|
/**
|
||||||
|
* NAME THE MAINLINE CARD, for exactly the reason the rotation is named above — reported from
|
||||||
|
* a table on Day 1 Stage 1 of v0.8.0.16 and the THIRD time this trap has been sprung.
|
||||||
|
*
|
||||||
|
* ABS Signals is played on a Division NODE rather than a grid square, so `i.placement` is
|
||||||
|
* absent and every one of its placements described itself as plain "play ABS Signals". The
|
||||||
|
* action list drops duplicate labels, so all but the lowest-index Mainline card were discarded
|
||||||
|
* before the menu saw them: the tooltip promised "any Mainline card" and the board offered
|
||||||
|
* one. Naming the card is what makes the choice both legible and survivable.
|
||||||
|
*/
|
||||||
|
const onNode = i.node === undefined ? undefined : s.division.nodes[i.node];
|
||||||
|
const mainline =
|
||||||
|
onNode?.kind === 'mainline' ? ` on the ${mainlineProfile(onNode.card).name}, out on the Mainline` : '';
|
||||||
return (
|
return (
|
||||||
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
|
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
|
||||||
`${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`
|
`${i.placement ? ` at ${at(i.placement)}` : ''}${mainline}${turn}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'card.discard': {
|
case 'card.discard': {
|
||||||
@@ -1036,8 +1049,65 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
const end = i.fromNose ? 'off the front' : 'off the back';
|
const end = i.fromNose ? 'off the front' : 'off the back';
|
||||||
return `set out ${carsLabel(cut)} ${end}`;
|
return `set out ${carsLabel(cut)} ${end}`;
|
||||||
}
|
}
|
||||||
case 'switch.sortConsist':
|
case 'switch.sortConsist': {
|
||||||
return `re-order consist [${i.order.join(',')}]`;
|
/**
|
||||||
|
* THE TRAIN IT WOULD MAKE, DRAWN THE WAY THE BOARD DRAWS IT.
|
||||||
|
*
|
||||||
|
* This read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person
|
||||||
|
* — and the option Jesse wanted was the first of five and unidentifiable (playtest,
|
||||||
|
* 2026-09-17). Naming the cars fixed that and left a second ambiguity he caught immediately:
|
||||||
|
* a list "front to back" means nothing at a table looking at a map, because which end is the
|
||||||
|
* front depends on which way the train is pointed.
|
||||||
|
*
|
||||||
|
* SO IT IS LAID OUT WEST TO EAST, exactly as `board-svg.ts` lays the crew strip: the consist
|
||||||
|
* is stored nose first, and a train facing EAST is reversed so its nose lands at the east end
|
||||||
|
* where it actually is. The engine is the same ◀ / ▶ arrow the board uses, seated where it
|
||||||
|
* will be, so "ahead of the engine" and "behind the engine" are read off the picture rather
|
||||||
|
* than asserted in words — and the button and the board cannot disagree.
|
||||||
|
*/
|
||||||
|
const sorting = s.trays.get(i.trayId);
|
||||||
|
if (!sorting) return `re-order consist [${i.order.join(',')}]`;
|
||||||
|
const after = i.order.map((n) => sorting.consist[n]!).filter((c) => c !== undefined);
|
||||||
|
const engineAt = i.engineAt ?? 0;
|
||||||
|
const facing = railFacingOf(sorting);
|
||||||
|
|
||||||
|
const items = after.map((c) => carLabel(c));
|
||||||
|
items.splice(engineAt, 0, facing === 'w' ? '◀ ENGINE' : 'ENGINE ▶');
|
||||||
|
// West on the left, like the map and like the crew strip on the board.
|
||||||
|
const strip = (facing === 'e' ? [...items].reverse() : items).join(' · ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHAT IT WOULD MEAN, from §8.2's own predicate rather than a copy of it: a train with its
|
||||||
|
* whole consist ahead of the engine is a PUSHING train and fit to run, a buried engine is not,
|
||||||
|
* and a caboose has to ride at the end away from the engine. Numbered trains only — a local
|
||||||
|
* crew has no card and never departs, so a departure verdict on one is noise.
|
||||||
|
*/
|
||||||
|
const unfit =
|
||||||
|
sorting.trainNumber === null
|
||||||
|
? null
|
||||||
|
: badlyMadeUp({ ...sorting, consist: after, engineAt });
|
||||||
|
// `badlyMadeUp` leads with "not made up — ", which reads as a stutter in front of HELD. The
|
||||||
|
// reason after it is the part worth showing, so the prefix comes off.
|
||||||
|
const because = unfit?.replace(/^not made up — /, '') ?? '';
|
||||||
|
const verdict =
|
||||||
|
sorting.trainNumber === null
|
||||||
|
? ''
|
||||||
|
: unfit === null
|
||||||
|
? ' · MADE UP, ready to leave'
|
||||||
|
: ` · HELD at the Office: ${because}`;
|
||||||
|
|
||||||
|
// A sort that only moves the engine says which errand it is running, rather than reprinting a
|
||||||
|
// car order that has not changed.
|
||||||
|
const sameOrder = i.order.every((n, at) => n === at);
|
||||||
|
const lead = sameOrder
|
||||||
|
? engineAt === 0
|
||||||
|
? 'pull the engine back to the front'
|
||||||
|
: engineAt === after.length
|
||||||
|
? 'put the whole consist ahead of the engine'
|
||||||
|
: `move the engine behind ${engineAt} car${engineAt === 1 ? '' : 's'}`
|
||||||
|
: 're-order';
|
||||||
|
return `${lead} — west to east: ${strip}${verdict}`;
|
||||||
|
}
|
||||||
case 'freightAgent.stockOutbound': {
|
case 'freightAgent.stockOutbound': {
|
||||||
/**
|
/**
|
||||||
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly
|
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly
|
||||||
@@ -1846,10 +1916,35 @@ export function cardName(s: GameState, id: string): string {
|
|||||||
case 'mainlineModifier':
|
case 'mainlineModifier':
|
||||||
case 'maneuver':
|
case 'maneuver':
|
||||||
case 'action':
|
case 'action':
|
||||||
return prettyKey(k.key);
|
/**
|
||||||
|
* THE PRINTED NAME WINS over `prettyKey`'s guess, on the same reasoning as the facility and
|
||||||
|
* modifier lookups above: the tables are for names `prettyKey` cannot derive.
|
||||||
|
*
|
||||||
|
* It guessed wrong more often than the fallback comment implies. `absSignals` came out as
|
||||||
|
* "Abs Signals" on the button while the rules, the tooltip and the card face all say **ABS**
|
||||||
|
* Signals — an acronym no key-splitter can recover — and `brokenCoupler` and `beanHouse`
|
||||||
|
* were title-cased past their printed "Broken coupler" and "Bean house".
|
||||||
|
*/
|
||||||
|
return SIMPLE_CARD_NAMES.get(k.key) ?? prettyKey(k.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every authored card name, keyed as the card kinds key themselves.
|
||||||
|
*
|
||||||
|
* Built from the content tables rather than restated, so a card renamed there is renamed here and
|
||||||
|
* the two cannot drift — which is the whole argument of TODO #15a, applied to names.
|
||||||
|
*/
|
||||||
|
const SIMPLE_CARD_NAMES: ReadonlyMap<string, string> = new Map(
|
||||||
|
[
|
||||||
|
...SPACE_USE_CARDS,
|
||||||
|
...ENHANCEMENT_CARDS,
|
||||||
|
...MAINLINE_MODIFIER_CARDS,
|
||||||
|
...MANEUVER_CARDS,
|
||||||
|
...ACTION_CARDS,
|
||||||
|
].map((c) => [c.key, c.name]),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a card actually DOES, in one line.
|
* What a card actually DOES, in one line.
|
||||||
*
|
*
|
||||||
|
|||||||
+93
-2
@@ -29,7 +29,7 @@ import type { GameEvent } from '../engine/events.ts';
|
|||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import { legalActions } from '../engine/legal.ts';
|
import { legalActions } from '../engine/legal.ts';
|
||||||
import { createGame } from '../engine/setup.ts';
|
import { createGame } from '../engine/setup.ts';
|
||||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
import type { CardId, GameConfig, GameState, GridCoord, PlayerIndex } from '../engine/state.ts';
|
||||||
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
|
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
|
||||||
import { playerAtSeat } from '../engine/state.ts';
|
import { playerAtSeat } from '../engine/state.ts';
|
||||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
LEGACY_HOUSE_RULES,
|
LEGACY_HOUSE_RULES,
|
||||||
collectiveRevenueFloor,
|
collectiveRevenueFloor,
|
||||||
houseRules,
|
houseRules,
|
||||||
|
industryProfile,
|
||||||
mainlineProfile,
|
mainlineProfile,
|
||||||
trainProfile,
|
trainProfile,
|
||||||
} from '../engine/content.ts';
|
} from '../engine/content.ts';
|
||||||
@@ -1269,6 +1270,81 @@ function uncapitalise(text: string): string {
|
|||||||
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Facility on one of a player's squares, or null — for naming the place a switching line is
|
||||||
|
* about. Shared by the narrator and the filter below, so both agree on what counts as an industry.
|
||||||
|
*/
|
||||||
|
function facilityOn(game: Game, player: PlayerIndex, at: GridCoord): string | null {
|
||||||
|
const card = areaOf(game.state, player).grid.get(`${at.row},${at.col}`);
|
||||||
|
const f = card?.facility;
|
||||||
|
// The name printed on the card, not the internal key: `industryProfile` is the one place that
|
||||||
|
// knows "grocersWarehouse" reads as "Grocer's Warehouse".
|
||||||
|
if (f) return f.subtype === 'office' ? 'the Office' : `the ${industryProfile(f.subtype).name}`;
|
||||||
|
/**
|
||||||
|
* A SMALL YARD IS A PLACE TOO, though it is an enhancement on a plain card rather than a Facility.
|
||||||
|
*
|
||||||
|
* It is the one square in a district a crew goes to ON PURPOSE without working an industry — the
|
||||||
|
* whole point of the trip is to arrive there and re-make the train — so "leaving Train 10 at
|
||||||
|
* (-1,1)" was the one line most in need of a name. Only this enhancement: the others change what a
|
||||||
|
* square DOES without being somewhere a player aims a crew at.
|
||||||
|
*/
|
||||||
|
return card?.enhancements.includes('smallYard') ? 'the Small Yard' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL (Jesse, 2026-09-17).
|
||||||
|
*
|
||||||
|
* All of it did. A six-Move turn wrote a line per move — "Moved Train 10 (0,3) → (-1,-3) — 4 of 6
|
||||||
|
* Moves left" — plus one per mandatory coupling, so two players shunting filled the panel with
|
||||||
|
* coordinates and pushed everything else off the top. His ruling, asked as a question from the
|
||||||
|
* table: a line saying somebody switched, the cars they set out at or picked up from an INDUSTRY,
|
||||||
|
* and the Small Yard sort. Not every move, and not every coupling.
|
||||||
|
*
|
||||||
|
* WHAT STAYS, and why each one earns its line: `localOpsOptionChosen` already says who is switching
|
||||||
|
* and is left alone; work at an industry is the point of switching and changes what can be loaded
|
||||||
|
* next; and `consistSorted` spends a Move and changes what the train can do. A plain move along
|
||||||
|
* one's own track changes nothing anybody needs to read back.
|
||||||
|
*
|
||||||
|
* THE LINE IS STILL WRITTEN, MARKED `trace`, AND THAT IS NOT A DETAIL. Dropping these events on the
|
||||||
|
* floor was the first attempt and the suite caught it: `dwellForStep` gives a step NO dwell when it
|
||||||
|
* produced no narration, so a switching move with no line became a silent step and the board stopped
|
||||||
|
* replaying switching altogether — it would have snapped through the very thing v0.8.0 was built to
|
||||||
|
* let the table watch. The line still rides with its display step and still captions the board as
|
||||||
|
* the move goes up; only the history panel skips it.
|
||||||
|
*
|
||||||
|
* THE REPLAY VIEWER IS UNAFFECTED for the same reason, and it renders through `narrate` directly.
|
||||||
|
*/
|
||||||
|
function inHistory(game: Game, e: GameEvent): boolean {
|
||||||
|
const industry = (player: PlayerIndex, ...coords: GridCoord[]): boolean =>
|
||||||
|
coords.some((c) => facilityOn(game, player, c) !== null);
|
||||||
|
switch (e.type) {
|
||||||
|
/**
|
||||||
|
* THE FIRST MOVE OF A TURN IS KEPT (Jesse, 2026-09-17: "also keep the first and last move").
|
||||||
|
*
|
||||||
|
* It says a crew set off and from where, which is the half of "somebody switched" that the
|
||||||
|
* opener does not carry. The LAST move cannot be kept the same way — nothing knows a move was
|
||||||
|
* the last until the turn is over, and by then the line has already been written and streamed to
|
||||||
|
* every client (`server/session.ts` § linesSince), so it cannot be revised. `switchingEnded`
|
||||||
|
* carries it instead, as the line that closes the turn.
|
||||||
|
*
|
||||||
|
* Recognised by the MOVE COUNT rather than by tracking state: the first move of a turn is the
|
||||||
|
* one that leaves `movesAllowed - 1` behind it, which the event now carries so this holds on a
|
||||||
|
* five-Move night Stage too.
|
||||||
|
*/
|
||||||
|
case 'trayMoved':
|
||||||
|
return e.movesRemaining === e.movesAllowed - 1;
|
||||||
|
// Coupling is mandatory when a crew runs over cars (§A.4), so most of these happen to a player
|
||||||
|
// rather than being chosen. The ones worth reading are where cars left or joined an industry —
|
||||||
|
// `from` names the cards the cars were actually lifted off, which is where they had been spotted.
|
||||||
|
case 'carsCoupled':
|
||||||
|
return industry(e.player, e.at, ...e.from);
|
||||||
|
case 'carsDropped':
|
||||||
|
return industry(e.player, e.at);
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
@@ -1277,6 +1353,7 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
|||||||
const n = narrate(e, {
|
const n = narrate(e, {
|
||||||
cardName: (id) => cardName(game.state, id),
|
cardName: (id) => cardName(game.state, id),
|
||||||
trainName: (id) => trainName(game.state, id),
|
trainName: (id) => trainName(game.state, id),
|
||||||
|
facilityAt: (player, at) => facilityOn(game, player, at),
|
||||||
// Whose district a train reached is not the actor — the Mainline Phase has none — so the
|
// Whose district a train reached is not the actor — the Mainline Phase has none — so the
|
||||||
// narration resolves the name itself rather than being prefixed with one by the code below.
|
// narration resolves the name itself rather than being prefixed with one by the code below.
|
||||||
// NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate
|
// NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate
|
||||||
@@ -1320,7 +1397,9 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
|||||||
: mine
|
: mine
|
||||||
? `Player ${who} ${uncapitalise(said)}`
|
? `Player ${who} ${uncapitalise(said)}`
|
||||||
: said;
|
: said;
|
||||||
game.log.push({ text, tone: mine || ruling ? 'act' : n.tone });
|
// `trace` is a tone the history panel does not draw — see `inHistory`. The line exists so the
|
||||||
|
// step that caused it has narration to caption the board with, and a dwell to be watched for.
|
||||||
|
game.log.push({ text, tone: inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace' });
|
||||||
|
|
||||||
}
|
}
|
||||||
game.cues.push(...cuesFor(events));
|
game.cues.push(...cuesFor(events));
|
||||||
@@ -1337,6 +1416,18 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
|||||||
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
||||||
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* THE FEDORA MOVING IS ANNOUNCED, NOT JUST LOGGED (playtest, 2026-09-16).
|
||||||
|
*
|
||||||
|
* It is the one thing in the game that changes hands on the clock rather than because somebody
|
||||||
|
* did something, so nobody is watching for it — and it decides who rules on clearances and who
|
||||||
|
* every round starts with. A line in the history is where you find it afterwards; this is what
|
||||||
|
* tells the table as it happens, the same treatment a completed run already gets.
|
||||||
|
*/
|
||||||
|
if (e.type === 'superintendentChanged') {
|
||||||
|
const name = game.state.players[e.player]?.name ?? 'the next player';
|
||||||
|
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.
|
// 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);
|
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ h1{font-size:32px;margin:0 0 2px;letter-spacing:.02em}
|
|||||||
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
|
a.door:hover{border-color:#4d6fa8;background:#1f2733;transform:translateY(-1px)}
|
||||||
.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
.door h2{font-size:17px;margin:0 0 5px;color:#9fb6d8}
|
||||||
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
.door p{margin:0;color:var(--dim);font-size:13px;line-height:1.5}
|
||||||
|
/* Not a fourth door: reading the guide is not a way to play, and giving it equal weight in the
|
||||||
|
grid would say it is. A line under the doors, where somebody who does not know what to click
|
||||||
|
will already be looking. */
|
||||||
|
.newhere{margin:16px 2px 0;color:var(--dim);font-size:13px;line-height:1.55}
|
||||||
|
.newhere a{color:#9fb6d8}
|
||||||
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
|
.door .go{display:inline-block;margin-top:11px;font-size:12px;color:#5aa9e6}
|
||||||
.door.disabled .go{color:var(--dim)}
|
.door.disabled .go{color:var(--dim)}
|
||||||
a.door.disabled{pointer-events:none}
|
a.door.disabled{pointer-events:none}
|
||||||
@@ -96,6 +101,11 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="newhere">New to Station Master?
|
||||||
|
<a href="./quickstart.md">Read the Quickstart guide</a> — what the game is, how you win,
|
||||||
|
how a Stage runs, what is on the screen, and a first twenty minutes. About twenty minutes to
|
||||||
|
read, and it will save you an hour of guessing.</p>
|
||||||
|
|
||||||
<div class="rule"></div>
|
<div class="rule"></div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
|||||||
+173
-23
@@ -337,9 +337,17 @@ function watchedDistrict(f: Frame): PublicDistrict | null {
|
|||||||
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
||||||
* which keeps the board where it was instead of snapping home mid-sequence.
|
* which keeps the board where it was instead of snapping home mid-sequence.
|
||||||
*/
|
*/
|
||||||
// A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`).
|
/**
|
||||||
if (peekPlayer !== null && peekPlayer !== f.viewer) {
|
* A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`)
|
||||||
return pub.districts.find((d) => d.player === peekPlayer) ?? null;
|
* — INCLUDING A LOOK AT YOUR OWN BOARD, which is why this returns rather than falling through.
|
||||||
|
*
|
||||||
|
* Falling through sent "show me mine" to the actor logic below, so the one player who could not
|
||||||
|
* reach their own Office Area was the player waiting on everybody else (playtest, 2026-09-16: Tom,
|
||||||
|
* hanging about while the board followed Jesse). Null IS your own district: it is what the caller
|
||||||
|
* draws from `f.cells` when nobody else is being watched.
|
||||||
|
*/
|
||||||
|
if (peekPlayer !== null) {
|
||||||
|
return peekPlayer === f.viewer ? null : (pub.districts.find((d) => d.player === peekPlayer) ?? null);
|
||||||
}
|
}
|
||||||
let player: PlayerIndex | null = f.actor;
|
let player: PlayerIndex | null = f.actor;
|
||||||
if (stepQueue.busy()) {
|
if (stepQueue.busy()) {
|
||||||
@@ -644,9 +652,48 @@ function renderGameCard(f: Frame): void {
|
|||||||
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
|
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
|
||||||
$('gamecardbody').innerHTML =
|
$('gamecardbody').innerHTML =
|
||||||
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
|
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
|
||||||
rulesListHtml(config, players, f.days);
|
rulesListHtml(config, players, f.days) +
|
||||||
|
GUIDE_HTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE DOCUMENTATION, REACHABLE FROM INSIDE A GAME — asked for directly from a table, 2026-09-21:
|
||||||
|
* "how can we link the documentation so it can be reached from the gameplay, whether someone is
|
||||||
|
* playing solitaire or multiplayer?"
|
||||||
|
*
|
||||||
|
* IT NEEDS NO MODE AWARENESS, which is the whole reason this is three lines rather than a feature.
|
||||||
|
* Solitaire and multiplayer are the same page on the same origin — `play.html?solitaire` and
|
||||||
|
* `play.html?lobby` — and the build publishes the documents beside it, so one relative link
|
||||||
|
* resolves identically in both, on the public site and on a StartOS box alike.
|
||||||
|
*
|
||||||
|
* IN THIS CARD RATHER THAN THE HEADER (Jesse's call, 2026-09-21), for the reason he gave the top
|
||||||
|
* line in the first place on 2026-08-30: it is "not something that they're likely to need all the
|
||||||
|
* time", and the header is the line that must not wrap. The card is already where reference lives
|
||||||
|
* — the seed, the seat, the house rules — and it holds the whole set rather than one door.
|
||||||
|
*
|
||||||
|
* NEW TAB, every one of them: a player reading the rules mid-turn must not lose the game behind
|
||||||
|
* them. `rel="noopener"` because `target="_blank"` without it hands the opened page a handle back.
|
||||||
|
*
|
||||||
|
* Built once at module scope — it never varies, and rebuilding it on every frame would be work
|
||||||
|
* nobody sees, the same reasoning the folded body above already follows.
|
||||||
|
*/
|
||||||
|
const GUIDE_DOCS: readonly { href: string; label: string; what: string }[] = [
|
||||||
|
{ href: './quickstart.md', 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/as-built.md', label: 'Every card', what: 'Generated from the code, so it cannot drift from what the game actually does.' },
|
||||||
|
{ href: './home-deck.md', label: 'Home deck', what: 'How the Home Office deck is dealt and played.' },
|
||||||
|
{ href: './mainline-deck.md', label: 'Mainline deck', what: 'The Mainline cards and what each does to a train.' },
|
||||||
|
{ href: './components.md', label: 'Components', what: 'Rolling stock, yards, trays, the Fedora.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GUIDE_HTML =
|
||||||
|
`<div class="guide"><h4>Guide</h4><p>` +
|
||||||
|
GUIDE_DOCS.map(
|
||||||
|
(d) =>
|
||||||
|
`<a href="${esc(d.href)}" target="_blank" rel="noopener" data-tip="${esc(d.what)}">${esc(d.label)}</a>`,
|
||||||
|
).join(' · ') +
|
||||||
|
`</p></div>`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30).
|
* THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30).
|
||||||
*
|
*
|
||||||
@@ -1749,8 +1796,17 @@ function render(): void {
|
|||||||
$('depts').classList.remove('aiming');
|
$('depts').classList.remove('aiming');
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- making up a train: the Division Yard chip that shows the car IS the button.
|
/**
|
||||||
if (menu.makeUp) {
|
* -- making up a train: the Division Yard chip that shows the car IS the button.
|
||||||
|
*
|
||||||
|
* NOT WHILE THE BOARD IS BEHIND (playtest, 2026-09-16). `renderActions` puts the action list away
|
||||||
|
* while the queue is catching up — a move offered against a position that has already moved on is
|
||||||
|
* a move made blind — but this wiring sat outside that guard, so the yard chips stayed lit and
|
||||||
|
* clickable. Jesse clicked one during a bot's make-up, a coach left the yard, and the train ended
|
||||||
|
* up with three cars: he had submitted a real intent against a board he could not see. The chips
|
||||||
|
* follow the same rule as every other control now.
|
||||||
|
*/
|
||||||
|
if (menu.makeUp && !stepQueue.busy()) {
|
||||||
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
|
||||||
const node = el as HTMLElement;
|
const node = el as HTMLElement;
|
||||||
const car = menu.makeUp!.cars.find(
|
const car = menu.makeUp!.cars.find(
|
||||||
@@ -1790,8 +1846,27 @@ function render(): void {
|
|||||||
* steps still queued are withheld, and each appears as its step goes up.
|
* steps still queued are withheld, and each appears as its step goes up.
|
||||||
*/
|
*/
|
||||||
const heldBack = stepQueue.pendingLines();
|
const heldBack = stepQueue.pendingLines();
|
||||||
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
|
/**
|
||||||
const shownLines = allLines.slice(-60);
|
* HELD BACK FIRST, THEN THE TRACE LINES DROPPED — the order matters.
|
||||||
|
*
|
||||||
|
* `pendingLines` counts lines in the log, including the `trace` ones a switching move writes for
|
||||||
|
* its caption (`web/game.ts` § inHistory), so the tail has to be cut off the RAW list or the
|
||||||
|
* arithmetic slips and the panel runs ahead of the board. Filtering afterwards only decides what
|
||||||
|
* is drawn.
|
||||||
|
*/
|
||||||
|
const allLines = (heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines()).filter(
|
||||||
|
(l) => l.tone !== 'trace',
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* NINETY LINES, IN THE SAME BOX (Jesse, 2026-09-16: "increase to 90, keep the box the same size").
|
||||||
|
*
|
||||||
|
* The panel scrolls already, so a longer tail costs no screen and lets a player scroll further
|
||||||
|
* back through a Stage they were not watching. It is capped at all only because the list is
|
||||||
|
* rebuilt on every render; the log itself is uncapped in memory, so the number is a display
|
||||||
|
* choice rather than a limit. The BOX stays 230px on purpose — growing it would push the newest
|
||||||
|
* line, the one being read, further from where the eye already is.
|
||||||
|
*/
|
||||||
|
const shownLines = allLines.slice(-90);
|
||||||
/**
|
/**
|
||||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||||
* by the time the board paints the log already has several turns in it and nothing says which of
|
* by the time the board paints the log already has several turns in it and nothing says which of
|
||||||
@@ -1914,16 +1989,49 @@ function renderUndo(): void {
|
|||||||
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
* left, and the moment the Division Yard empties a whole pile comes back at once.
|
||||||
*/
|
*/
|
||||||
function renderYards(f: Frame): void {
|
function renderYards(f: Frame): void {
|
||||||
$('divyard').innerHTML = yardHtml(f.yards.division);
|
/**
|
||||||
$('clsyard').innerHTML = yardHtml(f.yards.classification);
|
* THE YARDS BELONG TO THE BOARD ON SCREEN, NOT TO THE GAME (playtest, 2026-09-16).
|
||||||
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
|
*
|
||||||
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
|
* They were drawn from the live Frame while everything around them was held back, so a player
|
||||||
|
* five moves behind read yard counts from a future they had not been shown — "the yards may not
|
||||||
|
* be in sync with the turns behind", and they were not. Same rule as the turn chart: while the
|
||||||
|
* queue is behind, this is the shown board's yards; at rest the two are the same object.
|
||||||
|
*/
|
||||||
|
const pub = stepQueue.current();
|
||||||
|
const yards = pub && stepQueue.busy() ? pub.yards : f.yards;
|
||||||
|
$('divyard').innerHTML = yardHtml(yards.division);
|
||||||
|
$('clsyard').innerHTML = yardHtml(yards.classification);
|
||||||
|
$('divtot').textContent = `${yards.divisionTotal} cars`;
|
||||||
|
$('clstot').textContent = `${yards.classificationTotal} cars`;
|
||||||
|
|
||||||
// The one thing worth calling out: the yard about to turn over.
|
/**
|
||||||
const bare = f.yards.divisionTotal === 0;
|
* The one thing worth calling out: the yard about to turn over — or the one that never will.
|
||||||
|
*
|
||||||
|
* THE COACH CASE IS ITS OWN WARNING (Jesse's ruling, 2026-09-17: say it loudly, change no rule).
|
||||||
|
* Coaches travel one way. §9.2 boarding discards the emptied coach into the CLASSIFICATION Yard
|
||||||
|
* and detraining draws a fresh empty out of the Division Yard, and §2.2 sends Classification back
|
||||||
|
* only when the Division Yard runs bare. Measured over one three-Day game: sixteen coaches at
|
||||||
|
* setup, none left by Day 2 Stage 8, fifteen piled in Classification, and the Division Yard
|
||||||
|
* holding steady at 46-47 freight cars — so the refill will not fire before the game ends.
|
||||||
|
*
|
||||||
|
* Every consequence of that is silent on the board. A Depot simply offers no passenger action, and
|
||||||
|
* the Sparrow — three coaches and nothing else — is made up empty and runs the whole Division that
|
||||||
|
* way. The cars are in plain sight in Classification; what cannot be seen is that they are not
|
||||||
|
* coming back. So the panel says it where the pile is.
|
||||||
|
*/
|
||||||
|
const held = (row: { type: string; loaded: number; empty: number }[], type: string): number => {
|
||||||
|
const r = row.find((c) => c.type === type);
|
||||||
|
return r ? r.loaded + r.empty : 0;
|
||||||
|
};
|
||||||
|
const bare = yards.divisionTotal === 0;
|
||||||
|
const coachesStranded = !bare && held(yards.division, 'coach') === 0 && held(yards.classification, 'coach') > 0;
|
||||||
$('divyard').classList.toggle('bare', bare);
|
$('divyard').classList.toggle('bare', bare);
|
||||||
$('yardnote').textContent = bare
|
$('yardnote').textContent = bare
|
||||||
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
|
? `The Division Yard is bare — the ${yards.classificationTotal} cars in Classification return to it now.`
|
||||||
|
: coachesStranded
|
||||||
|
? `NO COACHES in the Division Yard — all ${held(yards.classification, 'coach')} are in Classification, ` +
|
||||||
|
`which comes back only when the Division Yard is bare, and it still holds ${yards.divisionTotal} cars. ` +
|
||||||
|
'Until then no passengers can board or detrain, and a train whose card calls for coaches is made up empty.'
|
||||||
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1996,16 +2104,36 @@ function renderDistrict(f: Frame): void {
|
|||||||
* from another person, and so the one that must never be pasted into markup.
|
* from another person, and so the one that must never be pasted into markup.
|
||||||
*/
|
*/
|
||||||
const peek = $('districtpeek');
|
const peek = $('districtpeek');
|
||||||
const others = f.players.filter((p) => p.index !== f.viewer);
|
/**
|
||||||
|
* EVERY SEAT, YOURS INCLUDED, IN MAP ORDER (playtest, 2026-09-16).
|
||||||
|
*
|
||||||
|
* Two faults, both reported from one game. There was no button for your OWN district, so a player
|
||||||
|
* waiting on everybody else could look at any board except the one they were playing — and the
|
||||||
|
* buttons came out in player order, which is the order people joined, not the order they sit.
|
||||||
|
*
|
||||||
|
* Sorted by SEAT, which is west-to-east along the Division exactly as the map draws it, so the row
|
||||||
|
* reads left to right the way the railroad does. Seat is not player index and must not be assumed
|
||||||
|
* to be: under Employee Rotation the seating moves, and because this sorts the Frame's own `seat`
|
||||||
|
* on every render, the buttons rotate with the players rather than having to be told.
|
||||||
|
*/
|
||||||
|
const seats = [...f.players].sort((a, b) => a.seat - b.seat);
|
||||||
peek.innerHTML = '';
|
peek.innerHTML = '';
|
||||||
peek.hidden = others.length === 0;
|
peek.hidden = seats.length < 2;
|
||||||
for (const p of others) {
|
const watchedNow = watchedDistrict(f);
|
||||||
|
for (const p of seats) {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.type = 'button';
|
b.type = 'button';
|
||||||
b.className = 'ghost';
|
b.className = 'ghost';
|
||||||
|
// NAMES GO IN AS TEXT, NEVER MARKUP: a display name is whatever somebody typed in the lobby.
|
||||||
b.textContent = p.name;
|
b.textContent = p.name;
|
||||||
b.title =
|
const isYou = p.index === f.viewer;
|
||||||
`Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
|
// Which board is up right now — yours when nothing is being watched, otherwise the watched one.
|
||||||
|
const showing = watchedNow === null ? f.viewer : watchedNow.player;
|
||||||
|
// `.seg button[aria-pressed="true"]` already lights the current one — no extra class to style.
|
||||||
|
b.setAttribute('aria-pressed', String(p.index === showing));
|
||||||
|
b.title = isYou
|
||||||
|
? 'Back to your own Office Area.'
|
||||||
|
: `Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` +
|
||||||
`redraws — press Pause first if you want to study it.`;
|
`redraws — press Pause first if you want to study it.`;
|
||||||
b.onclick = () => {
|
b.onclick = () => {
|
||||||
peekPlayer = p.index;
|
peekPlayer = p.index;
|
||||||
@@ -2352,11 +2480,34 @@ function renderActions(
|
|||||||
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
|
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
|
||||||
* however it is titled, on screen where a player can act on it.
|
* however it is titled, on screen where a player can act on it.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* WHAT A ROLE IS FOR, said where the role is chosen (playtest, 2026-09-17).
|
||||||
|
*
|
||||||
|
* Tom had a train standing at his Depot with a loaded coach on it, picked the Freight Agent to
|
||||||
|
* unload the passengers, and found nothing that would do it — "didn't have any opportunity to
|
||||||
|
* unload passengers". Both halves of that were working correctly and neither was visible: the
|
||||||
|
* Freight Agent stocks a box and never touches a train, and detraining is a PORTER's action in
|
||||||
|
* the Cargo phase. Why it was impossible as well (§9.2 wants an empty coach from the Division
|
||||||
|
* Yard) was already in the Blocked panel, off to the side — but a player who has chosen the
|
||||||
|
* wrong role is not reading a panel about the right one.
|
||||||
|
*
|
||||||
|
* A SENTENCE, NOT A HOVER. The tip on the heading would have answered him only if he already
|
||||||
|
* suspected he was in the wrong place, which is precisely what he did not.
|
||||||
|
*/
|
||||||
|
const ROLE_SCOPE: Record<string, string> = {
|
||||||
|
'freightAgent.':
|
||||||
|
'The Freight Agent stocks the green Loading box from the Division Yard, and frees a jam. ' +
|
||||||
|
'Passengers board and detrain in the Cargo phase, with a Porter — not here.',
|
||||||
|
'porter.': 'A Porter works passengers between a platform and a coach standing at your Office.',
|
||||||
|
'laborer.': 'A Laborer moves one load one step along MEN → AT → WORK, and loads or unloads a spotted car.',
|
||||||
|
};
|
||||||
|
|
||||||
html += menu.direct
|
html += menu.direct
|
||||||
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
|
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
|
||||||
.map(
|
.map(
|
||||||
(g) =>
|
(g) =>
|
||||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
||||||
|
(ROLE_SCOPE[g.kind] ? `<p class="dim scope">${esc(ROLE_SCOPE[g.kind]!)}</p>` : '') +
|
||||||
g.actions
|
g.actions
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
|
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
|
||||||
@@ -2407,9 +2558,8 @@ function renderActions(
|
|||||||
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
|
* on, and never again. "Click a car" then reads as "build this train", so a player adds one
|
||||||
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
|
* and the turn moves on with no explanation (Jesse, playtest 2026-09-16).
|
||||||
*/
|
*/
|
||||||
`<b>One car each:</b> you add a single car, then the round passes to the next player — ` +
|
`<b>Each player adds one car at a time</b>, starting from the Superintendent and working ` +
|
||||||
`starting from the Superintendent and working left, coming round again until the train is ` +
|
`eastward, repeating until the train is full or the Division Yard holds nothing it can take.`
|
||||||
`full or the Division Yard holds nothing it can take.`
|
|
||||||
: menu.makeUp.pass !== null
|
: menu.makeUp.pass !== null
|
||||||
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
|
||||||
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
|
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
|
||||||
|
|||||||
@@ -213,6 +213,9 @@ button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);colo
|
|||||||
/* actions */
|
/* actions */
|
||||||
#actions{max-height:none}
|
#actions{max-height:none}
|
||||||
.grp{margin-bottom:6px}
|
.grp{margin-bottom:6px}
|
||||||
|
/* What a role is for, under its heading — a sentence a player reads once and stops re-asking. Sized
|
||||||
|
below the buttons so it explains without competing with the thing being chosen. */
|
||||||
|
.grp .scope{margin:2px 0 4px;font-size:11.5px;line-height:1.35}
|
||||||
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
|
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
|
||||||
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
|
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
|
||||||
used nowhere else, so "this is a move" is answered before the label is read. */
|
used nowhere else, so "this is a move" is answered before the label is read. */
|
||||||
@@ -265,6 +268,13 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
|||||||
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
||||||
#gamecardbody dd.changed{color:#f0b64a}
|
#gamecardbody dd.changed{color:#f0b64a}
|
||||||
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
||||||
|
/* THE GUIDE, inside the card rather than the header (Jesse, 2026-09-21). Laid out as one wrapping
|
||||||
|
line of links rather than a list: six references stacked would make the card scroll past the
|
||||||
|
house rules it sits under, and these are a shelf to reach for, not a thing to read down. */
|
||||||
|
#gamecardbody .guide{margin-top:10px;border-top:1px solid var(--line);padding-top:6px}
|
||||||
|
#gamecardbody .guide p{margin:4px 0 0;font-size:12px;line-height:1.7;color:#8b94a3}
|
||||||
|
#gamecardbody .guide a{color:#9fb6d8;text-decoration:none;border-bottom:1px solid #33404f}
|
||||||
|
#gamecardbody .guide a:hover{color:#cfe0f5;border-bottom-color:#5aa9e6}
|
||||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||||
so a disabled button must not wear it. */
|
so a disabled button must not wear it. */
|
||||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||||
|
|||||||
+119
-1
@@ -7,7 +7,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 { advance, pump } from '../src/engine/advance.ts';
|
import { advance, badlyMadeUp, pump } from '../src/engine/advance.ts';
|
||||||
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.ts';
|
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.ts';
|
||||||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||||
import { legalActions } from '../src/engine/legal.ts';
|
import { legalActions } from '../src/engine/legal.ts';
|
||||||
@@ -1582,3 +1582,121 @@ describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §7's make-up round is silent about what it could NOT do, and that silence was reported from a
|
||||||
|
* table (2026-09-16): "train 5, the sparrow, has no coaches, which seems strange."
|
||||||
|
*
|
||||||
|
* The Sparrow's card calls for three coaches and nothing else. Every coach in that game had ended
|
||||||
|
* up in the Classification Yard, which §2.2 returns only when the Division Yard runs bare — and the
|
||||||
|
* Division Yard still held 46 freight cars, so it never would. `trainNeedingCars` therefore answered
|
||||||
|
* "nothing to ask for" exactly as it answers "everything is full", the phase moved on, and the train
|
||||||
|
* ran the length of the Division empty with no line anywhere saying why.
|
||||||
|
*/
|
||||||
|
describe('a train the Division Yard cannot supply says so', () => {
|
||||||
|
/** Puts one train in the yard-filling state, with a yard holding only `types`. */
|
||||||
|
const readyToFill = (yard: { type: string; loaded: boolean }[]) => {
|
||||||
|
const s = game();
|
||||||
|
// Stage 1's train is 1/2, the Crack Limited — coaches only, which is the shape that goes short.
|
||||||
|
s.timetable = Array(STAGES_PER_DAY).fill(null);
|
||||||
|
s.timetable[0] = 1;
|
||||||
|
s.yards.divisionYard = yard as never;
|
||||||
|
s.yards.classificationYard = [{ type: 'coach', loaded: false }] as never;
|
||||||
|
s.clock.stage = 1;
|
||||||
|
s.clock.phase = 'newTrain';
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reports the shortfall, with the cars, the waiting pile and why it will not come back', () => {
|
||||||
|
// A yard of pure freight: nothing the Crack Limited will take.
|
||||||
|
const s = readyToFill([
|
||||||
|
{ type: 'boxcar', loaded: true },
|
||||||
|
{ type: 'hopper', loaded: false },
|
||||||
|
]);
|
||||||
|
const { events } = advance(s);
|
||||||
|
const short = events.find((e) => e.type === 'makeUpShort');
|
||||||
|
assert.ok(short, 'a train that could be given nothing reported nothing');
|
||||||
|
assert.equal(short.type === 'makeUpShort' && short.placed, 0);
|
||||||
|
assert.ok(
|
||||||
|
short.type === 'makeUpShort' && short.missing.includes('coach'),
|
||||||
|
'the report did not name the category the yard could not supply',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
short.type === 'makeUpShort' && short.waiting === 1,
|
||||||
|
'the report did not count the cars waiting in the Classification Yard',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
short.type === 'makeUpShort' && short.divisionYardHolds === 2,
|
||||||
|
'the report did not say how far the Division Yard is from bare, which is what §2.2 turns on',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing when the round can still be asked for a car', () => {
|
||||||
|
// The same train, with coaches available: the phase must STOP for them rather than report.
|
||||||
|
const s = readyToFill([
|
||||||
|
{ type: 'coach', loaded: true },
|
||||||
|
{ type: 'coach', loaded: true },
|
||||||
|
]);
|
||||||
|
const { events, needsInput } = advance(s);
|
||||||
|
assert.equal(needsInput, true, 'the phase should be waiting for a car to be placed');
|
||||||
|
assert.equal(
|
||||||
|
events.some((e) => e.type === 'makeUpShort'),
|
||||||
|
false,
|
||||||
|
'a train that can still be filled was reported short',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §8.2 and the Small Yard's nose sort, together — Jesse's ruling of 2026-09-17 was "any train may
|
||||||
|
* sort to the nose, and it is held if it cannot depart".
|
||||||
|
*
|
||||||
|
* The holding half already existed and needed no new code, which is worth pinning precisely because
|
||||||
|
* it is easy to assume otherwise: `badlyMadeUp` is deliberately DIRECTION-FREE, so a train with its
|
||||||
|
* whole consist ahead of the engine is a PUSHING train and fit to run. What §8.2 refuses is a
|
||||||
|
* broken-backed train — the engine buried among its own cars — and a caboose anywhere but the end
|
||||||
|
* away from the engine.
|
||||||
|
*/
|
||||||
|
describe('a train sorted to the nose is judged by §8.2, not by where the engine is', () => {
|
||||||
|
const tray = (consist: { type: string; loaded: boolean }[], engineAt: number) =>
|
||||||
|
({ consist, engineAt }) as unknown as CrewTray;
|
||||||
|
|
||||||
|
it('lets a pushing train run — the whole consist ahead of the engine is made up', () => {
|
||||||
|
const pushing = tray([{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }], 2);
|
||||||
|
assert.equal(
|
||||||
|
badlyMadeUp(pushing),
|
||||||
|
null,
|
||||||
|
'a pushing train was refused; §8.2 is enforced direction-free on purpose',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a train whose engine is buried among its own cars', () => {
|
||||||
|
const buried = tray(
|
||||||
|
[{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: true }, { type: 'caboose', loaded: true }],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const why = badlyMadeUp(buried);
|
||||||
|
assert.ok(why !== null, 'a broken-backed train was allowed to leave the Office');
|
||||||
|
assert.match(why, /engine is buried/, `the hold did not say why: ${why}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a train whose caboose is not at the end away from the engine', () => {
|
||||||
|
// Exactly the arrangement asked for at the table: caboose second from the rear.
|
||||||
|
const wanted = tray(
|
||||||
|
[
|
||||||
|
{ type: 'hopper', loaded: true },
|
||||||
|
{ type: 'tank', loaded: false },
|
||||||
|
{ type: 'caboose', loaded: true },
|
||||||
|
{ type: 'boxcar', loaded: true },
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const why = badlyMadeUp(wanted);
|
||||||
|
assert.ok(why !== null, 'a train with its caboose mid-consist was allowed to leave');
|
||||||
|
assert.match(why, /caboose must be at the rear/, `the hold did not say why: ${why}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+30
-9
@@ -1361,6 +1361,10 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
|||||||
const under = district(s);
|
const under = district(s);
|
||||||
buildFacility(s, 'packingSheds', under);
|
buildFacility(s, 'packingSheds', under);
|
||||||
const cardId = modifierOf(s, 'iceHouse');
|
const cardId = modifierOf(s, 'iceHouse');
|
||||||
|
// `district` leaves the host ON the sign's column, which since 2026-09-17 puts its three eastern
|
||||||
|
// spots outside the Limits. The question here is whether DIAGONALS are offered at all, so the
|
||||||
|
// sign goes out one more column and the host keeps all nine.
|
||||||
|
areaOf(s, 0).limitsEast = at(areaOf(s, 0).runningRow, under.col + 1);
|
||||||
|
|
||||||
const offered = legalActions(s, 0)
|
const offered = legalActions(s, 0)
|
||||||
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
|
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
|
||||||
@@ -1375,13 +1379,18 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
|||||||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
|
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
|
it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
|
||||||
/**
|
/**
|
||||||
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
|
* Jesse's call, both halves — and the FIRST half reversed on 2026-09-17 after a Day 3 playtest
|
||||||
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
|
* put Transmission Lines at (-2,4) with the sign at column 3. It used to read the other way: a
|
||||||
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
|
* Modifier is not track (§9), so a host at the limit kept all nine of its spots, because
|
||||||
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
|
* refusing the outer three looked like it would make the card unplayable where the district
|
||||||
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
|
* ends. What decided it was the board — a card standing outside your own sign, in territory
|
||||||
|
* §8.1 and §10 reason about — and a count of what is actually lost: six of the nine spots
|
||||||
|
* survive, and the sign moves outward as the Running Track grows (§2.1, Gap 4a).
|
||||||
|
*
|
||||||
|
* The Running Track ROW stays barred for its own reason: it is the ground the main grows onto,
|
||||||
|
* and a Modifier parked there would block the player's own sign from moving outward.
|
||||||
*/
|
*/
|
||||||
const s = game();
|
const s = game();
|
||||||
const under = district(s);
|
const under = district(s);
|
||||||
@@ -1390,9 +1399,16 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
|||||||
const area = areaOf(s, 0);
|
const area = areaOf(s, 0);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, area.limitsEast.col + 1) }),
|
||||||
|
'OUTSIDE_LIMITS',
|
||||||
|
'a Modifier was allowed to stand outside the district it belongs to',
|
||||||
|
);
|
||||||
|
// The spot inside the sign, beside the same host, is the one a player actually has — free,
|
||||||
|
// adjacent, and on the sign's own column, which `withinLimits` includes.
|
||||||
|
assert.equal(
|
||||||
|
check(s, 0, { type: 'card.play', cardId, placement: at(under.row - 1, under.col) }),
|
||||||
null,
|
null,
|
||||||
'a Modifier beside a host at the limit was refused the spot outside it',
|
'a Modifier was refused a free, connected spot inside the Limits',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
|
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
|
||||||
@@ -1425,6 +1441,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
|||||||
const area = areaOf(s, 0);
|
const area = areaOf(s, 0);
|
||||||
const at = { row: -1, col: 4 };
|
const at = { row: -1, col: 4 };
|
||||||
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
|
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
|
||||||
|
// This test is about what a Modifier GRANTS, not about where it may stand, and it arranges a
|
||||||
|
// host well east of the opening sign. Modifiers have been bounded by the Limits since
|
||||||
|
// 2026-09-17, so the district has to reach the square the fixture uses or every case here would
|
||||||
|
// fail as OUTSIDE_LIMITS and prove nothing about flow.
|
||||||
|
area.limitsEast = { row: area.runningRow, col: at.col + 2 };
|
||||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
|
|
||||||
const card = [...s.cards.entries()].find(
|
const card = [...s.cards.entries()].find(
|
||||||
@@ -1881,7 +1902,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
|||||||
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
||||||
*/
|
*/
|
||||||
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
||||||
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, movesAllowed: 6, facing }) as const;
|
||||||
|
|
||||||
it('carries the east-west sense across north-south track', () => {
|
it('carries the east-west sense across north-south track', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ 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 } from '../src/engine/apply.ts';
|
||||||
|
import { legalActions } from '../src/engine/legal.ts';
|
||||||
|
import { describeIntent } from '../src/sim/view.ts';
|
||||||
|
import { badlyMadeUp } from '../src/engine/advance.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
|
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
|
||||||
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||||||
@@ -461,3 +464,161 @@ describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)'
|
|||||||
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
|
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Small Yard's re-order menu, reported from Day 3 of the 2026-09-17 playtest.
|
||||||
|
*
|
||||||
|
* Jesse had train 10 on his Small Yard — nose first, `[loaded boxcar, loaded hopper, empty tank,
|
||||||
|
* caboose]` — wanted the boxcar on the tail, and could not tell which button did it: every option
|
||||||
|
* read `re-order consist [1,2,3,0]`, the engine's own array indices offered to a person. The move he
|
||||||
|
* wanted was the FIRST of the five. One of the other four re-ordered nothing at all and would still
|
||||||
|
* have spent one of his six Moves.
|
||||||
|
*/
|
||||||
|
describe('the Small Yard says what each re-order would build', () => {
|
||||||
|
const smallYard = (standing: RollingStock[] = []): TrackCard => ({
|
||||||
|
...straight(standing),
|
||||||
|
enhancements: ['smallYard'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Train 10's consist as it actually stood, on a card carrying a Small Yard. */
|
||||||
|
const onTheYard = (): { s: GameState; trayId: string } => {
|
||||||
|
const s = game();
|
||||||
|
row(s, 3);
|
||||||
|
addCard(s, at(1, 1), smallYard());
|
||||||
|
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true), car('tank'), car('caboose', true)], 'e');
|
||||||
|
switching(s);
|
||||||
|
return { s, trayId };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('never offers a sort that changes nothing', () => {
|
||||||
|
/**
|
||||||
|
* THE PAIR IS WHAT COUNTS, since the engine position became part of a sort (2026-09-17). The
|
||||||
|
* identity car order is now a perfectly good option when it moves the ENGINE — that is the whole
|
||||||
|
* of the separate engine control — so what must never be offered is the pair that reproduces the
|
||||||
|
* train already standing there, and no pair may appear twice.
|
||||||
|
*/
|
||||||
|
const { s, trayId } = onTheYard();
|
||||||
|
const tray = s.trays.get(trayId)!;
|
||||||
|
const offered = legalActions(s, 0)
|
||||||
|
.filter((i) => i.type === 'switch.sortConsist')
|
||||||
|
.map((i) => {
|
||||||
|
const sort = i as { order: number[]; engineAt?: number };
|
||||||
|
return `${sort.order.join(',')}|${sort.engineAt ?? 0}`;
|
||||||
|
});
|
||||||
|
assert.ok(offered.length > 0, 'no sort was offered at all, so this proved nothing');
|
||||||
|
const unchanged = `${[...tray.consist.keys()].join(',')}|${tray.engineAt}`;
|
||||||
|
assert.ok(
|
||||||
|
!offered.includes(unchanged),
|
||||||
|
'the menu offered the train as it already stands — a Move spent to change nothing',
|
||||||
|
);
|
||||||
|
assert.equal(new Set(offered).size, offered.length, 'the menu offered the same sort twice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the engine every position, including ahead of its own cars', () => {
|
||||||
|
// Jesse's ruling, 2026-09-17, following `implications.md` against the v0.4.5 card text: a train
|
||||||
|
// in a Small Yard "may sort itself into any order, INCLUDING cars ahead of the engine".
|
||||||
|
const { s, trayId } = onTheYard();
|
||||||
|
const n = s.trays.get(trayId)!.consist.length;
|
||||||
|
const positions = legalActions(s, 0)
|
||||||
|
.filter((i) => i.type === 'switch.sortConsist')
|
||||||
|
.map((i) => (i as { engineAt?: number }).engineAt ?? 0);
|
||||||
|
for (let k = 1; k <= n; k++) {
|
||||||
|
assert.ok(positions.includes(k), `the engine was never offered position ${k} of ${n}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always offers a made-up order to a train that is not in one', () => {
|
||||||
|
/**
|
||||||
|
* Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the back… sorting all
|
||||||
|
* the cars back in, ready to leave the station."
|
||||||
|
*
|
||||||
|
* A train with its caboose mid-consist is one §8.2 will not let out of the Office, so at least
|
||||||
|
* one offer has to put it right. It comes out of the ordinary curated set — "bring car k to the
|
||||||
|
* tail" is enumerated for every car, and the caboose is one of them.
|
||||||
|
*/
|
||||||
|
const s = game();
|
||||||
|
row(s, 3);
|
||||||
|
addCard(s, at(1, 1), smallYard());
|
||||||
|
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('caboose', true), car('hopper', true)], 'e');
|
||||||
|
const tray = s.trays.get(trayId)!;
|
||||||
|
tray.trainNumber = 10;
|
||||||
|
switching(s);
|
||||||
|
|
||||||
|
const fit = legalActions(s, 0)
|
||||||
|
.filter((i) => i.type === 'switch.sortConsist')
|
||||||
|
.filter((i) => {
|
||||||
|
const sort = i as { order: number[]; engineAt?: number };
|
||||||
|
const after = sort.order.map((n) => tray.consist[n]!);
|
||||||
|
return badlyMadeUp({ ...tray, consist: after, engineAt: sort.engineAt ?? 0 }) === null;
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
fit.length > 0,
|
||||||
|
'a train that cannot leave the Office was offered no sort that would make it up',
|
||||||
|
);
|
||||||
|
// And the one that does it says so on the button, rather than leaving it to be discovered.
|
||||||
|
assert.ok(
|
||||||
|
fit.some((i) => describeIntent(s, i).includes('MADE UP, ready to leave')),
|
||||||
|
'the sort that makes the train up does not say so',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays the train out west to east, with the engine where it will be', () => {
|
||||||
|
/**
|
||||||
|
* "You specify above that the order is front to back, but on the screen, if it's eastbound or
|
||||||
|
* westbound, it may look different" — Jesse, 2026-09-17.
|
||||||
|
*
|
||||||
|
* `board-svg.ts` draws the crew strip west on the left, reversing a consist for an east-facing
|
||||||
|
* train so its nose lands at the east end. The button has to read the same way or it describes a
|
||||||
|
* different train from the one on the board.
|
||||||
|
*/
|
||||||
|
const s = game();
|
||||||
|
row(s, 3);
|
||||||
|
addCard(s, at(1, 1), smallYard());
|
||||||
|
// Engine points WEST: the consist is stored nose first, so west to east reads engine first.
|
||||||
|
const west = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true)], 'w');
|
||||||
|
switching(s);
|
||||||
|
const westLabel = legalActions(s, 0)
|
||||||
|
.filter((i) => i.type === 'switch.sortConsist')
|
||||||
|
.map((i) => describeIntent(s, i))
|
||||||
|
.find((l) => l.startsWith('re-order'));
|
||||||
|
assert.ok(westLabel, 'no re-order was offered for the west-facing train');
|
||||||
|
assert.match(westLabel, /west to east: ◀ ENGINE · /, `a west-facing engine was not drawn leading: ${westLabel}`);
|
||||||
|
|
||||||
|
// The same train pointing EAST puts the engine at the far end of the same sentence.
|
||||||
|
s.trays.get(west)!.railFacing = 'e';
|
||||||
|
s.trays.get(west)!.facing = 'e';
|
||||||
|
const eastLabel = legalActions(s, 0)
|
||||||
|
.filter((i) => i.type === 'switch.sortConsist')
|
||||||
|
.map((i) => describeIntent(s, i))
|
||||||
|
.find((l) => l.startsWith('re-order'));
|
||||||
|
assert.ok(eastLabel, 'no re-order was offered for the east-facing train');
|
||||||
|
assert.match(eastLabel, / · ENGINE ▶($| ·)/, `an east-facing engine was not drawn at the east end: ${eastLabel}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels each option with the train it would make, not with array indices', () => {
|
||||||
|
const { s } = onTheYard();
|
||||||
|
const labels = legalActions(s, 0)
|
||||||
|
.filter((i) => i.type === 'switch.sortConsist')
|
||||||
|
.map((i) => describeIntent(s, i));
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
labels.every((l) => !/\[\d(,\d)*\]/.test(l)),
|
||||||
|
`a re-order option still reads as a permutation: ${labels.find((l) => /\[\d(,\d)*\]/.test(l))}`,
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* The one Jesse wanted, written the way the board draws it: this crew faces EAST, so the strip
|
||||||
|
* runs west to east and the engine sits at the east end with the boxcar now furthest west.
|
||||||
|
*/
|
||||||
|
assert.ok(
|
||||||
|
labels.includes('re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶'),
|
||||||
|
`the move that puts the boxcar on the tail was not offered in words — got: ${labels.join(' | ')}`,
|
||||||
|
);
|
||||||
|
// And the engine's own positions read as what they do, not as an index.
|
||||||
|
assert.ok(
|
||||||
|
labels.some((l) => l.startsWith('put the whole consist ahead of the engine —')),
|
||||||
|
`the shoving sort was not offered in words — got: ${labels.join(' | ')}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { join } from 'node:path';
|
|||||||
|
|
||||||
import { fromSave, newGame, submit, toSave, view } from '../src/web/game.ts';
|
import { fromSave, newGame, 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';
|
||||||
|
|
||||||
const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8');
|
const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8');
|
||||||
|
|
||||||
@@ -47,6 +48,18 @@ const KNOWN_UNREDUCED = [
|
|||||||
'clearanceRequested',
|
'clearanceRequested',
|
||||||
'dispatchBonusUsed',
|
'dispatchBonusUsed',
|
||||||
'expediteFault',
|
'expediteFault',
|
||||||
|
/**
|
||||||
|
* The Freight Agent chose to do nothing (§6.3 requires no action). Emitted by `freightAgent.end`
|
||||||
|
* beside the `phaseEnded` that ends the turn, and reduces to nothing itself — exactly the
|
||||||
|
* `switchingEnded` pattern below.
|
||||||
|
*/
|
||||||
|
'freightAgentIdled',
|
||||||
|
/**
|
||||||
|
* The New Train Phase's report that it could give a train nothing (playtest, 2026-09-16, the
|
||||||
|
* Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to
|
||||||
|
* offer, so it describes rather than reduces, like every entry on this list.
|
||||||
|
*/
|
||||||
|
'makeUpShort',
|
||||||
'phaseBegan',
|
'phaseBegan',
|
||||||
/**
|
/**
|
||||||
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
|
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
|
||||||
@@ -61,6 +74,18 @@ const KNOWN_UNREDUCED = [
|
|||||||
// the pattern every entry on this list follows.
|
// the pattern every entry on this list follows.
|
||||||
'seatsRotated',
|
'seatsRotated',
|
||||||
'stageBegan',
|
'stageBegan',
|
||||||
|
/**
|
||||||
|
* §5's Fedora handover, emitted by `shiftChange` on the same mutate-then-describe path as its
|
||||||
|
* neighbours here: the clock moves the Superintendent and then says so. Added 2026-09-16 because
|
||||||
|
* riding on `actorChanged` meant the log dropped it as turn bookkeeping.
|
||||||
|
*/
|
||||||
|
'superintendentChanged',
|
||||||
|
/**
|
||||||
|
* The line that closes a switching turn. Emitted by `switch.end` beside the `phaseEnded` that
|
||||||
|
* actually ends the turn, and reduces to nothing itself: it reports what the Moves were spent on
|
||||||
|
* and where the crew was left, both of which the state already holds.
|
||||||
|
*/
|
||||||
|
'switchingEnded',
|
||||||
'trainArrived',
|
'trainArrived',
|
||||||
'trainCompleted',
|
'trainCompleted',
|
||||||
'trainDiverted',
|
'trainDiverted',
|
||||||
@@ -143,3 +168,76 @@ describe('the intents are what reconstructs a game', () => {
|
|||||||
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']);
|
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FREIGHT AGENT TURN, AS THE TABLE READS IT.
|
||||||
|
*
|
||||||
|
* Reported on Day 1 Stage 3 of v0.8.0.16, watching a bot: "one car moved to or from a facility —
|
||||||
|
* can we tell what car, what facility and whether it was to or from." Two separate faults sat
|
||||||
|
* behind that. The choice line ASSERTED a car had moved, which §6.3 does not require and the bot
|
||||||
|
* deliberately declines; and the three lines that do report the work named the grid coordinate
|
||||||
|
* rather than the industry standing on it.
|
||||||
|
*/
|
||||||
|
describe('a Freight Agent turn says what it did, to what, and where', () => {
|
||||||
|
const ctx = {
|
||||||
|
playerName: () => 'Bot 1',
|
||||||
|
facilityAt: (_p: number, c: { row: number; col: number }) =>
|
||||||
|
c.row === -1 && c.col === 1 ? 'the Freight House' : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('announces the OPTION without claiming a car moved', () => {
|
||||||
|
const line = narrate({ type: 'localOpsOptionChosen', player: 0, option: 'freightAgent' } as never, ctx);
|
||||||
|
assert.ok(
|
||||||
|
!/one car moved/.test(line.text),
|
||||||
|
`the choice line still asserts an outcome: ${line.text}`,
|
||||||
|
);
|
||||||
|
// It must still say what the Freight Agent is FOR, or the option is a bare name.
|
||||||
|
assert.match(line.text, /Outbound|Inbound|jam/, `the choice line says nothing about the work: ${line.text}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the Freight Agent deliberately does nothing', () => {
|
||||||
|
// The bot takes this route on purpose: unjamming a healthy box destroys a stocked load. Silence
|
||||||
|
// here is what made the turn read as a dropped turn.
|
||||||
|
const line = narrate({ type: 'freightAgentIdled', player: 0 } as never, ctx);
|
||||||
|
assert.match(line.text, /nothing worth doing/, `an idle Freight Agent is silent: ${line.text}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the industry and the direction, not a coordinate', () => {
|
||||||
|
const at = { row: -1, col: 1 };
|
||||||
|
const stocked = narrate(
|
||||||
|
{ type: 'stockToOutbound', player: 0, at, stock: { type: 'boxcar', loaded: true } } as never,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.match(stocked.text, /the Freight House/, `no industry named: ${stocked.text}`);
|
||||||
|
assert.ok(!/\(1, -1\)/.test(stocked.text), `still speaking in coordinates: ${stocked.text}`);
|
||||||
|
assert.match(stocked.text, /boxcar/, `the car is not named: ${stocked.text}`);
|
||||||
|
assert.match(stocked.text, /INTO/, `the direction is not stated: ${stocked.text}`);
|
||||||
|
|
||||||
|
const cleared = narrate(
|
||||||
|
{ type: 'inboundCleared', player: 0, at, stock: { type: 'hopper', loaded: true } } as never,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.match(cleared.text, /the Freight House/, `no industry named: ${cleared.text}`);
|
||||||
|
assert.match(cleared.text, /OUT of/, `the direction is not stated: ${cleared.text}`);
|
||||||
|
|
||||||
|
const jam = narrate(
|
||||||
|
{ type: 'facilityUnjammed', player: 0, at, from: 'menAtWork', stock: { type: 'tank', loaded: true } } as never,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.match(jam.text, /the Freight House/, `no industry named: ${jam.text}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where there is no industry, the coordinate is still the honest fallback rather than "nowhere".
|
||||||
|
*
|
||||||
|
* AND IT IS SPELLED THE WAY THE ACTION MENU SPELLS IT — X,Y, east/west then north/south. The
|
||||||
|
* log printed the internal row/col order until 2026-09-21, so the same square read "(1,-1)" in
|
||||||
|
* the menu and "(-1,1)" in the log, side by side. `test/web.test.ts` pins the two against each
|
||||||
|
* other; this pins the order itself.
|
||||||
|
*/
|
||||||
|
const plain = narrate(
|
||||||
|
{ type: 'stockToOutbound', player: 0, at: { row: -2, col: 4 }, stock: { type: 'boxcar', loaded: true } } as never,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
assert.match(plain.text, /\(4,-2\)/, `the fallback coordinate is not in X,Y order: ${plain.text}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+22
-4
@@ -44,8 +44,12 @@ const SAMPLES: GameEvent[] = [
|
|||||||
{ type: 'stageBegan', day: 1, stage: 7 },
|
{ type: 'stageBegan', day: 1, stage: 7 },
|
||||||
{ type: 'phaseBegan', phase: 'mainline' },
|
{ type: 'phaseBegan', phase: 'mainline' },
|
||||||
{ type: 'actorChanged', player: 0 },
|
{ type: 'actorChanged', player: 0 },
|
||||||
|
// Sampled rather than left to swell the unsampled count: this sentence is one a player reads at
|
||||||
|
// the table every third Stage, so its text is worth exercising.
|
||||||
|
{ type: 'superintendentChanged', player: 1, stage: 6 },
|
||||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5, movesAllowed: 6 },
|
||||||
|
{ type: 'switchingEnded', player: 0, movesUsed: 3, movesAllowed: 6, lastMove: { trayId: 't0', to: { row: 0, col: 1 } } },
|
||||||
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||||
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||||
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||||
@@ -55,11 +59,25 @@ const SAMPLES: GameEvent[] = [
|
|||||||
{ type: 'deckReshuffled', order: ['c1', 'c2', 'c3', 'c4'], rngState: 7 },
|
{ type: 'deckReshuffled', order: ['c1', 'c2', 'c3', 'c4'], rngState: 7 },
|
||||||
{ type: 'departmentRefilled', slot: 0, cardId: 'c2' },
|
{ type: 'departmentRefilled', slot: 0, cardId: 'c2' },
|
||||||
{ type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
{ type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
||||||
|
// Sampled from the start rather than joining the unsampled 25: an idle Freight Agent is the ONE
|
||||||
|
// line whose whole job is to be read when nothing happened, so an empty or fallback sentence
|
||||||
|
// would reproduce the silence it exists to fix.
|
||||||
|
{ type: 'freightAgentIdled', player: 0 },
|
||||||
{ type: '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 },
|
||||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } },
|
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false }, trainNumber: 10, isExtra: false },
|
||||||
{ type: 'carPassed', player: 0, trayId: 't0' },
|
{ type: 'carPassed', player: 0, trayId: 't0', trainNumber: 10, isExtra: false },
|
||||||
|
{
|
||||||
|
type: 'makeUpShort',
|
||||||
|
trainNumber: 5,
|
||||||
|
isExtra: false,
|
||||||
|
placed: 0,
|
||||||
|
wanted: 3,
|
||||||
|
missing: ['coach'],
|
||||||
|
waiting: 14,
|
||||||
|
divisionYardHolds: 46,
|
||||||
|
},
|
||||||
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
|
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
|
||||||
{ type: 'clearanceGiven', trainId: 't1', allow: false },
|
{ type: 'clearanceGiven', trainId: 't1', allow: false },
|
||||||
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
|
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
|
||||||
@@ -109,7 +127,7 @@ describe('narration', () => {
|
|||||||
/**
|
/**
|
||||||
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
|
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
|
||||||
*
|
*
|
||||||
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
|
* `SAMPLES` exercises the TEXT of 31 of the 56 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
|
||||||
|
|||||||
+1
-1
@@ -230,7 +230,7 @@ describe('card catalogue (component 1)', () => {
|
|||||||
* "Both" column loses its only argument.
|
* "Both" column loses its only argument.
|
||||||
*
|
*
|
||||||
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
|
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
|
||||||
* the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
|
* the Grocer's Warehouse only receives. `home-deck.md` prints both that way,
|
||||||
* and so does the modifier set — all three Refinery modifiers grant outbound.
|
* and so does the modifier set — all three Refinery modifiers grant outbound.
|
||||||
*/
|
*/
|
||||||
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
||||||
|
|||||||
+199
-3
@@ -12,9 +12,10 @@
|
|||||||
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 } from '../src/engine/apply.ts';
|
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||||
import { legalActions } from '../src/engine/legal.ts';
|
import { legalActions } from '../src/engine/legal.ts';
|
||||||
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
||||||
|
import { turnOf } from '../src/engine/state.ts';
|
||||||
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||||
import { currentActor } from '../src/web/game.ts';
|
import { currentActor } from '../src/web/game.ts';
|
||||||
import { applyPublicDelta } from '../src/sim/public-delta.ts';
|
import { applyPublicDelta } from '../src/sim/public-delta.ts';
|
||||||
@@ -55,7 +56,18 @@ const config: GameConfig = {
|
|||||||
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
|
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
|
||||||
|
|
||||||
/** How each of those four reads in the log, so the assertions can find them by text. */
|
/** How each of those four reads in the log, so the assertions can find them by text. */
|
||||||
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled \d+ car|set out |used the SMALL YARD)/;
|
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled at |set out |used the SMALL YARD)/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two tones a switching line may carry, and why attribution matters in BOTH.
|
||||||
|
*
|
||||||
|
* Since 2026-09-17 a plain move along your own track is written `trace`: the line still exists, so
|
||||||
|
* its display step has narration to caption the board with and a dwell to be watched for, but the
|
||||||
|
* history panel does not draw it (`web/game.ts` § inHistory). What must never happen either way is
|
||||||
|
* the line failing to say whose crew it was — the caption is read by the whole table while the move
|
||||||
|
* goes up, which is if anything the more public of the two places.
|
||||||
|
*/
|
||||||
|
const SWITCHING_TONES = ['act', 'trace'];
|
||||||
|
|
||||||
describe('switching is attributed — TODO #13', () => {
|
describe('switching is attributed — TODO #13', () => {
|
||||||
it('every switching event carries the player who acted', () => {
|
it('every switching event carries the player who acted', () => {
|
||||||
@@ -124,7 +136,10 @@ describe('switching is attributed — TODO #13', () => {
|
|||||||
`an unattributed switching line survived: ${line.text}`,
|
`an unattributed switching line survived: ${line.text}`,
|
||||||
);
|
);
|
||||||
if (SWITCHING_LINE.test(line.text)) {
|
if (SWITCHING_LINE.test(line.text)) {
|
||||||
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
|
assert.ok(
|
||||||
|
SWITCHING_TONES.includes(line.tone),
|
||||||
|
`a switching line must read as somebody's move: ${line.text} (tone ${line.tone})`,
|
||||||
|
);
|
||||||
lines++;
|
lines++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,6 +325,33 @@ describe('steps reach a seated player — TODO #13', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the Fedora passing is visible (playtest 2026-09-16)', () => {
|
||||||
|
it('names the new Superintendent in the history at the Stage it happens', async () => {
|
||||||
|
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||||
|
const { legalActions } = await import('../src/engine/legal.ts');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* It used to ride on `actorChanged`, which `record()` drops as turn bookkeeping — so the one
|
||||||
|
* moment that event meant something never reached a player. Driven far enough to cross a shift
|
||||||
|
* boundary (Stages 3, 6, 9, 12) rather than asserted on a hand-built event, because the point is
|
||||||
|
* that a real game produces the line.
|
||||||
|
*/
|
||||||
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
for (let i = 0; i < 900; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||||
|
if (game.state.clock.stage > 3 || game.state.clock.day > 1) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handover = game.log.filter((l) => /SUPERINTENDENT — the Fedora passes to/.test(l.text));
|
||||||
|
assert.ok(handover.length > 0, 'the game crossed a shift change and the log never said so');
|
||||||
|
assert.match(handover[0]!.text, /Alice|Bob|Carol/, 'the handover did not name the new Superintendent');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
||||||
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
||||||
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
||||||
@@ -377,3 +419,157 @@ describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL — Jesse's ruling, 2026-09-17, asked as a question
|
||||||
|
* from the table: *"Does switching show up in history at all? Should it? … I don't think I want all
|
||||||
|
* six moves showing up… maybe dropping off or picking up cars in industries should be recorded."*
|
||||||
|
*
|
||||||
|
* It all showed up. A six-Move turn wrote a line per move, every one of them a pair of coordinates,
|
||||||
|
* and two players shunting pushed everything else off the panel. What stays is the line saying
|
||||||
|
* somebody switched, work at an INDUSTRY, and the Small Yard sort.
|
||||||
|
*
|
||||||
|
* THE LINE IS STILL WRITTEN, MARKED `trace`. `dwellForStep` gives a step no dwell when it produced
|
||||||
|
* no narration, so dropping these outright stopped the board replaying switching at all — the first
|
||||||
|
* attempt at this did exactly that and the step-queue suite caught it. The tone is the seam: the
|
||||||
|
* caption still has its text, the panel filters the tone out.
|
||||||
|
*/
|
||||||
|
describe('the history panel keeps the switching that matters', () => {
|
||||||
|
const play = (seed: number, steps: number) => {
|
||||||
|
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const actor = currentActor(game);
|
||||||
|
if (actor === null) break;
|
||||||
|
const options = legalActions(game.state, actor);
|
||||||
|
if (options.length === 0) break;
|
||||||
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||||
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||||
|
}
|
||||||
|
return game;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps the first move of a turn and traces the ones after it', () => {
|
||||||
|
const game = play(1917398, 800);
|
||||||
|
const moves = game.log.filter((l) => / moved (Train|the local crew)/.test(l.text));
|
||||||
|
assert.ok(moves.length > 0, 'no crew move reached the log, so this proved nothing');
|
||||||
|
|
||||||
|
// The opener of a turn is the move that leaves `movesAllowed - 1` behind it, which the line
|
||||||
|
// prints — "5 of 6 Moves left". Those are drawn; everything after them is caption-only.
|
||||||
|
const opening = moves.filter((l) => / 5 of 6 Moves left/.test(l.text));
|
||||||
|
const later = moves.filter((l) => !/ 5 of 6 Moves left/.test(l.text));
|
||||||
|
assert.ok(opening.length > 0, 'no turn opened with a move, so this proved nothing');
|
||||||
|
assert.ok(later.length > 0, 'no turn made a second move, so this proved nothing');
|
||||||
|
assert.ok(
|
||||||
|
opening.every((l) => l.tone !== 'trace'),
|
||||||
|
`the first move of a switching turn was hidden: ${opening.find((l) => l.tone === 'trace')?.text}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
later.every((l) => l.tone === 'trace'),
|
||||||
|
`a move from the middle of a turn is still drawn: ${later.find((l) => l.tone !== 'trace')?.text}`,
|
||||||
|
);
|
||||||
|
// Every one keeps its text, because that is what captions the board as the move goes up.
|
||||||
|
assert.ok(moves.every((l) => /^Player /.test(l.text)), 'a trace line lost its attribution');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes a switching turn with what it cost and where the crew was left', () => {
|
||||||
|
/**
|
||||||
|
* BUILT, NOT PLAYED — the driver above never submits `switch.end`: it finds a move or another
|
||||||
|
* option every time, so 800 turns produced no closed switching turn at all and the assertion
|
||||||
|
* would have been vacuous.
|
||||||
|
*/
|
||||||
|
const { game, trayId } = crewOnAnIndustry();
|
||||||
|
assert.ok(submit(game, { type: 'switch.move', trayId, to: { row: 1, col: 2 }, reverse: false }));
|
||||||
|
assert.ok(submit(game, { type: 'switch.end' }), 'the turn would not end');
|
||||||
|
|
||||||
|
const closing = game.log.filter((l) => / finished switching/.test(l.text));
|
||||||
|
assert.equal(closing.length, 1, `expected one closing line, got ${closing.length}`);
|
||||||
|
assert.notEqual(closing[0]!.tone, 'trace', 'the closing summary was hidden from the history');
|
||||||
|
assert.match(
|
||||||
|
closing[0]!.text,
|
||||||
|
/1 of 6 Moves used, leaving .* at /,
|
||||||
|
`the closing line did not say what it cost and where the crew was left: ${closing[0]!.text}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A crew standing on a Freight House with a loaded boxcar, and plain track to its east.
|
||||||
|
*
|
||||||
|
* BUILT RATHER THAN PLAYED. The bot prefers moves over couplings, so 2400 driven turns across
|
||||||
|
* three seeds produced not one set-out at an industry, and none of them ever ended a switching
|
||||||
|
* turn — a driver that cannot reach the case cannot test it.
|
||||||
|
*/
|
||||||
|
const crewOnAnIndustry = (): { game: ReturnType<typeof newGame>; trayId: string } => {
|
||||||
|
const game = newGame(77);
|
||||||
|
const area = areaOf(game.state, 0);
|
||||||
|
const spot = { row: 1, col: 1 };
|
||||||
|
const track = (over: object = {}) => ({
|
||||||
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
|
baseOperationalRail: true,
|
||||||
|
standing: [],
|
||||||
|
standingWest: 0,
|
||||||
|
facility: null,
|
||||||
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
area.grid.set('1,1', track({
|
||||||
|
geometry: { kind: 'facility', facility: 'freightHouse', axis: 'ew' },
|
||||||
|
facility: {
|
||||||
|
kind: 'freight', subtype: 'freightHouse',
|
||||||
|
allows: { outbound: true, inbound: true },
|
||||||
|
outboundBox: [], inboundBox: [],
|
||||||
|
capacity: { outbound: 1, inbound: 1 },
|
||||||
|
menAtWork: [null, null, null],
|
||||||
|
industryTrack: { cars: [] },
|
||||||
|
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||||
|
},
|
||||||
|
}) as never);
|
||||||
|
area.grid.set('1,2', track() as never);
|
||||||
|
|
||||||
|
const trayId = game.state.freeTrays.pop()!;
|
||||||
|
game.state.trays.set(trayId, {
|
||||||
|
id: trayId, trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [{ type: 'boxcar', loaded: true }],
|
||||||
|
direction: 'east', facing: 'e', railFacing: 'e',
|
||||||
|
position: { at: 'grid', seat: 0, coord: spot }, movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
game.state.clock.phase = 'localOps';
|
||||||
|
game.state.clock.currentActor = 0;
|
||||||
|
turnOf(game.state, 0).option = 'switch';
|
||||||
|
return { game, trayId };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('keeps work at an industry, and drops the same move on plain track', () => {
|
||||||
|
const { game, trayId } = crewOnAnIndustry();
|
||||||
|
const plain = { row: 1, col: 2 };
|
||||||
|
assert.ok(submit(game, { type: 'switch.dropCars', trayId, count: 1 }), 'the set-out was refused');
|
||||||
|
const dropped = game.log.filter((l) => /set out/i.test(l.text));
|
||||||
|
assert.equal(dropped.length, 1, `expected one set-out line, got ${dropped.length}`);
|
||||||
|
assert.notEqual(
|
||||||
|
dropped[0]!.tone,
|
||||||
|
'trace',
|
||||||
|
'work at an industry was hidden from the history — it is the point of switching',
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
dropped[0]!.text,
|
||||||
|
/at the Freight House/,
|
||||||
|
`the line named a coordinate instead of the industry: ${dropped[0]!.text}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same crew moving onto ordinary track is the noise this ruling was about.
|
||||||
|
assert.ok(submit(game, { type: 'switch.move', trayId, to: plain, reverse: false }), 'the move was refused');
|
||||||
|
const moved = game.log.filter((l) => / moved the local crew/.test(l.text));
|
||||||
|
assert.equal(moved.length, 1, `expected one move line, got ${moved.length}`);
|
||||||
|
/**
|
||||||
|
* The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
|
||||||
|
* checked here is that it names the square by what stands on it rather than by its coordinates.
|
||||||
|
*
|
||||||
|
* The coordinate branch is `(2,1)` for `{ row: 1, col: 2 }`: X,Y, east/west then north/south,
|
||||||
|
* which is how `view.ts` has always written a square and how `narrate.ts` writes one since
|
||||||
|
* 2026-09-21. It read `(1,2)` here while the log still printed the internal storage order.
|
||||||
|
*/
|
||||||
|
assert.match(moved[0]!.text, /→ the Freight House|→ \(2,1\)/, `unexpected move line: ${moved[0]!.text}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+359
-4
@@ -22,7 +22,7 @@ import { cardDescription, cardName, describeIntent, variantLabel } from '../src/
|
|||||||
import { variantsFor } from '../src/engine/track.ts';
|
import { variantsFor } from '../src/engine/track.ts';
|
||||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
import { 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 } 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 { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||||
@@ -310,6 +310,119 @@ describe('the action menu presents choices the way they are made', () => {
|
|||||||
assert.deepEqual(shapes, ['en|ew', 'we|ws'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
|
assert.deepEqual(shapes, ['en|ew', 'we|ws'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers ABS Signals on EVERY Mainline card, each one named', () => {
|
||||||
|
/**
|
||||||
|
* REGRESSION — reported from a table on Day 1 Stage 1 of v0.8.0.16, and the THIRD instance of
|
||||||
|
* one trap. The action list drops duplicate labels, and `describeIntent` for a card play named
|
||||||
|
* the grid placement but never `node` — so every Mainline card produced the identical label
|
||||||
|
* "play ABS Signals" and all but the lowest-index one were discarded before the menu saw them.
|
||||||
|
* The card's own tooltip says "any Mainline card" while exactly one was ever on offer.
|
||||||
|
*
|
||||||
|
* The engine was never wrong: `check` accepts any node whose kind is 'mainline', and
|
||||||
|
* `legalActions` filters by `check`. The whole failure was in the label.
|
||||||
|
*/
|
||||||
|
const game = newGame(555);
|
||||||
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||||
|
|
||||||
|
let absId: string | undefined;
|
||||||
|
for (const [id, card] of game.state.cards) {
|
||||||
|
const k = card.kind as { kind: string; key?: string };
|
||||||
|
if (k.kind === 'enhancement' && k.key === 'absSignals') { absId = id; break; }
|
||||||
|
}
|
||||||
|
assert.ok(absId, 'the deck has no ABS Signals card');
|
||||||
|
game.state.decks.hands.set(0, [absId]);
|
||||||
|
|
||||||
|
const mainlineNodes = game.state.division.nodes
|
||||||
|
.map((n, i) => ({ n, i }))
|
||||||
|
.filter(({ n }) => n.kind === 'mainline');
|
||||||
|
assert.ok(mainlineNodes.length > 1, 'this division has only one Mainline card — nothing to distinguish');
|
||||||
|
|
||||||
|
// The engine offers one per Mainline card ...
|
||||||
|
const offered = actionGroups(game).options.filter(
|
||||||
|
(o) => o.type === 'card.play' && o.cardId === absId && o.node !== undefined,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
offered.length,
|
||||||
|
mainlineNodes.length,
|
||||||
|
`the engine offers ${offered.length} placements for ${mainlineNodes.length} Mainline cards`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ... and every one of them must survive into the menu, which means distinct labels.
|
||||||
|
const labels = offered.map((o) => describeIntent(game.state, o));
|
||||||
|
assert.equal(
|
||||||
|
new Set(labels).size,
|
||||||
|
offered.length,
|
||||||
|
`the labels collapse, so the menu drops all but one: ${[...new Set(labels)].join(' / ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const spots = actionMenu(game)
|
||||||
|
.placeable.flatMap((g) => g.items)
|
||||||
|
.filter((it) => it.subjectKey === `card:${absId}`)
|
||||||
|
.flatMap((it) => it.spots);
|
||||||
|
assert.equal(
|
||||||
|
spots.length,
|
||||||
|
mainlineNodes.length,
|
||||||
|
`only ${spots.length} of ${mainlineNodes.length} Mainline cards can be chosen`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* And the card is called what the card face calls it. `prettyKey` rendered `absSignals` as
|
||||||
|
* "Abs Signals" on a button while the tooltip beside it said ABS — an acronym no key-splitter
|
||||||
|
* can recover, so the authored name in `ENHANCEMENT_CARDS` has to win.
|
||||||
|
*/
|
||||||
|
assert.ok(
|
||||||
|
labels.every((l) => l.includes('ABS Signals')),
|
||||||
|
`the card is not called by its printed name: ${labels[0]}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each spot names the card it would go on, so the choice is legible rather than positional.
|
||||||
|
for (const { n } of mainlineNodes) {
|
||||||
|
const name = mainlineProfile((n as { card: Parameters<typeof mainlineProfile>[0] }).card).name;
|
||||||
|
assert.ok(
|
||||||
|
spots.some((sp) => sp.label.includes(name)),
|
||||||
|
`no spot names the ${name}: ${spots.map((sp) => sp.label).join(' / ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spells a square the same way in the action menu and in the log', () => {
|
||||||
|
/**
|
||||||
|
* REPORTED 2026-09-21. `view.ts` wrote "(col,row)" — X,Y, east/west then north/south, with a
|
||||||
|
* comment saying so — and `narrate.ts` wrote "(row,col)", the internal storage order, with no
|
||||||
|
* comment at all. So the action menu offered a move to "(1,-1)" and the log then reported it at
|
||||||
|
* "(-1,1)", in two panels a player reads side by side.
|
||||||
|
*
|
||||||
|
* PINNED AGAINST EACH OTHER rather than against a literal: a test asserting one format would
|
||||||
|
* have passed all along on whichever file it was written against. This compares the two
|
||||||
|
* renderers on the same square, which is the property that was actually broken.
|
||||||
|
*/
|
||||||
|
const game = newGame(555);
|
||||||
|
const square = { row: -1, col: 2 };
|
||||||
|
|
||||||
|
const logged = describeIntent(game.state, {
|
||||||
|
type: 'switch.move',
|
||||||
|
trayId: [...game.state.trays.keys()][0]!,
|
||||||
|
to: square,
|
||||||
|
reverse: false,
|
||||||
|
});
|
||||||
|
const menu = describeIntent(game.state, {
|
||||||
|
type: 'card.play',
|
||||||
|
cardId: game.state.decks.hands.get(0)![0]!,
|
||||||
|
placement: square,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both must render the square, and render it identically.
|
||||||
|
const coord = /\((-?\d+,-?\d+)\)/;
|
||||||
|
const inLog = coord.exec(logged)?.[1];
|
||||||
|
const inMenu = coord.exec(menu)?.[1];
|
||||||
|
assert.ok(inLog, `the log line names no square: ${logged}`);
|
||||||
|
assert.ok(inMenu, `the menu line names no square: ${menu}`);
|
||||||
|
assert.equal(inMenu, inLog, 'the action menu and the log spell the same square differently');
|
||||||
|
|
||||||
|
// And the shared spelling is X,Y — east/west first, which is the order the map is drawn in.
|
||||||
|
assert.equal(inMenu, '2,-1', `not X,Y order: ${inMenu}`);
|
||||||
|
});
|
||||||
|
|
||||||
it('says how deep a Department pile is, so a discard can be aimed', () => {
|
it('says how deep a Department pile is, so a discard can be aimed', () => {
|
||||||
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
|
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
|
||||||
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
|
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
|
||||||
@@ -1522,6 +1635,15 @@ describe('the static build', () => {
|
|||||||
attrs,
|
attrs,
|
||||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||||
getAttribute: (k: string) => attrs[k] ?? null,
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||||
|
* `setAttribute` taught this factory above, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel throws on every render.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||||
@@ -1779,6 +1901,15 @@ describe('the static build', () => {
|
|||||||
getAttribute: (k: string) => attrs[k] ?? null,
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||||
|
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel throws on every render.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||||
showModal() {
|
showModal() {
|
||||||
@@ -1894,6 +2025,15 @@ describe('the static build', () => {
|
|||||||
getAttribute: (k: string) => attrs[k] ?? null,
|
getAttribute: (k: string) => attrs[k] ?? null,
|
||||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||||
title: '', returnValue: '', open: false,
|
title: '', returnValue: '', open: false,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
|
||||||
|
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel throws on every render.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
showModal() {},
|
showModal() {},
|
||||||
close() {},
|
close() {},
|
||||||
@@ -2680,10 +2820,15 @@ describe('the static build', () => {
|
|||||||
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
||||||
/**
|
/**
|
||||||
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
||||||
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up "starting
|
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up starting
|
||||||
* with the Superintendent and working left" — but nothing on the board said who the
|
* with the Superintendent and working EASTWARD — but nothing on the board said who the
|
||||||
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
||||||
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
||||||
|
*
|
||||||
|
* The rule text said "working left" until 2026-09-16. It is the same rule — `playerLeftOf` is
|
||||||
|
* increasing seat index — but "left" describes a table nobody is looking at, while the map on
|
||||||
|
* screen runs west to east, so at a real three-player game it read as plainly wrong: the second
|
||||||
|
* car went to the player sitting to the EAST. The word changed; the order did not.
|
||||||
*/
|
*/
|
||||||
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
||||||
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
||||||
@@ -2697,7 +2842,7 @@ describe('the static build', () => {
|
|||||||
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
||||||
assert.match(
|
assert.match(
|
||||||
src,
|
src,
|
||||||
/starting with the Superintendent and working left/,
|
/starting with the Superintendent and working eastward/,
|
||||||
'the New Train pill does not say whose turn the make-up round starts on',
|
'the New Train pill does not say whose turn the make-up round starts on',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -2978,6 +3123,82 @@ describe('the Division map shows the whole route', () => {
|
|||||||
return divisionSvg(snapshot(s, [], null).division);
|
return divisionSvg(snapshot(s, [], null).division);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('draws a signal on a Mainline card carrying ABS Signals, not only a tooltip', () => {
|
||||||
|
/**
|
||||||
|
* REPORTED FROM A TABLE, Day 1 Stage 1 of v0.8.0.16: "when played on the trestle, there was no
|
||||||
|
* on-the-card indication. It's only when you look at the tooltip for trestle that you see that
|
||||||
|
* ABS exists." The same complaint the Heavy Grade wedge below answers, and it matters more
|
||||||
|
* here — ABS is what decides whether running a second train onto that card is safe.
|
||||||
|
*/
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'div-abs',
|
||||||
|
seed: 7,
|
||||||
|
config: {
|
||||||
|
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['A', 'B'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const before = divisionSvg(snapshot(s, [], null).division);
|
||||||
|
assert.ok(!before.includes('bs-abs'), 'a signal is drawn before ABS Signals was ever played');
|
||||||
|
|
||||||
|
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
||||||
|
assert.ok(node, 'this division has no Mainline card');
|
||||||
|
(node as { absSignals?: boolean }).absSignals = true;
|
||||||
|
|
||||||
|
const after = divisionSvg(snapshot(s, [], null).division);
|
||||||
|
assert.ok(after.includes('bs-abs-mast'), 'the card carrying ABS Signals draws no signal mast');
|
||||||
|
assert.ok(after.includes('bs-abs-lit'), 'the signal has no lit aspect');
|
||||||
|
// Exactly one card carries it, so the mark cannot be a row-wide decoration.
|
||||||
|
assert.equal((after.match(/bs-abs-mast/g) ?? []).length, 1, 'the signal is drawn on more than one card');
|
||||||
|
// And it stays in the tooltip too — the mark says THAT, the tip still says what it does.
|
||||||
|
assert.ok(after.includes('ABS Signals'), 'the tooltip stopped naming ABS Signals');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the Mainline modifiers on the card, not only in the tooltip', () => {
|
||||||
|
/**
|
||||||
|
* The other half of the ABS report (Jesse, 2026-09-21): "Brakeman, Airbrakes, Helpers and
|
||||||
|
* Realignment should also be drawn on the card, not just the tooltip."
|
||||||
|
*
|
||||||
|
* REALIGNMENT IS NOT IN THIS LIST ON PURPOSE. It never sits on a card — `reduce` takes the
|
||||||
|
* `became` branch and changes `node.card` outright — so a realigned card already announces
|
||||||
|
* itself by being a different card. Asserted below so the absence is a recorded finding rather
|
||||||
|
* than something that looks forgotten.
|
||||||
|
*/
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'div-mods',
|
||||||
|
seed: 7,
|
||||||
|
config: {
|
||||||
|
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
|
||||||
|
pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['A', 'B', 'C', 'D'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
||||||
|
assert.ok(node, 'this division has no Mainline card');
|
||||||
|
|
||||||
|
assert.ok(!divisionSvg(snapshot(s, [], null).division).includes('bs-mod'), 'a tag is drawn with no modifier on');
|
||||||
|
|
||||||
|
(node as { modifiers?: string[] }).modifiers = ['brakeman', 'airbrakes', 'helpers'];
|
||||||
|
const svg = divisionSvg(snapshot(s, [], null).division);
|
||||||
|
for (const tag of ['BRK', 'AIR', 'HLP']) {
|
||||||
|
assert.ok(svg.includes(tag), `the ${tag} modifier is not drawn on the card`);
|
||||||
|
}
|
||||||
|
// And the tooltip still names them in full — the tag says THAT, the tip says WHICH.
|
||||||
|
assert.match(svg, /Brakeman/, 'the tooltip stopped naming the modifiers');
|
||||||
|
|
||||||
|
// Realignment converts the card instead of sitting on it, so it must never produce a tag.
|
||||||
|
(node as { modifiers?: string[] }).modifiers = ['realignment'];
|
||||||
|
assert.ok(
|
||||||
|
!divisionSvg(snapshot(s, [], null).division).includes('bs-mod'),
|
||||||
|
'Realignment drew a tag, but it changes the card rather than standing on it',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('draws which way a Heavy Grade climbs, instead of only saying it in the tooltip', () => {
|
it('draws which way a Heavy Grade climbs, instead of only saying it in the tooltip', () => {
|
||||||
/**
|
/**
|
||||||
* REPORTED BY JESSE 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card
|
* REPORTED BY JESSE 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card
|
||||||
@@ -4170,6 +4391,16 @@ describe('the lobby screen', () => {
|
|||||||
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
||||||
querySelectorAll: (sel: string) => matching(sel),
|
querySelectorAll: (sel: string) => matching(sel),
|
||||||
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
||||||
|
/**
|
||||||
|
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the same
|
||||||
|
* lesson `setAttribute` taught this factory in 2026-08-30, learned again on 2026-09-16.
|
||||||
|
*
|
||||||
|
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
|
||||||
|
* display name is another player's text and must never be interpolated into markup. Without
|
||||||
|
* this the district panel threw on every render, which took out 21 tests across three suites
|
||||||
|
* — and the thing it was hiding was a control nothing had ever exercised.
|
||||||
|
*/
|
||||||
|
appendChild: () => {},
|
||||||
};
|
};
|
||||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||||
return node;
|
return node;
|
||||||
@@ -4567,6 +4798,9 @@ describe('the solitaire setup screen', () => {
|
|||||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||||
checked: false, disabled: false, hidden: false, className: '',
|
checked: false, disabled: false, hidden: false, className: '',
|
||||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||||
|
// The Office Area's per-seat buttons are created and appended rather than interpolated, so a
|
||||||
|
// node that cannot be appended to throws on every render — see the note in the other factory.
|
||||||
|
appendChild: () => {},
|
||||||
addEventListener: (type: string, fn: () => void) =>
|
addEventListener: (type: string, fn: () => void) =>
|
||||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||||
showModal: () => void ((node as { open: boolean }).open = true),
|
showModal: () => void ((node as { open: boolean }).open = true),
|
||||||
@@ -5435,3 +5669,124 @@ describe('the Superintendent ruling names the train it is ruling on', () => {
|
|||||||
assert.match(title, /Train 4/);
|
assert.match(title, /Train 4/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §2.2 and §9.2 together strand every coach in the Classification Yard, and the board does not say
|
||||||
|
* so — Jesse's ruling of 2026-09-17 is that the rule stands and the game says it loudly.
|
||||||
|
*
|
||||||
|
* Tested on the FRAME rather than the DOM, because the counts are what the warning is derived from
|
||||||
|
* and they are what could go wrong: the panel asks for a per-type count the Frame already carries.
|
||||||
|
*/
|
||||||
|
describe('a Division Yard with no coaches is a reportable condition', () => {
|
||||||
|
it('carries per-type counts for both yards, so the panel can tell coaches from cars', () => {
|
||||||
|
const game = newGame(4242);
|
||||||
|
game.state.yards.divisionYard = [{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: false }] as never;
|
||||||
|
game.state.yards.classificationYard = [
|
||||||
|
{ type: 'coach', loaded: false },
|
||||||
|
{ type: 'coach', loaded: true },
|
||||||
|
] as never;
|
||||||
|
const f = view(game);
|
||||||
|
|
||||||
|
const coachesInDivision = f.yards.division.find((c) => c.type === 'coach');
|
||||||
|
assert.equal(coachesInDivision, undefined, 'the fixture put no coach in the Division Yard');
|
||||||
|
assert.notEqual(f.yards.divisionTotal, 0, 'a yard holding freight is not bare, which is the whole point');
|
||||||
|
|
||||||
|
const waiting = f.yards.classification.find((c) => c.type === 'coach');
|
||||||
|
assert.ok(waiting, 'the coaches in Classification were not reported by type');
|
||||||
|
assert.equal(waiting.loaded + waiting.empty, 2, 'the waiting coaches were miscounted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Quickstart is published beside the game, so a tester on the box can reach it.
|
||||||
|
*
|
||||||
|
* WHY THIS IS A TEST. The link on the splash page is a plain href to a file the BUILD copies out of
|
||||||
|
* `docs/`. Nothing else connects the two: rename the document, or move it, and the build quietly
|
||||||
|
* publishes nothing while the splash page keeps offering a link that 404s. Neither `tsc` nor any
|
||||||
|
* other test would notice — the whole failure lives between a file name and a string.
|
||||||
|
*/
|
||||||
|
describe('the Quickstart guide reaches the site', () => {
|
||||||
|
it('is published into dist and linked from the splash page', () => {
|
||||||
|
const guide = join(dist, 'quickstart.md');
|
||||||
|
assert.ok(existsSync(guide), 'the build did not publish quickstart.md');
|
||||||
|
|
||||||
|
const text = readFileSync(guide, 'utf8');
|
||||||
|
assert.match(text, /^# Station Master — Quickstart/, 'quickstart.md is not the guide');
|
||||||
|
assert.match(
|
||||||
|
text,
|
||||||
|
/Describes the game as built at v/,
|
||||||
|
'the guide does not say which build it describes',
|
||||||
|
);
|
||||||
|
|
||||||
|
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||||
|
assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes everything the guide links, so "Where to read more" is not five dead links', () => {
|
||||||
|
/**
|
||||||
|
* v0.8.0.16 published the Quickstart alone. Its §8 links five further documents by relative
|
||||||
|
* path, and every one of them 404'd on the package — verified against the running container,
|
||||||
|
* 5 of 6 paths missing. Publishing a guide without what it points at is the same broken-link
|
||||||
|
* failure as the test above, one hop further out, so it is pinned the same way: the links are
|
||||||
|
* read OUT OF THE GUIDE rather than listed here, or this test goes stale exactly as the
|
||||||
|
* references it guards did.
|
||||||
|
*/
|
||||||
|
const guide = readFileSync(join(dist, 'quickstart.md'), 'utf8');
|
||||||
|
const section = guide.slice(guide.indexOf('## 8. Where to read more'));
|
||||||
|
assert.ok(section.length > 0, 'the guide no longer has a "Where to read more" section');
|
||||||
|
|
||||||
|
// Markdown links, minus anchors and absolute URLs — what a reader can actually click.
|
||||||
|
const targets = [...section.matchAll(/\]\(([^)#][^)]*)\)/g)]
|
||||||
|
.map((m) => m[1]!.replace(/^`|`$/g, ''))
|
||||||
|
.filter((t) => !/^https?:/.test(t));
|
||||||
|
assert.ok(targets.length >= 4, `only ${targets.length} references parsed out of the guide`);
|
||||||
|
|
||||||
|
for (const t of targets) {
|
||||||
|
assert.ok(existsSync(join(dist, t)), `the guide links ${t}, which the build does not publish`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reaches the documentation from inside a game, in solitaire and multiplayer alike', () => {
|
||||||
|
/**
|
||||||
|
* Asked from a table, 2026-09-21: "how can we link the documentation so it can be reached from
|
||||||
|
* the gameplay, whether someone is playing solitaire or multiplayer?" The links live in the
|
||||||
|
* This Game card (Jesse's call) — and the point is that they need NO mode awareness, because
|
||||||
|
* both modes are the same page on the same origin. So this asserts the links exist and resolve,
|
||||||
|
* which is the whole of the mechanism.
|
||||||
|
*
|
||||||
|
* Read out of the built bundle rather than the source: what matters is what the shipped page
|
||||||
|
* offers, and a link that resolves in `src/` and not in `dist/` is the exact failure the two
|
||||||
|
* tests above exist to catch.
|
||||||
|
*/
|
||||||
|
const bundle = readFileSync(join(dist, 'web', 'main.js'), 'utf8');
|
||||||
|
const guide = bundle.slice(bundle.indexOf('GUIDE_DOCS'), bundle.indexOf('GUIDE_DOCS') + 4000);
|
||||||
|
assert.ok(bundle.includes('GUIDE_DOCS') || bundle.includes('quickstart.md'), 'the bundle has no guide links');
|
||||||
|
|
||||||
|
// Every document offered in-game must be a file the build published.
|
||||||
|
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.md)["'`]/g)].map((m) => m[1]!);
|
||||||
|
assert.ok(hrefs.length >= 5, `only ${hrefs.length} in-game guide links found`);
|
||||||
|
for (const h of hrefs) {
|
||||||
|
assert.ok(existsSync(join(dist, h.replace(/^\.\//, ''))), `the game links ${h}, which is not published`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reference opened mid-turn must not take the game with it.
|
||||||
|
assert.ok(guide.includes('_blank'), 'the guide links would navigate away from a game in progress');
|
||||||
|
assert.ok(guide.includes('noopener'), 'a new-tab link without rel=noopener hands out a window handle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is served as text rather than handed over as a download', () => {
|
||||||
|
/**
|
||||||
|
* The server's MIME fallback is `application/octet-stream`, which a browser downloads instead of
|
||||||
|
* displaying — so the link would hand a tester a file to save rather than a page to read. The
|
||||||
|
* table is read straight out of the source: asserting on a copy of it would pass while the real
|
||||||
|
* one was wrong.
|
||||||
|
*/
|
||||||
|
const http = readFileSync(join(root, 'src/server/http.ts'), 'utf8');
|
||||||
|
const table = http.slice(http.indexOf('const MIME'), http.indexOf('const HEARTBEAT_MS'));
|
||||||
|
assert.match(table, /'\.md':\s*'text\/plain/, 'a .md file would be served as a download');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user