Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b90c0413d2 |
+112
-5
@@ -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
|
||||
|
||||
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
|
||||
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.*
|
||||
**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.
|
||||
@@ -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
|
||||
records of what a document said at the time.
|
||||
|
||||
- **`StationMaster-Quickstart.md` — NEW.** What the game is, how you win, the shape of a Stage, what
|
||||
- **`quickstart.md` — NEW.** What the game is, how you win, the shape of a Stage, what
|
||||
is on the screen, a first twenty minutes, the things that surprise new players, and what to report.
|
||||
Written for somebody about to play rather than somebody building it.
|
||||
- **Rules.** §3.4 replaced outright — the `firstToTarget` / `highestAfterDays` victory model and the
|
||||
@@ -3509,7 +3616,7 @@ that same occupant list.
|
||||
|
||||
`buildDivision` drew uniformly from the nine card TYPES **with replacement**, so a Division could be
|
||||
dealt two Interchanges or two Tunnels, and Plains — printed twice in the deck — carried the same
|
||||
weight as cards printed once. `docs/StationMaster-Mainline-Deck-v0.4.5.md` had flagged the mismatch
|
||||
weight as cards printed once. `docs/mainline-deck.md` had flagged the mismatch
|
||||
as needing correction; "an Extra may start at the Interchange if one is on the board" is what forced
|
||||
it, since that only reads as a rule if the board holds at most one. Now dealt from `MAINLINE_DECK`
|
||||
without replacement, verified over 1600 deals across 1–4 players.
|
||||
@@ -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
|
||||
transcription fix, so `Trains3.pdf` and the transcription in `implications.md` §5 keep the original
|
||||
numbers with a footnote; `content.ts` and `StationMaster-Home-Deck-v0.4.5.md` carry what the game
|
||||
numbers with a footnote; `content.ts` and `home-deck.md` carry what the game
|
||||
plays. Both consists remain inside the four-car Crew Tray limit.
|
||||
|
||||
A test had to follow: `multiplayer.test.ts` used Train 1 *because* it had three cars, to exercise a
|
||||
@@ -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 —
|
||||
Pipelines, Oil Depot, Viscosity Breakers — grant **+1 outbound**; a two-way Refinery would be the only
|
||||
industry in the game with no card able to raise one of its two directions.
|
||||
`StationMaster-Home-Deck-v0.4.5.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
|
||||
`home-deck.md` prints "Refinery · Outbound · 1 out / 0 in" and "Grocer's
|
||||
Warehouse · Inbound · 0 out / 1 in".
|
||||
|
||||
So the Refinery ships and the Grocer's receives, and the **Freight House is the one two-way industry**
|
||||
|
||||
@@ -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
|
||||
the Oil Refinery. See **Reference · #88**.
|
||||
|
||||
- [ ] **#109** — **Render the published Quickstart instead of serving it as plain text.** v0.8.0.16
|
||||
publishes `docs/StationMaster-Quickstart.md` to `dist/quickstart.md` and links it from the
|
||||
splash page, served as `text/plain` — so a tester reads the guide's tables as rows of pipes and
|
||||
its links do not click. That was the fifteen-minute version, taken deliberately to get the
|
||||
guide in front of testers for this round rather than to leave them without one.
|
||||
- [ ] **#109** — **Render the published references instead of serving them as plain text.**
|
||||
v0.8.0.17 publishes all five documents plus `rules/as-built.md`, links them from the splash
|
||||
page and from the This Game card inside a running game, and serves them as `text/plain` — so a
|
||||
tester reads their tables as rows of pipes and their links do not click. That was the
|
||||
deliberate short version, to get the references in front of testers for this round rather than
|
||||
leave them without any.
|
||||
|
||||
**Now SIX documents rather than one**, which raises the value and the cost together: the
|
||||
Quickstart's §8 is a table of links, and a reader following one lands on another wall of
|
||||
pipes.
|
||||
|
||||
**Copy the document, do not re-write it.** A hand-written HTML twin drifts from the Markdown on
|
||||
the first edit, which is the whole argument of #15a. The step is a small Markdown-to-HTML
|
||||
@@ -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
|
||||
answer to "where do we keep track of that?" turned out to be **five places of three different
|
||||
vintages**: `src/engine/content.ts` (the truth), `docs/StationMaster-Home-Deck-v0.4.5.md` (a
|
||||
vintages**: `src/engine/content.ts` (the truth), `docs/home-deck.md` (a
|
||||
readable per-card table, a version-stamped snapshot), `docs/rules/implications.md` §5 (the
|
||||
transcription of `Trains3.pdf`, deliberately frozen at what the design SAYS),
|
||||
`docs/Trains3.pdf` (the artwork), and `docs/rules/card-reference.md` (an invented placeholder
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Station Master — Components and Markers
|
||||
|
||||
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the
|
||||
prototype rules edition these references were first written against; the filename keeps that stamp
|
||||
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
|
||||
**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
|
||||
every release rather than versioned as editions, so there is no version in the filename: this file
|
||||
is always the latest, and the build it describes is stated here.
|
||||
|
||||
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles,
|
||||
hand state, timetable state and other markers are documented with their cards or in the
|
||||
[rules book](StationMaster-Rules-v0.4.5.md).
|
||||
[rules book](rules.md).
|
||||
|
||||
The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in
|
||||
`src/engine/content.ts`. They are physical inventory rather than deck tuning, which is why they are
|
||||
@@ -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
|
||||
> Division Yard except the bare-yard refill — and a yard kept topped up with freight cars returning
|
||||
> from industries may never run bare. Measured over one three-Day game, every coach was here by the
|
||||
> middle of Day 2 and stayed. See [Rules](StationMaster-Rules-v0.4.5.md) §4.6.
|
||||
> middle of Day 2 and stayed. See [Rules](rules.md) §4.6.
|
||||
|
||||
## Crew Trays and trains
|
||||
|
||||
+11
-9
@@ -26,16 +26,18 @@ Written to be handed to somebody who is about to play, rather than to somebody b
|
||||
|
||||
| Document | What it is |
|
||||
| --- | --- |
|
||||
| [`StationMaster-Quickstart.md`](StationMaster-Quickstart.md) | **Start here if you have never played.** The point of the game, how a Stage runs, what is on screen, how you win, a first twenty minutes, and what to report. |
|
||||
| [`StationMaster-Rules-v0.4.5.md`](StationMaster-Rules-v0.4.5.md) | **The rules in full**, as the engine actually runs them, with a FAQ. |
|
||||
| [`quickstart.md`](quickstart.md) | **Start here if you have never played.** The point of the game, how a Stage runs, what is on screen, how you win, a first twenty minutes, and what to report. |
|
||||
| [`rules.md`](rules.md) | **The rules in full**, as the engine actually runs them, with a FAQ. |
|
||||
| [`rules/as-built.md`](rules/as-built.md) | **Every card, GENERATED from `src/engine/content.ts`** and checked by a test, so it cannot disagree with the game. The table of record for per-card facts. |
|
||||
| [`StationMaster-Home-Deck-v0.4.5.md`](StationMaster-Home-Deck-v0.4.5.md) | How the Home Office deck is dealt, drawn and played out. |
|
||||
| [`StationMaster-Mainline-Deck-v0.4.5.md`](StationMaster-Mainline-Deck-v0.4.5.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. |
|
||||
| [`StationMaster-Components-v0.4.5.md`](StationMaster-Components-v0.4.5.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
|
||||
| [`home-deck.md`](home-deck.md) | How the Home Office deck is dealt, drawn and played out. |
|
||||
| [`mainline-deck.md`](mainline-deck.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. |
|
||||
| [`components.md`](components.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
|
||||
|
||||
The `v0.4.5` in four of those filenames is the **prototype rules edition they were first written
|
||||
against**, not the version they describe — each says at the top which build it is current to. The
|
||||
names are kept because `src/`, `CHANGELOG.md` and `docs/rules/` cite them.
|
||||
**None of these carry a version in the filename**, and that is deliberate (Jesse, 2026-09-21): they
|
||||
are kept current with every release rather than published as editions, so the name is always the
|
||||
latest and each says at the top which build it describes. Four of them were stamped `v0.4.5` until
|
||||
v0.8.0.17 — the prototype rules edition they were first written against, never the version they
|
||||
described — which read as though they documented a build five minor versions old.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -66,7 +68,7 @@ must do.
|
||||
|
||||
## 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
|
||||
[`../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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Station Master — Home Deck
|
||||
|
||||
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the
|
||||
prototype rules edition these references were first written against; the filename keeps that stamp
|
||||
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
|
||||
**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
|
||||
every release rather than versioned as editions, so there is no version in the filename: this file
|
||||
is always the latest, and the build it describes is stated here.
|
||||
|
||||
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
|
||||
rules are for playing each kind of card out of it.
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
## Track cards
|
||||
@@ -136,11 +136,15 @@ every one of which the engine enforces.
|
||||
## Enhancements, Mainline modifiers and Maneuvers
|
||||
|
||||
- **Enhancements** are played into your district and change what a square does — the **Small Yard**
|
||||
(re-order a consist for one Move), **Interlocking**, **Yard Office**, **ABS Signals** and the rest.
|
||||
`as-built.md` marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the
|
||||
implementation knows.
|
||||
(re-order a consist for one Move), **Interlocking**, **Yard Office** and the rest. `as-built.md`
|
||||
marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the implementation
|
||||
knows.
|
||||
- **ABS Signals is the exception, and it matters.** It is dealt as an Enhancement but is **not
|
||||
played into your district**: it goes onto a **Mainline card** — any one of them — and stops
|
||||
trains there rear-ending each other. It takes no square and is not part of anybody's Office
|
||||
Area. See [Mainline deck](mainline-deck.md).
|
||||
- **Mainline modifiers** are played onto a Mainline card: the Heavy Grade helpers and Realignment.
|
||||
See [Mainline deck](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.
|
||||
Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
|
||||
implement.
|
||||
@@ -1,8 +1,8 @@
|
||||
# Station Master — Mainline Deck
|
||||
|
||||
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the
|
||||
prototype rules edition these references were first written against; the filename keeps that stamp
|
||||
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
|
||||
**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
|
||||
every release rather than versioned as editions, so there is no version in the filename: this file
|
||||
is always the latest, and the build it describes is stated here.
|
||||
|
||||
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a
|
||||
card does to a train crossing it, and the Home Deck cards played onto one.
|
||||
@@ -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
|
||||
never reduce a crossing below one Stage.
|
||||
|
||||
### ABS Signals — an Enhancement, but it lives out here
|
||||
|
||||
**ABS Signals is dealt from the Home Office deck as an Enhancement, and it is the one Enhancement
|
||||
that is not played into a district.** It goes onto a **Mainline card — any of them**, not only a
|
||||
grade — and takes no square in anybody's Office Area.
|
||||
|
||||
What it does: trains on that card **will not rear-end each other**. A following train is held short
|
||||
of the one ahead instead of running into it, so the Superintendent has no §8.1 judgment to make and
|
||||
no collision is scored. It is the only thing besides a Red Flag that prevents a rear-end collision
|
||||
out on the Mainline, and unlike a Red Flag it stays on the card for the rest of the game.
|
||||
|
||||
On the board a card carrying it draws a **signal mast with a lit lamp** at its top-right corner; the
|
||||
three grade helpers draw as **BRK**, **AIR** and **HLP** beside the card's name. Realignment draws
|
||||
nothing, because a realigned card simply becomes the card it was converted into.
|
||||
|
||||
## Heavy Grade orientation is settled, not missing
|
||||
|
||||
Heavy Grade orientation is **rolled from the seed**, not chosen by a player. **This is a decision,
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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 |
|
||||
| --- | --- |
|
||||
| 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) |
|
||||
| How the Home Office deck is dealt and played | [Home deck](StationMaster-Home-Deck-v0.4.5.md) |
|
||||
| The Mainline cards and what they do to a train | [Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md) |
|
||||
| Rolling stock, yards, trays, the Fedora | [Components](StationMaster-Components-v0.4.5.md) |
|
||||
| How the Home Office deck is dealt and played | [Home deck](home-deck.md) |
|
||||
| The Mainline cards and what they do to a train | [Mainline deck](mainline-deck.md) |
|
||||
| Rolling stock, yards, trays, the Fedora | [Components](components.md) |
|
||||
@@ -1,15 +1,15 @@
|
||||
# Station Master — Rules
|
||||
|
||||
**Describes the game as built at v0.8.0.16** (2026-09-20). Previously stamped "v0.4.5", the
|
||||
prototype rules edition this reference was first written against; the filename keeps that stamp
|
||||
because `src/`, `CHANGELOG.md` and `docs/rules/` all cite this file by name.
|
||||
**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with
|
||||
every release rather than versioned as editions, so there is no version in the filename: this file
|
||||
is always the latest, and the build it describes is stated here.
|
||||
|
||||
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable
|
||||
behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
|
||||
Per-card numbers are not repeated here — [`rules/as-built.md`](rules/as-built.md) is generated from
|
||||
`src/engine/content.ts` and is the table of record.
|
||||
|
||||
**New to the game? Start with the [Quickstart](StationMaster-Quickstart.md).**
|
||||
**New to the game? Start with the [Quickstart](quickstart.md).**
|
||||
|
||||
## 1. Overview and background
|
||||
|
||||
@@ -27,9 +27,9 @@ This book is divided as follows:
|
||||
6. the implemented multiplayer/engine status; and
|
||||
7. FAQs and implementation limits.
|
||||
|
||||
The companion references are the [Quickstart](StationMaster-Quickstart.md) for a new player,
|
||||
[Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md), [Home deck](StationMaster-Home-Deck-v0.4.5.md),
|
||||
[components](StationMaster-Components-v0.4.5.md), and the generated per-card table
|
||||
The companion references are the [Quickstart](quickstart.md) for a new player,
|
||||
[Mainline deck](mainline-deck.md), [Home deck](home-deck.md),
|
||||
[components](components.md), and the generated per-card table
|
||||
[`rules/as-built.md`](rules/as-built.md).
|
||||
|
||||
## 2. Definitions
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
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
|
||||
not the train's Fast/Slow rating, which only **Hilly** reads. See the
|
||||
[Mainline deck](StationMaster-Mainline-Deck-v0.4.5.md) reference for what moves a train's entry
|
||||
[Mainline deck](mainline-deck.md) reference for what moves a train's entry
|
||||
point. On a normal card, a train must check the entire next Subdivision before entering it:
|
||||
|
||||
- an opposing train normally blocks entry;
|
||||
@@ -193,9 +193,9 @@ cannot carry this column, which is the argument for generating the page rather t
|
||||
| --- | --- | --- | :---: |
|
||||
| Interlocking | runningTrackStraight | — | **live** |
|
||||
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
|
||||
| Yard office | secondaryTrackStraight | — | **live** |
|
||||
| Small yard | secondaryTrackStraight | — | **live** |
|
||||
| Water column | runningTrackStraight | — | **dormantSolo** |
|
||||
| Yard Office | secondaryTrackStraight | — | **live** |
|
||||
| Small Yard | secondaryTrackStraight | — | **live** |
|
||||
| Water Column | runningTrackStraight | — | **dormantSolo** |
|
||||
| Overpass | onCard | — | **unbuilt** |
|
||||
| Telegraph | runningTrackStraight | — | **live** |
|
||||
| Telephone | onCard | telegraph on the same card | **live** |
|
||||
|
||||
@@ -68,7 +68,7 @@ read "Both" for both of them, and that was the *other half* of the same mistaken
|
||||
House" named those two, §9.3 had to be describing them, so they had to be two-way. Once the Freight
|
||||
House is its own card the argument evaporates, and playtesting settled it — "Grocer's Warehouse
|
||||
should be receive only, does not ship anything out"; "Refinery: only ships out tanks, does not
|
||||
receive anything" (Jesse). `StationMaster-Home-Deck-v0.4.5.md` prints both that way, and the modifier
|
||||
receive anything" (Jesse). `home-deck.md` prints both that way, and the modifier
|
||||
set agrees: all three Refinery modifiers (Pipelines, Oil Depot, Viscosity Breakers) grant **+1
|
||||
outbound**, which would be an odd card set for a facility that receives half the time.
|
||||
<!-- TODO v0.5.0: Mine Tipple, Produce Shed and Power Plant above (3/3/4, 2/2/3, 3/3/4) were NOT
|
||||
|
||||
@@ -142,7 +142,7 @@ asked a third time:
|
||||
rising to 50% at four players. A pre-game interrupt for a rule four games in five never see.
|
||||
|
||||
The docs were the actual defect. `README.md` still listed it among three open rules questions (all
|
||||
three closed in v0.5.0) and `StationMaster-Mainline-Deck-v0.4.5.md` still said "the implementation
|
||||
three closed in v0.5.0) and `mainline-deck.md` still said "the implementation
|
||||
needs a player-selection step to match the card". Both now say settled, and why.
|
||||
|
||||
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
|
||||
@@ -555,7 +555,7 @@ Hotel) are what grow them.
|
||||
now carries **2** coaches and The Sparrow **3**. The table above is left as `Trains3.pdf` prints it,
|
||||
because that is what this section is for — what the design SAYS. What the game plays is
|
||||
`src/engine/content.ts`, with the per-card table in
|
||||
[`../StationMaster-Home-Deck-v0.4.5.md`](../StationMaster-Home-Deck-v0.4.5.md).
|
||||
[`../home-deck.md`](../home-deck.md).
|
||||
|
||||
### Extras (X13–X22) — ten distinct trains, not four generic ones
|
||||
|
||||
@@ -910,7 +910,7 @@ any setting** — being a place an Extra can start is part of what upgrading buy
|
||||
- **The Mainline cards were rolled, not dealt.** `buildDivision` drew uniformly from the nine card
|
||||
TYPES **with replacement**, so a Division could be dealt two Interchanges or two Tunnels, and
|
||||
Plains — printed twice in the deck — carried the same weight as cards printed once.
|
||||
`docs/StationMaster-Mainline-Deck-v0.4.5.md` had already flagged the mismatch as needing
|
||||
`docs/mainline-deck.md` had already flagged the mismatch as needing
|
||||
correction; "an Extra may start at the Interchange if one is on the board" is what forced it, since
|
||||
that only reads as a rule if the board holds at most one. Now dealt from the printed ten-card deck
|
||||
without replacement.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.0.16",
|
||||
"version": "0.8.0.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+43
-14
@@ -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
|
||||
* twin would drift from it on the first edit, which is the whole lesson of TODO #15a and of the
|
||||
* 2026-09-20 documentation pass that found four references a month out of date.
|
||||
* COPIED, NOT RE-WRITTEN. The Markdown in `docs/` is the one copy; a hand-written HTML twin would
|
||||
* drift from it on the first edit, which is the whole lesson of TODO #15a and of the 2026-09-20
|
||||
* documentation pass that found four references a month out of date.
|
||||
*
|
||||
* THE WHOLE SET, NOT ONLY THE QUICKSTART. v0.8.0.16 published the guide alone, and the guide's own
|
||||
* §8 "Where to read more" links five further documents by relative path — so every one of them
|
||||
* 404'd on the package (verified on the box: 5 of 6 paths missing). Publishing the guide without
|
||||
* what it points at is the same broken-link failure the test below was written to catch, one hop
|
||||
* further out. The names are kept exactly as the guide writes them, because those links are what
|
||||
* has to resolve.
|
||||
*
|
||||
* SERVED AS PLAIN TEXT for now, which is honest rather than good: tables render as pipes and the
|
||||
* links do not click. Rendering it into a styled page 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
|
||||
* for this round rather than leaving them without one.
|
||||
* links do not click. Rendering them into styled pages needs a small Markdown converter and is
|
||||
* filed as TODO #109 — this is the version that gets the references in front of testers for this
|
||||
* round rather than leaving them without any.
|
||||
*
|
||||
* PUBLISHED UNDER THEIR OWN NAMES, which since v0.8.0.17 carry no version: the documents are kept
|
||||
* current with every release rather than published as editions, so `docs/rules.md` is served as
|
||||
* `rules.md` and the splash page and the This Game card link it by that name. Four of them were
|
||||
* `StationMaster-<name>-v0.4.5.md` until then — the prototype edition they were first written
|
||||
* against, never the version they described.
|
||||
*/
|
||||
const guideSrc = join(root, 'docs/StationMaster-Quickstart.md');
|
||||
if (existsSync(guideSrc)) {
|
||||
copyFileSync(guideSrc, join(dist, 'quickstart.md'));
|
||||
} else {
|
||||
// Loud rather than silent: a missing guide is a broken link on the splash page, and the build is
|
||||
// the only place that can still notice.
|
||||
console.error('WARNING: docs/StationMaster-Quickstart.md is missing — the splash link will 404');
|
||||
const GUIDE_DOCS: readonly string[] = [
|
||||
'quickstart.md',
|
||||
'rules.md',
|
||||
'home-deck.md',
|
||||
'mainline-deck.md',
|
||||
'components.md',
|
||||
// Generated by `build:cards` and checked in; a test fails when it disagrees with the code, which
|
||||
// is why it is the one reference that has never drifted. Its `rules/` directory is preserved
|
||||
// because that is the path the Quickstart links it by.
|
||||
'rules/as-built.md',
|
||||
];
|
||||
|
||||
for (const rel of GUIDE_DOCS) {
|
||||
const src = join(root, 'docs', rel);
|
||||
if (!existsSync(src)) {
|
||||
// Loud rather than silent: a missing document is a broken link on a page already published, and
|
||||
// the build is the only place that can still notice.
|
||||
console.error(`WARNING: docs/${rel} is missing — a published link will 404`);
|
||||
continue;
|
||||
}
|
||||
const out = join(dist, rel);
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
copyFileSync(src, out);
|
||||
}
|
||||
|
||||
// A tiny note for whoever unzips this later and wonders what it needs.
|
||||
|
||||
+13
-1
@@ -1825,9 +1825,21 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
}
|
||||
|
||||
case 'draw.end':
|
||||
case 'freightAgent.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
|
||||
case 'freightAgent.end':
|
||||
/**
|
||||
* An idle Freight Agent SAYS SO. §6.3 requires no action and choosing to take none is a real
|
||||
* decision — see `freightAgentIdled`. Without the line the log announced the option and then
|
||||
* fell silent, which reads as the game having dropped the turn.
|
||||
*/
|
||||
return [
|
||||
...(turnOf(s, player).freightAgentUsed
|
||||
? []
|
||||
: [{ type: 'freightAgentIdled', player } as const]),
|
||||
{ type: 'phaseEnded', player, phase: 'localOps' },
|
||||
];
|
||||
|
||||
case 'draw.fromHomeOffice': {
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
|
||||
+17
-6
@@ -289,7 +289,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
* The card set says the same thing on its own. All three Refinery modifiers — Pipelines, Oil
|
||||
* Depot, Viscosity Breakers — grant `+1 outbound`; a two-way Refinery would be the one industry in
|
||||
* the game with no way to raise the direction it is supposed to use half its capacity on.
|
||||
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
|
||||
* `home-deck.md` prints it "Outbound, 1 out / 0 in".
|
||||
*/
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
|
||||
@@ -297,7 +297,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
/**
|
||||
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
|
||||
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
|
||||
* not ship anything out". `StationMaster-Home-Deck-v0.4.5.md` prints it "Inbound, 0 out / 1 in".
|
||||
* not ship anything out". `home-deck.md` prints it "Inbound, 0 out / 1 in".
|
||||
*
|
||||
* THE ICE HOUSE IS THEREFORE A DEAD CARD BESIDE A GROCER'S, and that is the design, not an
|
||||
* oversight: `usableGrant` drops a Modifier's grant on a direction its host cannot use, and the
|
||||
@@ -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
|
||||
* Interchanges (or two Trestles, or two Tunnels) an ordinary outcome and gave Plains the same weight
|
||||
* as everything else although the deck prints two of it. `Mainline Cards.pdf` is the
|
||||
* inventory, transcribed in `docs/StationMaster-Mainline-Deck-v0.4.5.md`, which had already flagged
|
||||
* inventory, transcribed in `docs/mainline-deck.md`, which had already flagged
|
||||
* the mismatch as needing correction.
|
||||
*
|
||||
* It matters more than card flavour now that an Extra may start at an Interchange (§7): "if an
|
||||
@@ -1008,14 +1008,25 @@ export function enhancementRule(key: string): EnhancementRule | null {
|
||||
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE NAMES HERE ARE WHAT A PLAYER READS, since v0.8.0.17 — `cardName` takes them in preference to
|
||||
* `prettyKey`'s guess, which cannot recover an acronym (it rendered `absSignals` as "Abs Signals"
|
||||
* beside a tooltip saying ABS).
|
||||
*
|
||||
* So their CASE matters, and three were transcribed in sentence case while the rules, the panels
|
||||
* and every other line of this repository treat them as proper terms: "Yard Office" outnumbered
|
||||
* "Yard office" 36 to 2, "Small Yard" 36 to 2, "Water Column" 10 to 2. Corrected here rather than
|
||||
* special-cased in `cardName`, because this table is the source and a lookup that second-guesses
|
||||
* its own source is the drift it exists to prevent.
|
||||
*/
|
||||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an
|
||||
// Event held out until built, so at zero it defends against nothing that can be dealt anyway.
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
|
||||
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
|
||||
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
|
||||
{ key: 'waterColumn', name: 'Water column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
|
||||
{ key: 'yardOffice', name: 'Yard Office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
|
||||
{ key: 'smallYard', name: 'Small Yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
|
||||
{ key: 'waterColumn', name: 'Water Column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
|
||||
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
|
||||
/**
|
||||
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
|
||||
|
||||
@@ -115,6 +115,17 @@ export type GameEvent =
|
||||
movesAllowed: number;
|
||||
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||
}
|
||||
/**
|
||||
* THE FREIGHT AGENT WAS CHOSEN AND DID NOTHING, which is a decision rather than an absence.
|
||||
*
|
||||
* §6.3 requires no action, and the bot deliberately takes that route — unjamming a healthy box
|
||||
* destroys a load that cost a whole Local Operations action to stock, so an idle Stage is
|
||||
* strictly better. Reported from a table on Day 1 Stage 3 of v0.8.0.16: the log announced
|
||||
* FREIGHT AGENT work and then said nothing at all, so the turn read as a bug.
|
||||
*
|
||||
* Reduces to nothing, like `switchingEnded` above: it reports a choice the state already holds.
|
||||
*/
|
||||
| { type: 'freightAgentIdled'; player: PlayerIndex }
|
||||
| { 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
|
||||
|
||||
+1
-1
@@ -230,7 +230,7 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
||||
* `MAINLINE_PROFILES` is a list of card TYPES and this drew from it uniformly WITH replacement, so
|
||||
* a Division could be handed two Interchanges or two Tunnels, and Plains — printed twice in the
|
||||
* deck — carried the same weight as cards printed once. `MAINLINE_DECK` is the printed inventory
|
||||
* (`docs/StationMaster-Mainline-Deck-v0.4.5.md`, which flagged this as needing correction), and the
|
||||
* (`docs/mainline-deck.md`, which flagged this as needing correction), and the
|
||||
* deal is now a deal: take cards out of it and do not put them back.
|
||||
*
|
||||
* The Extra-start rules are what forced the issue. "An Extra may start at the Interchange if one is
|
||||
|
||||
@@ -71,6 +71,22 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
||||
* anything outside its own body.
|
||||
*/
|
||||
/**
|
||||
* The Mainline modifiers, as the three letters drawn on a card.
|
||||
*
|
||||
* DECLARED INSIDE THIS FUNCTION, like every constant around it, because `sim/replay.ts` emits
|
||||
* `divisionSvg.toString()` into the replay page — a module-scope const it closed over would be
|
||||
* undefined there, and the page threw exactly that way before this was moved.
|
||||
*
|
||||
* Keyed by the name the VIEW builds (`prettyKey`'d in `view.ts`), because that is what arrives
|
||||
* here — not the engine's key, which this file never sees. A modifier with no tag draws nothing
|
||||
* rather than a raw key: `flatMap` over a missing entry yields none, so a card added upstream is
|
||||
* silently unmarked instead of printing "rotaryDumps" at a player.
|
||||
*
|
||||
* ABS Signals is deliberately absent: it has a signal mast of its own.
|
||||
*/
|
||||
const MOD_TAGS: Record<string, string> = { Brakeman: 'BRK', Airbrakes: 'AIR', Helpers: 'HLP' };
|
||||
|
||||
const CW = { dp: 118, ml: 152, run: 78 };
|
||||
/**
|
||||
* TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row.
|
||||
@@ -144,6 +160,10 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
tip: string;
|
||||
/** Set on a card that has just changed under the players' feet — drawn with a brief pulse. */
|
||||
flash?: boolean;
|
||||
/** ABS Signals standing on this Mainline card — drawn as a signal mast, not only described. */
|
||||
abs?: boolean;
|
||||
/** The Mainline modifiers on this card, as short tags — see `MOD_TAGS`. */
|
||||
mods?: string[];
|
||||
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
|
||||
seat: number | null;
|
||||
/** Set on an Office cell when a roster was supplied: whose district this is. */
|
||||
@@ -291,6 +311,10 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
// words with no gameplay attached — reported exactly that way.
|
||||
(n.what ? `\n\n${n.what}` : ''),
|
||||
seat: null,
|
||||
// Attachments are drawn, not only described: `modifiers` carries ABS Signals alongside the
|
||||
// Mainline modifiers, and the name is the one the view already built for the tooltip.
|
||||
abs: !dp && n.modifiers.includes('ABS Signals'),
|
||||
mods: dp ? [] : n.modifiers.flatMap((m) => MOD_TAGS[m] ?? []),
|
||||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||
regions: dp ? 1 : (n.regions ?? 0),
|
||||
gradeUp: dp ? null : (n.gradeUp ?? null),
|
||||
@@ -416,6 +440,57 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
out += `</g>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ABS SIGNALS, DRAWN RATHER THAN ONLY SAID — reported from a table on Day 1 Stage 1 of
|
||||
* v0.8.0.16: "when played on the trestle, there was no on-the-card indication; it's only when
|
||||
* you look at the tooltip for trestle that you see that ABS exists."
|
||||
*
|
||||
* Exactly the complaint the grade wedge below answers, and the card it protects is the one a
|
||||
* player is deciding whether to run a second train onto. A signal is the literal object — ABS
|
||||
* is Automatic Block Signals — so the mark is a mast with a lit lamp rather than a badge
|
||||
* reading "ABS", and it needs no room for text, which is what lets it sit clear of a name as
|
||||
* long as "Uncontrolled Siding" on a 152px card.
|
||||
*
|
||||
* TOP-RIGHT, above the rail and clear of the chip registers: the flag owns the cell ends at
|
||||
* rail height and the grade wedge owns the bottom-right, so this is the corner left.
|
||||
*/
|
||||
if (c.abs) {
|
||||
const mx = c.x + c.w - 10;
|
||||
const top = c.y + 5;
|
||||
out += `<g class="bs-abs">`;
|
||||
out += `<line class="bs-abs-mast" x1="${mx}" y1="${top}" x2="${mx}" y2="${top + 15}"/>`;
|
||||
// Two lamps, the upper one lit: a signal showing an aspect, not a bare post.
|
||||
out += `<circle class="bs-abs-lit" cx="${mx}" cy="${top + 3}" r="2.6"/>`;
|
||||
out += `<circle class="bs-abs-dark" cx="${mx}" cy="${top + 9}" r="2.6"/>`;
|
||||
out += `</g>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE MAINLINE MODIFIERS, DRAWN RATHER THAN ONLY SAID (Jesse, 2026-09-21) — the other half of
|
||||
* the ABS report above: "Brakeman, Airbrakes, Helpers and Realignment should also be drawn on
|
||||
* the card, not just the tooltip."
|
||||
*
|
||||
* ONLY THREE OF THOSE FOUR CAN EVER BE HERE. Realignment does not sit on a card — `reduce`
|
||||
* takes the `became` branch and CHANGES `node.card`, so a realigned Trestle simply is an
|
||||
* Uncontrolled Siding afterwards and the card face already says so. The other three are
|
||||
* `gradeOnly`, so in practice this is a Heavy Grade's row.
|
||||
*
|
||||
* TAGS, NOT NAMES, and only because the measurements leave no choice: "Brakeman · Airbrakes ·
|
||||
* Helpers" is 30 characters where about eleven fit beside a card name on a 152px cell. Three
|
||||
* letters is the most that fits while still mapping to one card each, and the tooltip — which
|
||||
* has always named them in full — is what expands it. The mark says THAT there is one, which
|
||||
* is the half that was missing.
|
||||
*
|
||||
* Right-aligned on the name row: the only band on the cell that is clear, with the chip
|
||||
* registers starting at `CHIP_Y` below and the capacity line and grade wedge at the foot.
|
||||
* Shifted left of the signal when a card carries both.
|
||||
*/
|
||||
if (c.mods && c.mods.length > 0) {
|
||||
out +=
|
||||
`<text class="bs-mod" text-anchor="end" x="${c.x + c.w - (c.abs ? 20 : 7)}" y="${c.y + 14}">` +
|
||||
`${esc(c.mods.join('·'))}</text>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
|
||||
*
|
||||
@@ -1392,6 +1467,12 @@ export const BOARD_CSS = `
|
||||
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
|
||||
.bs-mod{font:10px ui-monospace,monospace}
|
||||
text.bs-mod{fill:#c8a04a}
|
||||
/* A signal standing on a Mainline card carrying ABS Signals. Green is otherwise unused on the
|
||||
Division row apart from the Division Point's dashed border, so a lit lamp does not compete with
|
||||
the amber "it is happening here" or the red flag for meaning. */
|
||||
.bs-abs-mast{stroke:#9aa3b0;stroke-width:1.6}
|
||||
.bs-abs-lit{fill:#4fae6a;stroke:#2c6b40;stroke-width:0.8}
|
||||
.bs-abs-dark{fill:#2a3038;stroke:#59626f;stroke-width:0.8}
|
||||
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
||||
.bs-enh-spent{fill:#5b6b7d;text-decoration:line-through}
|
||||
.bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em}
|
||||
|
||||
+38
-5
@@ -74,7 +74,16 @@ export function carsLabel(cars: RollingStock[]): string {
|
||||
return cars.map(carLabel).join(', ');
|
||||
}
|
||||
|
||||
const at = (c: GridCoord): string => `(${c.row},${c.col})`;
|
||||
/**
|
||||
* X,Y — EAST/WEST THEN NORTH/SOUTH, exactly as `view.ts` writes it, and NOT the internal row/col
|
||||
* storage order.
|
||||
*
|
||||
* These two disagreed until 2026-09-21: the action menu said "(1,-1)" and the log said "(-1,1)" for
|
||||
* the same square, side by side on the same screen. `view.ts` carried the comment explaining why
|
||||
* the display order is X,Y; this one had no comment at all and was simply the storage order
|
||||
* reaching the page. Jesse's call — the log and the action menu spell a square the same way.
|
||||
*/
|
||||
const at = (c: GridCoord): string => `(${c.col},${c.row})`;
|
||||
|
||||
const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const;
|
||||
const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`;
|
||||
@@ -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.'
|
||||
: e.option === 'draw'
|
||||
? 'Chose to DRAW a card'
|
||||
: 'Chose FREIGHT AGENT work — one car moved to or from a facility',
|
||||
/**
|
||||
* SAYS WHAT MAY BE DONE, NOT WHAT WAS. This read "one car moved to or from a
|
||||
* facility" — an assertion — and §6.3 requires no action at all, so when the Freight
|
||||
* Agent went idle the log claimed a car had moved and then fell silent about which.
|
||||
* Reported from a table on Day 1 Stage 3 of v0.8.0.16. The work itself is narrated by
|
||||
* `stockToOutbound`, `inboundCleared` and `facilityUnjammed`, each naming the car and
|
||||
* the industry; an idle Agent is narrated by `freightAgentIdled`.
|
||||
*/
|
||||
: 'Chose FREIGHT AGENT work — may stock a green Outbound box, clear a red Inbound one, or free a jam',
|
||||
};
|
||||
case 'trayMoved':
|
||||
// `via` rides on the event only when there was another legal route to the same square
|
||||
@@ -466,23 +483,39 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
|
||||
// -- freight agent
|
||||
/**
|
||||
* THE INDUSTRY, NOT THE COORDINATE — `place` over `at`, for the reason its own comment gives:
|
||||
* "(-1,1)" is the grid's notation and means nothing at a table where people are looking at
|
||||
* cards. The switching lines were moved to it and these three were missed, so a Freight Agent
|
||||
* turn was the one place the log still spoke in coordinates. Asked directly from a table on
|
||||
* Day 1 Stage 3 of v0.8.0.16: "can we tell what car, what facility and whether it was to or
|
||||
* from." The car and the direction were already here; the facility was not.
|
||||
*/
|
||||
case 'stockToOutbound':
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Freight Agent put a ${carLabel(e.stock)} into the green Outbound box at ${at(e.at)}`,
|
||||
text: `Freight Agent loaded a ${carLabel(e.stock)} INTO the green Outbound box at ${place(e.player, e.at)}`,
|
||||
};
|
||||
case 'inboundCleared':
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Freight Agent cleared a ${carLabel(e.stock)} from the red Inbound box at ${at(e.at)}`,
|
||||
text: `Freight Agent cleared a ${carLabel(e.stock)} OUT of the red Inbound box at ${place(e.player, e.at)}`,
|
||||
};
|
||||
case 'facilityUnjammed':
|
||||
return {
|
||||
tone: 'bad',
|
||||
text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
|
||||
where: e.at,
|
||||
text: `UNJAMMED ${at(e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
|
||||
};
|
||||
case '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
|
||||
|
||||
+40
-2
@@ -953,9 +953,22 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`)
|
||||
: undefined;
|
||||
const upgrade = over?.geometry.kind === 'track';
|
||||
/**
|
||||
* NAME THE MAINLINE CARD, for exactly the reason the rotation is named above — reported from
|
||||
* a table on Day 1 Stage 1 of v0.8.0.16 and the THIRD time this trap has been sprung.
|
||||
*
|
||||
* ABS Signals is played on a Division NODE rather than a grid square, so `i.placement` is
|
||||
* absent and every one of its placements described itself as plain "play ABS Signals". The
|
||||
* action list drops duplicate labels, so all but the lowest-index Mainline card were discarded
|
||||
* before the menu saw them: the tooltip promised "any Mainline card" and the board offered
|
||||
* one. Naming the card is what makes the choice both legible and survivable.
|
||||
*/
|
||||
const onNode = i.node === undefined ? undefined : s.division.nodes[i.node];
|
||||
const mainline =
|
||||
onNode?.kind === 'mainline' ? ` on the ${mainlineProfile(onNode.card).name}, out on the Mainline` : '';
|
||||
return (
|
||||
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
|
||||
`${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`
|
||||
`${i.placement ? ` at ${at(i.placement)}` : ''}${mainline}${turn}`
|
||||
);
|
||||
}
|
||||
case 'card.discard': {
|
||||
@@ -1903,10 +1916,35 @@ export function cardName(s: GameState, id: string): string {
|
||||
case 'mainlineModifier':
|
||||
case 'maneuver':
|
||||
case 'action':
|
||||
return prettyKey(k.key);
|
||||
/**
|
||||
* THE PRINTED NAME WINS over `prettyKey`'s guess, on the same reasoning as the facility and
|
||||
* modifier lookups above: the tables are for names `prettyKey` cannot derive.
|
||||
*
|
||||
* It guessed wrong more often than the fallback comment implies. `absSignals` came out as
|
||||
* "Abs Signals" on the button while the rules, the tooltip and the card face all say **ABS**
|
||||
* Signals — an acronym no key-splitter can recover — and `brokenCoupler` and `beanHouse`
|
||||
* were title-cased past their printed "Broken coupler" and "Bean house".
|
||||
*/
|
||||
return SIMPLE_CARD_NAMES.get(k.key) ?? prettyKey(k.key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every authored card name, keyed as the card kinds key themselves.
|
||||
*
|
||||
* Built from the content tables rather than restated, so a card renamed there is renamed here and
|
||||
* the two cannot drift — which is the whole argument of TODO #15a, applied to names.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
+40
-1
@@ -652,9 +652,48 @@ function renderGameCard(f: Frame): void {
|
||||
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
|
||||
$('gamecardbody').innerHTML =
|
||||
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
|
||||
rulesListHtml(config, players, f.days);
|
||||
rulesListHtml(config, players, f.days) +
|
||||
GUIDE_HTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE DOCUMENTATION, REACHABLE FROM INSIDE A GAME — asked for directly from a table, 2026-09-21:
|
||||
* "how can we link the documentation so it can be reached from the gameplay, whether someone is
|
||||
* playing solitaire or multiplayer?"
|
||||
*
|
||||
* IT NEEDS NO MODE AWARENESS, which is the whole reason this is three lines rather than a feature.
|
||||
* Solitaire and multiplayer are the same page on the same origin — `play.html?solitaire` and
|
||||
* `play.html?lobby` — and the build publishes the documents beside it, so one relative link
|
||||
* resolves identically in both, on the public site and on a StartOS box alike.
|
||||
*
|
||||
* IN THIS CARD RATHER THAN THE HEADER (Jesse's call, 2026-09-21), for the reason he gave the top
|
||||
* line in the first place on 2026-08-30: it is "not something that they're likely to need all the
|
||||
* time", and the header is the line that must not wrap. The card is already where reference lives
|
||||
* — the seed, the seat, the house rules — and it holds the whole set rather than one door.
|
||||
*
|
||||
* NEW TAB, every one of them: a player reading the rules mid-turn must not lose the game behind
|
||||
* them. `rel="noopener"` because `target="_blank"` without it hands the opened page a handle back.
|
||||
*
|
||||
* Built once at module scope — it never varies, and rebuilding it on every frame would be work
|
||||
* nobody sees, the same reasoning the folded body above already follows.
|
||||
*/
|
||||
const GUIDE_DOCS: readonly { href: string; label: string; what: string }[] = [
|
||||
{ href: './quickstart.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).
|
||||
*
|
||||
|
||||
@@ -268,6 +268,13 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
||||
#gamecardbody dd.changed{color:#f0b64a}
|
||||
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
||||
/* THE GUIDE, inside the card rather than the header (Jesse, 2026-09-21). Laid out as one wrapping
|
||||
line of links rather than a list: six references stacked would make the card scroll past the
|
||||
house rules it sits under, and these are a shelf to reach for, not a thing to read down. */
|
||||
#gamecardbody .guide{margin-top:10px;border-top:1px solid var(--line);padding-top:6px}
|
||||
#gamecardbody .guide p{margin:4px 0 0;font-size:12px;line-height:1.7;color:#8b94a3}
|
||||
#gamecardbody .guide a{color:#9fb6d8;text-decoration:none;border-bottom:1px solid #33404f}
|
||||
#gamecardbody .guide a:hover{color:#cfe0f5;border-bottom-color:#5aa9e6}
|
||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||
so a disabled button must not wear it. */
|
||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { join } from 'node:path';
|
||||
|
||||
import { fromSave, newGame, submit, toSave, view } from '../src/web/game.ts';
|
||||
import { actionGroups, currentActor } from '../src/web/game.ts';
|
||||
import { narrate } from '../src/sim/narrate.ts';
|
||||
|
||||
const src = (rel: string): string => readFileSync(join(import.meta.dirname, '..', 'src', rel), 'utf8');
|
||||
|
||||
@@ -47,6 +48,12 @@ const KNOWN_UNREDUCED = [
|
||||
'clearanceRequested',
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
/**
|
||||
* The Freight Agent chose to do nothing (§6.3 requires no action). Emitted by `freightAgent.end`
|
||||
* beside the `phaseEnded` that ends the turn, and reduces to nothing itself — exactly the
|
||||
* `switchingEnded` pattern below.
|
||||
*/
|
||||
'freightAgentIdled',
|
||||
/**
|
||||
* The New Train Phase's report that it could give a train nothing (playtest, 2026-09-16, the
|
||||
* Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to
|
||||
@@ -161,3 +168,76 @@ describe('the intents are what reconstructs a game', () => {
|
||||
assert.deepEqual(Object.keys(toSave(game)).sort(), ['history', 'rules', 'seed']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A FREIGHT AGENT TURN, AS THE TABLE READS IT.
|
||||
*
|
||||
* Reported on Day 1 Stage 3 of v0.8.0.16, watching a bot: "one car moved to or from a facility —
|
||||
* can we tell what car, what facility and whether it was to or from." Two separate faults sat
|
||||
* behind that. The choice line ASSERTED a car had moved, which §6.3 does not require and the bot
|
||||
* deliberately declines; and the three lines that do report the work named the grid coordinate
|
||||
* rather than the industry standing on it.
|
||||
*/
|
||||
describe('a Freight Agent turn says what it did, to what, and where', () => {
|
||||
const ctx = {
|
||||
playerName: () => 'Bot 1',
|
||||
facilityAt: (_p: number, c: { row: number; col: number }) =>
|
||||
c.row === -1 && c.col === 1 ? 'the Freight House' : null,
|
||||
};
|
||||
|
||||
it('announces the OPTION without claiming a car moved', () => {
|
||||
const line = narrate({ type: 'localOpsOptionChosen', player: 0, option: 'freightAgent' } as never, ctx);
|
||||
assert.ok(
|
||||
!/one car moved/.test(line.text),
|
||||
`the choice line still asserts an outcome: ${line.text}`,
|
||||
);
|
||||
// It must still say what the Freight Agent is FOR, or the option is a bare name.
|
||||
assert.match(line.text, /Outbound|Inbound|jam/, `the choice line says nothing about the work: ${line.text}`);
|
||||
});
|
||||
|
||||
it('says so when the Freight Agent deliberately does nothing', () => {
|
||||
// The bot takes this route on purpose: unjamming a healthy box destroys a stocked load. Silence
|
||||
// here is what made the turn read as a dropped turn.
|
||||
const line = narrate({ type: 'freightAgentIdled', player: 0 } as never, ctx);
|
||||
assert.match(line.text, /nothing worth doing/, `an idle Freight Agent is silent: ${line.text}`);
|
||||
});
|
||||
|
||||
it('names the industry and the direction, not a coordinate', () => {
|
||||
const at = { row: -1, col: 1 };
|
||||
const stocked = narrate(
|
||||
{ type: 'stockToOutbound', player: 0, at, stock: { type: 'boxcar', loaded: true } } as never,
|
||||
ctx,
|
||||
);
|
||||
assert.match(stocked.text, /the Freight House/, `no industry named: ${stocked.text}`);
|
||||
assert.ok(!/\(1, -1\)/.test(stocked.text), `still speaking in coordinates: ${stocked.text}`);
|
||||
assert.match(stocked.text, /boxcar/, `the car is not named: ${stocked.text}`);
|
||||
assert.match(stocked.text, /INTO/, `the direction is not stated: ${stocked.text}`);
|
||||
|
||||
const cleared = narrate(
|
||||
{ type: 'inboundCleared', player: 0, at, stock: { type: 'hopper', loaded: true } } as never,
|
||||
ctx,
|
||||
);
|
||||
assert.match(cleared.text, /the Freight House/, `no industry named: ${cleared.text}`);
|
||||
assert.match(cleared.text, /OUT of/, `the direction is not stated: ${cleared.text}`);
|
||||
|
||||
const jam = narrate(
|
||||
{ type: 'facilityUnjammed', player: 0, at, from: 'menAtWork', stock: { type: 'tank', loaded: true } } as never,
|
||||
ctx,
|
||||
);
|
||||
assert.match(jam.text, /the Freight House/, `no industry named: ${jam.text}`);
|
||||
|
||||
/**
|
||||
* Where there is no industry, the coordinate is still the honest fallback rather than "nowhere".
|
||||
*
|
||||
* AND IT IS SPELLED THE WAY THE ACTION MENU SPELLS IT — X,Y, east/west then north/south. The
|
||||
* log printed the internal row/col order until 2026-09-21, so the same square read "(1,-1)" in
|
||||
* the menu and "(-1,1)" in the log, side by side. `test/web.test.ts` pins the two against each
|
||||
* other; this pins the order itself.
|
||||
*/
|
||||
const plain = narrate(
|
||||
{ type: 'stockToOutbound', player: 0, at: { row: -2, col: 4 }, stock: { type: 'boxcar', loaded: true } } as never,
|
||||
ctx,
|
||||
);
|
||||
assert.match(plain.text, /\(4,-2\)/, `the fallback coordinate is not in X,Y order: ${plain.text}`);
|
||||
});
|
||||
});
|
||||
|
||||
+5
-1
@@ -59,6 +59,10 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'deckReshuffled', order: ['c1', 'c2', 'c3', 'c4'], rngState: 7 },
|
||||
{ type: 'departmentRefilled', slot: 0, cardId: 'c2' },
|
||||
{ type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
||||
// Sampled from the start rather than joining the unsampled 25: an idle Freight Agent is the ONE
|
||||
// line whose whole job is to be read when nothing happened, so an empty or fallback sentence
|
||||
// would reproduce the silence it exists to fix.
|
||||
{ type: 'freightAgentIdled', player: 0 },
|
||||
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
|
||||
@@ -123,7 +127,7 @@ describe('narration', () => {
|
||||
/**
|
||||
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
|
||||
*
|
||||
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
|
||||
* `SAMPLES` exercises the TEXT of 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
|
||||
* 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
|
||||
|
||||
+1
-1
@@ -230,7 +230,7 @@ describe('card catalogue (component 1)', () => {
|
||||
* "Both" column loses its only argument.
|
||||
*
|
||||
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
|
||||
* the Grocer's Warehouse only receives. `StationMaster-Home-Deck-v0.4.5.md` prints both that way,
|
||||
* the Grocer's Warehouse only receives. `home-deck.md` prints both that way,
|
||||
* and so does the modifier set — all three Refinery modifiers grant outbound.
|
||||
*/
|
||||
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
|
||||
|
||||
@@ -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');
|
||||
const moved = game.log.filter((l) => / moved the local crew/.test(l.text));
|
||||
assert.equal(moved.length, 1, `expected one move line, got ${moved.length}`);
|
||||
// The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
|
||||
// checked here is that it names the square by what stands on it rather than by its coordinates.
|
||||
assert.match(moved[0]!.text, /→ the Freight House|→ \(1,2\)/, `unexpected move line: ${moved[0]!.text}`);
|
||||
/**
|
||||
* The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
|
||||
* checked here is that it names the square by what stands on it rather than by its coordinates.
|
||||
*
|
||||
* The coordinate branch is `(2,1)` for `{ row: 1, col: 2 }`: X,Y, east/west then north/south,
|
||||
* which is how `view.ts` has always written a square and how `narrate.ts` writes one since
|
||||
* 2026-09-21. It read `(1,2)` here while the log still printed the internal storage order.
|
||||
*/
|
||||
assert.match(moved[0]!.text, /→ the Freight House|→ \(2,1\)/, `unexpected move line: ${moved[0]!.text}`);
|
||||
});
|
||||
});
|
||||
|
||||
+242
-1
@@ -22,7 +22,7 @@ import { cardDescription, cardName, describeIntent, variantLabel } from '../src/
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { 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(' / ')}`);
|
||||
});
|
||||
|
||||
it('offers ABS Signals on EVERY Mainline card, each one named', () => {
|
||||
/**
|
||||
* REGRESSION — reported from a table on Day 1 Stage 1 of v0.8.0.16, and the THIRD instance of
|
||||
* one trap. The action list drops duplicate labels, and `describeIntent` for a card play named
|
||||
* the grid placement but never `node` — so every Mainline card produced the identical label
|
||||
* "play ABS Signals" and all but the lowest-index one were discarded before the menu saw them.
|
||||
* The card's own tooltip says "any Mainline card" while exactly one was ever on offer.
|
||||
*
|
||||
* The engine was never wrong: `check` accepts any node whose kind is 'mainline', and
|
||||
* `legalActions` filters by `check`. The whole failure was in the label.
|
||||
*/
|
||||
const game = newGame(555);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
|
||||
let absId: string | undefined;
|
||||
for (const [id, card] of game.state.cards) {
|
||||
const k = card.kind as { kind: string; key?: string };
|
||||
if (k.kind === 'enhancement' && k.key === 'absSignals') { absId = id; break; }
|
||||
}
|
||||
assert.ok(absId, 'the deck has no ABS Signals card');
|
||||
game.state.decks.hands.set(0, [absId]);
|
||||
|
||||
const mainlineNodes = game.state.division.nodes
|
||||
.map((n, i) => ({ n, i }))
|
||||
.filter(({ n }) => n.kind === 'mainline');
|
||||
assert.ok(mainlineNodes.length > 1, 'this division has only one Mainline card — nothing to distinguish');
|
||||
|
||||
// The engine offers one per Mainline card ...
|
||||
const offered = actionGroups(game).options.filter(
|
||||
(o) => o.type === 'card.play' && o.cardId === absId && o.node !== undefined,
|
||||
);
|
||||
assert.equal(
|
||||
offered.length,
|
||||
mainlineNodes.length,
|
||||
`the engine offers ${offered.length} placements for ${mainlineNodes.length} Mainline cards`,
|
||||
);
|
||||
|
||||
// ... and every one of them must survive into the menu, which means distinct labels.
|
||||
const labels = offered.map((o) => describeIntent(game.state, o));
|
||||
assert.equal(
|
||||
new Set(labels).size,
|
||||
offered.length,
|
||||
`the labels collapse, so the menu drops all but one: ${[...new Set(labels)].join(' / ')}`,
|
||||
);
|
||||
|
||||
const spots = actionMenu(game)
|
||||
.placeable.flatMap((g) => g.items)
|
||||
.filter((it) => it.subjectKey === `card:${absId}`)
|
||||
.flatMap((it) => it.spots);
|
||||
assert.equal(
|
||||
spots.length,
|
||||
mainlineNodes.length,
|
||||
`only ${spots.length} of ${mainlineNodes.length} Mainline cards can be chosen`,
|
||||
);
|
||||
|
||||
/**
|
||||
* And the card is called what the card face calls it. `prettyKey` rendered `absSignals` as
|
||||
* "Abs Signals" on a button while the tooltip beside it said ABS — an acronym no key-splitter
|
||||
* can recover, so the authored name in `ENHANCEMENT_CARDS` has to win.
|
||||
*/
|
||||
assert.ok(
|
||||
labels.every((l) => l.includes('ABS Signals')),
|
||||
`the card is not called by its printed name: ${labels[0]}`,
|
||||
);
|
||||
|
||||
// Each spot names the card it would go on, so the choice is legible rather than positional.
|
||||
for (const { n } of mainlineNodes) {
|
||||
const name = mainlineProfile((n as { card: Parameters<typeof mainlineProfile>[0] }).card).name;
|
||||
assert.ok(
|
||||
spots.some((sp) => sp.label.includes(name)),
|
||||
`no spot names the ${name}: ${spots.map((sp) => sp.label).join(' / ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('spells a square the same way in the action menu and in the log', () => {
|
||||
/**
|
||||
* REPORTED 2026-09-21. `view.ts` wrote "(col,row)" — X,Y, east/west then north/south, with a
|
||||
* comment saying so — and `narrate.ts` wrote "(row,col)", the internal storage order, with no
|
||||
* comment at all. So the action menu offered a move to "(1,-1)" and the log then reported it at
|
||||
* "(-1,1)", in two panels a player reads side by side.
|
||||
*
|
||||
* PINNED AGAINST EACH OTHER rather than against a literal: a test asserting one format would
|
||||
* have passed all along on whichever file it was written against. This compares the two
|
||||
* renderers on the same square, which is the property that was actually broken.
|
||||
*/
|
||||
const game = newGame(555);
|
||||
const square = { row: -1, col: 2 };
|
||||
|
||||
const logged = describeIntent(game.state, {
|
||||
type: 'switch.move',
|
||||
trayId: [...game.state.trays.keys()][0]!,
|
||||
to: square,
|
||||
reverse: false,
|
||||
});
|
||||
const menu = describeIntent(game.state, {
|
||||
type: 'card.play',
|
||||
cardId: game.state.decks.hands.get(0)![0]!,
|
||||
placement: square,
|
||||
});
|
||||
|
||||
// Both must render the square, and render it identically.
|
||||
const coord = /\((-?\d+,-?\d+)\)/;
|
||||
const inLog = coord.exec(logged)?.[1];
|
||||
const inMenu = coord.exec(menu)?.[1];
|
||||
assert.ok(inLog, `the log line names no square: ${logged}`);
|
||||
assert.ok(inMenu, `the menu line names no square: ${menu}`);
|
||||
assert.equal(inMenu, inLog, 'the action menu and the log spell the same square differently');
|
||||
|
||||
// And the shared spelling is X,Y — east/west first, which is the order the map is drawn in.
|
||||
assert.equal(inMenu, '2,-1', `not X,Y order: ${inMenu}`);
|
||||
});
|
||||
|
||||
it('says how deep a Department pile is, so a discard can be aimed', () => {
|
||||
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
|
||||
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
|
||||
@@ -3010,6 +3123,82 @@ describe('the Division map shows the whole route', () => {
|
||||
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', () => {
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
|
||||
it('publishes everything the guide links, so "Where to read more" is not five dead links', () => {
|
||||
/**
|
||||
* v0.8.0.16 published the Quickstart alone. Its §8 links five further documents by relative
|
||||
* path, and every one of them 404'd on the package — verified against the running container,
|
||||
* 5 of 6 paths missing. Publishing a guide without what it points at is the same broken-link
|
||||
* failure as the test above, one hop further out, so it is pinned the same way: the links are
|
||||
* read OUT OF THE GUIDE rather than listed here, or this test goes stale exactly as the
|
||||
* references it guards did.
|
||||
*/
|
||||
const guide = readFileSync(join(dist, 'quickstart.md'), 'utf8');
|
||||
const section = guide.slice(guide.indexOf('## 8. Where to read more'));
|
||||
assert.ok(section.length > 0, 'the guide no longer has a "Where to read more" section');
|
||||
|
||||
// Markdown links, minus anchors and absolute URLs — what a reader can actually click.
|
||||
const targets = [...section.matchAll(/\]\(([^)#][^)]*)\)/g)]
|
||||
.map((m) => m[1]!.replace(/^`|`$/g, ''))
|
||||
.filter((t) => !/^https?:/.test(t));
|
||||
assert.ok(targets.length >= 4, `only ${targets.length} references parsed out of the guide`);
|
||||
|
||||
for (const t of targets) {
|
||||
assert.ok(existsSync(join(dist, t)), `the guide links ${t}, which the build does not publish`);
|
||||
}
|
||||
});
|
||||
|
||||
it('reaches the documentation from inside a game, in solitaire and multiplayer alike', () => {
|
||||
/**
|
||||
* Asked from a table, 2026-09-21: "how can we link the documentation so it can be reached from
|
||||
* the gameplay, whether someone is playing solitaire or multiplayer?" The links live in the
|
||||
* This Game card (Jesse's call) — and the point is that they need NO mode awareness, because
|
||||
* both modes are the same page on the same origin. So this asserts the links exist and resolve,
|
||||
* which is the whole of the mechanism.
|
||||
*
|
||||
* Read out of the built bundle rather than the source: what matters is what the shipped page
|
||||
* offers, and a link that resolves in `src/` and not in `dist/` is the exact failure the two
|
||||
* tests above exist to catch.
|
||||
*/
|
||||
const bundle = readFileSync(join(dist, 'web', 'main.js'), 'utf8');
|
||||
const guide = bundle.slice(bundle.indexOf('GUIDE_DOCS'), bundle.indexOf('GUIDE_DOCS') + 4000);
|
||||
assert.ok(bundle.includes('GUIDE_DOCS') || bundle.includes('quickstart.md'), 'the bundle has no guide links');
|
||||
|
||||
// Every document offered in-game must be a file the build published.
|
||||
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.md)["'`]/g)].map((m) => m[1]!);
|
||||
assert.ok(hrefs.length >= 5, `only ${hrefs.length} in-game guide links found`);
|
||||
for (const h of hrefs) {
|
||||
assert.ok(existsSync(join(dist, h.replace(/^\.\//, ''))), `the game links ${h}, which is not published`);
|
||||
}
|
||||
|
||||
// A reference opened mid-turn must not take the game with it.
|
||||
assert.ok(guide.includes('_blank'), 'the guide links would navigate away from a game in progress');
|
||||
assert.ok(guide.includes('noopener'), 'a new-tab link without rel=noopener hands out a window handle');
|
||||
});
|
||||
|
||||
it('is served as text rather than handed over as a download', () => {
|
||||
/**
|
||||
* The server's MIME fallback is `application/octet-stream`, which a browser downloads instead of
|
||||
|
||||
Reference in New Issue
Block a user