v0.8.0.17 — four things the game knew and the screen did not say

All four reported from a table on Day 1 of v0.8.0.16, and all the same shape.

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

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

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

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

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

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

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

1010 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
This commit is contained in:
Jesse.Markowitz
2026-09-21 05:53:52 -04:00
co-authored by Claude Opus 5
parent dc31555625
commit b90c0413d2
27 changed files with 819 additions and 94 deletions
+112 -5
View File
@@ -19,6 +19,113 @@ 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 ## 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 The Quickstart put where a tester can actually reach it, the last place that still told the old
@@ -31,7 +138,7 @@ v0.8.0.15 wrote a Quickstart for a tester who has never played, and then left it
a tester does not look — reachable only by someone who already has the repository. Nobody being a tester does not look — reachable only by someone who already has the repository. Nobody being
handed the box has it. handed the box has it.
`build-web.ts` now copies `docs/StationMaster-Quickstart.md` into `dist/quickstart.md`, and the `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.* 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 **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. grid would say it is.
@@ -132,7 +239,7 @@ is the prototype rules edition they were first written against, and the names ar
`src/`, `CHANGELOG.md` and `docs/rules/` all cite them, and several of those citations are historical `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. records of what a document said at the time.
- **`StationMaster-Quickstart.md` — NEW.** What the game is, how you win, the shape of a Stage, what - **`quickstart.md` — NEW.** What the game is, how you win, the shape of a Stage, what
is on the screen, a first twenty minutes, the things that surprise new players, and what to report. 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. Written for somebody about to play rather than somebody building it.
- **Rules.** §3.4 replaced outright — the `firstToTarget` / `highestAfterDays` victory model and the - **Rules.** §3.4 replaced outright — the `firstToTarget` / `highestAfterDays` victory model and the
@@ -3509,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.
@@ -3546,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
@@ -3680,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**
+11 -6
View File
@@ -582,11 +582,16 @@ 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 Quickstart instead of serving it as plain text.** v0.8.0.16 - [ ] **#109** — **Render the published references instead of serving them as plain text.**
publishes `docs/StationMaster-Quickstart.md` to `dist/quickstart.md` and links it from the v0.8.0.17 publishes all five documents plus `rules/as-built.md`, links them from the splash
splash page, served as `text/plain` — so a tester reads the guide's tables as rows of pipes and page and from the This Game card inside a running game, and serves them as `text/plain` — so a
its links do not click. That was the fifteen-minute version, taken deliberately to get the tester reads their tables as rows of pipes and their links do not click. That was the
guide in front of testers for this round rather than to leave them without one. 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 **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 the first edit, which is the whole argument of #15a. The step is a small Markdown-to-HTML
@@ -2009,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,12 +1,12 @@
# Station Master — Components and Markers # Station Master — Components and Markers
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the **Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
prototype rules edition these references were first written against; the filename keeps that stamp every release rather than versioned as editions, so there is no version in the filename: this file
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name. is always the latest, and the build it describes is stated here.
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles, **Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles,
hand state, timetable state and other markers are documented with their cards or in the hand state, timetable state and other markers are documented with their cards or in the
[rules book](StationMaster-Rules-v0.4.5.md). [rules book](rules.md).
The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in
`src/engine/content.ts`. They are physical inventory rather than deck tuning, which is why they are `src/engine/content.ts`. They are physical inventory rather than deck tuning, which is why they are
@@ -60,7 +60,7 @@ player-selectable source. It returns to service only when the Division Yard is e
> here; detraining takes a fresh empty out of the *Division* Yard. Nothing returns a coach to the > 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 > 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 > from industries may never run bare. Measured over one three-Day game, every coach was here by the
> middle of Day 2 and stayed. See [Rules](StationMaster-Rules-v0.4.5.md) §4.6. > middle of Day 2 and stayed. See [Rules](rules.md) §4.6.
## Crew Trays and trains ## Crew Trays and trains
+11 -9
View File
@@ -26,16 +26,18 @@ Written to be handed to somebody who is about to play, rather than to somebody b
| Document | What it is | | Document | What it is |
| --- | --- | | --- | --- |
| [`StationMaster-Quickstart.md`](StationMaster-Quickstart.md) | **Start here if you have never played.** The point of the game, how a Stage runs, what is on screen, how you win, a first twenty minutes, and what to report. | | [`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. |
| [`StationMaster-Rules-v0.4.5.md`](StationMaster-Rules-v0.4.5.md) | **The rules in full**, as the engine actually runs them, with a FAQ. | | [`rules.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. | | [`rules/as-built.md`](rules/as-built.md) | **Every card, GENERATED from `src/engine/content.ts`** and checked by a test, so it cannot disagree with the game. The table of record for per-card facts. |
| [`StationMaster-Home-Deck-v0.4.5.md`](StationMaster-Home-Deck-v0.4.5.md) | How the Home Office deck is dealt, drawn and played out. | | [`home-deck.md`](home-deck.md) | How the Home Office deck is dealt, drawn and played out. |
| [`StationMaster-Mainline-Deck-v0.4.5.md`](StationMaster-Mainline-Deck-v0.4.5.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. | | [`mainline-deck.md`](mainline-deck.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. |
| [`StationMaster-Components-v0.4.5.md`](StationMaster-Components-v0.4.5.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. | | [`components.md`](components.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
The `v0.4.5` in four of those filenames is the **prototype rules edition they were first written **None of these carry a version in the filename**, and that is deliberate (Jesse, 2026-09-21): they
against**, not the version they describe — each says at the top which build it is current to. The are kept current with every release rather than published as editions, so the name is always the
names are kept because `src/`, `CHANGELOG.md` and `docs/rules/` cite them. 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
@@ -66,7 +68,7 @@ must do.
## Current status ## Current status
**v0.8.0.16.** Rules formalized, card faces specified, architecture documented, and the game **v0.8.0.17.** Rules formalized, card faces specified, architecture documented, and the game
playable **solitaire and multiplayer** in a browser against an authoritative server. See playable **solitaire and multiplayer** in a browser against an authoritative server. See
[`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and [`../TODO.md`](../TODO.md) for [`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and [`../TODO.md`](../TODO.md) for
what is open; this section is the shape of the project, not a running tally, because a what is open; this section is the shape of the project, not a running tally, because a
@@ -1,8 +1,8 @@
# Station Master — Home Deck # Station Master — Home Deck
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the **Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
prototype rules edition these references were first written against; the filename keeps that stamp every release rather than versioned as editions, so there is no version in the filename: this file
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name. is always the latest, and the build it describes is stated here.
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the **Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
rules are for playing each kind of card out of it. rules are for playing each kind of card out of it.
@@ -42,7 +42,7 @@ three cards from one shuffled deck; `threeTrackThreeOther` deals three track and
two separately shuffled piles, deliberately over the hand limit, so the first turn is spent choosing two separately shuffled piles, deliberately over the hand limit, so the first turn is spent choosing
which district you can afford to build. which district you can afford to build.
Drawing is one of the three Local Operations options — see [Rules](StationMaster-Rules-v0.4.5.md) 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. §4.2. Taking the option lets you draw **and** play or discard within the same turn.
## Track cards ## Track cards
@@ -136,11 +136,15 @@ every one of which the engine enforces.
## Enhancements, Mainline modifiers and Maneuvers ## Enhancements, Mainline modifiers and Maneuvers
- **Enhancements** are played into your district and change what a square does — the **Small Yard** - **Enhancements** are played into your district and change what a square does — the **Small Yard**
(re-order a consist for one Move), **Interlocking**, **Yard Office**, **ABS Signals** and the rest. (re-order a consist for one Move), **Interlocking**, **Yard Office** and the rest. `as-built.md`
`as-built.md` marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the implementation
implementation knows. 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. - **Mainline modifiers** are played onto a Mainline card: the Heavy Grade helpers and Realignment.
See [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md). See [Mainline deck](mainline-deck.md).
- **Maneuvers** are held and spent: **Red Flags** and **Flying Switch** have their own actions. - **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 Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
implement. implement.
@@ -1,8 +1,8 @@
# Station Master — Mainline Deck # Station Master — Mainline Deck
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the **Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
prototype rules edition these references were first written against; the filename keeps that stamp every release rather than versioned as editions, so there is no version in the filename: this file
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name. is always the latest, and the build it describes is stated here.
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a **Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a
card does to a train crossing it, and the Home Deck cards played onto one. card does to a train crossing it, and the Home Deck cards played onto one.
@@ -104,6 +104,21 @@ Home Office cards, played onto a Mainline card during a player's Draw option.
A Heavy Grade is 3 regions, so it is 3 Stages to climb and 3 to run down before help. Modifiers A Heavy Grade is 3 regions, so it is 3 Stages to climb and 3 to run down before help. Modifiers
never reduce a crossing below one Stage. never reduce a crossing below one Stage.
### 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 settled, not missing
Heavy Grade orientation is **rolled from the seed**, not chosen by a player. **This is a decision, Heavy Grade orientation is **rolled from the seed**, not chosen by a player. **This is a decision,
@@ -1,6 +1,6 @@
# Station Master — Quickstart # Station Master — Quickstart
**For a tester who has never played. Describes the game as built at v0.8.0.16** (2026-09-20). **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 Read this once before you sit down. It is about twenty minutes of reading and will save you an hour
of confusion. The deeper references are listed at the end. of confusion. The deeper references are listed at the end.
@@ -192,8 +192,8 @@ Bugs go to the tracker; anything unclear in this guide is also worth saying.
| For | Read | | For | Read |
| --- | --- | | --- | --- |
| The rules in full, with the FAQ | [Rules](StationMaster-Rules-v0.4.5.md) | | The rules in full, with the FAQ | [Rules](rules.md) |
| Every card, generated from the code | [`rules/as-built.md`](rules/as-built.md) | | Every card, generated from the code | [`rules/as-built.md`](rules/as-built.md) |
| How the Home Office deck is dealt and played | [Home deck](StationMaster-Home-Deck-v0.4.5.md) | | 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](StationMaster-Mainline-Deck-v0.4.5.md) | | The Mainline cards and what they do to a train | [Mainline deck](mainline-deck.md) |
| Rolling stock, yards, trays, the Fedora | [Components](StationMaster-Components-v0.4.5.md) | | Rolling stock, yards, trays, the Fedora | [Components](components.md) |
@@ -1,15 +1,15 @@
# Station Master — Rules # Station Master — Rules
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the **Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
prototype rules edition this reference was first written against; the filename keeps that stamp every release rather than versioned as editions, so there is no version in the filename: this file
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name. is always the latest, and the build it describes is stated here.
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable **Authority:** observed code paths and tests. Where a card face, a prototype document and executable
behaviour differ, this document reports **executable behaviour** and marks unimplemented material. behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
Per-card numbers are not repeated here — [`rules/as-built.md`](rules/as-built.md) is generated from Per-card numbers are not repeated here — [`rules/as-built.md`](rules/as-built.md) is generated from
`src/engine/content.ts` and is the table of record. `src/engine/content.ts` and is the table of record.
**New to the game? Start with the [Quickstart](StationMaster-Quickstart.md).** **New to the game? Start with the [Quickstart](quickstart.md).**
## 1. Overview and background ## 1. Overview and background
@@ -27,9 +27,9 @@ 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 the [Quickstart](StationMaster-Quickstart.md) for a new player, The companion references are the [Quickstart](quickstart.md) for a new player,
[Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md), [Home deck](StationMaster-Home-Deck-v0.4.5.md), [Mainline deck](mainline-deck.md), [Home deck](home-deck.md),
[components](StationMaster-Components-v0.4.5.md), and the generated per-card table [components](components.md), and the generated per-card table
[`rules/as-built.md`](rules/as-built.md). [`rules/as-built.md`](rules/as-built.md).
## 2. Definitions ## 2. Definitions
@@ -206,7 +206,7 @@ roll a tail cut into a connected Freight Facility.
**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, still true at v0.8.0.16: `card.discard` is accepted during Local Operations 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 without checking that the Draw option was chosen — unlike `card.play`, which does check. This is an
implementation quirk rather than a fourth published turn option. implementation quirk rather than a fourth published turn option.
@@ -243,7 +243,7 @@ Mainline movement is automatic and processes lower train numbers first; a timeta
**Crossing time is the card's REGIONS**, one per Stage — not its printed mph, which is scenery, and **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 not the train's Fast/Slow rating, which only **Hilly** reads. See the
[Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md) reference for what moves a train's entry [Mainline deck](mainline-deck.md) reference for what moves a train's entry
point. On a normal card, a train must check the entire next Subdivision before entering it: 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;
+3 -3
View File
@@ -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** |
+1 -1
View File
@@ -68,7 +68,7 @@ read "Both" for both of them, and that was the *other half* of the same mistaken
House" named those two, §9.3 had to be describing them, so they had to be two-way. Once the Freight House" 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
+3 -3
View File
@@ -142,7 +142,7 @@ asked a third time:
rising to 50% at four players. A pre-game interrupt for a rule four games in five never see. 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
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.0.16", "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",
+43 -14
View File
@@ -189,24 +189,53 @@ if (existsSync(imageSrc)) {
} }
/** /**
* The Quickstart, published beside the game so a tester can reach it from the box. * The player-facing documentation, published beside the game so a tester can reach it from the box.
* *
* COPIED, NOT RE-WRITTEN. `docs/StationMaster-Quickstart.md` is the one copy; a hand-written HTML * COPIED, NOT RE-WRITTEN. The Markdown in `docs/` is the one copy; a hand-written HTML twin would
* twin would drift from it on the first edit, which is the whole lesson of TODO #15a and of the * drift from it on the first edit, which is the whole lesson of TODO #15a and of the 2026-09-20
* 2026-09-20 documentation pass that found four references a month out of date. * 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 * SERVED AS PLAIN TEXT for now, which is honest rather than good: tables render as pipes and the
* links do not click. Rendering it into a styled page needs a small Markdown converter and is * links do not click. Rendering them into styled pages needs a small Markdown converter and is
* filed as TODO #109 — this is the fifteen-minute version that gets the guide in front of testers * filed as TODO #109 — this is the version that gets the references in front of testers for this
* for this round rather than leaving them without one. * 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 guideSrc = join(root, 'docs/StationMaster-Quickstart.md'); const GUIDE_DOCS: readonly string[] = [
if (existsSync(guideSrc)) { 'quickstart.md',
copyFileSync(guideSrc, join(dist, 'quickstart.md')); 'rules.md',
} else { 'home-deck.md',
// Loud rather than silent: a missing guide is a broken link on the splash page, and the build is 'mainline-deck.md',
// the only place that can still notice. 'components.md',
console.error('WARNING: docs/StationMaster-Quickstart.md is missing — the splash link will 404'); // 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.
+13 -1
View File
@@ -1825,9 +1825,21 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
} }
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[] = [
{ {
+17 -6
View File
@@ -289,7 +289,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil * 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
@@ -668,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
@@ -1008,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
+11
View File
@@ -115,6 +115,17 @@ export type GameEvent =
movesAllowed: number; movesAllowed: number;
lastMove?: { trayId: TrayId; to: GridCoord }; 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
+1 -1
View File
@@ -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
+81
View File
@@ -71,6 +71,22 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for * 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}
+38 -5
View File
@@ -74,7 +74,16 @@ export function carsLabel(cars: RollingStock[]): string {
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}`;
@@ -188,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
@@ -466,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
+40 -2
View File
@@ -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': {
@@ -1903,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.
* *
+40 -1
View File
@@ -652,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).
* *
+7
View File
@@ -268,6 +268,13 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0} #gamecardbody dd{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;
+80
View File
@@ -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,12 @@ 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 * 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 * Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to
@@ -161,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}`);
});
});
+5 -1
View File
@@ -59,6 +59,10 @@ 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 },
@@ -123,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
View File
@@ -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);
+9 -3
View File
@@ -562,8 +562,14 @@ describe('the history panel keeps the switching that matters', () => {
assert.ok(submit(game, { type: 'switch.move', trayId, to: plain, reverse: false }), 'the move was refused'); 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)); 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}`); 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 FIRST move of a turn is kept, and this crew's first move is this one — so what is being
assert.match(moved[0]!.text, /→ the Freight House|→ \(1,2\)/, `unexpected move line: ${moved[0]!.text}`); * 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}`);
}); });
}); });
+242 -1
View File
@@ -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.
@@ -3010,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
@@ -5537,6 +5726,58 @@ describe('the Quickstart guide reaches the site', () => {
assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide'); 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', () => { 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 * The server's MIME fallback is `application/octet-stream`, which a browser downloads instead of