Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2a8dff09 | ||
|
|
3befc420da |
+184
@@ -19,6 +19,190 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.2 — 2026-09-23
|
||||
|
||||
A playtest read back against the save file, and the rules that came out of it. Nine questions were
|
||||
asked of one three-Day game; three were bugs, three were the rules working and undocumented, and
|
||||
three were decisions. **Every save on the test server was replayed against this build before
|
||||
release**, which is how the cost of each rule was known before it was chosen.
|
||||
|
||||
### Every district opens on a Depot
|
||||
|
||||
**The single biggest change.** A Whistle Post has one A/D track and is not a Passenger Facility, so
|
||||
the opening of every game was spent unable to work a passenger and one arrival away from a
|
||||
collision. Every district opens on a **Depot** now: two A/D tracks, passengers from Stage 1.
|
||||
|
||||
**"Players start with Whistle Posts, not Depots"** is a setting for a table that wants the harder
|
||||
game. The deck follows the choice — starting on Depots, the four **Depot upgrade cards are left out**,
|
||||
because an upgrade must be to the next tier and a Depot card at a table of Depots is a dead draw.
|
||||
|
||||
How much easier it is showed up in a test rather than an argument: the cue-coverage pool needed
|
||||
widening from 24 seeded games to 60 before it contained a single collision.
|
||||
|
||||
**NO SAVE WAS STRANDED BY IT**, which took care. This is the one house rule that changes how a game
|
||||
is DEALT rather than how it plays, so replaying a save under the wrong opening is a different
|
||||
railroad from intent one — silently. Saves written before the setting existed name the rules that
|
||||
existed then and cannot name this one, so `withSavedOpening` fills it on the replay paths only.
|
||||
Putting it in the resolver instead made a fresh Cutthroat game deal Whistle Posts and read as
|
||||
Custom, which is how the distinction was found.
|
||||
|
||||
### An Office held two trains on one A/D track
|
||||
|
||||
Reported from the table and confirmed on the save: at Day 2 Stage 9 a Whistle Post with one A/D
|
||||
track held Trains 8 and 19 at once.
|
||||
|
||||
An ordering fault in `arriveAtOffice`. The capacity test passed with nothing standing, the train the
|
||||
Interlocking had been holding at the Limits was then moved into the free slot, and the arriving
|
||||
train was pushed in after it — without anyone asking again whether there was room. **So the
|
||||
collision §8.3 calls for never happened.**
|
||||
|
||||
The held train keeps its priority, because it has been waiting. The NEWCOMER takes the consequence,
|
||||
and it is the same one it would have met had the held train arrived first: held at its own Limits
|
||||
where there is an Interlocking, a collision where there is not.
|
||||
|
||||
### The history froze, permanently, and the log cap was not really the cause
|
||||
|
||||
One player's history stopped gaining lines at Day 2 Stage 8 and the other's at Day 2 Stage 4, in a
|
||||
two-player game that never reached Day 5.
|
||||
|
||||
The log is trimmed to a limit — but each seat's "what have I sent you" bookmark was an **index** into
|
||||
that array. Once a seat's bookmark reached the limit the array never grew past it again, so the
|
||||
slice returned nothing for the rest of the game. Different moments per seat because each holds its
|
||||
own bookmark. That game's log ended at exactly the cap.
|
||||
|
||||
Raising the cap only delays it. Lines carry a **sequence number** now, which survives trimming, so
|
||||
the bookmark stays meaningful however much is dropped. Proven by pushing twice the cap through a
|
||||
simulated seat and asserting all of it arrives. The limit is also much larger, on its own merits: a
|
||||
four-player game over ten Days is several times the game that first hit it.
|
||||
|
||||
### §8.1 asked the wrong question twice
|
||||
|
||||
**"Trains may pass" was short-circuiting the whole Subdivision.** The check returned `clear` before
|
||||
the Subdivision was looked at, so a train entering a Double Track was released however busy the rest
|
||||
of it was — including against a train coming the other way three cards deeper in. That is what let
|
||||
Train 8 out of the Western Division Point with no ruling asked, and the reason had nothing to do
|
||||
with Control Points. The card prints that TWO TRAINS MAY SHARE IT, so it excuses occupants on that
|
||||
card and nothing else.
|
||||
|
||||
**A train standing at an Office was invisible to it.** Train 19 was released from the Eastern
|
||||
Division Point towards Train 14 and nobody was asked — because at that moment Train 14 was not in
|
||||
transit at all, it was standing in a district. §8.1 was only ever reading trains on Mainline cards,
|
||||
so a train about to re-enter the very Subdivision being entered counted for nothing.
|
||||
|
||||
**Capacity is the test, not presence** (Jesse's reasoning exactly): at a Whistle Post, one A/D track
|
||||
with a train on it means there is nowhere for the two to pass and no choice to be made. At a Depot
|
||||
or a Terminal with a track still free, the train at the Office is not in the way.
|
||||
|
||||
### Things that happened silently now say so
|
||||
|
||||
**A train held against a facing one.** An absolute bar that returned without a word — the train
|
||||
simply did not depart, Stage after Stage. Only the ABS Signals case announced itself, and it had
|
||||
been given a line for exactly this reason. The line names the train that is coming and says there is
|
||||
no Control Point between them to pass at.
|
||||
|
||||
**A train released from the Limits.** It happened as a side effect of somebody else's arrival, so
|
||||
the held train appeared at the Office with nothing said — "wasn't clear what changed and why train 8
|
||||
was suddenly released". The line names the train whose arrival freed the track, because *why now* is
|
||||
the whole question.
|
||||
|
||||
**A train the Interlocking is holding.** The tooltip explaining it has existed since #99 and **no
|
||||
renderer ever read the flag**, so the train drew like any other crew and nothing told a player to
|
||||
hover. It is drawn held now — red and dashed, the same "stopped, and not by choice" the Red Flag
|
||||
means elsewhere on the map.
|
||||
|
||||
### Where a move is refused, and why
|
||||
|
||||
Asked directly: *"how does a user know what rule is violated and why you can't go there?"* Nowhere,
|
||||
was the answer. `exploreMoves` decides where the rails go, and the pick-up restrictions are enforced
|
||||
afterwards in `check` — so a square the rails reached and the card forbade was reachable,
|
||||
un-offered, and absent from the block list with no reason given.
|
||||
|
||||
Those squares are blocked with the rule that blocks them now, as `cardRule`. The reasons are got by
|
||||
**asking `check`**, not by re-deriving the rules: a second implementation is exactly the failure the
|
||||
block list was built to avoid, and a reason that does not match the refusal is worse than none.
|
||||
|
||||
**And a train may always recover its own caboose.** X13 prints "may drop MTs but not pick up
|
||||
anything", and a train needs its caboose at the far end to be made up — so a train that parted with
|
||||
its caboose could never legally leave again. It stranded itself, permanently and silently. The
|
||||
caboose only, not "your own cars" generally: it is the one car whose absence stops the train
|
||||
departing.
|
||||
|
||||
### Two Modifier rules, decided in September and applied here
|
||||
|
||||
**A Modifier must sit square against its host** — north, south, east or west. No diagonals: touching
|
||||
at a corner is not touching. This reverses an earlier report in the other direction, and the test
|
||||
that asserted the old rule now asserts the new one on the very square that prompted it.
|
||||
|
||||
**A passenger Modifier may not be played at a Whistle Post.** A Waiting Area, Restaurant or Hotel
|
||||
needs an Office upgraded to at least a Depot. It reverses the ruling that let them stand dormant: a
|
||||
Whistle Post allows neither direction, so the outbound slot was discarded on the spot and only the
|
||||
porter landed, and a card that can be played to no effect is a trap however well it is labelled.
|
||||
The grant-recovery path in `officeUpgraded` is kept and is now unreachable by play.
|
||||
|
||||
Both were built, measured, backed out for a fortnight so a playtest could finish, and applied here.
|
||||
The cost is known rather than guessed: of the thirteen saves on the test server, the Whistle Post
|
||||
rule is what strands six of them.
|
||||
|
||||
### The documentation is a set of pages now, not five text files
|
||||
|
||||
They were served as `text/plain`, which is honest and unreadable: a card reference is mostly tables,
|
||||
and as plain text a table is rows of pipes. That was TODO #109, taken deliberately as the short
|
||||
version to get the references in front of testers for one round.
|
||||
|
||||
**Markdown is still the one copy.** `docs/*.md` is what is written and reviewed; the build renders
|
||||
it. A hand-written HTML twin drifts on the first edit, which is the whole lesson of #15a. The
|
||||
Markdown is published beside each page too — it costs nothing, it is what a reader wanting a diff
|
||||
actually wants, and it keeps every link handed out while the documents were text working.
|
||||
|
||||
**No Markdown library.** `scripts/markdown.ts` covers the subset these five documents use. This
|
||||
project has no runtime dependencies at all and one would be a poor first — and the renderer is
|
||||
ten tests' worth of behaviour, not a general-purpose parser: it escapes unconditionally, and there
|
||||
is no raw-HTML passthrough.
|
||||
|
||||
What the page adds over the text, each because the Markdown cannot carry it without drifting: a nav
|
||||
across the five documents; a contents list built from the headings actually rendered; an anchor on
|
||||
every heading, so a section can be linked in a bug report; a ~70-character measure, because long
|
||||
lines are the single biggest thing making long documents hard to read; and tables that are tables,
|
||||
with numeric columns right-aligned and wide ones scrolling inside the page rather than widening it
|
||||
on a phone. It prints as ink on paper, nav and contents dropped — a rules reference is a thing
|
||||
people print.
|
||||
|
||||
### The references caught up with the rules
|
||||
|
||||
Checked against this release rather than assumed, and two statements had gone from stale to
|
||||
misleading. The Quickstart told a new player they start on a **Whistle Post** and to "get a Depot
|
||||
down as soon as one appears" — advice for a game that no longer exists. Components listed "Whistle
|
||||
Post cards ×4" as what everyone starts on.
|
||||
|
||||
Also added, because the playtest showed each was a rule nobody could look up: the **Office tier
|
||||
table** (A/D tracks, Porters, passengers, Control Point) and what A/D tracks decide; **§8.1 in
|
||||
practice**, including that a card printing "trains may pass" excuses only that card and that an
|
||||
Office with no free A/D track occupies the Subdivision; the three conditions the **Circus Train**
|
||||
pays on; and that a **Realignment can be a card with no legal target**, which is exactly what
|
||||
happened at the table.
|
||||
|
||||
### Smaller, from the same session
|
||||
|
||||
**An industry may be built over a straight** off the Running Track, the way a turnout may upgrade
|
||||
one — so rail can go down before the industry that will serve it. Only a plain straight: a Facility
|
||||
carries east–west track, so the swap cannot break a neighbour's join, where a curve or turnout
|
||||
could.
|
||||
|
||||
**Revenue lines name the player who earned them.** The history prefixes the ACTOR, and revenue is
|
||||
not always the actor's — a train completing its run pays everybody with no actor at all, so those
|
||||
lines carried no name whatsoever.
|
||||
|
||||
**"Waiting on" flashes when it is your turn**, amber, reading the move on screen rather than the
|
||||
live one so it does not flash while your board is still catching up.
|
||||
|
||||
**The Circus Train says what it pays for** on its own line while it is being made up, instead of as a
|
||||
clause trailing the consist — it pays for STOPPING, once per district, and only if every car but the
|
||||
caboose is loaded. The history line says the same when the point lands.
|
||||
|
||||
**A Realignment says what it can convert.** "Convert one Mainline type to another" is true and
|
||||
useless when only four of the nine types convert at all and the Division may have dealt none of
|
||||
them — which is exactly what happened.
|
||||
|
||||
## 0.8.1.0 — 2026-09-22
|
||||
|
||||
A second-digit bump, and a deliberate one. **0.8.1 had been reserved for the seatless display
|
||||
|
||||
@@ -24,6 +24,29 @@ at all. One item per place now.
|
||||
|
||||
Not items. Things that are true of every change, and that have gone wrong when skipped.
|
||||
|
||||
- **Update the documentation set in the same change.** `docs/quickstart.md`, `rules.md`,
|
||||
`home-deck.md`, `mainline-deck.md` and `components.md` describe the game as built, and every
|
||||
release restamps them — `**Version x.y.z** · date` is the second line of each. **The wrapper's
|
||||
`instructions.md` is part of the set**: it is what a StartOS operator reads, so a change to how
|
||||
the service is set up, run or recovered belongs there in the same commit. If a change alters what
|
||||
a player does, sees or may rely on, the affected document changes with it. Four rules govern what
|
||||
goes in them:
|
||||
- **Version at the top**, before anything else on the page.
|
||||
- **No history and no rationale.** No "this used to", no "corrected in v0.8.x", no ruling dates,
|
||||
no TODO numbers. The documents say what the rules ARE. The reasoning belongs in `CHANGELOG.md`
|
||||
and the argument in this file.
|
||||
- **`instructions.md` is a manual, not a changelog.** It accumulated twenty "What changed in …"
|
||||
blocks — 380 of its 469 lines — before they were deleted in 0.8.2. What changed in a release
|
||||
goes in the wrapper's `releaseNotes`, which is what StartOS actually shows on update; the
|
||||
instructions say how to run the service as it is now.
|
||||
- **Card tables are generated, never typed.** `npm run build:cards` writes them into `home-deck.md`
|
||||
and `mainline-deck.md` between `<!-- BEGIN CARDS: … -->` markers, and
|
||||
`test/card-reference.test.ts` fails if a checked-in table disagrees with `content.ts`.
|
||||
- **The Markdown is the source; the pages are built.** `scripts/build-web.ts` renders each
|
||||
document to `<name>.html` through `scripts/markdown.ts`. Never edit a published page — and if a
|
||||
document needs a construct the renderer does not cover, extend the renderer and test it rather
|
||||
than writing HTML into the Markdown.
|
||||
|
||||
- **Ask Jesse what the version bump should be.** Third digit is a bug fix, second is a new set of
|
||||
features, 1.0 is the first release worth the name — but which one a batch deserves is a judgment
|
||||
about how finished it feels, and it is his. The number lives only in `package.json`;
|
||||
@@ -83,7 +106,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
||||
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
|
||||
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
|
||||
9. **Code health and housekeeping** — #46 #45 #84 #87
|
||||
10. **Documentation and assets** — #15a #86 #88
|
||||
10. **Documentation and assets** — #15a #86 #88 #111
|
||||
|
||||
Then, at the back: **Reference** (the measurements, rulings and rejected approaches behind the
|
||||
items above) and **Done** (everything closed, kept because several of them are the only record of a
|
||||
@@ -214,14 +237,13 @@ right — most of this release's defects were legible-but-wrong rather than brok
|
||||
laptop, are unanswered. **Needs a `days: 1` game — the timetable does not run out in five
|
||||
Days, so extended play fired in 0/10 measured games.** See **Reference · #35**.
|
||||
|
||||
- [ ] **#42a** — **Nobody has clicked through the solitaire setup screen's own fields** and confirmed
|
||||
the dealt game matches what was chosen. It took three attempts to become reachable at all —
|
||||
reachable is not the same as correct. See **Reference · #42a**.
|
||||
- [x] **#42a** — **CONFIRMED at a table, 2026-09-23.** The solitaire setup screen's own fields were
|
||||
clicked through and the dealt game matched what was chosen.
|
||||
|
||||
- [ ] **#40** — **An older save may not replay, and players are not told so anywhere they will see
|
||||
it.** Not a v0.7.4 fact and not a bug: a save is re-played through the current rules, so any
|
||||
narrowing of what is legal can stop one. The rule is written down now (`README.md` § Design
|
||||
notes); what is still owed is a line **wherever a build is announced**. See **Reference · #40**.
|
||||
- [x] **#40** — **DONE, 2026-09-23.** One sentence, in the four places a player meets a save: the
|
||||
Quickstart's reporting section, a Rules FAQ entry ("Will an old save still replay?"), the
|
||||
replay viewer's own page, and a tooltip on the **replays** link in the game — which had no
|
||||
tooltip at all before. Also in the package's `instructions.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -424,7 +446,9 @@ need RAR or Jesse rather than code.**
|
||||
to back" depends on which way the train points and the board has reversed east-facing consists
|
||||
since v0.8.0. Each says `MADE UP, ready to leave` or `HELD at the Office: <why>`.
|
||||
|
||||
- [ ] **#108** — **The coach ratchet: every coach ends up in the Classification Yard and never comes
|
||||
- [x] **#108** — **RULED AND CLOSED, 2026-09-23.** It stands: further table evidence supports
|
||||
it, and part of the mid-game is players deliberately adding cars to clear the Division Yard so
|
||||
the Classification refresh can happen. Originally: **The coach ratchet: every coach ends up in the Classification Yard and never comes
|
||||
back.** RULED 2026-09-17 — *the rule stands, the game says so loudly* — and recorded here
|
||||
because the ruling was made on one game's evidence and the balance question behind it is open.
|
||||
|
||||
@@ -582,27 +606,10 @@ 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 references instead of serving them as plain text.**
|
||||
v0.8.0.17 publishes all five documents plus `rules/as-built.md`, links them from the splash
|
||||
page and from the This Game card inside a running game, and serves them as `text/plain` — so a
|
||||
tester reads their tables as rows of pipes and their links do not click. That was the
|
||||
deliberate short version, to get the references in front of testers for this round rather than
|
||||
leave them without any.
|
||||
|
||||
**Now SIX documents rather than one**, which raises the value and the cost together: the
|
||||
Quickstart's §8 is a table of links, and a reader following one lands on another wall of
|
||||
pipes.
|
||||
|
||||
**Copy the document, do not re-write it.** A hand-written HTML twin drifts from the Markdown on
|
||||
the first edit, which is the whole argument of #15a. The step is a small Markdown-to-HTML
|
||||
converter in `scripts/build-web.ts` writing `quickstart.html` beside the game, styled like the
|
||||
splash page — headings, lists, tables, links and code spans are the whole of what the guide
|
||||
uses. The `.md` MIME entry in `src/server/http.ts` and the two assertions in
|
||||
`test/web.test.ts` (`the Quickstart guide reaches the site`) move to the rendered file with it.
|
||||
|
||||
**Cost:** an afternoon, most of it in the converter's table and list handling. No dependency —
|
||||
a Markdown library would be the only runtime dependency this project has, and the guide uses a
|
||||
small enough subset that it is not worth becoming the first.
|
||||
- [ ] **#111** — A full pass over the five player-facing documents: strip the playtest commentary and
|
||||
the pointers into the code, publish the card counts, and take out every section number and
|
||||
repository-file reference. Jesse's read after the 0.8.2 rendering landed — much better, still
|
||||
too much of the workshop showing. See **Reference · #111**.
|
||||
|
||||
---
|
||||
|
||||
@@ -2039,7 +2046,9 @@ unbuilt, each row citing the file that reads it); the same honesty is what makes
|
||||
reference worth more than a transcription. `enhancementText()` and `mainlineDescription()` are
|
||||
the model: prose composed from the data, so a tooltip cannot drift from the rule it describes.
|
||||
|
||||
**Deliberately NOT including card counts per category.** Jesse's call in the same breath: the
|
||||
**Deliberately NOT including card counts per category — REVERSED 2026-09-23, see #111.** The
|
||||
ruling below stood from 2026-08-22 until Jesse asked for the counts to be published; generation
|
||||
answers the staleness it was guarding against. Read it as history. Jesse's call in the same breath: the
|
||||
counts move with play balance, so a document that prints them is stale on the next retune. The
|
||||
same rule was applied to `content.ts`'s own comments on 2026-08-22 — see the pass recorded in
|
||||
CHANGELOG for what came out and what was kept.
|
||||
@@ -2106,6 +2115,82 @@ placeholder said. **The balance question the entry was really guarding is #70**
|
||||
supply and the uniform 1/1/1 industry model, both marked provisional in `content.ts`) — that is
|
||||
where it belongs, and it is still open.
|
||||
|
||||
#### #111 — A full editorial pass over the player-facing documentation.
|
||||
|
||||
**Raised by Jesse 2026-09-23**, on reading the rendered guide and the rewritten wrapper
|
||||
instructions that shipped in 0.8.2. The verdict was that both are dramatically better and that the
|
||||
workshop is still visible through them: *"There is still far too much of the feedback from
|
||||
playtesting, pointing directly into code, commentary about decisions made versus gaps… People
|
||||
playing the game do not need reference to old, outdated source material. They just want the
|
||||
rules."* Four things to fix, and they are separable.
|
||||
|
||||
**1. Take the section numbers out.** Measured on 2026-09-23: `rules.md` 11, `home-deck.md` 5,
|
||||
`mainline-deck.md` 5, `components.md` 4, `quickstart.md` 0. They are two different problems wearing
|
||||
the same notation:
|
||||
|
||||
- **References to a rulebook nobody has.** §8.1, §8.2, §8.3, §10, §2.2 and the §7 cited in the deck
|
||||
documents do not resolve to anything published — they are the numbering of the prototype rules
|
||||
document. `rules.md:456` is the worst of them: a section whose own heading is
|
||||
`## §8.1 in practice`, named after a document the reader cannot open. `rules.md:203` quotes one
|
||||
outright — `§6.2: *"If any of the Department decks is empty…"*`.
|
||||
- **References that do resolve, but only by number.** §3.5, §4.2–§4.6 and §6 are real sections of
|
||||
`rules.md`, which numbers its own headings 1–7. These are not wrong, they are brittle and
|
||||
unfriendly: `home-deck.md:207` "see Rules §4.4" asks a player to go count.
|
||||
|
||||
**The renderer settles how to fix these.** `scripts/markdown.ts`'s `slug()` deliberately strips a
|
||||
leading section number, so §4.6 has no anchor to link to — the target is
|
||||
`rules.html#passenger-work`. A cross-reference therefore becomes a named link
|
||||
(`[Passenger work](rules.md#passenger-work)`) and the heading numbers themselves come off. Do
|
||||
that first: it is what makes the rest of the pass mechanical.
|
||||
|
||||
**2. Take the pointers into the repository out.** Four of the five documents cite
|
||||
`src/engine/content.ts` by path — `home-deck.md:8`, `mainline-deck.md:8`, `rules.md:8`,
|
||||
`components.md:10` — to vouch that a table is generated. The guarantee is worth keeping and the
|
||||
path is not; say the tables are generated from the game itself. No `.ts`, `.md` or `docs/` path
|
||||
belongs in a player's document. (`docs/design.md` is a developer document and out of scope.)
|
||||
|
||||
**3. Publish the card counts.** *"It will change as playtesting evolves and things alter, but the
|
||||
current count should be listed here. That is crucial information."*
|
||||
|
||||
> **This reverses a standing ruling — #15a's "Deliberately NOT including card counts per
|
||||
> category", Jesse's call of 2026-08-22 — and it is the same person reversing it.** Two years of
|
||||
> that ruling are baked into the files and all of it has to come out: the banner at
|
||||
> `home-deck.md:12` (*"Card counts are not published…"*) and the clause at `components.md:11`
|
||||
> (*"per-category CARD counts are not published, because they move with play balance"*). Update
|
||||
> #15a's reference entry so it does not read as still in force.
|
||||
|
||||
The staleness the old ruling guarded against is answered by generation, not by omission. Every
|
||||
catalogue row already carries its own count — `copiesInDeck` on track and office cards, `copies` on
|
||||
industries, modifiers, enhancements, Mainline cards and Second Section — so
|
||||
`scripts/build-card-reference.ts` gains a **Copies** column and `test/card-reference.test.ts` keeps
|
||||
it honest for free. Nothing is typed by hand, and the count cannot drift from the deck.
|
||||
|
||||
**The one hard part is which deck a printed count describes.** Since 0.8.2 the office cards dealt
|
||||
depend on the house rule: a game opening on Depots pulls the four Depot cards, one opening on
|
||||
Whistle Posts keeps them. A single number is wrong for one of those two games. Print the default
|
||||
game — every district opens on a Depot — and say so where the office table gives its counts.
|
||||
`deckComposition()` and `SOLITAIRE_DECK_SIZE` are the totals to reconcile against.
|
||||
|
||||
**4. Strip the build-status commentary, without deleting true information.** Measured lines:
|
||||
`rules.md` 5, `home-deck.md` 4, `mainline-deck.md` 3, `quickstart.md` 2, `components.md` 0. The
|
||||
distinction that matters:
|
||||
|
||||
- **Commentary about the project — goes.** `rules.md:6` "this document reports **executable
|
||||
behaviour** and marks unimplemented material"; `mainline-deck.md:104` "**It is not implemented,
|
||||
and never has been.**"; the `live` / `dormantSolo` / `unbuilt` vocabulary in `home-deck.md`'s
|
||||
enhancement table (251, 265, 273, 276) — that is an engineering status column printed for
|
||||
players.
|
||||
- **The fact underneath it — stays, in the player's language.** A player does need to know that
|
||||
the opponent-directed cards are not in the deck, that the Interchange does not sort cars, and
|
||||
that Facing Point Locks and the Water Column do nothing to a solitaire opponent. Say what happens
|
||||
at the table ("this card is not dealt", "this card has no effect in a solitaire game"), not what
|
||||
the code has got round to. A pass that deletes the sentence and the fact together makes the
|
||||
documents wrong rather than clean.
|
||||
|
||||
**Where this leaves #15a.** Nothing here reopens it — the tables are generated and that holds. This
|
||||
is the editorial half that generation was never going to do.
|
||||
|
||||
|
||||
## Done
|
||||
|
||||
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
|
||||
|
||||
+34
-9
@@ -1,8 +1,6 @@
|
||||
# Station Master — Components and Markers
|
||||
|
||||
**Describes the game as built at v0.8.1.0** (2026-09-22). 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.
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
|
||||
**Scope:** non-card physical components and supplies. Card-created facilities, workers, deck piles,
|
||||
hand state, timetable state and other markers are documented with their cards or in the
|
||||
@@ -10,7 +8,7 @@ hand state, timetable state and other markers are documented with their cards or
|
||||
|
||||
The figures below are checked against `ROLLING_STOCK_SUPPLY` and the supply constants in
|
||||
`src/engine/content.ts`. They are physical inventory rather than deck tuning, which is why they are
|
||||
printed here at all — per-category CARD counts are deliberately not published anywhere (TODO #15a).
|
||||
printed here at all — per-category CARD counts are not published, because they move with play balance.
|
||||
|
||||
## Rolling stock
|
||||
|
||||
@@ -33,7 +31,7 @@ The engine is not rolling stock and does not count against the four-car Crew Tra
|
||||
| Component | Count | Note |
|
||||
| --- | ---: | --- |
|
||||
| Crew Trays | players + 3 | Engine and tray are one combined resource; there is no "engine without a tray". |
|
||||
| Whistle Post cards | 4 | Every player starts on one; it is not drawn from the deck. |
|
||||
| Office cards | 4 | Every player starts on one. The starting Office is not drawn from the deck. |
|
||||
| Limits signs | 8 | "2N + spares", so relocating one is never a supply question. |
|
||||
|
||||
## The two yards
|
||||
@@ -75,10 +73,10 @@ following-train clearance decisions, takes the Yard Office and Red Flag question
|
||||
every round round the table starts with. A Mainline collision is their fault by §10, and costs 5
|
||||
Revenue. The initial holder is the first player tied for the highest Superintendent setup D12 roll.
|
||||
|
||||
It passes at the end of Stages 3, 6, 9 and 12 — every third Stage, at the Supervisor Shift. Since
|
||||
v0.8.0.13 the handover is **announced on screen and written into the history**: it is the one thing
|
||||
in the game that changes hands on the clock rather than because somebody did something, so nobody is
|
||||
watching for it. Note that the Supervisor Shift refreshes every Laborer and Porter *every* Stage
|
||||
It passes at the end of Stages 3, 6, 9 and 12 — every third Stage, at the Supervisor Shift. The
|
||||
handover is **announced on screen and written into the history**: it is the one thing in the game
|
||||
that changes hands on the clock rather than because somebody did something, so nobody is watching
|
||||
for it. Note that the Supervisor Shift refreshes every Laborer and Porter *every* Stage
|
||||
while the Fedora moves only every third.
|
||||
|
||||
## D12 and seeded randomness
|
||||
@@ -91,3 +89,30 @@ The D12 is used by the engine for:
|
||||
- all shuffled deck order and automatic setup selection through the same seeded random stream.
|
||||
|
||||
The rules engine uses a deterministic 32-bit seeded random generator. The same numeric seed, player configuration, house rules, and accepted intent sequence reconstruct the same game. A seed by itself is not enough when house rules differ.
|
||||
|
||||
## The Office, tier by tier
|
||||
|
||||
Every player has one Office card. It is a property of the district rather than a card that is
|
||||
swapped, so an upgrade raises the numbers in place and leaves anything built beside it alone.
|
||||
|
||||
| Office | A/D tracks | Porters | Passengers out / in | Control Point |
|
||||
| --- | ---: | ---: | --- | :---: |
|
||||
| Whistle Post | 1 | 0 | 0 / 0 | — |
|
||||
| Depot | 2 | 1 | 1 / 1 | yes |
|
||||
| Station | 3 | 2 | 2 / 2 | yes |
|
||||
| Terminal | 4 | 3 | 3 / 3 | yes |
|
||||
|
||||
**A/D tracks are what decide whether an arrival is a collision.** A train pulling in to an Office
|
||||
with every track occupied collides (§8.3) unless an **Interlocking** holds it out on the Limit
|
||||
Track. A train held that way takes the first track to free, ahead of anything arriving after it.
|
||||
|
||||
**A Whistle Post is not a Passenger Facility.** It has no Porters and no passenger boxes, so nobody
|
||||
boards or gets off there however well the district is built, and the passenger Modifiers — Waiting
|
||||
Area, Restaurant, Hotel — cannot be played at one.
|
||||
|
||||
**A Control Point divides the Mainline into Subdivisions**, which is what §8.1 reasons about. A
|
||||
table of Whistle Posts is one Subdivision from end to end; every upgrade splits one in two and buys
|
||||
the Division capacity.
|
||||
|
||||
**Games open on a Depot** unless the table turns on "Players start with Whistle Posts, not Depots"
|
||||
when the game is created. See the Home deck reference.
|
||||
|
||||
+5
-5
@@ -28,7 +28,6 @@ Written to be handed to somebody who is about to play, rather than to somebody b
|
||||
| --- | --- |
|
||||
| [`quickstart.md`](quickstart.md) | **Start here if you have never played.** The point of the game, how a Stage runs, what is on screen, how you win, a first twenty minutes, and what to report. |
|
||||
| [`rules.md`](rules.md) | **The rules in full**, as the engine actually runs them, with a FAQ. |
|
||||
| [`rules/as-built.md`](rules/as-built.md) | **Every card, GENERATED from `src/engine/content.ts`** and checked by a test, so it cannot disagree with the game. The table of record for per-card facts. |
|
||||
| [`home-deck.md`](home-deck.md) | How the Home Office deck is dealt, drawn and played out. |
|
||||
| [`mainline-deck.md`](mainline-deck.md) | The Mainline cards, how the deck is dealt, and what a card does to a train crossing it. |
|
||||
| [`components.md`](components.md) | Rolling stock, the two yards, Crew Trays, the Fedora, the D12. |
|
||||
@@ -45,7 +44,7 @@ described — which read as though they documented a build five minor versions o
|
||||
| --- | --- |
|
||||
| [`rules/rules-v0.1.md`](rules/rules-v0.1.md) | Faithful markdown transcription of the PDFs. No corrections. The baseline everything diffs against. |
|
||||
| [`rules/rules-v0.2.md`](rules/rules-v0.2.md) | **The working ruleset.** v0.1 with all ten gaps resolved, each change marked with its gap number. |
|
||||
| [`rules/card-reference.md`](rules/card-reference.md) | **⚠ SUPERSEDED** — an invented 52-card placeholder, kept for its economy summary and its history. For what is printed on every card, read [`rules/as-built.md`](rules/as-built.md), which is generated from the code. |
|
||||
| [`rules/card-reference.md`](rules/card-reference.md) | **⚠ SUPERSEDED** — an invented 52-card placeholder, kept for its economy summary and its history. For what is printed on every card, read the generated tables in [`home-deck.md`](home-deck.md) and [`mainline-deck.md`](mainline-deck.md). |
|
||||
| [`rules/glossary.md`](rules/glossary.md) | Every defined term, alphabetized. |
|
||||
| [`rules/open-questions.md`](rules/open-questions.md) | All thirteen gaps, each with the options considered, the decision, and the rationale. |
|
||||
| [`rules/implications.md`](rules/implications.md) | **Read this first.** What the four recovered design files (`Deck cards2.xlsx`, `Mainline Cards.pdf`, `Trains3.pdf`, `tracks.png`) change — and which decisions they supersede. |
|
||||
@@ -68,7 +67,7 @@ must do.
|
||||
|
||||
## Current status
|
||||
|
||||
**v0.8.1.0.** Rules formalized, card faces specified, architecture documented, and the game
|
||||
**v0.8.2.** Rules formalized, card faces specified, architecture documented, and the game
|
||||
playable **solitaire and multiplayer** in a browser against an authoritative server. See
|
||||
[`../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
|
||||
@@ -109,8 +108,9 @@ TypeScript natively, so there is no build step during development, which also me
|
||||
only**: no `enum`, no parameter properties, no namespaces.
|
||||
|
||||
Running alongside, and independent of all of it: **print-and-play components.**
|
||||
[`rules/as-built.md`](rules/as-built.md) carries every card face as the game actually deals it, so
|
||||
layout and art are the only remaining work before a table playtest — which answers the one question
|
||||
The generated tables in [`home-deck.md`](home-deck.md) and [`mainline-deck.md`](mainline-deck.md)
|
||||
carry every card face as the game actually deals it, so layout and art are the only remaining work
|
||||
before a table playtest — which answers the one question
|
||||
simulation cannot, whether it is fun.
|
||||
|
||||
Run the harness with `node src/sim/harness.ts [games] [length]`.
|
||||
|
||||
+267
-28
@@ -1,22 +1,18 @@
|
||||
# Station Master — Home Deck
|
||||
|
||||
**Describes the game as built at v0.8.1.0** (2026-09-22). 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.
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
|
||||
**Scope:** the Home Office deck — how it is dealt, drawn, discarded and reshuffled, and what the
|
||||
rules are for playing each kind of card out of it.
|
||||
|
||||
> **Per-card facts live in [`rules/as-built.md`](rules/as-built.md)**, which is GENERATED from
|
||||
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with
|
||||
> the game. Read it for every card's name, effect, placement and whether its printed effect actually
|
||||
> resolves yet. This document is how the deck WORKS; that one is what is in it.
|
||||
> **The card tables in this document are GENERATED from `src/engine/content.ts`** and checked by a
|
||||
> test, so they cannot disagree with the game. `npm run build:cards` rebuilds them; do not edit a
|
||||
> table by hand. The prose around them is how the deck WORKS; the tables are what is in it.
|
||||
>
|
||||
> **No card counts appear here, deliberately** (TODO #15a, Jesse's call 2026-08-22): counts move
|
||||
> with play balance, so a document printing them is answering a question that has a different answer
|
||||
> **Card counts are not published.** Counts move with play balance, so a printed count answers a
|
||||
> question that has a different answer
|
||||
> after the next retune. Where a count matters it is rendered as a yes/no — whether the deck deals
|
||||
> the card at all — which is a fact about the design. This page used to print a full counts table
|
||||
> and it was wrong for a month before anyone noticed.
|
||||
> the card at all — which is a fact about the design.
|
||||
|
||||
## The piles
|
||||
|
||||
@@ -41,7 +37,7 @@ of nothing but those has exactly one way forward, which is to play one.
|
||||
|
||||
The opening deal is a house-rule choice made when the game is dealt. The default (`threeRandom`) is
|
||||
three cards from one shuffled deck; `threeTrackThreeOther` deals three track and three others from
|
||||
two separately shuffled piles, deliberately over the hand limit, so the first turn is spent choosing
|
||||
two separately shuffled piles, over the hand limit, so the first turn is spent choosing
|
||||
which district you can afford to build.
|
||||
|
||||
Drawing is one of the three Local Operations options — see [Rules](rules.md)
|
||||
@@ -56,16 +52,31 @@ Track cards are ordinary Home Office cards, not a separate personal supply.
|
||||
through route, or it breaks the main.
|
||||
- Placing track **on a Limit sign** extends the Running Track and moves that sign outward. The sign
|
||||
is a physical card, so it moves rather than being left stranded mid-track.
|
||||
- **Nothing may be placed outside your Limits** — track, industries and, since v0.8.0.14, Modifiers
|
||||
too. Your district ends at its sign.
|
||||
- **Nothing may be placed outside your Limits** — track, industries and Modifiers alike. Your
|
||||
district ends at its sign.
|
||||
- Curves and turnouts are printed left- or right-handed. A card may be turned 180° but never flipped
|
||||
over, so its 45° leg never changes diagonal.
|
||||
- A **turnout may upgrade** an existing straight, or a curve whose arc is exactly the turnout's
|
||||
diverging arc. Not if the card holds standing cars or an enhancement. Every other occupied square
|
||||
is unavailable.
|
||||
diverging arc. An **industry may be built over a straight** off the Running Track, on the same
|
||||
principle. Neither is allowed if the card holds standing cars or an Enhancement, and every other
|
||||
occupied square is unavailable.
|
||||
- A turnout may be **run through but not stopped on**: it is not Operational Rail, so a Move may not
|
||||
end there.
|
||||
|
||||
<!-- BEGIN CARDS: track -->
|
||||
| Track | Geometry | Hand | Operational rail | Move cost | Dealt |
|
||||
| --- | --- | --- | :---: | ---: | :---: |
|
||||
| Straight track | straight | none | yes | 1 | yes |
|
||||
| Curved track (right) | curved | right | yes | 1 | yes |
|
||||
| Curved track (left) | curved | left | yes | 1 | yes |
|
||||
| Sharp Curved Track (right) | sharpCurved | right | yes | 2 | no |
|
||||
| Sharp Curved Track (left) | sharpCurved | left | yes | 2 | no |
|
||||
| Turnout (right) | turnout | right | — | 1 | yes |
|
||||
| Turnout (left) | turnout | left | — | 1 | yes |
|
||||
|
||||
A row marked "no" is a shape the engine understands but the deck does not currently print.
|
||||
<!-- END CARDS: track -->
|
||||
|
||||
## Office cards
|
||||
|
||||
Every player begins at a **Whistle Post**, which is not drawn from the deck: one A/D track, no
|
||||
@@ -78,12 +89,34 @@ passenger slot; a Depot and above is a Control Point and a Passenger Facility.
|
||||
|
||||
An upgrade takes no placement: the Office is where it already is.
|
||||
|
||||
<!-- BEGIN CARDS: office -->
|
||||
Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in
|
||||
order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**
|
||||
to Porters rather than one more.
|
||||
|
||||
| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |
|
||||
| --- | :---: | :---: | ---: | ---: | ---: | ---: |
|
||||
| Whistle Post | — | — | 1 | 0 | 0 | 0 |
|
||||
| Depot | yes | yes | 2 | 1 | 1 | 1 |
|
||||
| Station | yes | yes | 3 | 2 | 2 | 2 |
|
||||
| Terminal | yes | yes | 4 | 3 | 3 | 3 |
|
||||
|
||||
Whistle Posts are a fixed supply of 4 outside the deck, and Limits signs a
|
||||
supply of 8.
|
||||
<!-- END CARDS: office -->
|
||||
|
||||
## Freight facilities
|
||||
|
||||
An industry is placed on a connected straight **stub off the Running Track** — never on the Running
|
||||
Track itself, and never outside the Limits. A Facility carries its own rails, so placing one places
|
||||
track.
|
||||
|
||||
**It may also be built over a straight already laid**, in the same way a turnout may upgrade one, so
|
||||
rail can go down before the industry that will serve it. Only a plain straight may be built over: a
|
||||
Facility carries east–west track, so replacing a straight cannot break a neighbour's connection,
|
||||
while a curve or a turnout carries rails the Facility does not. The square must be clear of standing
|
||||
cars and carry no Enhancement — the replaced card leaves play.
|
||||
|
||||
Each begins with one Laborer and a three-box **MEN | AT | WORK** pipeline. No Office Area may hold a
|
||||
duplicate industry, or both ends of a lockout pair — a producer and the consumer of the same
|
||||
commodity cannot be built in one district.
|
||||
@@ -92,22 +125,67 @@ commodity cannot be built in one district.
|
||||
count. That distinction was a real bug: box count is how much WORK an industry can hold, not how
|
||||
much RAIL it has, and conflating the two invented a printed siding no industry card carries.
|
||||
|
||||
<!-- BEGIN CARDS: facilities -->
|
||||
Each lists the car types it works, which way its traffic flows, and the industries it may not sit
|
||||
beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may
|
||||
build one end of a chain or the other, never both, which is what forces traffic to run between
|
||||
districts rather than in circles inside one. No two of the same industry may share an Office Area,
|
||||
and that rule is enforced for every kind rather than repeated in each row.
|
||||
|
||||
| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |
|
||||
| --- | --- | --- | ---: | ---: | ---: | --- |
|
||||
| Freight House | boxcar | both | 1 | 1 | 1 | Grocer's Warehouse |
|
||||
| Mine Tipple | hopper | outbound | 1 | 0 | 1 | Power Plant |
|
||||
| Refinery | tank | outbound | 1 | 0 | 1 | Power Plant |
|
||||
| Power Plant | hopper, tank | inbound | 0 | 1 | 1 | Mine Tipple, Refinery |
|
||||
| Packing Sheds | reefer | outbound | 1 | 0 | 1 | Grocer's Warehouse |
|
||||
| Grocer's Warehouse | boxcar, reefer | inbound | 0 | 1 | 1 | Packing Sheds, Freight House |
|
||||
<!-- END CARDS: facilities -->
|
||||
|
||||
## Facility modifiers
|
||||
|
||||
A Modifier sits on an empty square among the **nine spots around its host Facility** — and, since
|
||||
v0.8.0.14, **inside your Limits**, like everything else. It may not stand in the Running Track row.
|
||||
One of each kind per Office Area.
|
||||
A Modifier sits on an empty square **square against its host Facility — north, south, east or
|
||||
west**. It may not go on a diagonal: touching at a corner is not touching. It must be **inside your
|
||||
Limits**, like everything else, and may not stand in the Running Track row. One of each kind per
|
||||
Office Area.
|
||||
|
||||
**A Modifier adds a BOX, never room for a car.** A Truck Dock beside a Grocer's Warehouse gives it a
|
||||
second red box — somewhere for one more arriving load to be cleared to — and changes nothing about
|
||||
how many cars may be spotted there.
|
||||
|
||||
**A passenger Modifier needs a Passenger Facility.** A Waiting Area, Restaurant or Hotel may not be
|
||||
played at an Office that is still a **Whistle Post** — a Whistle Post is not a Passenger Facility, so
|
||||
there is nothing for the card to add to. Upgrade the Office to a **Depot** or better first.
|
||||
|
||||
**A grant the host cannot use does nothing**, and the game says so rather than pretending. An
|
||||
outbound bonus beside an inbound-only facility, or the Truck Dock's inbound bonus beside the
|
||||
outbound-only Packing Sheds, is discarded — the latter leaving that card with no effect at all.
|
||||
Passenger modifiers beside a **Whistle Post** add Porters but create no outbound slot until the
|
||||
Office becomes a Passenger Facility; the panel reports that as DORMANT rather than claiming the
|
||||
facility "only receives", which was wrong in both directions.
|
||||
|
||||
<!-- BEGIN CARDS: modifiers -->
|
||||
Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger
|
||||
Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which
|
||||
is not one.
|
||||
|
||||
| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |
|
||||
| --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Waiting area | any Passenger Facility | 1 | — | — | 1 |
|
||||
| Restaurant | any Passenger Facility | 1 | — | — | 1 |
|
||||
| Hotel | any Passenger Facility | 1 | — | — | 1 |
|
||||
| Truck dock | Freight House, Packing Sheds, Grocer's Warehouse | — | 1 | — | — |
|
||||
| Railroad Express Agency | Freight House | 1 | — | 1 | — |
|
||||
| Forklifts | Freight House, Packing Sheds | 1 | — | 1 | — |
|
||||
| Prep Plant | Mine Tipple | 1 | — | 1 | — |
|
||||
| Coal Piles | Mine Tipple | 1 | — | 1 | — |
|
||||
| Conveyor Belts | Mine Tipple | 1 | — | 1 | — |
|
||||
| Pipelines | Refinery | 1 | — | 1 | — |
|
||||
| Oil Depot | Refinery | 1 | — | 1 | — |
|
||||
| Viscosity breakers | Refinery | 1 | — | 1 | — |
|
||||
| Transmission lines | Power Plant | — | — | 1 | — |
|
||||
| Rotary Dumps | Power Plant | — | — | 1 | — |
|
||||
| Steam Turbines | Power Plant | — | — | 1 | — |
|
||||
| Ice House | Packing Sheds, Grocer's Warehouse | 1 | — | 1 | — |
|
||||
| Local small groceries | Grocer's Warehouse | — | — | 1 | — |
|
||||
<!-- END CARDS: modifiers -->
|
||||
|
||||
## Train cards
|
||||
|
||||
@@ -121,9 +199,6 @@ house rule — `divisionPointsOnly`, `ownOffice`, or `anyOffice` (the default)
|
||||
also available, because it is the one Mainline card with a yard. An Extra runs once and its card
|
||||
goes to the Salvage Yard.
|
||||
|
||||
> The v0.4.5 behaviour of launching every Extra eastbound from the Western Division Point was
|
||||
> replaced in v0.6.2. The number no longer decides an Extra's direction; the start does.
|
||||
|
||||
The listed consist is a **maximum, not a minimum**. A train may depart with fewer cars, but not with
|
||||
more, not of the wrong category, not with a car behind the caboose, and not with the engine buried
|
||||
among its own cars. A Crew Tray holds four pieces, and a caboose counts toward the four.
|
||||
@@ -131,14 +206,48 @@ among its own cars. A Crew Tray holds four pieces, and a caboose counts toward t
|
||||
A train made up short of what its card calls for is reported as such, with what it wanted and why
|
||||
the yard could not supply it — see Rules §4.4.
|
||||
|
||||
**Per-train consists and printed rules: [`rules/as-built.md`](rules/as-built.md) § Trains.** It
|
||||
**Per-train consists and printed rules are in the table under § Train cards below.** It
|
||||
carries the `empties only`, `reefers only`, `drop only` and `pick up empties only` restrictions,
|
||||
every one of which the engine enforces.
|
||||
|
||||
<!-- BEGIN CARDS: trains -->
|
||||
### Timetabled
|
||||
|
||||
| # | Class | Speed | Runs | Consist | Printed rules |
|
||||
| ---: | --- | --- | --- | --- | --- |
|
||||
| 1 | Crack Limited | fast | west | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
|
||||
| 2 | Crack Limited | fast | east | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
|
||||
| 3 | Express | fast | west | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
|
||||
| 4 | Express | fast | east | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
|
||||
| 5 | The Sparrow | fast | west | 3 coaches (3 pieces) | no switching; expedite |
|
||||
| 6 | The Sparrow | fast | east | 3 coaches (3 pieces) | no switching; expedite |
|
||||
| 7 | Local | slow | west | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
|
||||
| 8 | Local | slow | east | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
|
||||
| 9 | Heavy Freight | slow | west | 3 freight + 1 caboose (4 pieces) | — |
|
||||
| 10 | Heavy Freight | slow | east | 3 freight + 1 caboose (4 pieces) | — |
|
||||
| 11 | Drag Freight | slow | west | 2 freight + 1 caboose (3 pieces) | — |
|
||||
| 12 | Drag Freight | slow | east | 2 freight + 1 caboose (3 pieces) | — |
|
||||
|
||||
### Extras
|
||||
|
||||
| # | Class | Speed | Runs | Consist | Printed rules |
|
||||
| ---: | --- | --- | --- | --- | --- |
|
||||
| X13 | Appleseed Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces), empties only | drop only; *"May drop MTs but not pick up anything."* |
|
||||
| X14 | Fruit Growers Express | fast | player's choice | 2 reefers + 1 caboose (3 pieces) | expedite; *"Reefers only. May pick up one extra loaded reefer."* |
|
||||
| X15 | Yard Xfer | slow | player's choice | 2 freight + 1 caboose (3 pieces) | — |
|
||||
| X16 | Light Engine Move | fast | player's choice | engine only | no switching; *"No cars at all."* |
|
||||
| X17 | Campaign Train | fast | player's choice | 1 coach (1 piece) | no switching; stop then expedite; stop earns point; must run loaded; *"One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard."* |
|
||||
| X18 | Circus Train | slow | player's choice | 2 freight + 1 coach + 1 caboose (4 pieces) | no switching; stop earns point; must run loaded; *"One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded."* |
|
||||
| X19 | Military Train | slow | player's choice | 1 freight + 2 coaches (3 pieces) | no switching; no passenger work; expedite; must run loaded; *"Troops and materiel: runs loaded where the yard can supply it."* |
|
||||
| X20 | Director's private car | slow | player's choice | 2 freight + 1 coach (3 pieces) | no passenger work |
|
||||
| X21 | Freight Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces) | — |
|
||||
| X22 | Pee-Dee | slow | player's choice | 1 caboose (1 piece) | pick up empties only; *"Per-diem train. May only pick up MTs."* |
|
||||
<!-- END CARDS: trains -->
|
||||
|
||||
## Enhancements, Mainline modifiers and Maneuvers
|
||||
|
||||
- **Enhancements** are played into your district and change what a square does — the **Small Yard**
|
||||
(re-order a consist for one Move), **Interlocking**, **Yard Office** and the rest. `as-built.md`
|
||||
(re-order a consist for one Move), **Interlocking**, **Yard Office** and the rest. The table below
|
||||
marks each one `live`, `dormantSolo` or `unbuilt`, which is the part only the implementation
|
||||
knows.
|
||||
- **ABS Signals is the exception, and it matters.** It is dealt as an Enhancement but is **not
|
||||
@@ -151,9 +260,139 @@ every one of which the engine enforces.
|
||||
Poling is catalogued but its effect is recorded as "TBD in the source", so there is nothing to
|
||||
implement.
|
||||
|
||||
<!-- BEGIN CARDS: enhancements -->
|
||||
The column that only the implementation can fill in: **whether the printed effect actually
|
||||
resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack
|
||||
but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a
|
||||
solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription
|
||||
cannot carry this column, which is the argument for generating the page rather than writing it.
|
||||
|
||||
| Enhancement | Placement | Requires | Effect resolves |
|
||||
| --- | --- | --- | :---: |
|
||||
| Interlocking | runningTrackStraight | — | **live** |
|
||||
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
|
||||
| Yard Office | secondaryTrackStraight | — | **live** |
|
||||
| Small Yard | secondaryTrackStraight | — | **live** |
|
||||
| Water Column | runningTrackStraight | — | **dormantSolo** |
|
||||
| Overpass | onCard | — | **unbuilt** |
|
||||
| Telegraph | runningTrackStraight | — | **live** |
|
||||
| Telephone | onCard | telegraph on the same card | **live** |
|
||||
| Radio | onCard | telephone on the same card | **live** |
|
||||
| ABS Signals | mainlineCard | — | **live** |
|
||||
<!-- END CARDS: enhancements -->
|
||||
|
||||
## Opponent-directed cards — not dealt
|
||||
|
||||
The **Action** and **Space-use** categories are opponent-directed and are **excluded from every
|
||||
dealt deck**, because their play rules are not implemented. They are catalogued in
|
||||
`as-built.md` so the composition is on record, and `check` refuses to play one. This is deliberate:
|
||||
the table below so the composition is on record, and `check` refuses to play one. This is deliberate:
|
||||
silently accepting them would make a card look playable while doing nothing.
|
||||
|
||||
<!-- BEGIN CARDS: opponent -->
|
||||
**None of these is dealt in any deck today.** A card that can only be played at another player
|
||||
has no legal target in a solitaire game, and a defence with nothing to defend against is as dead
|
||||
a draw as the attack — so both halves are held out until the attacks are implemented. They are
|
||||
listed because they are the design, and because what a defence answers is the only record of why
|
||||
it exists.
|
||||
|
||||
### Action cards — opponent-directed
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Derail | a moving train in the Local Phase | That train must stop for the remainder of the turn. | — |
|
||||
| Broken coupler | a moving train in the Mainline Phase | That train must stop and not move. | — |
|
||||
| Railroad crossing | any Secondary Track Straight | May not be used as a stop point for switching. May not become an Industry. | — |
|
||||
| Per Diem inventory | another player | Lose one point per 2 empty cars on Secondary Tracks. | — |
|
||||
| Demurrage charge | another player | Lose one point per 2 loaded freight cars on Secondary Tracks. | — |
|
||||
| Customer complaints | another player | Lose one point per 2 coaches in loading boxes. | — |
|
||||
| Vandalism | another player | A train passing a Hobo Jungle has a boxcar looted (converted to empty). | — |
|
||||
| Hotbox | another player | A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs. | — |
|
||||
| Outlawed | another player | A train just arrived may not depart for one turn — the crew’s hours have expired. | — |
|
||||
|
||||
### Space-use cards — opponent-directed
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Bean house | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
| Flop house | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||
| Watertower | adjacent to any straight, turnout on Running Track | Burns tablespace. | — |
|
||||
| Hobo Jungle | adjacent to any straight, turnout, Limit on Running Track | Burns tablespace. Vandalism can loot a boxcar passing it. | — |
|
||||
| Section House | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||
| City blocks | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
| Engine Shops | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||
| Tenderloin District | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
| Engineer cemetery | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
|
||||
### Maneuver cards
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Red Flags | any time | A stopped train is prevented from being hit; the approaching train is prevented from moving. | — |
|
||||
| Flying Switch | any time | Break a cut of cars away from behind the engine and roll them into an industry. | — |
|
||||
| Poling | any time | TBD in the source. | — |
|
||||
|
||||
Mainline modifier cards, for completeness — these ARE dealt:
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Brakeman | a GRADE Mainline card | Faster passage downhill. | — |
|
||||
| Airbrakes | a GRADE Mainline card | Faster passage downhill. Brakeman must be in effect. | — |
|
||||
| Helpers | a GRADE Mainline card | Faster passage uphill. | — |
|
||||
| Realignment | a Mainline card | Convert one Mainline type to another. Not while a train is on it. | — |
|
||||
| Facing Point Locks | adjacent to Interlocking | Prevents Derail being played on you. | Derail |
|
||||
<!-- END CARDS: opponent -->
|
||||
|
||||
## The Second Section card
|
||||
|
||||
**Ordering a Second Section costs the card.** Played on a train that is **due out this Stage**, it
|
||||
sends a second, identical train out right behind the first. That second train needs a Crew Tray of
|
||||
its own, so there has to be one free.
|
||||
|
||||
It is the one card that deliberately creates the **following-train** situation §8.1 makes the
|
||||
Superintendent rule on — so playing it is choosing to put that question to them.
|
||||
|
||||
One copy in the deck, spent when it is played.
|
||||
|
||||
## Trains that pay for stopping
|
||||
|
||||
Two Extras pay for **standing still** rather than for running: the **Circus Train** (X18) and the
|
||||
**Campaign Train** (X17). Their card is worth nothing if it is run like an ordinary train.
|
||||
|
||||
Three conditions, all of them required:
|
||||
|
||||
- **Stopped.** The train spent a whole Mainline Phase without moving. Switching around inside a
|
||||
district does not break it — what breaks it is leaving before a Mainline Phase passes.
|
||||
- **In an Office Area.** Any square in a player's district. Standing on the Mainline or at a
|
||||
Division Point pays nothing. A **Whistle Post district counts** — the rule is about the area, not
|
||||
the Office's tier.
|
||||
- **Fully loaded.** Every car except the caboose is carrying something. A train made up short, or
|
||||
carrying an empty, earns nothing however long it stands.
|
||||
|
||||
**One point per district**, and the point goes to whoever sits in the district it stopped in. A
|
||||
Circus touring three districts is paid three times; one parked in the same district all game is paid
|
||||
once. Neither train may switch, so it cannot load itself — it must be made up loaded before it goes.
|
||||
|
||||
## The Office you start on
|
||||
|
||||
Every district opens on a **Depot** unless the table chooses otherwise. A Depot has two A/D tracks
|
||||
and is a Passenger Facility, so passengers earn from the first Stage and a second train can stand at
|
||||
an Office without wrecking.
|
||||
|
||||
**"Players start with Whistle Posts, not Depots"** is the harder game, set when the game is created.
|
||||
A Whistle Post has **one** A/D track and is not a Passenger Facility: no passenger earns anything
|
||||
until somebody draws and plays a Depot upgrade, and a second train arriving is a collision unless an
|
||||
Interlocking holds it at the Limits.
|
||||
|
||||
The deck follows the choice. Starting on Depots, the **Depot upgrade cards are left out** — an
|
||||
upgrade must be to the next tier up, so a Depot card at a table that already has Depots is a dead
|
||||
draw. Station and Terminal are still upgrades and stay in.
|
||||
|
||||
## What a train may pick up
|
||||
|
||||
Coupling is mandatory, so a train forbidden to pick cars up may not make the move that would pick
|
||||
them up — there is no "move but leave them". A square the rails reach but the train's card forbids
|
||||
is shown as blocked, with the rule that forbids it.
|
||||
|
||||
**A train may always recover its own caboose.** A caboose at the far end is what makes a train
|
||||
ready to leave, so a card that says "may drop but not pick up anything" would otherwise let a train
|
||||
strand itself for good the moment it set its caboose down.
|
||||
|
||||
+45
-23
@@ -1,22 +1,19 @@
|
||||
# Station Master — Mainline Deck
|
||||
|
||||
**Describes the game as built at v0.8.1.0** (2026-09-22). 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.
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
|
||||
**Scope:** the tarot-sized Mainline cards placed between Offices — how the deck is dealt, what a
|
||||
card does to a train crossing it, and the Home Deck cards played onto one.
|
||||
|
||||
> **Per-card numbers live in [`rules/as-built.md`](rules/as-built.md)**, which is GENERATED from
|
||||
> `src/engine/content.ts` and checked by `test/card-reference.test.ts`, so it cannot disagree with
|
||||
> the game. This document explains how the deck is used; that one is the table of record. Where the
|
||||
> two ever differ, as-built is right.
|
||||
> **The card table in this document is GENERATED from `src/engine/content.ts`** and checked by a
|
||||
> test, so it cannot disagree with the game. `npm run build:cards` rebuilds it; do not edit it by
|
||||
> hand. The prose explains how the deck is used; the table is the record of what is in it.
|
||||
|
||||
## How Mainline cards work
|
||||
|
||||
At setup the game lays one Mainline card between each neighbouring pair of Offices and one beyond
|
||||
each end Office, between it and a Division Point — so a game with *N* players uses **N + 1** cards.
|
||||
They are **dealt from a finite deck without replacement** (since v0.6.2), so no Division can hold
|
||||
They are **dealt from a finite deck without replacement**, so no Division can hold
|
||||
two of a card printed once. The two Division Points are the fixed ends of the Division and are not
|
||||
in the deck: `buildDivision` lays them itself.
|
||||
|
||||
@@ -27,7 +24,7 @@ A card does not belong to either neighbouring Office. It is shared Division.
|
||||
A card is divided into **regions**, and a train advances **one region per Stage**. Crossing time is
|
||||
therefore `regions − startRegion`, and nothing else. **The printed mph is scenery.**
|
||||
|
||||
This is the part most likely to be remembered wrong, because it used to work the other way: mph set
|
||||
This is the part most likely to be remembered wrong. The printed mph does not set
|
||||
the cost and a Slow train added a Stage to *every* card. It does not. Four things move a train's
|
||||
start region and nothing else does:
|
||||
|
||||
@@ -73,23 +70,45 @@ The PDF art labels the Interchange "Yard". This reference uses **Interchange** t
|
||||
it apart from the Division Yard, the Classification Yard, the Yard Office and the Small Yard — five
|
||||
different things.
|
||||
|
||||
**Region counts and entry points per card are in [`rules/as-built.md`](rules/as-built.md).**
|
||||
**Region counts and entry points per card are in the table under § The deck.**
|
||||
|
||||
<!-- BEGIN CARDS: mainline -->
|
||||
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |
|
||||
| --- | ---: | ---: | --- | :---: | :---: |
|
||||
| Plains | 1 | 0 | — | — | — |
|
||||
| Curves | 2 | 0 | — | — | — |
|
||||
| Hilly | 2 | 0 | 1 / 0 | — | — |
|
||||
| Heavy Grade | 3 | 0 | — | — | — |
|
||||
| Double Track | 1 | 0 | — | yes | — |
|
||||
| Uncontrolled Siding | 2 | 1 | — | — | — |
|
||||
| Tunnel | 2 | 0 | — | — | — |
|
||||
| Trestle | 1 | 0 | — | — | — |
|
||||
| Interchange | 2 | 1 | — | — | yes |
|
||||
|
||||
The Mainline deck is 10 cards; the two Division Points are the fixed ends of
|
||||
the Division and are not dealt. What each card does, in the words the game uses on screen:
|
||||
|
||||
- **Plains** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
|
||||
- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · This is the one Mainline card with a yard, so an Extra Train may be made up and started here. Its printed car-sorting is NOT implemented — a consist is re-ordered at a Small Yard in a district.
|
||||
<!-- END CARDS: mainline -->
|
||||
|
||||
## The Interchange, and what it does NOT do
|
||||
|
||||
The Interchange prints a car-sorting capability. **It is not implemented, and never has been.** The
|
||||
one thing the card's `sortsCars` flag actually gates is that an **Extra Train may be made up and
|
||||
started here** — it is the Mainline card with a yard, which is why §7 allows it (v0.6.2). An Extra
|
||||
started here** — it is the Mainline card with a yard, which is why §7 allows it. An Extra
|
||||
starting here begins in the back region and takes the extra Stage.
|
||||
|
||||
Re-ordering a consist is done at a **Small Yard** in an Office Area, for one switching Move. See
|
||||
Rules §4.3.
|
||||
|
||||
> **Corrected 2026-09-20.** Until this pass the card said "Cars may be sorted into any new order
|
||||
> here" — on the board, in the tooltip a player reads, and in the generated reference. A card
|
||||
> advertising an action the game will not offer sends a player hunting for a button that does not
|
||||
> exist. The description now says what the card does.
|
||||
|
||||
## Heavy Grade modifiers
|
||||
|
||||
Home Office cards, played onto a Mainline card during a player's Draw option.
|
||||
@@ -99,11 +118,17 @@ Home Office cards, played onto a Mainline card during a player's Draw option.
|
||||
| Brakeman | Heavy Grade only. A **downhill** train starts one region further on. |
|
||||
| Airbrakes | Heavy Grade only, and **Brakeman must already be on that card**. A downhill train starts one region further again. |
|
||||
| Helpers | Heavy Grade only. An **uphill** train starts one region further on. |
|
||||
| Realignment | Only onto an **unoccupied** Mainline card. Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, Trestle → Uncontrolled Siding. No other card may be realigned. |
|
||||
| Realignment | Only onto an **unoccupied** Mainline card, and only these four conversions: Plains → Double Track, Curves → Plains, Uncontrolled Siding → Double Track, Trestle → Uncontrolled Siding. |
|
||||
|
||||
A Heavy Grade is 3 regions, so it is 3 Stages to climb and 3 to run down before help. Modifiers
|
||||
never reduce a crossing below one Stage.
|
||||
|
||||
> **A Realignment can be a card with no legal target.** Only four of the nine Mainline types convert
|
||||
> at all — Plains, Curves, Uncontrolled Siding and Trestle — so a Division dealt Heavy Grade, Tunnel
|
||||
> and Double Track has nowhere to play one. A card already converted cannot be converted again, and
|
||||
> a card with a train on it is refused while the train is there. The card says which four it can
|
||||
> convert when you look at it in hand.
|
||||
|
||||
### ABS Signals — an Enhancement, but it lives out here
|
||||
|
||||
**ABS Signals is dealt from the Home Office deck as an Enhancement, and it is the one Enhancement
|
||||
@@ -135,9 +160,6 @@ around decides which modifiers are worth anything and which direction of traffic
|
||||
the whole game. Handing that to one of two neighbours advantages them over the other, and neither
|
||||
has a fair claim to it.
|
||||
|
||||
**Re-opened and closed again on 2026-08-23**, when giving the choice to the Superintendent was
|
||||
considered and rejected. Jesse's call: v0.5.0's ruling stands. Rolling from the seed is
|
||||
deterministic, roughly even (51/49 east/west over 400 games), identical in solitaire and
|
||||
multiplayer, and keeps setup non-interactive — the game has no setup phase, so the question would
|
||||
have to interrupt play before the first Local Operations, in the minority of games that deal the
|
||||
card at all (20% at one player, rising to 50% at four).
|
||||
So the orientation is **rolled from the game's seed**. That is deterministic, roughly even
|
||||
(51/49 east/west), identical in solitaire and multiplayer, and keeps setup non-interactive: the game
|
||||
has no setup phase, so asking would mean interrupting play before the first Local Operations.
|
||||
|
||||
+60
-48
@@ -1,6 +1,8 @@
|
||||
# Station Master — Quickstart
|
||||
|
||||
**For a tester who has never played. Describes the game as built at v0.8.1.0** (2026-09-22).
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
|
||||
For a player who has never played.
|
||||
|
||||
Read this once before you sit down. It is about twenty minutes of reading and will save you an hour
|
||||
of confusion. The deeper references are listed at the end.
|
||||
@@ -32,21 +34,26 @@ and do not wait.**
|
||||
|
||||
## 2. How you win
|
||||
|
||||
The game runs a set number of **Days** — five by default. Each Day is **12 Stages**, which you can
|
||||
think of as two-hour clock periods from midnight.
|
||||
A game runs for a set number of **Days**, chosen when the game is created. Each Day is **12
|
||||
Stages**, which you can think of as two-hour clock periods from midnight.
|
||||
|
||||
At the end of the last Day:
|
||||
At the end of the last Day, the table's **combined Revenue** is checked against a floor. **Miss it
|
||||
and everybody loses**, however well you personally did — this is the number to watch. The floor is
|
||||
worked out from the number of players and Days when the game is dealt, and whoever sets the game up
|
||||
can raise or lower it.
|
||||
|
||||
1. **The table's combined Revenue is checked first**, against a floor of **3 × players × Days**. At
|
||||
three players over five Days that is 45. **Miss it and everybody loses**, however well you
|
||||
personally did. This is the number to watch.
|
||||
2. **Co-op:** meeting the floor is the win, together.
|
||||
3. **Competitive:** meeting the floor puts the game on, and the **highest individual Revenue** wins.
|
||||
4. **Solitaire:** meet the floor by yourself.
|
||||
Clear the floor, and how you win depends on the game type:
|
||||
|
||||
- **Co-op** — clearing the floor is the win, together.
|
||||
- **Competitive** — clearing the floor puts the game on, and the **highest individual Revenue**
|
||||
wins. Everyone is still working towards the same floor first.
|
||||
- **Cutthroat** — competitive, with the opponent-directed cards in play, so players can act against
|
||||
each other directly. Those cards are not implemented yet, so this plays as Competitive today.
|
||||
- **Solitaire** — clear the floor by yourself.
|
||||
|
||||
**Collisions can end it early and badly.** Breaching the collision limit stops play at once in a
|
||||
collective loss — the railroad has been declared unsafe. Default limits are 3 in one Day and 5 in
|
||||
the game.
|
||||
collective loss: the railroad has been declared unsafe. The limits are configurable, and can be
|
||||
switched off entirely.
|
||||
|
||||
**Falling short offers another Day** rather than just ending, so a game that misses the floor can be
|
||||
played on. A game stopped by collisions cannot.
|
||||
@@ -59,11 +66,11 @@ played on. A game stopped by collisions cannot.
|
||||
| A passenger getting off at your platform | 1 |
|
||||
| Completing an outbound freight load | 1 |
|
||||
| Completing an inbound freight unload | 1 |
|
||||
| A train completing its run across the whole Division | 0 by default, to every player |
|
||||
| A train completing its run across the whole Division | paid to every player; usually set to 0 |
|
||||
|
||||
Those rates are set when the game is dealt and can be changed. The default means **your score comes
|
||||
almost entirely from working cars in your own district** — trains passing through pay nothing by
|
||||
themselves.
|
||||
Every rate is set when the game is dealt and can be changed. As they usually stand, **your score
|
||||
comes almost entirely from working cars in your own district** — trains passing through pay nothing
|
||||
by themselves.
|
||||
|
||||
---
|
||||
|
||||
@@ -100,7 +107,13 @@ Stage not spent switching.
|
||||
|
||||
## 4. The screen
|
||||
|
||||
Left column, top to bottom:
|
||||
**Across the top:** your Revenue, the target, the Day and Stage, collisions, and the game code.
|
||||
|
||||
**When other players are acting**, their turns are replayed on your board a step at a time rather
|
||||
than arriving already rearranged, with a `[N behind]` counter, **Pause** and **Skip**. Your own moves
|
||||
are not replayed at you — they are already on your screen.
|
||||
|
||||
**The main display, on the left**, top to bottom:
|
||||
|
||||
- **The Division — west to east.** The shared main line: every Office and the Mainline cards between
|
||||
them, with trains drawn where they are. **West is always on the left**, and a train's engine is
|
||||
@@ -109,7 +122,7 @@ Left column, top to bottom:
|
||||
outside the phases that change it, unless you pin it open.
|
||||
- **History.** What has happened, most recent first.
|
||||
|
||||
Right column:
|
||||
**The right-hand column:**
|
||||
|
||||
- **Your Move** — the buttons. If it is not your turn this is empty, and the board tells you who is
|
||||
acting.
|
||||
@@ -121,23 +134,20 @@ Right column:
|
||||
- **Blocked — why nothing is moving.** *Read this panel.* When something will not work, this is
|
||||
where the game explains why, in rules terms.
|
||||
- **Facilities** — the load pipelines at each industry.
|
||||
|
||||
Across the top: your Revenue, the target, the Day and Stage, collisions, and the game code.
|
||||
|
||||
**When other players are acting**, their turns are replayed on your board a step at a time rather
|
||||
than arriving already rearranged, with a `[N behind]` counter, **Pause** and **Skip**. Your own moves
|
||||
are not replayed at you — they are already on your screen.
|
||||
- **This Game** — the seed or your seat, the game code, the rules this game was dealt under, and
|
||||
links to this guide and the other references.
|
||||
|
||||
---
|
||||
|
||||
## 5. Your first twenty minutes
|
||||
## 5. Start with solitaire
|
||||
|
||||
Play a **solitaire** game first. It needs no server and nobody else, and it is the same rules.
|
||||
|
||||
1. Open the site, choose **Play solitaire**, accept the defaults, **Deal**.
|
||||
2. **Stage 1 — Draw.** You start on a **Whistle Post** with almost nothing. Play a track card or two
|
||||
to extend your Running Track, and get a **Depot** down as soon as one appears: it is the upgrade
|
||||
that makes you a passenger facility and gives you a second A/D track.
|
||||
2. **Stage 1 — Draw.** You start on a **Depot** with almost nothing else. Play a track card or two to
|
||||
extend your Running Track. A Depot already works passengers and has two A/D tracks, so a second
|
||||
train can stand at your Office without wrecking; a **Station** upgrade, when one appears, buys a
|
||||
third track and another Porter.
|
||||
3. **Build one industry** on a stub off the main — not on the Running Track itself, which the game
|
||||
will not allow.
|
||||
4. **Play a train card** when you get one. It rolls onto the timetable and then runs at that Stage
|
||||
@@ -147,8 +157,6 @@ Play a **solitaire** game first. It needs no server and nobody else, and it is t
|
||||
6. Watch the **Blocked** panel whenever you are stuck. It is usually one missing thing: no empty car
|
||||
spotted, no loaded car in the yard, a full box, a locked industry track.
|
||||
|
||||
Then play a Day or two of multiplayer with bots filling the other seats, to see the table take turns.
|
||||
|
||||
---
|
||||
|
||||
## 6. Things that surprise new players
|
||||
@@ -167,33 +175,37 @@ Then play a Day or two of multiplayer with bots filling the other seats, to see
|
||||
- **Passengers may dry up completely.** Coaches move one way — boarding sends the emptied coach to
|
||||
the Classification Yard, and it only comes back when the Division Yard is bare, which may never
|
||||
happen. When it does, the yard panel warns you and passenger trains are made up empty. **This is
|
||||
the rules working as designed**, not a bug; report how it felt, not that it happened.
|
||||
the rules working as designed.**
|
||||
- **Expedited trains leave the same Stage they arrived**, after the Cargo phase. Ordinary ones wait.
|
||||
|
||||
---
|
||||
|
||||
## 7. What to report
|
||||
|
||||
Most useful, in order:
|
||||
|
||||
1. **What you expected versus what happened**, with the Day and Stage. "Day 2 Stage 9, train 5 had
|
||||
no coaches" is worth more than "passengers seem broken".
|
||||
2. **Save the game** (the **Save replay** button) and send the file. A save is the seed and the moves
|
||||
made, so it replays exactly and the bug can be looked at directly.
|
||||
3. **Anything the screen did not explain.** If you had to guess a rule, that is a finding even when
|
||||
the game was right.
|
||||
4. **Anything you went looking for and could not find.**
|
||||
|
||||
Bugs go to the tracker; anything unclear in this guide is also worth saying.
|
||||
After playing some solitaire, play a day or two of multiplayer with bots or friends to get a feel
|
||||
for how players interact. Then you are ready to run a real division.
|
||||
|
||||
---
|
||||
|
||||
## 8. Where to read more
|
||||
## 7. Issues / Suggestions
|
||||
|
||||
Please do report anything that looks like a bug or an area to be improved.
|
||||
|
||||
- **What you expected versus what happened**, with the Day and Stage. "Day 2 Stage 9, train 5 had
|
||||
no coaches" is worth more than "passengers seem broken".
|
||||
- **Save the game** (the **Save replay** button) and send the file. A save is the seed and the moves
|
||||
made, so it replays exactly and the problem can be looked at directly.
|
||||
- **Anything the screen did not explain.** If you had to guess a rule, that is worth saying even
|
||||
when the game was right.
|
||||
- **Anything you went looking for and could not find.**
|
||||
|
||||
**A save replays under the rules of the build that opens it.** When a rule changes, a save made
|
||||
before it may stop part-way — the game says which move it stopped on and leaves your file untouched,
|
||||
so the build you played on will still finish it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Documentation / References
|
||||
|
||||
| For | Read |
|
||||
| --- | --- |
|
||||
| The rules in full, with the FAQ | [Rules](rules.md) |
|
||||
| Every card, generated from the code | [`rules/as-built.md`](rules/as-built.md) |
|
||||
| How the Home Office deck is dealt and played | [Home deck](home-deck.md) |
|
||||
| The Mainline cards and what they do to a train | [Mainline deck](mainline-deck.md) |
|
||||
| Rolling stock, yards, trays, the Fedora | [Components](components.md) |
|
||||
|
||||
+42
-27
@@ -1,12 +1,10 @@
|
||||
# Station Master — Rules
|
||||
|
||||
**Describes the game as built at v0.8.1.0** (2026-09-22). 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.
|
||||
**Version 0.8.2** · 2026-09-23
|
||||
|
||||
**Authority:** observed code paths and tests. Where a card face, a prototype document and executable
|
||||
behaviour differ, this document reports **executable behaviour** and marks unimplemented material.
|
||||
Per-card numbers are not repeated here — [`rules/as-built.md`](rules/as-built.md) is generated from
|
||||
Per-card numbers are not repeated here — the Home deck and Mainline deck references carry tables generated from
|
||||
`src/engine/content.ts` and is the table of record.
|
||||
|
||||
**New to the game? Start with the [Quickstart](quickstart.md).**
|
||||
@@ -30,7 +28,7 @@ This book is divided as follows:
|
||||
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).
|
||||
[Home deck](home-deck.md) and [Mainline deck](mainline-deck.md).
|
||||
|
||||
## 2. Definitions
|
||||
|
||||
@@ -119,9 +117,7 @@ The browser also stores the current local game and resumes it automatically when
|
||||
|
||||
Three modes: **Solitaire**, **Competitive** and **Co-op**. All three are playable.
|
||||
|
||||
**Length is a free `days` count**, not a preset. The old `short`/`standard`/`campaign` presets
|
||||
carried a `target` and were dropped in 2026-08; they survive only as a convenience argument for the
|
||||
simulation tooling, resolving to 3, 5 and 10 Days. The default is 5.
|
||||
**Length is a free `days` count**, not a preset — any number of Days may be set. The default is 5.
|
||||
|
||||
**How a game ends and who wins:**
|
||||
|
||||
@@ -196,7 +192,7 @@ On a player’s Local Operations turn, choose exactly one available option.
|
||||
|
||||
**Switch.** Select any Crew Tray currently in that player’s Office Area. It gets six Moves, or five under Reduced Visibility on the listed night Stages. A Move travels any connected distance in one direction and must end on Operational Rail. Reversing is a separate Move. A train may pass through a turnout but cannot stop on it. It may not share or pass through another train except through an Office with a free A/D track.
|
||||
|
||||
Standing cars couple automatically when the train reaches them; it may not pass them, and the resulting consist may not exceed four cars. Coupling forward places cars ahead of the engine; coupling while backing places them behind it. Setting out cars does not spend a Move, but the cut must come from an outer end of the consist and may not be left on the Office. A **Small Yard** re-orders a consist for one Move, and since v0.8.0.14 may also place cars **ahead
|
||||
Standing cars couple automatically when the train reaches them; it may not pass them, and the resulting consist may not exceed four cars. Coupling forward places cars ahead of the engine; coupling while backing places them behind it. Setting out cars does not spend a Move, but the cut must come from an outer end of the consist and may not be left on the Office. A **Small Yard** re-orders a consist for one Move, and may also place cars **ahead
|
||||
of the engine** — which is how a cut is set up to be shoved into a facing industry. Each option on
|
||||
the menu shows the train it would build, laid out west to east as the board draws it, and says
|
||||
whether the result may leave the Office or would be held there. Flying Switch spends one Move to
|
||||
@@ -211,18 +207,14 @@ roll a tail cut into a connected Freight Facility.
|
||||
> draw actually empties the pile; a pile with cards buried under the one taken is not refilled, or
|
||||
> the Departments would grow without limit and drain the deck into themselves.
|
||||
>
|
||||
> This surprised a player at the table (2026-09-22), which is why it is written down here: the rule
|
||||
> was implemented from the prototype rules and had never reached this document.
|
||||
|
||||
**Freight Agent.** Make one of these operations, then the turn ends: stock one green outbound box from a matching loaded Division Yard car; clear one red inbound box to the Classification Yard; unjam one outbound, inbound, or MEN | AT | WORK load to the Classification Yard; or explicitly end without acting. Freight can be stocked only when an unclaimed empty matching car is already spotted at that industry. Passengers may wait in a green Office box without a train present.
|
||||
|
||||
> **A car cleared from a red Inbound box comes back empty.** Whatever was in it has arrived — the
|
||||
> passengers are out of the station, or the load is in the industry — and the Revenue for it was
|
||||
> paid on arrival, not on clearing. What returns to the Classification Yard is the *car*, in the
|
||||
> common supply and carrying nothing. Before v0.8.1.0 it returned still marked loaded, which put
|
||||
> coaches into the yards that could never be used to unload another passenger.
|
||||
> common supply and carrying nothing.
|
||||
|
||||
Implementation note, still true at v0.8.1.0: `card.discard` is accepted during Local Operations
|
||||
Implementation note: `card.discard` is accepted during Local Operations
|
||||
without checking that the Draw option was chosen — unlike `card.play`, which does check. This is an
|
||||
implementation quirk rather than a fourth published turn option.
|
||||
|
||||
@@ -294,10 +286,9 @@ Car permit no passenger work. A Whistle Post has no Porters.
|
||||
> could board or detrain anywhere for the rest of the game, while trains whose cards call for coaches
|
||||
> were made up empty.
|
||||
>
|
||||
> **This is the rules working as printed and the ruling is that it stands** (Jesse, 2026-09-17, the
|
||||
> same ruling Gitea#2 got: running out is part of the game). What changed is that the game now says
|
||||
> it — the yard panel warns while the shortage lasts, and a train made up short reports why. Whether
|
||||
> the ratchet should be broken is open as TODO #108, to be decided on a second game's evidence.
|
||||
> **This is the rules working as printed: running out of cars is part of the game.** The yard panel
|
||||
> warns while the shortage lasts, and a train made up short reports what it wanted and why none is
|
||||
> coming.
|
||||
|
||||
### 4.7 Freight work
|
||||
|
||||
@@ -334,7 +325,7 @@ score has to come principally from passenger and freight work.
|
||||
|
||||
### 6.1 What the engine supports
|
||||
|
||||
The rules engine has always supported multiple named players; since v0.7 the lobby, server and
|
||||
The rules engine supports multiple named players; the lobby, server and
|
||||
client around it are delivered too, so this section now describes a game people actually play. The
|
||||
engine has player, seat, score, Office Area, Division, phase-order, co-op and competitive-mode data
|
||||
for multiple named players. It deals each player a hand, creates one Office Area per seat, starts acting order at the Superintendent and proceeds eastward, and models the following multiplayer-specific outcomes:
|
||||
@@ -423,16 +414,15 @@ train is still present for that Stage's Load/Unload phase.
|
||||
### Can I choose the direction of an Extra or a Heavy Grade?
|
||||
|
||||
**An Extra, yes** — the player who played the card chooses where it starts and which way it runs, and
|
||||
loads it as they choose (v0.6.2). **A Heavy Grade, no**: orientation is rolled from the seed. That is
|
||||
loads it as they choose. **A Heavy Grade, no**: orientation is rolled from the seed. That is
|
||||
a decision rather than a gap — the card sits between two districts and belongs to neither, so handing
|
||||
the choice to one neighbour would advantage them permanently. See the Mainline deck reference.
|
||||
|
||||
### Can I use an Interchange to reorder a train?
|
||||
|
||||
**No, and the card used to claim otherwise.** Its printed car-sorting has never been implemented; the
|
||||
description was corrected on 2026-09-20 to stop advertising it. What the Interchange actually offers
|
||||
is the one Mainline card with a yard, so an **Extra may be made up and started there**. Re-ordering a
|
||||
consist is done at a **Small Yard** in a district.
|
||||
**No.** Its printed car-sorting is not implemented. What the Interchange offers is the one Mainline
|
||||
card with a yard, so an **Extra may be made up and started there**. Re-ordering a consist is done at
|
||||
a **Small Yard** in a district.
|
||||
|
||||
### Can I play attack cards on another player?
|
||||
|
||||
@@ -442,8 +432,8 @@ is rejected.
|
||||
### Is multiplayer playable?
|
||||
|
||||
**Yes.** Lobby, game codes, seating, bots, an authoritative server that survives restarts, per-seat
|
||||
reconnection and a replayed view of everyone else's turns are all delivered — see §3.5 and §6. The
|
||||
opponent-directed cards remain unimplemented in every mode, so there are still no card attacks.
|
||||
reconnection and a replayed view of everyone else's turns — see §3.5 and §6. The opponent-directed
|
||||
cards are unimplemented in every mode, so there are no card attacks.
|
||||
|
||||
### Why did my passenger train arrive with no coaches?
|
||||
|
||||
@@ -456,3 +446,28 @@ has happened, and a train made up short says so in the log.
|
||||
Because that train is **already made up**, so every re-order on offer would break it — most often by
|
||||
moving the caboose off the rear, which §8.2 will not let a train depart with. If the train is *not*
|
||||
currently fit to run, at least one option will be marked "MADE UP, ready to leave".
|
||||
|
||||
### Will an old save still replay?
|
||||
|
||||
Not always. A save is a seed and the list of moves, replayed through the rules of whatever build
|
||||
opens it — so a change that makes a once-legal move illegal stops the replay at that move. The game
|
||||
names the move and leaves the file untouched; the build the game was played on will still finish it.
|
||||
|
||||
## §8.1 in practice — what holds a train at the end of the line
|
||||
|
||||
A train is held, released or put to the Superintendent by what is in the **Subdivision** ahead of
|
||||
it: the run of Mainline cards between two Control Points. Every Office that is not a Control Point
|
||||
lies inside one, so a table of Whistle Posts is a single Subdivision from end to end.
|
||||
|
||||
- A train coming **towards** it is an absolute bar. The train holds, and the history says which
|
||||
train is coming and that there is no Control Point between them to pass at.
|
||||
- A train going the **same way** is the Superintendent's ruling.
|
||||
- A card printing **"trains may pass"** excuses only what is standing on **that card**. It does not
|
||||
clear the rest of the Subdivision.
|
||||
- A train **standing at an Office with no free A/D track** occupies the Subdivision too: it is about
|
||||
to re-enter and there is nowhere for the two to pass. An Office with a track still free does not
|
||||
hold anyone up.
|
||||
|
||||
**An Office never holds more trains than it has A/D tracks.** A train the Interlocking is holding at
|
||||
the Limits takes the first track to free, ahead of anything arriving afterwards — and the train that
|
||||
arrives to find it taken is held at its own Limits, or collides if there is no Interlocking.
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
# Station Master — the cards as built
|
||||
|
||||
> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by
|
||||
> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file
|
||||
> and the code disagree.
|
||||
|
||||
This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything
|
||||
else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)
|
||||
transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in
|
||||
them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and
|
||||
[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the
|
||||
reasoning; read this for the numbers.
|
||||
|
||||
The engine instantiates from the same constants this is emitted from, so a disagreement between
|
||||
this page and the game is a bug in the generator, not a stale table.
|
||||
|
||||
**No card counts appear here, deliberately** (TODO #15a, Jesse 2026-08-22): the counts move with
|
||||
play balance, so a document that prints them is answering a question that will have a different
|
||||
answer next retune. Where a count matters it is a yes/no — whether the deck deals the card at
|
||||
all — which is a fact about the design rather than about the current tuning.
|
||||
|
||||
---
|
||||
|
||||
## Trains
|
||||
|
||||
12 timetabled and 10 Extras, 22 in all.
|
||||
Odd numbers run west, even run east; a pair shares a class and is the same card face in two
|
||||
directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no
|
||||
train with a caboose carries more than three revenue cars.
|
||||
|
||||
### Timetabled
|
||||
|
||||
| # | Class | Speed | Runs | Consist | Printed rules |
|
||||
| ---: | --- | --- | --- | --- | --- |
|
||||
| 1 | Crack Limited | fast | west | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
|
||||
| 2 | Crack Limited | fast | east | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* |
|
||||
| 3 | Express | fast | west | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
|
||||
| 4 | Express | fast | east | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* |
|
||||
| 5 | The Sparrow | fast | west | 3 coaches (3 pieces) | no switching; expedite |
|
||||
| 6 | The Sparrow | fast | east | 3 coaches (3 pieces) | no switching; expedite |
|
||||
| 7 | Local | slow | west | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
|
||||
| 8 | Local | slow | east | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* |
|
||||
| 9 | Heavy Freight | slow | west | 3 freight + 1 caboose (4 pieces) | — |
|
||||
| 10 | Heavy Freight | slow | east | 3 freight + 1 caboose (4 pieces) | — |
|
||||
| 11 | Drag Freight | slow | west | 2 freight + 1 caboose (3 pieces) | — |
|
||||
| 12 | Drag Freight | slow | east | 2 freight + 1 caboose (3 pieces) | — |
|
||||
|
||||
### Extras
|
||||
|
||||
| # | Class | Speed | Runs | Consist | Printed rules |
|
||||
| ---: | --- | --- | --- | --- | --- |
|
||||
| X13 | Appleseed Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces), empties only | drop only; *"May drop MTs but not pick up anything."* |
|
||||
| X14 | Fruit Growers Express | fast | player's choice | 2 reefers + 1 caboose (3 pieces) | expedite; *"Reefers only. May pick up one extra loaded reefer."* |
|
||||
| X15 | Yard Xfer | slow | player's choice | 2 freight + 1 caboose (3 pieces) | — |
|
||||
| X16 | Light Engine Move | fast | player's choice | engine only | no switching; *"No cars at all."* |
|
||||
| X17 | Campaign Train | fast | player's choice | 1 coach (1 piece) | no switching; stop then expedite; stop earns point; must run loaded; *"One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard."* |
|
||||
| X18 | Circus Train | slow | player's choice | 2 freight + 1 coach + 1 caboose (4 pieces) | no switching; stop earns point; must run loaded; *"One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded."* |
|
||||
| X19 | Military Train | slow | player's choice | 1 freight + 2 coaches (3 pieces) | no switching; no passenger work; expedite; must run loaded; *"Troops and materiel: runs loaded where the yard can supply it."* |
|
||||
| X20 | Director's private car | slow | player's choice | 2 freight + 1 coach (3 pieces) | no passenger work |
|
||||
| X21 | Freight Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces) | — |
|
||||
| X22 | Pee-Dee | slow | player's choice | 1 caboose (1 piece) | pick up empties only; *"Per-diem train. May only pick up MTs."* |
|
||||
|
||||
---
|
||||
|
||||
## Mainline cards
|
||||
|
||||
A card is divided into **regions**, and a train advances one region per Stage — so the regions a
|
||||
card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast
|
||||
train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than
|
||||
part of the road all change the entry point rather than the card's length.
|
||||
|
||||
| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |
|
||||
| --- | ---: | ---: | --- | :---: | :---: |
|
||||
| Plains | 1 | 0 | — | — | — |
|
||||
| Curves | 2 | 0 | — | — | — |
|
||||
| Hilly | 2 | 0 | 1 / 0 | — | — |
|
||||
| Heavy Grade | 3 | 0 | — | — | — |
|
||||
| Double Track | 1 | 0 | — | yes | — |
|
||||
| Uncontrolled Siding | 2 | 1 | — | — | — |
|
||||
| Tunnel | 2 | 0 | — | — | — |
|
||||
| Trestle | 1 | 0 | — | — | — |
|
||||
| Interchange | 2 | 1 | — | — | yes |
|
||||
|
||||
The Mainline deck is 10 cards; the two Division Points are the fixed ends of
|
||||
the Division and are not dealt. What each card does, in the words the game uses on screen:
|
||||
|
||||
- **Plains** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Curves** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Double Track** — 1 region — one Stage each. · 1 Stage for every train. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one.
|
||||
- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Trestle** — 1 region — one Stage each. · 1 Stage for every train. · One train at a time — anything following has to wait for it to clear.
|
||||
- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · This is the one Mainline card with a yard, so an Extra Train may be made up and started here. Its printed car-sorting is NOT implemented — a consist is re-ordered at a Small Yard in a district.
|
||||
|
||||
---
|
||||
|
||||
## Office cards
|
||||
|
||||
Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in
|
||||
order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**
|
||||
to Porters rather than one more.
|
||||
|
||||
| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |
|
||||
| --- | :---: | :---: | ---: | ---: | ---: | ---: |
|
||||
| Whistle Post | — | — | 1 | 0 | 0 | 0 |
|
||||
| Depot | yes | yes | 2 | 1 | 1 | 1 |
|
||||
| Station | yes | yes | 3 | 2 | 2 | 2 |
|
||||
| Terminal | yes | yes | 4 | 3 | 3 | 3 |
|
||||
|
||||
Whistle Posts are a fixed supply of 4 outside the deck, and Limits signs a
|
||||
supply of 8.
|
||||
|
||||
---
|
||||
|
||||
## Freight facilities
|
||||
|
||||
Each lists the car types it works, which way its traffic flows, and the industries it may not sit
|
||||
beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may
|
||||
build one end of a chain or the other, never both, which is what forces traffic to run between
|
||||
districts rather than in circles inside one. No two of the same industry may share an Office Area,
|
||||
and that rule is enforced for every kind rather than repeated in each row.
|
||||
|
||||
| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |
|
||||
| --- | --- | --- | ---: | ---: | ---: | --- |
|
||||
| Freight House | boxcar | both | 1 | 1 | 1 | Grocer's Warehouse |
|
||||
| Mine Tipple | hopper | outbound | 1 | 0 | 1 | Power Plant |
|
||||
| Refinery | tank | outbound | 1 | 0 | 1 | Power Plant |
|
||||
| Power Plant | hopper, tank | inbound | 0 | 1 | 1 | Mine Tipple, Refinery |
|
||||
| Packing Sheds | reefer | outbound | 1 | 0 | 1 | Grocer's Warehouse |
|
||||
| Grocer's Warehouse | boxcar, reefer | inbound | 0 | 1 | 1 | Packing Sheds, Freight House |
|
||||
|
||||
---
|
||||
|
||||
## Modifier cards
|
||||
|
||||
Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger
|
||||
Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which
|
||||
is not one.
|
||||
|
||||
| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |
|
||||
| --- | --- | ---: | ---: | ---: | ---: |
|
||||
| Waiting area | any Passenger Facility | 1 | — | — | 1 |
|
||||
| Restaurant | any Passenger Facility | 1 | — | — | 1 |
|
||||
| Hotel | any Passenger Facility | 1 | — | — | 1 |
|
||||
| Truck dock | Freight House, Packing Sheds, Grocer's Warehouse | — | 1 | — | — |
|
||||
| Railroad Express Agency | Freight House | 1 | — | 1 | — |
|
||||
| Forklifts | Freight House, Packing Sheds | 1 | — | 1 | — |
|
||||
| Prep Plant | Mine Tipple | 1 | — | 1 | — |
|
||||
| Coal Piles | Mine Tipple | 1 | — | 1 | — |
|
||||
| Conveyor Belts | Mine Tipple | 1 | — | 1 | — |
|
||||
| Pipelines | Refinery | 1 | — | 1 | — |
|
||||
| Oil Depot | Refinery | 1 | — | 1 | — |
|
||||
| Viscosity breakers | Refinery | 1 | — | 1 | — |
|
||||
| Transmission lines | Power Plant | — | — | 1 | — |
|
||||
| Rotary Dumps | Power Plant | — | — | 1 | — |
|
||||
| Steam Turbines | Power Plant | — | — | 1 | — |
|
||||
| Ice House | Packing Sheds, Grocer's Warehouse | 1 | — | 1 | — |
|
||||
| Local small groceries | Grocer's Warehouse | — | — | 1 | — |
|
||||
|
||||
---
|
||||
|
||||
## Track cards
|
||||
|
||||
Track is **in the Home Office deck** and is drawn and played like any other card — not a separate
|
||||
per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout
|
||||
may be run through but not stopped on.
|
||||
|
||||
| Track | Geometry | Hand | Operational rail | Move cost | Dealt |
|
||||
| --- | --- | --- | :---: | ---: | :---: |
|
||||
| Straight track | straight | none | yes | 1 | yes |
|
||||
| Curved track (right) | curved | right | yes | 1 | yes |
|
||||
| Curved track (left) | curved | left | yes | 1 | yes |
|
||||
| Sharp Curved Track (right) | sharpCurved | right | yes | 2 | no |
|
||||
| Sharp Curved Track (left) | sharpCurved | left | yes | 2 | no |
|
||||
| Turnout (right) | turnout | right | — | 1 | yes |
|
||||
| Turnout (left) | turnout | left | — | 1 | yes |
|
||||
|
||||
A row marked "no" is a shape the engine understands but the deck does not currently print.
|
||||
|
||||
---
|
||||
|
||||
## Enhancements
|
||||
|
||||
The column that only the implementation can fill in: **whether the printed effect actually
|
||||
resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack
|
||||
but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a
|
||||
solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription
|
||||
cannot carry this column, which is the argument for generating the page rather than writing it.
|
||||
|
||||
| Enhancement | Placement | Requires | Effect resolves |
|
||||
| --- | --- | --- | :---: |
|
||||
| Interlocking | runningTrackStraight | — | **live** |
|
||||
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
|
||||
| Yard Office | secondaryTrackStraight | — | **live** |
|
||||
| Small Yard | secondaryTrackStraight | — | **live** |
|
||||
| Water Column | runningTrackStraight | — | **dormantSolo** |
|
||||
| Overpass | onCard | — | **unbuilt** |
|
||||
| Telegraph | runningTrackStraight | — | **live** |
|
||||
| Telephone | onCard | telegraph on the same card | **live** |
|
||||
| Radio | onCard | telephone on the same card | **live** |
|
||||
| ABS Signals | mainlineCard | — | **live** |
|
||||
|
||||
---
|
||||
|
||||
## Opponent-directed cards, and what answers them
|
||||
|
||||
**None of these is dealt in any deck today.** A card that can only be played at another player
|
||||
has no legal target in a solitaire game, and a defence with nothing to defend against is as dead
|
||||
a draw as the attack — so both halves are held out until the attacks are implemented. They are
|
||||
listed because they are the design, and because what a defence answers is the only record of why
|
||||
it exists.
|
||||
|
||||
### Action cards — opponent-directed
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Derail | a moving train in the Local Phase | That train must stop for the remainder of the turn. | — |
|
||||
| Broken coupler | a moving train in the Mainline Phase | That train must stop and not move. | — |
|
||||
| Railroad crossing | any Secondary Track Straight | May not be used as a stop point for switching. May not become an Industry. | — |
|
||||
| Per Diem inventory | another player | Lose one point per 2 empty cars on Secondary Tracks. | — |
|
||||
| Demurrage charge | another player | Lose one point per 2 loaded freight cars on Secondary Tracks. | — |
|
||||
| Customer complaints | another player | Lose one point per 2 coaches in loading boxes. | — |
|
||||
| Vandalism | another player | A train passing a Hobo Jungle has a boxcar looted (converted to empty). | — |
|
||||
| Hotbox | another player | A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs. | — |
|
||||
| Outlawed | another player | A train just arrived may not depart for one turn — the crew’s hours have expired. | — |
|
||||
|
||||
### Space-use cards — opponent-directed
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Bean house | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
| Flop house | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||
| Watertower | adjacent to any straight, turnout on Running Track | Burns tablespace. | — |
|
||||
| Hobo Jungle | adjacent to any straight, turnout, Limit on Running Track | Burns tablespace. Vandalism can loot a boxcar passing it. | — |
|
||||
| Section House | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||
| City blocks | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
| Engine Shops | adjacent to any straight, curve, turnout | Burns tablespace. | — |
|
||||
| Tenderloin District | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
| Engineer cemetery | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
|
||||
|
||||
### Maneuver cards
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Red Flags | any time | A stopped train is prevented from being hit; the approaching train is prevented from moving. | — |
|
||||
| Flying Switch | any time | Break a cut of cars away from behind the engine and roll them into an industry. | — |
|
||||
| Poling | any time | TBD in the source. | — |
|
||||
|
||||
Mainline modifier cards, for completeness — these ARE dealt:
|
||||
|
||||
| Card | Played on | Effect | Answers |
|
||||
| --- | --- | --- | --- |
|
||||
| Brakeman | a GRADE Mainline card | Faster passage downhill. | — |
|
||||
| Airbrakes | a GRADE Mainline card | Faster passage downhill. Brakeman must be in effect. | — |
|
||||
| Helpers | a GRADE Mainline card | Faster passage uphill. | — |
|
||||
| Realignment | a Mainline card | Convert one Mainline type to another. Not while a train is on it. | — |
|
||||
| Facing Point Locks | adjacent to Interlocking | Prevents Derail being played on you. | Derail |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.1.0",
|
||||
"version": "0.8.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,22 @@
|
||||
/**
|
||||
* Generate `docs/rules/as-built.md` — what the cards say, as the code actually has them.
|
||||
* Generate the card tables inside `docs/home-deck.md` and `docs/mainline-deck.md`.
|
||||
*
|
||||
* WHY THIS IS GENERATED RATHER THAN WRITTEN.
|
||||
* WHY GENERATED RATHER THAN WRITTEN. Nothing fails when a hand-written table falls behind a
|
||||
* constant, so the tables are emitted from the same exported catalogues the engine instantiates
|
||||
* from, and `test/card-reference.test.ts` re-runs this generator and asserts the checked-in docs
|
||||
* match. Change a card face and the suite goes red until the docs are regenerated.
|
||||
*
|
||||
* Every other file in `docs/rules/` is a historical record and says so: `rules-v0.1.md` is a
|
||||
* faithful transcription of the prototype PDFs, `open-questions.md` is the gap tracker,
|
||||
* `rules-v0.2.md` and `card-reference.md` both carry SUPERSEDED banners. None of them describes the
|
||||
* game as built, and none of them should be edited to — the record is worth more intact than
|
||||
* patched.
|
||||
* EACH TABLE LANDS UNDER THE SECTION IT BELONGS TO, between a marker pair the deck documents carry:
|
||||
*
|
||||
* So there was no current reference at all, and `content.ts` spent several releases pointing at
|
||||
* `card-reference.md` as "the place that now carries what the cards say" while that file's own
|
||||
* banner said "do not use its numbers". A reader following the code's advice landed on the v0.4.5
|
||||
* deck: twelve numbered trains, "3 / 4 Mail-Express, 3 coaches", against a `content.ts` whose train
|
||||
* 3 is the Express with two freight cars and a per-location freight rule.
|
||||
* <!-- BEGIN CARDS: track --> …generated… <!-- END CARDS: track -->
|
||||
*
|
||||
* A HAND-WRITTEN REPLACEMENT WOULD HAVE DRIFTED THE SAME WAY, and for the same reason: nothing
|
||||
* fails when a table falls behind a constant. So the reference is emitted from the same exported
|
||||
* catalogues the engine instantiates from, and `test/card-reference.test.ts` re-runs this generator
|
||||
* and asserts the checked-in file matches byte for byte. Change a card face and the suite goes red
|
||||
* until the doc is regenerated — which is the only mechanism this project has found that keeps a
|
||||
* document honest.
|
||||
* Everything around the markers is hand-written and is never touched. Only the Mainline card table
|
||||
* goes to `mainline-deck.md`; every other table belongs to the Home Office deck.
|
||||
*
|
||||
* `npm run build:cards` writes it. Nothing at runtime reads it; it is for people.
|
||||
* `npm run build:cards` writes them. Nothing at runtime reads them; they are for people.
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -70,40 +61,17 @@ const trainRow = (t: TrainProfile): string =>
|
||||
`| ${t.isExtra ? 'X' : ''}${t.number} | ${t.name} | ${t.speed} | ` +
|
||||
`${t.direction === 'playerChoice' ? "player's choice" : t.direction} | ${consistOf(t.consist)} | ${rulesOf(t.rules)} |`;
|
||||
|
||||
const lines: string[] = [];
|
||||
const w = (s = ''): void => void lines.push(s);
|
||||
/** Generated blocks, keyed by the marker name the deck documents wrap them in. */
|
||||
const blocks = new Map<string, string[]>();
|
||||
let current: string[] = [];
|
||||
/** Start a new generated block. Everything `w` writes lands here until the next `section`. */
|
||||
const section = (key: string): void => {
|
||||
current = [];
|
||||
blocks.set(key, current);
|
||||
};
|
||||
const w = (s = ''): void => void current.push(s);
|
||||
|
||||
w('# Station Master — the cards as built');
|
||||
w();
|
||||
w('> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by');
|
||||
w('> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file');
|
||||
w('> and the code disagree.');
|
||||
w();
|
||||
w('This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything');
|
||||
w('else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)');
|
||||
w('transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in');
|
||||
w('them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and');
|
||||
w('[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the');
|
||||
w('reasoning; read this for the numbers.');
|
||||
w();
|
||||
w('The engine instantiates from the same constants this is emitted from, so a disagreement between');
|
||||
w('this page and the game is a bug in the generator, not a stale table.');
|
||||
w();
|
||||
w('**No card counts appear here, deliberately** (TODO #15a, Jesse 2026-08-22): the counts move with');
|
||||
w('play balance, so a document that prints them is answering a question that will have a different');
|
||||
w('answer next retune. Where a count matters it is a yes/no — whether the deck deals the card at');
|
||||
w('all — which is a fact about the design rather than about the current tuning.');
|
||||
w();
|
||||
w('---');
|
||||
w();
|
||||
|
||||
w('## Trains');
|
||||
w();
|
||||
w(`${TIMETABLED_TRAINS.length} timetabled and ${EXTRA_TRAINS.length} Extras, ${ALL_TRAINS.length} in all.`);
|
||||
w('Odd numbers run west, even run east; a pair shares a class and is the same card face in two');
|
||||
w('directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no');
|
||||
w('train with a caboose carries more than three revenue cars.');
|
||||
w();
|
||||
section('trains');
|
||||
w('### Timetabled');
|
||||
w();
|
||||
w('| # | Class | Speed | Runs | Consist | Printed rules |');
|
||||
@@ -116,16 +84,7 @@ w('| # | Class | Speed | Runs | Consist | Printed rules |');
|
||||
w('| ---: | --- | --- | --- | --- | --- |');
|
||||
for (const t of EXTRA_TRAINS) w(trainRow(t));
|
||||
w();
|
||||
w('---');
|
||||
w();
|
||||
|
||||
w('## Mainline cards');
|
||||
w();
|
||||
w('A card is divided into **regions**, and a train advances one region per Stage — so the regions a');
|
||||
w('card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast');
|
||||
w('train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than');
|
||||
w('part of the road all change the entry point rather than the card\'s length.');
|
||||
w();
|
||||
section('mainline');
|
||||
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |');
|
||||
w('| --- | ---: | ---: | --- | :---: | :---: |');
|
||||
for (const m of MAINLINE_PROFILES) {
|
||||
@@ -138,11 +97,7 @@ w('the Division and are not dealt. What each card does, in the words the game us
|
||||
w();
|
||||
for (const m of MAINLINE_PROFILES) w(`- **${m.name}** — ${mainlineDescription(m.kind)}`);
|
||||
w();
|
||||
w('---');
|
||||
w();
|
||||
|
||||
w('## Office cards');
|
||||
w();
|
||||
section('office');
|
||||
w('Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in');
|
||||
w('order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**');
|
||||
w('to Porters rather than one more.');
|
||||
@@ -157,11 +112,7 @@ w();
|
||||
w(`Whistle Posts are a fixed supply of ${WHISTLE_POST_SUPPLY} outside the deck, and Limits signs a`);
|
||||
w(`supply of ${LIMITS_SUPPLY}.`);
|
||||
w();
|
||||
w('---');
|
||||
w();
|
||||
|
||||
w('## Freight facilities');
|
||||
w();
|
||||
section('facilities');
|
||||
w('Each lists the car types it works, which way its traffic flows, and the industries it may not sit');
|
||||
w('beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may');
|
||||
w('build one end of a chain or the other, never both, which is what forces traffic to run between');
|
||||
@@ -177,11 +128,7 @@ for (const f of INDUSTRY_PROFILES) {
|
||||
w(`| ${f.name} | ${f.carTypes.join(', ')} | ${f.flow} | ${f.baseOut} | ${f.baseIn} | ${f.baseLoaders} | ${lo} |`);
|
||||
}
|
||||
w();
|
||||
w('---');
|
||||
w();
|
||||
|
||||
w('## Modifier cards');
|
||||
w();
|
||||
section('modifiers');
|
||||
w('Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger');
|
||||
w('Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which');
|
||||
w('is not one.');
|
||||
@@ -195,15 +142,7 @@ for (const m of MODIFIER_PROFILES) {
|
||||
w(`| ${m.name} | ${hosts} | ${m.addOut || '—'} | ${m.addIn || '—'} | ${m.addLoaders || '—'} | ${m.addPorters || '—'} |`);
|
||||
}
|
||||
w();
|
||||
w('---');
|
||||
w();
|
||||
|
||||
w('## Track cards');
|
||||
w();
|
||||
w('Track is **in the Home Office deck** and is drawn and played like any other card — not a separate');
|
||||
w('per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout');
|
||||
w('may be run through but not stopped on.');
|
||||
w();
|
||||
section('track');
|
||||
w('| Track | Geometry | Hand | Operational rail | Move cost | Dealt |');
|
||||
w('| --- | --- | --- | :---: | ---: | :---: |');
|
||||
for (const t of TRACK_CARDS) {
|
||||
@@ -212,11 +151,7 @@ for (const t of TRACK_CARDS) {
|
||||
w();
|
||||
w('A row marked "no" is a shape the engine understands but the deck does not currently print.');
|
||||
w();
|
||||
w('---');
|
||||
w();
|
||||
|
||||
w('## Enhancements');
|
||||
w();
|
||||
section('enhancements');
|
||||
w('The column that only the implementation can fill in: **whether the printed effect actually');
|
||||
w('resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack');
|
||||
w('but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a');
|
||||
@@ -235,11 +170,7 @@ for (const r of ENHANCEMENT_RULES) {
|
||||
w(`| ${card?.name ?? r.key} | ${r.placement} | ${needs} | **${r.effect}** |`);
|
||||
}
|
||||
w();
|
||||
w('---');
|
||||
w();
|
||||
|
||||
w('## Opponent-directed cards, and what answers them');
|
||||
w();
|
||||
section('opponent');
|
||||
w('**None of these is dealt in any deck today.** A card that can only be played at another player');
|
||||
w('has no legal target in a solitaire game, and a defence with nothing to defend against is as dead');
|
||||
w('a draw as the attack — so both halves are held out until the attacks are implemented. They are');
|
||||
@@ -264,5 +195,33 @@ w('| --- | --- | --- | --- |');
|
||||
for (const c of MAINLINE_MODIFIER_CARDS) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
|
||||
w();
|
||||
|
||||
writeFileSync(join(root, 'docs/rules/as-built.md'), `${lines.join('\n')}\n`);
|
||||
console.log(`built -> docs/rules/as-built.md (${lines.length} lines)`);
|
||||
/**
|
||||
* Splice each block into its document, between the markers that name it.
|
||||
*
|
||||
* Strict on purpose: a block with nowhere to go, or a marker pair with no block, is a mistake that
|
||||
* would otherwise show up as a silently missing table. Both throw.
|
||||
*/
|
||||
const WHERE: Record<string, string> = {
|
||||
mainline: 'docs/mainline-deck.md',
|
||||
};
|
||||
const DEFAULT_DOC = 'docs/home-deck.md';
|
||||
|
||||
const edited = new Map<string, string>();
|
||||
for (const [key, body] of blocks) {
|
||||
const rel = WHERE[key] ?? DEFAULT_DOC;
|
||||
const text = edited.get(rel) ?? readFileSync(join(root, rel), 'utf8');
|
||||
const begin = `<!-- BEGIN CARDS: ${key} -->`;
|
||||
const finish = `<!-- END CARDS: ${key} -->`;
|
||||
const from = text.indexOf(begin);
|
||||
const to = text.indexOf(finish);
|
||||
if (from < 0 || to < 0) throw new Error(`${rel} has no markers for "${key}" — expected ${begin} … ${finish}`);
|
||||
if (to < from) throw new Error(`${rel}: markers for "${key}" are the wrong way round`);
|
||||
// Trailing blank lines are trimmed so the block sits the same way however the section ends.
|
||||
const inner = body.join('\n').replace(/\n+$/, '');
|
||||
edited.set(rel, `${text.slice(0, from + begin.length)}\n${inner}\n${text.slice(to)}`);
|
||||
}
|
||||
|
||||
for (const [rel, text] of edited) {
|
||||
writeFileSync(join(root, rel), text);
|
||||
console.log(`built -> ${rel}`);
|
||||
}
|
||||
|
||||
+47
-31
@@ -14,6 +14,9 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync,
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { DOC_PAGES, docPage } from './docs-page.ts';
|
||||
import { renderMarkdown } from './markdown.ts';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
/**
|
||||
* Overridable so a test can point the build at an isolated directory instead of the shared
|
||||
@@ -189,41 +192,46 @@ if (existsSync(imageSrc)) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The player-facing documentation, published beside the game so a tester can reach it from the box.
|
||||
* The player-facing documentation, RENDERED and published beside the game.
|
||||
*
|
||||
* COPIED, NOT RE-WRITTEN. The Markdown in `docs/` is the one copy; a hand-written HTML twin would
|
||||
* 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.
|
||||
* MARKDOWN IS STILL THE ONE COPY. `docs/*.md` is what is written and reviewed; this turns it into
|
||||
* a page at build time. A hand-written HTML twin would drift from it on the first edit, which is
|
||||
* the whole lesson of TODO #15a and of the documentation pass that found four references a month
|
||||
* out of date.
|
||||
*
|
||||
* 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.
|
||||
* WHY RENDER AT ALL. They were served as `text/plain`, which is honest and unreadable: a card
|
||||
* reference is mostly tables, and as plain text a table is rows of pipes. That was TODO #109, taken
|
||||
* deliberately as the short version to get the references in front of testers for one round.
|
||||
*
|
||||
* SERVED AS PLAIN TEXT for now, which is honest rather than good: tables render as pipes and the
|
||||
* links do not click. Rendering them into styled pages needs a small Markdown converter and is
|
||||
* filed as TODO #109 — this is the version that gets the references in front of testers for this
|
||||
* round rather than leaving them without any.
|
||||
* NO MARKDOWN LIBRARY. `scripts/markdown.ts` covers the subset these five documents use, and this
|
||||
* project has no runtime dependencies at all — one would be a poor first.
|
||||
*
|
||||
* 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.
|
||||
* THE `.md` IS PUBLISHED TOO, beside the page. It costs nothing, it is what a reader who wants the
|
||||
* source or a diff actually wants, and it keeps every link that was handed out while the documents
|
||||
* were served as text working rather than 404ing.
|
||||
*/
|
||||
const GUIDE_DOCS: readonly string[] = [
|
||||
'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',
|
||||
];
|
||||
const GUIDE_DOCS: readonly string[] = DOC_PAGES.map((d) => `${d.slug}.md`);
|
||||
|
||||
/** `rules.md` → `rules.html`, so a link between documents lands on the rendered page. */
|
||||
const docLink = (href: string): string =>
|
||||
/^https?:/.test(href) || href.startsWith('#') ? href : href.replace(/\.md(#|$)/, '.html$1');
|
||||
|
||||
/**
|
||||
* The title and version line, lifted out of the Markdown body.
|
||||
*
|
||||
* The documents open with `# Title` then `**Version x.y.z** · date`, and the page draws both in its
|
||||
* own header — so rendering them again in the body would print each twice. Taken by pattern rather
|
||||
* than by line count, and the body is only trimmed where the pattern actually matched.
|
||||
*/
|
||||
function splitHead(src: string): { title: string; version: string; body: string } {
|
||||
const m = /^#\s+(.+?)\n+\*\*Version\s+([^*]+)\*\*\s*·\s*([^\n]+)\n/.exec(src);
|
||||
if (!m) return { title: 'Station Master', version: '', body: src };
|
||||
return {
|
||||
title: m[1]!.replace(/^Station Master\s*[—-]\s*/, '').trim(),
|
||||
version: `<b>Version ${m[2]!.trim()}</b> · ${m[3]!.trim()}`,
|
||||
body: src.slice(m[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
for (const rel of GUIDE_DOCS) {
|
||||
const src = join(root, 'docs', rel);
|
||||
@@ -233,9 +241,17 @@ for (const rel of GUIDE_DOCS) {
|
||||
console.error(`WARNING: docs/${rel} is missing — a published link will 404`);
|
||||
continue;
|
||||
}
|
||||
const md = readFileSync(src, 'utf8');
|
||||
const out = join(dist, rel);
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
copyFileSync(src, out);
|
||||
writeFileSync(out, md);
|
||||
|
||||
const { title, version, body } = splitHead(md);
|
||||
const { html, headings } = renderMarkdown(body, docLink);
|
||||
writeFileSync(
|
||||
join(dist, rel.replace(/\.md$/, '.html')),
|
||||
docPage({ slug: rel.replace(/\.md$/, ''), title, version, body: html, headings }),
|
||||
);
|
||||
}
|
||||
|
||||
// A tiny note for whoever unzips this later and wonders what it needs.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The page a documentation file is rendered into.
|
||||
*
|
||||
* ONE TEMPLATE FOR ALL FIVE, so the guide reads as one publication rather than five files that
|
||||
* happen to be linked. It carries the same dark palette, the same type and the same blue as the
|
||||
* game, because a player arrives here from the board and should not feel they have left the site.
|
||||
*
|
||||
* WHAT THE PAGE ADDS OVER THE MARKDOWN, and why each is here rather than in the source:
|
||||
*
|
||||
* - a **nav** across the five documents, so the set is navigable from any one of them. The
|
||||
* Markdown cannot carry this: it would have to be repeated in every file and would drift.
|
||||
* - a **contents list** built from the headings actually rendered, so it cannot fall out of step
|
||||
* with the document the way a hand-written one does.
|
||||
* - **anchors** on every heading, so a section can be linked to in a bug report.
|
||||
* - a **measure** of about 70 characters. Long lines are the single biggest thing making plain
|
||||
* text hard to read, and these documents are long.
|
||||
*
|
||||
* PRINTS SANELY TOO: the nav and contents drop out, the palette goes to ink on paper, and tables
|
||||
* keep their rules. A rules reference is a thing people print.
|
||||
*/
|
||||
|
||||
import type { Heading } from './markdown.ts';
|
||||
|
||||
export type DocPage = {
|
||||
/** Published filename, without the extension — also the nav's identity for "you are here". */
|
||||
slug: string;
|
||||
/** What the nav calls it. */
|
||||
nav: string;
|
||||
};
|
||||
|
||||
export const DOC_PAGES: readonly DocPage[] = [
|
||||
{ slug: 'quickstart', nav: 'Quickstart' },
|
||||
{ slug: 'rules', nav: 'Rules' },
|
||||
{ slug: 'home-deck', nav: 'Home deck' },
|
||||
{ slug: 'mainline-deck', nav: 'Mainline deck' },
|
||||
{ slug: 'components', nav: 'Components' },
|
||||
];
|
||||
|
||||
export const DOCS_CSS = `
|
||||
:root{
|
||||
--bg:#12161c; --panel:#161b22; --line:#2c333d; --fg:#cfd6e0; --dim:#8b94a3;
|
||||
--head:#cfe0f5; --link:#5aa9e6; --accent:#9fb6d8; --rule:#39424e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{scroll-behavior:smooth}
|
||||
body{
|
||||
margin:0;background:var(--bg);color:var(--fg);
|
||||
font:15px/1.65 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
-webkit-text-size-adjust:100%;
|
||||
}
|
||||
a{color:var(--link)}
|
||||
a:hover{color:#9fd0f5}
|
||||
|
||||
/* THE NAV. Sticky, because these documents are long and the set has to stay reachable from the
|
||||
middle of one. Horizontally scrollable on a phone rather than wrapping into three rows. */
|
||||
.docnav{
|
||||
position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);
|
||||
display:flex;align-items:center;gap:4px;padding:8px 16px;overflow-x:auto;
|
||||
}
|
||||
.docnav .home{color:var(--head);font-weight:700;margin-right:10px;text-decoration:none;white-space:nowrap}
|
||||
.docnav a.tab{
|
||||
color:var(--dim);text-decoration:none;padding:4px 10px;border-radius:6px;white-space:nowrap;
|
||||
border:1px solid transparent;font-size:13px;
|
||||
}
|
||||
.docnav a.tab:hover{color:var(--fg);background:#1f2733}
|
||||
.docnav a.tab[aria-current="page"]{color:#f2e6cf;background:#2b3444;border-color:#c8912f}
|
||||
|
||||
.wrap{max-width:78ch;margin:0 auto;padding:22px 16px 72px}
|
||||
|
||||
/* THE HEADER — what this document is and which build it describes, lifted out of the prose so the
|
||||
version is the first thing on the page, as the process rules require. */
|
||||
.dochead{border-bottom:1px solid var(--rule);padding-bottom:12px;margin-bottom:8px}
|
||||
.dochead h1{margin:0 0 6px;font-size:26px;line-height:1.25;color:var(--head);letter-spacing:.01em}
|
||||
.dochead .ver{color:var(--dim);font-size:13px}
|
||||
.dochead .ver b{color:#c8912f;font-weight:700}
|
||||
|
||||
/* CONTENTS, built from the headings actually rendered. Collapsed by default on a phone. */
|
||||
.toc{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin:18px 0 26px}
|
||||
.toc summary{cursor:pointer;color:var(--accent);font-size:13px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}
|
||||
.toc ol{list-style:none;margin:10px 0 2px;padding:0;columns:2;column-gap:26px}
|
||||
.toc li{margin:0 0 4px;break-inside:avoid}
|
||||
.toc li.l3{padding-left:14px;font-size:13px}
|
||||
.toc a{text-decoration:none;color:var(--fg)}
|
||||
.toc a:hover{color:var(--link)}
|
||||
@media (max-width:640px){.toc ol{columns:1}}
|
||||
|
||||
h2,h3,h4{color:var(--head);line-height:1.3;margin:28px 0 8px}
|
||||
h2{font-size:20px;border-bottom:1px solid var(--rule);padding-bottom:5px}
|
||||
h3{font-size:16px}
|
||||
h4{font-size:14px;color:var(--accent);text-transform:uppercase;letter-spacing:.05em}
|
||||
p{margin:0 0 12px}
|
||||
strong{color:#e8eef7}
|
||||
hr{border:none;border-top:1px solid var(--rule);margin:26px 0}
|
||||
|
||||
/* The anchor beside a heading: invisible until the heading is hovered, so it never competes with
|
||||
the words but is always there to copy. */
|
||||
.anchor{margin-left:.45em;color:var(--rule);text-decoration:none;font-weight:400;opacity:0}
|
||||
h1:hover .anchor,h2:hover .anchor,h3:hover .anchor,h4:hover .anchor{opacity:1}
|
||||
.anchor:hover{color:var(--link)}
|
||||
|
||||
ul,ol{margin:0 0 12px;padding-left:22px}
|
||||
li{margin:0 0 5px}
|
||||
li>ul,li>ol{margin-top:5px}
|
||||
|
||||
code{background:#1d232c;border:1px solid var(--line);border-radius:4px;padding:1px 5px;
|
||||
font:13px ui-monospace,SFMono-Regular,Menlo,monospace;color:#e0c89a}
|
||||
pre{background:#1d232c;border:1px solid var(--line);border-radius:7px;padding:12px 14px;overflow-x:auto}
|
||||
pre code{background:none;border:none;padding:0;color:var(--fg)}
|
||||
|
||||
blockquote{
|
||||
margin:16px 0;padding:10px 14px;background:#181f28;
|
||||
border-left:3px solid #c8912f;border-radius:0 7px 7px 0;
|
||||
}
|
||||
blockquote > :last-child{margin-bottom:0}
|
||||
|
||||
/* TABLES THAT LOOK LIKE TABLES. This is the whole reason the documentation is rendered rather than
|
||||
served as text: a card reference is mostly tables, and as plain text they are rows of pipes. */
|
||||
.tablewrap{overflow-x:auto;margin:0 0 16px;border:1px solid var(--line);border-radius:8px}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
thead th{
|
||||
background:#1f2733;color:var(--accent);text-align:left;font-weight:600;
|
||||
padding:8px 12px;border-bottom:1px solid var(--line);white-space:nowrap;
|
||||
}
|
||||
td{padding:7px 12px;border-bottom:1px solid #222a34;vertical-align:top}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
tbody tr:nth-child(even){background:#151a21}
|
||||
tbody tr:hover{background:#1b222b}
|
||||
.ta-right{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.ta-center{text-align:center}
|
||||
th.ta-right,th.ta-center{text-align:inherit}
|
||||
|
||||
footer{margin-top:40px;padding-top:14px;border-top:1px solid var(--rule);color:var(--dim);font-size:12px}
|
||||
footer a{color:var(--accent)}
|
||||
|
||||
@media print{
|
||||
.docnav,.toc,.anchor{display:none}
|
||||
body{background:#fff;color:#111;font-size:11pt}
|
||||
h1,h2,h3,h4,strong{color:#000}
|
||||
a{color:#000;text-decoration:underline}
|
||||
.wrap{max-width:none;padding:0}
|
||||
.tablewrap{border-color:#999}
|
||||
thead th{background:#eee;color:#000;border-bottom-color:#999}
|
||||
td{border-bottom-color:#ccc}
|
||||
tbody tr:nth-child(even){background:#f6f6f6}
|
||||
blockquote{background:#f4f4f4;border-left-color:#888}
|
||||
code,pre{background:#f4f4f4;border-color:#ccc;color:#111}
|
||||
}
|
||||
`;
|
||||
|
||||
/** The contents list, from the headings the renderer actually produced. */
|
||||
function toc(headings: readonly Heading[]): string {
|
||||
// h2 and h3 only: h1 is the page title, and h4 is a label inside a section rather than a place.
|
||||
const items = headings.filter((h) => h.level === 2 || h.level === 3);
|
||||
if (items.length < 3) return '';
|
||||
const lis = items
|
||||
.map((h) => `<li class="l${h.level}"><a href="#${h.id}">${h.text.replace(/&/g, '&').replace(/</g, '<')}</a></li>`)
|
||||
.join('');
|
||||
return `<details class="toc" open><summary>On this page</summary><ol>${lis}</ol></details>`;
|
||||
}
|
||||
|
||||
export function docPage(opts: {
|
||||
slug: string;
|
||||
title: string;
|
||||
version: string;
|
||||
body: string;
|
||||
headings: readonly Heading[];
|
||||
}): string {
|
||||
const tabs = DOC_PAGES.map(
|
||||
(d) =>
|
||||
`<a class="tab" href="./${d.slug}.html"${d.slug === opts.slug ? ' aria-current="page"' : ''}>${d.nav}</a>`,
|
||||
).join('');
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${opts.title} — Station Master</title>
|
||||
<style>${DOCS_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="docnav"><a class="home" href="./index.html">Station Master</a>${tabs}</nav>
|
||||
<div class="wrap">
|
||||
<header class="dochead">
|
||||
<h1>${opts.title}</h1>
|
||||
<div class="ver">${opts.version}</div>
|
||||
</header>
|
||||
${toc(opts.headings)}
|
||||
${opts.body}
|
||||
<footer>
|
||||
Station Master — <a href="./index.html">back to the game</a> ·
|
||||
these references are kept current with every release.
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* A small Markdown renderer, for the player-facing documentation only.
|
||||
*
|
||||
* WHY NOT A LIBRARY. This project has no runtime dependencies at all, and the guide uses a small,
|
||||
* known subset of Markdown — headings, paragraphs, lists, tables, links, code spans, block quotes
|
||||
* and rules. A Markdown library would be the first dependency in the tree, pulled in to render five
|
||||
* files whose whole vocabulary fits below. `docs/` is written by hand, not by users, so this does
|
||||
* not have to survive hostile input; it has to render what those five documents actually contain
|
||||
* and fail loudly on anything else.
|
||||
*
|
||||
* WHY NOT HAND-WRITTEN HTML. The Markdown is the one copy (TODO #15a). An HTML twin drifts from it
|
||||
* on the first edit, which is the failure the whole documentation pass was about.
|
||||
*
|
||||
* ESCAPING IS UNCONDITIONAL. Every scrap of text goes through `esc` before any markup is added, and
|
||||
* the inline pass only ever inserts tags around already-escaped content. A `<` in the prose is a
|
||||
* less-than sign, not the start of an element — there is no raw-HTML passthrough, deliberately.
|
||||
*/
|
||||
|
||||
/** HTML-escape. Ampersand first, or it double-escapes the entities added after it. */
|
||||
export function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** A heading found while rendering, for the contents list the page builds from it. */
|
||||
export type Heading = { level: number; text: string; id: string };
|
||||
|
||||
/**
|
||||
* `## 3. The shape of a Stage` → `the-shape-of-a-stage`.
|
||||
*
|
||||
* The leading number is dropped: it is a position in the document, and a link that carries it
|
||||
* breaks when a section is inserted above. Duplicate slugs get a numeric suffix rather than
|
||||
* silently pointing at the first one.
|
||||
*/
|
||||
function slug(text: string, taken: Set<string>): string {
|
||||
const base =
|
||||
text
|
||||
.toLowerCase()
|
||||
// A whole section number, dotted or not: "4.2 Local Operations" and "7. FAQ" both lose it.
|
||||
.replace(/^\d+(?:\.\d+)*[.)]?\s+/, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'section';
|
||||
let id = base;
|
||||
for (let n = 2; taken.has(id); n++) id = `${base}-${n}`;
|
||||
taken.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline markup, applied to text that is ALREADY escaped.
|
||||
*
|
||||
* Order matters: code spans are taken out first and put back last, so `**` inside backticks stays
|
||||
* literal. That is not a corner case here — the rules reference quotes field names like
|
||||
* `**Version**` when describing the page header.
|
||||
*/
|
||||
function inline(escaped: string, linkHref: (href: string) => string): string {
|
||||
const code: string[] = [];
|
||||
let s = escaped.replace(/`([^`]+)`/g, (_m, body: string) => {
|
||||
code.push(`<code>${body}</code>`);
|
||||
return `\u0000${code.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
// Links: [text](target). The target is rewritten so a link between documents lands on the
|
||||
// rendered page rather than the Markdown source.
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text: string, href: string) => {
|
||||
const target = linkHref(href);
|
||||
const external = /^https?:/.test(target);
|
||||
return `<a href="${target}"${external ? ' target="_blank" rel="noopener"' : ''}>${text}</a>`;
|
||||
});
|
||||
|
||||
// Bold before italic, or `**x**` is read as an empty italic wrapping a bold.
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
s = s.replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
|
||||
return s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => code[Number(i)]!);
|
||||
}
|
||||
|
||||
/** One table row's cells, from `| a | b |`. */
|
||||
function cells(line: string): string[] {
|
||||
return line
|
||||
.replace(/^\s*\|/, '')
|
||||
.replace(/\|\s*$/, '')
|
||||
.split('|')
|
||||
.map((c) => c.trim());
|
||||
}
|
||||
|
||||
/** `---`, `:--`, `--:` and `:-:` → the CSS alignment a column wants. */
|
||||
function alignments(sep: string): (string | null)[] {
|
||||
return cells(sep).map((c) => {
|
||||
const left = c.startsWith(':');
|
||||
const right = c.endsWith(':');
|
||||
if (left && right) return 'center';
|
||||
if (right) return 'right';
|
||||
if (left) return 'left';
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
const isTableSep = (line: string): boolean => /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-');
|
||||
|
||||
export type Rendered = { html: string; headings: Heading[] };
|
||||
|
||||
/**
|
||||
* Render a Markdown document to HTML.
|
||||
*
|
||||
* `linkHref` rewrites link targets — the build uses it to send `rules.md` to `rules.html` — and
|
||||
* defaults to leaving them alone so the function is testable on its own.
|
||||
*/
|
||||
export function renderMarkdown(src: string, linkHref: (href: string) => string = (h) => h): Rendered {
|
||||
const lines = src.replace(/\r\n/g, '\n').split('\n');
|
||||
const out: string[] = [];
|
||||
const headings: Heading[] = [];
|
||||
const taken = new Set<string>();
|
||||
const ln = (s: string): void => void out.push(s);
|
||||
const text = (s: string): string => inline(esc(s), linkHref);
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
|
||||
// The generated-card markers, and any other HTML comment: structural, never shown.
|
||||
if (/^\s*<!--/.test(line)) {
|
||||
while (i < lines.length && !lines[i]!.includes('-->')) i++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
|
||||
ln('<hr>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
const level = heading[1]!.length;
|
||||
const raw = heading[2]!.trim();
|
||||
const id = slug(raw, taken);
|
||||
headings.push({ level, text: raw.replace(/[*`]/g, ''), id });
|
||||
// The anchor is a link to itself, so a section can be pointed at without a separate widget.
|
||||
ln(
|
||||
`<h${level} id="${id}">${text(raw)}` +
|
||||
`<a class="anchor" href="#${id}" aria-label="Link to this section">#</a></h${level}>`,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fenced code.
|
||||
if (/^\s*```/.test(line)) {
|
||||
i++;
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && !/^\s*```/.test(lines[i]!)) body.push(lines[i++]!);
|
||||
i++;
|
||||
ln(`<pre><code>${esc(body.join('\n'))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tables: a header row, an alignment row, then body rows.
|
||||
if (line.includes('|') && i + 1 < lines.length && isTableSep(lines[i + 1]!)) {
|
||||
const head = cells(line);
|
||||
const align = alignments(lines[i + 1]!);
|
||||
i += 2;
|
||||
const body: string[][] = [];
|
||||
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim() !== '') body.push(cells(lines[i++]!));
|
||||
const th = head
|
||||
.map((c, n) => `<th${align[n] ? ` class="ta-${align[n]}"` : ''}>${text(c)}</th>`)
|
||||
.join('');
|
||||
const rows = body
|
||||
.map(
|
||||
(r) =>
|
||||
'<tr>' +
|
||||
r.map((c, n) => `<td${align[n] ? ` class="ta-${align[n]}"` : ''}>${text(c)}</td>`).join('') +
|
||||
'</tr>',
|
||||
)
|
||||
.join('');
|
||||
// Wrapped so a wide table scrolls inside the page rather than widening it on a phone.
|
||||
ln(`<div class="tablewrap"><table><thead><tr>${th}</tr></thead><tbody>${rows}</tbody></table></div>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block quote: consecutive `>` lines, rendered through this same function so a quote may hold
|
||||
// a list or a table — the rules reference puts both inside one.
|
||||
if (/^\s*>/.test(line)) {
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && /^\s*>/.test(lines[i]!)) body.push(lines[i++]!.replace(/^\s*>\s?/, ''));
|
||||
ln(`<blockquote>${renderMarkdown(body.join('\n'), linkHref).html}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lists. A bullet or a number opens one; continuation lines are indented under their item.
|
||||
const bullet = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
||||
if (bullet) {
|
||||
const ordered = /\d/.test(bullet[2]!);
|
||||
const baseIndent = bullet[1]!.length;
|
||||
const items: string[] = [];
|
||||
let current: string[] | null = null;
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]!;
|
||||
if (l.trim() === '') {
|
||||
// A blank line ends the list unless the next line is still inside it.
|
||||
const next = lines[i + 1] ?? '';
|
||||
const continues = /^(\s*)([-*+]|\d+[.)])\s+/.test(next) || /^\s{2,}\S/.test(next);
|
||||
if (!continues) break;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(l);
|
||||
if (m && m[1]!.length <= baseIndent) {
|
||||
if (current) items.push(current.join(' '));
|
||||
current = [m[3]!];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (m || /^\s{2,}\S/.test(l)) {
|
||||
// A nested item or a wrapped continuation. Nesting is rendered by recursion on the block.
|
||||
if (!current) break;
|
||||
current.push(l.trim());
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (current) items.push(current.join(' '));
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
ln(`<${tag}>${items.map((it) => `<li>${text(it)}</li>`).join('')}</${tag}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anything else is a paragraph, running to the next blank line or block opener.
|
||||
const para: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]!;
|
||||
if (
|
||||
l.trim() === '' ||
|
||||
/^(#{1,6})\s/.test(l) ||
|
||||
/^\s*>/.test(l) ||
|
||||
/^\s*```/.test(l) ||
|
||||
/^\s*<!--/.test(l) ||
|
||||
/^\s*(---|\*\*\*|___)\s*$/.test(l) ||
|
||||
/^(\s*)([-*+]|\d+[.)])\s+/.test(l) ||
|
||||
(l.includes('|') && isTableSep(lines[i + 1] ?? ''))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
para.push(l.trim());
|
||||
i++;
|
||||
}
|
||||
if (para.length) ln(`<p>${text(para.join(' '))}</p>`);
|
||||
}
|
||||
|
||||
return { html: out.join('\n'), headings };
|
||||
}
|
||||
+96
-5
@@ -1101,9 +1101,20 @@ function evaluateClearance(
|
||||
const node = s.division.nodes[targetIndex];
|
||||
if (!node || node.kind !== 'mainline') return 'clear';
|
||||
|
||||
// Double Track and Uncontrolled Siding print "Trains may pass", so occupancy does not block.
|
||||
/**
|
||||
* "TRAINS MAY PASS" IS A PROPERTY OF ONE CARD, NOT OF THE SUBDIVISION (Jesse, 2026-09-23).
|
||||
*
|
||||
* This returned `clear` outright, before the subdivision was looked at — so a train entering a
|
||||
* Double Track was released however busy the rest of the Subdivision was, including against a
|
||||
* train coming the other way three cards deeper in. Reported from a table: Train 8 highballed
|
||||
* from the Western Division Point with no ruling asked, and the reason was this line rather than
|
||||
* anything about Control Points.
|
||||
*
|
||||
* What the card actually prints is that TWO TRAINS MAY SHARE IT. So it excuses occupants ON THIS
|
||||
* CARD and nothing else, which is what `passesHere` below is for.
|
||||
*/
|
||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === node.card);
|
||||
if (profile?.trainsMayPass) return 'clear';
|
||||
const passesHere = profile?.trainsMayPass === true;
|
||||
|
||||
/**
|
||||
* §8.1 asks about the next SUBDIVISION, not the next card.
|
||||
@@ -1138,9 +1149,32 @@ function evaluateClearance(
|
||||
const occupants: { tray: TrayId; onCard: number }[] = [];
|
||||
for (const i of subdivision) {
|
||||
const n = s.division.nodes[i];
|
||||
if (!n || n.kind !== 'mainline') continue;
|
||||
if (behind(i)) continue;
|
||||
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
||||
if (n?.kind === 'mainline') {
|
||||
// A card that lets trains pass is not an obstruction on its own account.
|
||||
if (i === targetIndex && passesHere) continue;
|
||||
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
||||
continue;
|
||||
}
|
||||
/**
|
||||
* A TRAIN STANDING AT AN OFFICE WITH NOWHERE TO PUT IT OCCUPIES THE SUBDIVISION TOO.
|
||||
*
|
||||
* Jesse's ruling, 2026-09-23, from a table where Train 19 was released from the Eastern
|
||||
* Division Point towards Train 14 and nobody was asked: at the moment of the decision Train 14
|
||||
* was not in `transits` at all, it was standing in a district. §8.1 was only ever reading
|
||||
* trains in transit, so a train about to re-enter the very Subdivision being entered counted
|
||||
* for nothing.
|
||||
*
|
||||
* CAPACITY IS THE TEST, not the mere presence of a train — his reasoning exactly. At a Whistle
|
||||
* Post, one A/D track and a train on it means there is nowhere for the two to pass and no
|
||||
* choice to be made. At a Depot or a Terminal with a track still free there is somewhere to go,
|
||||
* and the train at the Office is not in the way.
|
||||
*/
|
||||
if (n?.kind === 'office') {
|
||||
const area = areaAtSeat(s, n.seat);
|
||||
if (area.adOccupancy.length < officeProfile(area.tier).adTracks) continue;
|
||||
for (const held of area.adOccupancy) occupants.push({ tray: held, onCard: i });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1167,6 +1201,24 @@ function evaluateClearance(
|
||||
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
|
||||
// way to pass the order, so the train simply holds.
|
||||
if (spendDispatchBonus(s, tray, otherTray, events) > 0) continue;
|
||||
/**
|
||||
* SAY SO (Jesse, 2026-09-23: "does it make sense to have something listed in history or
|
||||
* somewhere else when the train is not allowed to pass?").
|
||||
*
|
||||
* A facing train is an absolute bar and this returned silently — the train simply did not
|
||||
* depart, Stage after Stage, with nothing on screen saying why. Only the ABS Signals case
|
||||
* below announced itself, and it was given a line for exactly this reason.
|
||||
*
|
||||
* NAMES WHAT IS IN THE WAY, because the answer to "why is nothing happening" is a specific
|
||||
* train somewhere specific, not a rule number.
|
||||
*/
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason:
|
||||
`Train ${otherTray.trainNumber ?? '?'} is coming the other way in the same Subdivision — ` +
|
||||
'§8.1 holds a train against a facing one, and there is no Control Point between them to pass at',
|
||||
});
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
@@ -1495,12 +1547,51 @@ function arriveAtOffice(
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
// A train held at the Limits takes the first free A/D track before any newcomer.
|
||||
/**
|
||||
* A TRAIN HELD AT THE LIMITS TAKES THE FIRST FREE A/D TRACK BEFORE ANY NEWCOMER — and taking it
|
||||
* FILLS IT, which is what this used to forget.
|
||||
*
|
||||
* Reported from a table, 2026-09-23: a Whistle Post with one A/D track held Trains 8 and 19 at
|
||||
* once. The capacity test above had passed (nothing standing), this block then moved the held
|
||||
* train in, and the arriving train was pushed in after it without anyone asking again whether
|
||||
* there was room. So the Office ended up over capacity and the collision §8.3 calls for never
|
||||
* happened.
|
||||
*
|
||||
* The held train has priority — it has been waiting — so the NEWCOMER takes the consequence, and
|
||||
* it is the same consequence it would have met had the held train got there first: held at its
|
||||
* own Limits where there is an Interlocking, and a collision where there is not.
|
||||
*
|
||||
* IT IS ALSO ANNOUNCED. The release used to be a silent side effect of somebody else's arrival:
|
||||
* the train simply appeared at the Office, and the report was "wasn't clear what changed and why
|
||||
* train 8 was suddenly released".
|
||||
*/
|
||||
if (area.heldAtLimits.length > 0 && area.heldAtLimits[0] !== id) {
|
||||
const first = area.heldAtLimits.shift()!;
|
||||
area.adOccupancy.push(first);
|
||||
const held = s.trays.get(first);
|
||||
if (held) held.position = { at: 'grid', seat, coord: area.officeCoord };
|
||||
events.push({
|
||||
type: 'trainReleasedFromLimits',
|
||||
trainNumber: held?.trainNumber ?? 0,
|
||||
office: officeProfile(area.tier).name,
|
||||
owner: playerAtSeat(s, seat),
|
||||
freedBy: tray.trainNumber ?? 0,
|
||||
});
|
||||
// The slot it just took is gone. Ask again for the train that is arriving now.
|
||||
if (area.adOccupancy.length >= capacity) {
|
||||
if (hasEnhancement('interlocking')) {
|
||||
area.heldAtLimits.push(id);
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Limits',
|
||||
reason: 'Interlocking held it clear of a full Office instead of a collision',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
collide(s, playerAtSeat(s, seat), [id], events, 'no free A/D track', 'the Office');
|
||||
return 'moved';
|
||||
}
|
||||
}
|
||||
area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);
|
||||
|
||||
|
||||
+160
-17
@@ -352,6 +352,35 @@ function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCo
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* May this Facility be built ON TOP of the card already on this square?
|
||||
*
|
||||
* Reported from a table: a player who has already laid a straight down a stub and then draws the
|
||||
* industry they wanted has no move — the square had to have been empty when the card came up, so
|
||||
* building your district in the sensible order (rail first, then what it serves) is punished.
|
||||
* Turnouts have upgraded a straight since the same complaint was made about branching.
|
||||
*
|
||||
* A STRAIGHT ONLY, and the reason is geometry rather than taste. `protoCard` builds every Facility
|
||||
* as plain east-west track, so replacing a straight is a port-for-port swap: `{e,w}` before and
|
||||
* `{e,w}` after, and no neighbour can lose a join it was relying on. A curve or a turnout carries
|
||||
* ports a Facility does not, so building over one COULD sever a join — those stay refused.
|
||||
*
|
||||
* The Running Track row is already barred by the caller (§11.2's "not on Running Track"), so this
|
||||
* only ever sees stub track.
|
||||
*
|
||||
* Two things block it, both about the card being in use rather than its shape — the same pair that
|
||||
* blocks a turnout upgrade: you cannot swap the track out from under a standing car, and an
|
||||
* Interlocking or Telegraph built on it would have to be lifted with it. The replaced card leaves
|
||||
* play, as a lifted card does at a table.
|
||||
*/
|
||||
function checkFacilityUpgrade(existing: TrackCard): RejectionCode | null {
|
||||
const g = existing.geometry;
|
||||
if (g.kind !== 'track' || g.geometry !== 'straight') return 'NOT_UPGRADEABLE_TRACK';
|
||||
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
|
||||
if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MEN | AT | WORK pipeline of a Freight Facility.
|
||||
*
|
||||
@@ -899,8 +928,22 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
* those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent:
|
||||
* the restriction has to bite on the pick-up itself, the same reasoning as the comment above.
|
||||
*/
|
||||
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||
/**
|
||||
* A TRAIN MAY ALWAYS RECOVER ITS OWN CABOOSE (Jesse's ruling, 2026-09-23).
|
||||
*
|
||||
* `ownCutFor` above exempts only what is standing on the square the crew is on, so a
|
||||
* caboose set out and then moved away from became a fresh pick-up — and X13 prints "may
|
||||
* drop MTs but not pick up anything". A train needs its caboose at the far end to be made
|
||||
* up (§8.2), so a dropOnly train that parted with its caboose could never legally leave
|
||||
* again: it stranded itself, permanently, with nothing on screen saying so.
|
||||
*
|
||||
* Narrow on purpose. It is the caboose only, not "your own cars" generally: the caboose is
|
||||
* the one car whose absence makes the train unable to depart, so recovering it is repairing
|
||||
* a consist rather than doing fresh work.
|
||||
*/
|
||||
const notOwnCaboose = fresh.filter((c) => c.type !== 'caboose');
|
||||
if (rules.noSwitching && notOwnCaboose.length > 0) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.dropOnly && notOwnCaboose.length > 0) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.pickUpEmptiesOnly && fresh.some(carriesLoad)) return 'EMPTIES_ONLY';
|
||||
const freight = fresh.filter(isFreight).length;
|
||||
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
||||
@@ -1224,6 +1267,18 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
// Only on a train that is actually due out this Stage — a second section follows a first.
|
||||
if (s.timetable[s.clock.stage - 1] !== i.trainNumber) return 'NO_SUCH_TRAY';
|
||||
if (s.freeTrays.length === 0) return 'NO_SUCH_TRAY';
|
||||
/**
|
||||
* Q9 — IT COSTS THE CARD (Jesse's ruling, 2026-09-23).
|
||||
*
|
||||
* This was offered free on every train due out. `SECOND_SECTION` has existed in `content.ts`
|
||||
* the whole time and `setup.ts` never dealt it, so the card that gates the action did not
|
||||
* exist and the action was unpriced — the bot ordered 26 accidental Second Sections in one
|
||||
* measured round, every one of them from the New Train fallback taking `options[0]`.
|
||||
*
|
||||
* The card is dealt now (`setup.ts`) and spent here, so ordering a Second Section is a
|
||||
* decision with a cost, and it can be done once per deal.
|
||||
*/
|
||||
if (secondSectionCard(s, player) === null) return 'NO_SUCH_CARD';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1430,6 +1485,13 @@ function checkPlay(
|
||||
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
|
||||
const proto = protoCard(card.kind, variant);
|
||||
if (!proto) return 'NO_PLACEMENT';
|
||||
/**
|
||||
* An occupied square is a build-over, which has its own rule — `canPlaceAt` refuses every
|
||||
* occupied square except a movable Limits sign, and a sign is not something to build an
|
||||
* industry on top of.
|
||||
*/
|
||||
const under = area.grid.get(coordKey(placement));
|
||||
if (under) return checkFacilityUpgrade(under);
|
||||
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
case 'modifier': {
|
||||
@@ -1462,8 +1524,25 @@ function checkPlay(
|
||||
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
||||
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
||||
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
// the nine nearby spots) or it does nothing at all, so anywhere else is not a legal play.
|
||||
/**
|
||||
* A PASSENGER MODIFIER NEEDS A PASSENGER FACILITY, and a Whistle Post is not one (§9).
|
||||
*
|
||||
* Jesse's ruling, 2026-09-23. A Waiting Area, Restaurant or Hotel may not be built at an
|
||||
* Office until it is a Depot or better: a Whistle Post allows neither direction, so the extra
|
||||
* outbound slot is discarded on the spot and only the porter lands. A card that can be played
|
||||
* to no effect is a trap however well the panel labels it.
|
||||
*
|
||||
* It bites far less often than it would have before the same day's other ruling, which opens
|
||||
* every district on a Depot — this is now the harder game's rule.
|
||||
*/
|
||||
if (
|
||||
modifierProfile(card.kind.modifier).hosts.includes('office') &&
|
||||
!officeProfile(area.tier).isPassengerFacility
|
||||
) {
|
||||
return 'OFFICE_NOT_PASSENGER';
|
||||
}
|
||||
// §9 — a Modifier is not track. It must sit square against a Facility THAT CAN HOST IT —
|
||||
// north, south, east or west — or it does nothing at all.
|
||||
return adjacentFacilityCoord(area, placement, card.kind.modifier) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
case 'enhancement': {
|
||||
@@ -1644,6 +1723,45 @@ export function movesFor(
|
||||
if (!blocked.has(coordKey(b.coord))) blocked.set(coordKey(b.coord), b);
|
||||
}
|
||||
|
||||
/**
|
||||
* AND THE SQUARES THE WALK ALLOWS BUT THE TRAIN'S OWN CARD DOES NOT.
|
||||
*
|
||||
* Asked from a table, 2026-09-23: "where does it show that you can't make a particular move
|
||||
* because of a rule that's violated… how does a user know what rule is violated and why you can't
|
||||
* go there?" Nowhere, was the answer. `exploreMoves` decides where the RAILS go, and the pick-up
|
||||
* restrictions — `noSwitching`, `dropOnly`, empties-only, the per-location freight budget — are
|
||||
* enforced afterwards in `check`. So a square the walk reached and the card forbids was reachable,
|
||||
* un-offered, and absent from this list: it simply was not there, with no reason given.
|
||||
*
|
||||
* ASKED THROUGH `check` RATHER THAN RE-DERIVED. A second implementation of the pick-up rules is
|
||||
* exactly the failure this function's own header warns about — a reason that does not match the
|
||||
* refusal is worse than no reason. `check` is the authority, so the answer comes from asking it.
|
||||
*/
|
||||
const WHY: Partial<Record<string, string>> = {
|
||||
PICKUP_NOT_ALLOWED:
|
||||
"this train's card forbids picking cars up, and coupling is mandatory — there are cars here it would have to take",
|
||||
EMPTIES_ONLY: 'this train may only pick up empties, and there is a loaded car here it would have to take',
|
||||
FREIGHT_WORKED_HERE: 'this train has already worked its freight allowance at this location',
|
||||
TOO_MANY_CARS: 'the cars here would take the train over four',
|
||||
};
|
||||
for (const [key, coord] of [...to.entries()]) {
|
||||
if (blocked.has(key)) continue;
|
||||
/**
|
||||
* BOTH DIRECTIONS BEFORE DECLARING IT BLOCKED. A square can be reachable forwards and
|
||||
* backwards, and the two do not pick up the same cars — `ownCutFor` depends on which end the
|
||||
* train pulls out through. If either way is legal the square stays offered.
|
||||
*/
|
||||
const codes = [false, true].map((reverse) =>
|
||||
check(s, player, { type: 'switch.move', trayId, to: coord, reverse }),
|
||||
);
|
||||
if (codes.includes(null)) continue;
|
||||
const why = codes.map((c) => (c === null ? undefined : WHY[c])).find((w) => w !== undefined);
|
||||
if (!why) continue;
|
||||
// Not reachable after all, so it must not stay in `to` claiming otherwise.
|
||||
to.delete(key);
|
||||
blocked.set(key, { coord, kind: 'cardRule', why });
|
||||
}
|
||||
|
||||
return { to: [...to.values()], blocked: [...blocked.values()] };
|
||||
}
|
||||
|
||||
@@ -2049,7 +2167,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
}
|
||||
|
||||
case 'newTrain.secondSection':
|
||||
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
|
||||
// `check` has established the card is in hand, so the id resolves.
|
||||
return [
|
||||
{
|
||||
type: 'secondSectionOrdered',
|
||||
player,
|
||||
trainNumber: i.trainNumber,
|
||||
cardId: secondSectionCard(s, player)!,
|
||||
},
|
||||
];
|
||||
|
||||
case 'mainline.clearance':
|
||||
return [
|
||||
@@ -2621,6 +2747,8 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
|
||||
case 'secondSectionOrdered':
|
||||
s.pendingSecondSections.push(e.trainNumber);
|
||||
// The card is spent on the order, like every other card played from hand.
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
|
||||
case 'trainScheduled':
|
||||
@@ -2946,19 +3074,26 @@ function adjacentFacilityCoord(
|
||||
coord: GridCoord,
|
||||
modifier?: ModifierKind,
|
||||
): GridCoord | null {
|
||||
// A turnout's 45° leg reaches north as readily as south (turn the card 180°), so a district grows
|
||||
// on both sides of the Running Track and §9's "nine nearby spots" really is nine. The old Q7
|
||||
// guard here rejected the three above outright.
|
||||
/**
|
||||
* NORTH, SOUTH, EAST OR WEST — NOT THE DIAGONALS (Jesse's ruling, 2026-09-23).
|
||||
*
|
||||
* This offered all eight surrounding squares, reading §9's "nine nearby spots" as every
|
||||
* neighbour. A Modifier has to sit SQUARE against what it serves: a card on a corner touches it
|
||||
* at a point, not along an edge.
|
||||
*/
|
||||
const hosts = modifier ? modifierProfile(modifier).hosts : null;
|
||||
for (let dr = -1; dr <= 1; dr++) {
|
||||
for (let dc = -1; dc <= 1; dc++) {
|
||||
if (dr === 0 && dc === 0) continue;
|
||||
const c = { row: coord.row + dr, col: coord.col + dc };
|
||||
const f = area.grid.get(coordKey(c))?.facility;
|
||||
if (!f) continue;
|
||||
if (hosts && !hosts.includes(f.subtype)) continue;
|
||||
return c;
|
||||
}
|
||||
const ORTHOGONAL = [
|
||||
{ dr: -1, dc: 0 },
|
||||
{ dr: 1, dc: 0 },
|
||||
{ dr: 0, dc: -1 },
|
||||
{ dr: 0, dc: 1 },
|
||||
];
|
||||
for (const { dr, dc } of ORTHOGONAL) {
|
||||
const c = { row: coord.row + dr, col: coord.col + dc };
|
||||
const f = area.grid.get(coordKey(c))?.facility;
|
||||
if (!f) continue;
|
||||
if (hosts && !hosts.includes(f.subtype)) continue;
|
||||
return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3075,6 +3210,14 @@ function emptyCard(): TrackCard {
|
||||
}
|
||||
|
||||
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
|
||||
/** The Second Section card in this player's hand, or null. Q9 — the action costs it. */
|
||||
function secondSectionCard(s: GameState, player: PlayerIndex): CardId | null {
|
||||
for (const id of s.decks.hands.get(player) ?? []) {
|
||||
if (s.cards.get(id)?.kind.kind === 'secondSection') return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
|
||||
s.decks.salvageYard.push(cardId);
|
||||
|
||||
+48
-1
@@ -481,7 +481,7 @@ export const TIMETABLED_TRAINS: readonly TrainProfile[] = [
|
||||
* COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited
|
||||
* drops from three coaches to two, and The Sparrow rises from two to three. A change to the card
|
||||
* faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it
|
||||
* still print the old numbers, so `docs/rules/as-built.md` is the place that now carries what
|
||||
* still print the old numbers, so `docs/home-deck.md` and `docs/mainline-deck.md` carry what
|
||||
* the cards say — GENERATED from the constants below by `scripts/build-card-reference.ts`, with
|
||||
* `test/card-reference.test.ts` failing if the two disagree. This comment used to name
|
||||
* `card-reference.md`, which describes the v0.4.5 deck and carries a banner saying not to use its
|
||||
@@ -1224,6 +1224,8 @@ export type HouseRules = {
|
||||
startingHand: StartingHand;
|
||||
revenue: RevenueRules;
|
||||
extraStart: ExtraStartRule;
|
||||
/** Which Office every player opens on — see `StartingOffice`. */
|
||||
startingOffice: StartingOffice;
|
||||
/**
|
||||
* §6.2 — MAY A TIMETABLED TRAIN BE THROWN AWAY? (Gitea#9, superseding Gitea#6.)
|
||||
*
|
||||
@@ -1244,12 +1246,22 @@ export type HouseRules = {
|
||||
discardTimetabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which Office every player starts on.
|
||||
*
|
||||
* `depot` is the default: a Depot is a Passenger Facility with two A/D tracks, so passengers work
|
||||
* from the first Stage and a second train can stand at an Office. `whistlePost` is the harder game
|
||||
* — one A/D track, no passenger work at all until somebody draws and plays an upgrade.
|
||||
*/
|
||||
export type StartingOffice = 'depot' | 'whistlePost';
|
||||
|
||||
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
||||
export type HouseRuleOverrides = {
|
||||
startingHand?: StartingHand;
|
||||
revenue?: Partial<RevenueRules>;
|
||||
extraStart?: ExtraStartRule;
|
||||
discardTimetabled?: boolean;
|
||||
startingOffice?: StartingOffice;
|
||||
};
|
||||
|
||||
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
||||
@@ -1267,6 +1279,14 @@ export const DEFAULT_HOUSE_RULES: HouseRules = {
|
||||
// recently gave rather than the one it replaced. This keeps main and the 0.4.9 playtest line —
|
||||
// which has no setting and simply allows it — playing the same game.
|
||||
discardTimetabled: true,
|
||||
/**
|
||||
* EVERYBODY STARTS ON A DEPOT (Jesse, 2026-09-23). A Whistle Post has one A/D track and is not a
|
||||
* Passenger Facility, so the opening of every game was spent unable to work a passenger and
|
||||
* unable to hold a second train — and a single A/D track is what makes an arriving train a
|
||||
* collision. Starting on a Depot makes the game markedly easier; `whistlePost` is offered as the
|
||||
* harder setting for a table that wants it.
|
||||
*/
|
||||
startingOffice: 'depot',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1286,6 +1306,8 @@ export const LEGACY_HOUSE_RULES: HouseRules = {
|
||||
// These games predate Gitea#6 as well as Gitea#9: a train card could simply be discarded. `true`
|
||||
// is what they were played under, and a replay that discards a Timetabled train needs it.
|
||||
discardTimetabled: true,
|
||||
// Every game before 2026-09-23 opened on a Whistle Post.
|
||||
startingOffice: 'whistlePost',
|
||||
};
|
||||
|
||||
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||
@@ -1306,9 +1328,31 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
|
||||
},
|
||||
extraStart: given.extraStart ?? d.extraStart,
|
||||
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
|
||||
startingOffice: given.startingOffice ?? d.startingOffice,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* THE OPENING A SAVE THAT PREDATES THE SETTING WAS DEALT UNDER.
|
||||
*
|
||||
* `startingOffice` is unlike every other house rule: the others change how a game PLAYS, and
|
||||
* getting one wrong stops a replay part-way where it can be seen. This one changes how the game is
|
||||
* DEALT — a different Office, and a deck with four more cards in it — so a save replayed under the
|
||||
* wrong opening is a different railroad from intent one, and the failure is silent.
|
||||
*
|
||||
* Every game saved before 2026-09-23 opened on a Whistle Post and its `houseRules` cannot say so.
|
||||
* So a saved config that names house rules but not this one gets what it was played under.
|
||||
*
|
||||
* APPLIED ON THE REPLAY PATHS ONLY, never inside `houseRules()`. A preset, the setup form and the
|
||||
* lobby all build configs that name some rules and not others, and they mean today's default —
|
||||
* putting this in the resolver made a fresh Cutthroat game deal Whistle Posts and read as Custom.
|
||||
*/
|
||||
export function withSavedOpening<T extends { houseRules?: HouseRuleOverrides }>(config: T): T {
|
||||
const given = config.houseRules;
|
||||
if (!given || given.startingOffice !== undefined) return config;
|
||||
return { ...config, houseRules: { ...given, startingOffice: 'whistlePost' } };
|
||||
}
|
||||
|
||||
/** What the dialog calls each option, in the order it offers them. */
|
||||
export const STARTING_HAND_LABELS: readonly { value: StartingHand; label: string }[] = [
|
||||
{ value: 'threeRandom', label: 'Three random cards' },
|
||||
@@ -1403,6 +1447,9 @@ export function deckComposition(): { category: string; count: number }[] {
|
||||
{ category: 'mainlineModifier', count: sum(MAINLINE_MODIFIER_CARDS) },
|
||||
{ category: 'maneuver', count: sum(MANEUVER_CARDS) },
|
||||
{ category: 'action', count: sum(ACTION_CARDS) },
|
||||
// Q9 — dealt since 2026-09-23. It was declared here and left out of `buildDeck`, which is what
|
||||
// made `newTrain.secondSection` a free action rather than one that costs a card.
|
||||
{ category: 'secondSection', count: SECOND_SECTION.copies },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -126,6 +126,21 @@ export type GameEvent =
|
||||
* Reduces to nothing, like `switchingEnded` above: it reports a choice the state already holds.
|
||||
*/
|
||||
| { type: 'freightAgentIdled'; player: PlayerIndex }
|
||||
/**
|
||||
* A train the Interlocking was holding at the Limits has taken the A/D track that just freed.
|
||||
*
|
||||
* It happens as a side effect of ANOTHER train arriving and clearing the Office, so without this
|
||||
* the held train simply appeared at the Office with nothing said — reported from a table as
|
||||
* "wasn't clear what changed and why train 8 was suddenly released". `freedBy` names the train
|
||||
* whose arrival did it, because "why now" is the whole question.
|
||||
*/
|
||||
| {
|
||||
type: 'trainReleasedFromLimits';
|
||||
trainNumber: number;
|
||||
office: string;
|
||||
owner: SeatIndex;
|
||||
freedBy: number;
|
||||
}
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||
@@ -185,7 +200,7 @@ export type GameEvent =
|
||||
*/
|
||||
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at?: GridCoord; node?: number }
|
||||
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number; cardId: CardId }
|
||||
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
||||
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
||||
/** A train whose card pays for standing still (X18 Circus) collected on it. */
|
||||
|
||||
@@ -313,6 +313,11 @@ export type RejectionCode =
|
||||
| 'INBOUND_BOX_FULL'
|
||||
| 'NO_EMPTY_COACH_IN_YARD'
|
||||
| 'NOT_A_CONTROL_POINT'
|
||||
/**
|
||||
* A passenger Modifier — Waiting Area, Restaurant, Hotel — played at an Office that is still a
|
||||
* Whistle Post. A Whistle Post is not a Passenger Facility (§9), so it has nothing to add to.
|
||||
*/
|
||||
| 'OFFICE_NOT_PASSENGER'
|
||||
| 'NO_EXTRA_PENDING'
|
||||
/** §7 gives an Extra to the player who played the card; another seat may not place it for them. */
|
||||
| 'NOT_YOUR_EXTRA'
|
||||
|
||||
+13
-2
@@ -313,12 +313,19 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
*
|
||||
* Deduplicated because the two lists overlap: `placements` already contains the Limits signs.
|
||||
*/
|
||||
/**
|
||||
* A TURNOUT AND AN INDUSTRY MAY BOTH BUILD OVER TRACK ALREADY DOWN, so both are offered the
|
||||
* occupied cells as well as the empty ones and `check` decides which it will take. Without this
|
||||
* the rule exists in the engine and is never once presented — which is how the 18 Enhancement
|
||||
* cards came to be permanently dead.
|
||||
*/
|
||||
const isTurnout = kind?.kind === 'track' && kind.geometry === 'turnout';
|
||||
const isFacility = kind?.kind === 'freightFacility';
|
||||
const targets = onMainline
|
||||
? []
|
||||
: kind?.kind === 'enhancement'
|
||||
? attachments
|
||||
: isTurnout
|
||||
: isTurnout || isFacility
|
||||
? dedupe([...placements, ...attachments])
|
||||
: placements;
|
||||
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
|
||||
@@ -375,7 +382,11 @@ function newTrainCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
}
|
||||
const due = s.timetable[s.clock.stage - 1];
|
||||
if (due !== null && due !== undefined) {
|
||||
out.push({ type: 'newTrain.secondSection', trainNumber: due });
|
||||
// Q9 — the order costs the card, so it is only a candidate while the card is in hand.
|
||||
// `check` is still the authority; this keeps the menu from offering what it will refuse.
|
||||
if ((s.decks.hands.get(player) ?? []).some((id) => s.cards.get(id)?.kind.kind === 'secondSection')) {
|
||||
out.push({ type: 'newTrain.secondSection', trainNumber: due });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* WHERE A PENDING EXTRA MAY START (§7, Jesse's ruling) — every candidate offered, with `check`
|
||||
|
||||
+36
-6
@@ -23,11 +23,13 @@ import {
|
||||
TRACK_CARDS,
|
||||
ROLLING_STOCK_SUPPLY,
|
||||
STAGES_PER_DAY,
|
||||
SECOND_SECTION,
|
||||
TIMETABLED_TRAINS,
|
||||
crewTrayCount,
|
||||
mainlineCardCount,
|
||||
officeProfile,
|
||||
} from './content.ts';
|
||||
import type { StartingOffice } from './content.ts';
|
||||
import type { Rng } from './rng.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import type {
|
||||
@@ -53,7 +55,11 @@ export type SetupOptions = {
|
||||
};
|
||||
|
||||
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
|
||||
export function buildDeck(mode: GameConfig['mode'] = 'competitive', pvpCardsAllowed = false): Card[] {
|
||||
export function buildDeck(
|
||||
mode: GameConfig['mode'] = 'competitive',
|
||||
pvpCardsAllowed = false,
|
||||
startingOffice: StartingOffice = 'whistlePost',
|
||||
): Card[] {
|
||||
/**
|
||||
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK REGARDLESS OF `pvpCardsAllowed`, for now.
|
||||
*
|
||||
@@ -81,7 +87,16 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive', pvpCardsAllo
|
||||
|
||||
for (const t of TIMETABLED_TRAINS) push({ kind: 'timetabledTrain', number: t.number });
|
||||
for (const t of EXTRA_TRAINS) push({ kind: 'extraTrain', number: t.number });
|
||||
/**
|
||||
* OFFICE UPGRADES, MINUS THE TIER EVERYBODY ALREADY HAS.
|
||||
*
|
||||
* A table that starts on Depots has no use for a Depot card: `check` refuses it, because an
|
||||
* upgrade must be to the NEXT tier and a Depot is not an upgrade on a Depot. Leaving them in
|
||||
* would deal four dead cards into a 90-odd card deck — the same dead draw the opponent-directed
|
||||
* cards are held out for. Station and Terminal are still upgrades and stay.
|
||||
*/
|
||||
for (const o of OFFICE_PROFILES) {
|
||||
if (o.tier === startingOffice) continue;
|
||||
for (let i = 0; i < o.copiesInDeck; i++) push({ kind: 'office', tier: o.tier });
|
||||
}
|
||||
for (const f of FREIGHT_PROFILES) {
|
||||
@@ -90,6 +105,18 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive', pvpCardsAllo
|
||||
for (const m of MODIFIER_PROFILES) {
|
||||
for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind });
|
||||
}
|
||||
/**
|
||||
* Q9 — THE SECOND SECTION CARD, WHICH WAS DECLARED AND NEVER DEALT.
|
||||
*
|
||||
* `content.ts` has carried `SECOND_SECTION` (1 copy) all along and `setup.ts` never built it into
|
||||
* the deck, while `newTrain.secondSection` was offered free on every train due out — so the one
|
||||
* card that is supposed to gate the action did not exist and the action cost nothing. The bot ran
|
||||
* 26 accidental Second Sections in one measured round because of it.
|
||||
*
|
||||
* Jesse's ruling, 2026-09-23: the action requires the card. Dealing it is the other half — gating
|
||||
* on a card the deck never holds would delete the mechanic rather than fix it.
|
||||
*/
|
||||
for (let i = 0; i < SECOND_SECTION.copies; i++) push({ kind: 'secondSection' });
|
||||
if (opponentCardsInDeck) {
|
||||
for (const c of SPACE_USE_CARDS) {
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key });
|
||||
@@ -147,7 +174,7 @@ export function buildRollingStock(): RollingStock[] {
|
||||
* branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
|
||||
* drawn turnout cards only.
|
||||
*/
|
||||
function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
function buildOfficeArea(seat: SeatIndex, tier: StartingOffice): OfficeArea {
|
||||
const row = 0;
|
||||
const officeCoord = { row, col: 0 };
|
||||
const limitsWest = { row, col: -1 };
|
||||
@@ -158,7 +185,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: buildPassengerFacility('whistlePost'),
|
||||
facility: buildPassengerFacility(tier),
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
@@ -180,7 +207,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
|
||||
return {
|
||||
seat,
|
||||
tier: 'whistlePost',
|
||||
tier,
|
||||
grid,
|
||||
officeCoord,
|
||||
runningRow: row,
|
||||
@@ -285,6 +312,9 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
|
||||
const rng = createRng(seed);
|
||||
const playerCount = playerNames.length;
|
||||
// Resolved once, here, because it decides BOTH the Office every area is built on and which office
|
||||
// cards the deck holds — and the two must not be able to disagree.
|
||||
const startingOffice = houseRules(config).startingOffice;
|
||||
|
||||
const players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
|
||||
|
||||
@@ -292,7 +322,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
// the players occupying them, and starts as the identity mapping, which is what makes the
|
||||
// seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else.
|
||||
const officeAreas = new Map<SeatIndex, OfficeArea>();
|
||||
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat));
|
||||
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat, startingOffice));
|
||||
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
|
||||
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
|
||||
const divisionRolls = players.map(() => rng.d12());
|
||||
@@ -339,7 +369,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
*/
|
||||
const rules = houseRules(config);
|
||||
const deal = OPENING_DEALS[rules.startingHand];
|
||||
const deck = buildDeck(config.mode, config.pvpCardsAllowed);
|
||||
const deck = buildDeck(config.mode, config.pvpCardsAllowed, rules.startingOffice);
|
||||
const cards = new Map<CardId, Card>();
|
||||
for (const c of deck) cards.set(c.id, c);
|
||||
|
||||
|
||||
@@ -554,6 +554,8 @@ export type CardKind =
|
||||
| { kind: 'timetabledTrain'; number: number }
|
||||
| { kind: 'extraTrain'; number: number }
|
||||
| { kind: 'office'; tier: OfficeTier }
|
||||
/** Q9 — ordered on a train that is due out; a second, identical train runs right behind it. */
|
||||
| { kind: 'secondSection' }
|
||||
| { kind: 'freightFacility'; facility: FreightKind }
|
||||
| { kind: 'modifier'; modifier: ModifierKind }
|
||||
/** Handedness is printed on the card: it is the diagonal the 45° leg lies on. */
|
||||
|
||||
+13
-1
@@ -389,7 +389,19 @@ export function reachableDestinations(
|
||||
* and only the obstructions are worth listing in the "why nothing is moving" panel, where every
|
||||
* turnout in the district would otherwise appear.
|
||||
*/
|
||||
export type MoveBlockKind = 'noJoin' | 'occupied' | 'locked' | 'tooManyCars' | 'noStopping';
|
||||
export type MoveBlockKind =
|
||||
| 'noJoin'
|
||||
| 'occupied'
|
||||
| 'locked'
|
||||
| 'tooManyCars'
|
||||
| 'noStopping'
|
||||
/**
|
||||
* The rails go there and the TRAIN'S OWN CARD does not allow it — a no-switching or drop-only
|
||||
* train that would have to couple something, an empties-only train facing a loaded car, or a
|
||||
* freight allowance already spent at that location. Decided by `check` rather than by the walk,
|
||||
* so it is added in `movesFor` rather than emitted by `exploreMoves`.
|
||||
*/
|
||||
| 'cardRule';
|
||||
export type MoveBlock = { coord: GridCoord; kind: MoveBlockKind; why: string };
|
||||
|
||||
/**
|
||||
|
||||
+25
-5
@@ -18,6 +18,7 @@
|
||||
* checked, before `submit` is ever called — see `intent()` below.
|
||||
*/
|
||||
|
||||
import { withSavedOpening } from '../engine/content.ts';
|
||||
import { check } from '../engine/apply.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
@@ -212,10 +213,25 @@ function buildSession(
|
||||
return snapshot(game.state, [], null, null, null, false, seat);
|
||||
}
|
||||
|
||||
function linesSince(seat: PlayerIndex): { text: string; tone: string }[] {
|
||||
const already = sentLines.get(seat) ?? 0;
|
||||
sentLines.set(seat, game.log.length);
|
||||
return game.log.slice(already);
|
||||
/**
|
||||
* WHAT THIS SEAT HAS NOT BEEN SENT YET, bookmarked by SEQUENCE rather than by position.
|
||||
*
|
||||
* This was `game.log.slice(sentLines.get(seat))` against an array the game trims to `LOG_LIMIT`.
|
||||
* Once a seat's bookmark reached that limit the array stopped growing past it, so the slice
|
||||
* returned an empty list on every push from then on and that seat's history froze permanently —
|
||||
* at a different moment for each seat, because each holds its own bookmark. Reported from a
|
||||
* two-player game that did not reach Day 5.
|
||||
*
|
||||
* A sequence number cannot run past the end: lines that have been trimmed are simply gone, and
|
||||
* everything still held with a higher `seq` is sent. A seat that has missed more than the log
|
||||
* keeps gets what remains rather than nothing.
|
||||
*/
|
||||
function linesSince(seat: PlayerIndex): { text: string; tone: string; seq: number }[] {
|
||||
const already = sentLines.get(seat) ?? -1;
|
||||
const fresh = game.log.filter((l) => l.seq > already);
|
||||
const last = game.log[game.log.length - 1];
|
||||
if (last) sentLines.set(seat, last.seq);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
function menuFor(seat: PlayerIndex): Menu | null {
|
||||
@@ -511,7 +527,11 @@ export function createSession(
|
||||
export type ResumeFailure = { stoppedAt: number; of: number; intent: string; code: string };
|
||||
|
||||
export function tryResumeSession(saved: SavedGame): { ok: true; session: GameSession } | { ok: false; failure: ResumeFailure } {
|
||||
const { game, stopped } = fromMultiplayerSave(saved.seed, saved.config, saved.playerNames, saved.history);
|
||||
// A save written before `startingOffice` existed opened on a Whistle Post and cannot say so —
|
||||
// replaying it under today's Depot default would deal a different railroad. See `withSavedOpening`.
|
||||
const { game, stopped } = fromMultiplayerSave(
|
||||
saved.seed, withSavedOpening(saved.config), saved.playerNames, saved.history,
|
||||
);
|
||||
if (stopped) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
+15
-1
@@ -1259,7 +1259,17 @@ export function officeSvg(
|
||||
const tx = W / 2 - tw / 2;
|
||||
const facingWord = t.facing === 'e' ? 'east' : 'west';
|
||||
const consistWords = t.cars.length === 0 ? 'no cars' : t.cars.join(', ');
|
||||
out += `<g class="bs-crew" data-tip="${esc(
|
||||
/**
|
||||
* HELD AT THE LIMITS, DRAWN RATHER THAN ONLY DESCRIBED (Jesse, 2026-09-23).
|
||||
*
|
||||
* The view has carried this flag since #99 and NO renderer read it, so the train drew like
|
||||
* any other crew and nothing told a player to hover — and hovering was the only way to learn
|
||||
* why a train had stopped short of the Office for several Stages. The tooltip text is already
|
||||
* on `t.what`; this is the mark that sends you to it. Red, the same "stopped, and not by
|
||||
* choice" the Red Flag means elsewhere on this map, and dashed because it is waiting.
|
||||
*/
|
||||
const held = t.heldAtLimits === true;
|
||||
out += `<g class="bs-crew${held ? ' bs-held' : ''}" data-tip="${esc(
|
||||
`${t.label} — engine pointing ${facingWord}, carrying ${consistWords}` + (t.what ? `\n\n${t.what}` : ''),
|
||||
)}"><rect x="${tx}" y="${RAIL - 11}" width="${tw}" height="22" rx="3"/>`;
|
||||
out += `<text class="bs-tlab" x="${tx + 4}" y="${RAIL + 4}">${esc(t.label)}</text>`;
|
||||
@@ -1470,6 +1480,10 @@ text.bs-mod{fill:#c8a04a}
|
||||
/* A signal standing on a Mainline card carrying ABS Signals. Green is otherwise unused on the
|
||||
Division row apart from the Division Point's dashed border, so a lit lamp does not compete with
|
||||
the amber "it is happening here" or the red flag for meaning. */
|
||||
/* A train the Interlocking is holding on the Limit Track. Red, matching the Red Flag's "stopped and
|
||||
not by choice"; dashed, because it is waiting rather than parked. */
|
||||
.bs-crew.bs-held rect{fill:#2c1d1d;stroke:#d2453f;stroke-width:1.6;stroke-dasharray:4 2}
|
||||
.bs-crew.bs-held .bs-tlab{fill:#f0c2be}
|
||||
.bs-abs-mast{stroke:#9aa3b0;stroke-width:1.6}
|
||||
.bs-abs-lit{fill:#4fae6a;stroke:#2c6b40;stroke-width:0.8}
|
||||
.bs-abs-dark{fill:#2a3038;stroke:#59626f;stroke-width:0.8}
|
||||
|
||||
+39
-5
@@ -441,11 +441,18 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
text: `${e.isExtra ? `EXTRA X${e.trainNumber}` : `TRAIN ${e.trainNumber}`} MADE UP at the ${e.at}, running ${e.direction} — crew assigned, now taking cars`,
|
||||
};
|
||||
case 'trainStoodStill':
|
||||
/**
|
||||
* SPELLS OUT WHAT EARNED IT (Jesse, 2026-09-23: "make sure history calls out when point
|
||||
* earned"). The line said the train had stood still and a point arrived; it did not say that
|
||||
* the point is per DISTRICT and can be earned again in the next one, which is the whole of
|
||||
* how the card is played.
|
||||
*/
|
||||
return {
|
||||
tone: 'good',
|
||||
text:
|
||||
`Train ${e.trainNumber} stood still for a whole Stage at ${e.where} and earned a point — ` +
|
||||
'its card pays for the stop, not for the run (circus set-up)',
|
||||
`CIRCUS SET-UP — Train ${e.trainNumber} stood still for a whole Stage at ${e.where}, ` +
|
||||
'fully loaded, and earned 1 Revenue. Its card pays for the STOP, not for the run: one ' +
|
||||
'point per district, and it can earn again in the next district it stands a Stage in.',
|
||||
};
|
||||
case 'trainHeld':
|
||||
return {
|
||||
@@ -535,6 +542,21 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
text: `UNJAMMED ${place(e.player, e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
|
||||
where: e.at,
|
||||
};
|
||||
case 'trainReleasedFromLimits': {
|
||||
/**
|
||||
* WHY NOW, which is the whole question. The train has been sitting at the Limits for however
|
||||
* many Stages, and what changed is that somebody else's train cleared the Office.
|
||||
*/
|
||||
const who = ctx.playerName?.(e.owner as never) ?? null;
|
||||
return {
|
||||
tone: 'good',
|
||||
text:
|
||||
`Train ${e.trainNumber} RELEASED from the Limits into the ${e.office}` +
|
||||
`${who ? ` at ${who}'s district` : ''} — the Interlocking had been holding it clear of a ` +
|
||||
`full Office, and Train ${e.freedBy} arriving freed the A/D track it was waiting for. ` +
|
||||
'A held train takes the first track to free, ahead of anything arriving after it.',
|
||||
};
|
||||
}
|
||||
case 'freightAgentIdled':
|
||||
return {
|
||||
tone: 'quiet',
|
||||
@@ -701,10 +723,22 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
|
||||
// -- consequences
|
||||
case 'revenueChanged':
|
||||
case 'revenueChanged': {
|
||||
/**
|
||||
* WHOSE REVENUE, NAMED HERE RATHER THAN LEFT TO THE PREFIX.
|
||||
*
|
||||
* Reported from a table: the line gives the change and the running total and no player. The
|
||||
* history's own prefix names the ACTOR, and revenue is not always the actor's — a train
|
||||
* completing its run pays every player with no actor at all, so those lines carried no name
|
||||
* whatsoever. `e.player` is the seat that earned it, which is the only right answer, so the
|
||||
* line resolves its own name and `record` leaves it alone (`SELF_NAMED` in `web/game.ts`).
|
||||
*/
|
||||
const whose = ctx.playerName?.(e.player) ?? null;
|
||||
const owner = whose === null ? '' : `${whose} `;
|
||||
return e.delta < 0
|
||||
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
|
||||
: { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||
? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${owner}${e.delta} Revenue (now ${e.total})` }
|
||||
: { tone: 'good', text: `${owner}+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
|
||||
}
|
||||
case 'phaseEnded':
|
||||
return { tone: 'quiet', text: `Finished ${phaseLabel(e.phase)}` };
|
||||
|
||||
|
||||
+28
-2
@@ -40,7 +40,17 @@ export type TurnChartFrame = {
|
||||
* screen. `Frame` has carried `superintendent` all along and the standalone replay printed it; the
|
||||
* live game never did.
|
||||
*/
|
||||
export function turnChartHtml(f: TurnChartFrame, actorName: string | null, superName: string | null = null): string {
|
||||
/**
|
||||
* `yours` is true when the player reading this is the one being waited on. It makes the line flash,
|
||||
* because the commonest way a table stalls is somebody not noticing their own turn has come round
|
||||
* (reported from a playtest). The replay viewers pass nothing: a recording waits on nobody.
|
||||
*/
|
||||
export function turnChartHtml(
|
||||
f: TurnChartFrame,
|
||||
actorName: string | null,
|
||||
superName: string | null = null,
|
||||
yours = false,
|
||||
): string {
|
||||
const PHASES: { key: string; label: string; tip: string; icon: string }[] = [
|
||||
{
|
||||
key: 'localOps',
|
||||
@@ -138,7 +148,8 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
|
||||
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||
`<div class="tc-who">${waiting}<b>${esc(who)}</b>${asked}</div>` +
|
||||
`<div class="tc-who${yours && actorName !== null ? ' tc-yours' : ''}">` +
|
||||
`${waiting}<b>${esc(who)}</b>${asked}</div>` +
|
||||
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
|
||||
// between the phases and everything above them, which put a thing that changes every third
|
||||
// Stage in the middle of the things that change every Stage. The row it belongs beside is the
|
||||
@@ -169,6 +180,21 @@ export const TURNCHART_CSS = `
|
||||
.tc-asks{color:#a99ac4;font-style:italic}
|
||||
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
||||
border-radius:11px;padding:1px 9px;font-size:12px}
|
||||
/* IT IS YOUR TURN. The commonest way a table stalls is a player not noticing their turn came round,
|
||||
so the chip flashes rather than merely changing colour — motion is what catches an eye that is
|
||||
somewhere else on the board. Amber, because that is what "you can act" means everywhere else on
|
||||
this screen, and it separates "waiting on YOU" from the violet "where we are" news around it.
|
||||
A reduced-motion preference holds it steady and lit instead of dropping the cue. */
|
||||
.tc-who.tc-yours{color:#f0b64a}
|
||||
.tc-who.tc-yours b{color:#1a1f27;background:#f0b64a;border-color:#f0b64a;font-weight:700;
|
||||
animation:tc-flash 1s steps(1,end) infinite}
|
||||
@keyframes tc-flash{
|
||||
0%,49%{background:#f0b64a;border-color:#f0b64a;color:#1a1f27;box-shadow:0 0 0 3px rgba(240,182,74,.25)}
|
||||
50%,100%{background:transparent;border-color:#f0b64a;color:#f0b64a;box-shadow:none}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
.tc-who.tc-yours b{animation:none;box-shadow:0 0 0 3px rgba(240,182,74,.25)}
|
||||
}
|
||||
/* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not
|
||||
something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one
|
||||
that changes every turn, while this changes four times a Day. */
|
||||
|
||||
+29
-1
@@ -33,6 +33,7 @@ import {
|
||||
MANEUVER_CARDS,
|
||||
MODIFIER_PROFILES,
|
||||
REALIGNMENTS,
|
||||
SECOND_SECTION,
|
||||
OFFICE_ORDER,
|
||||
SPACE_USE_CARDS,
|
||||
STAGES_PER_SHIFT,
|
||||
@@ -1923,6 +1924,8 @@ export function cardName(s: GameState, id: string): string {
|
||||
// The hand is on the card face and decides which diagonal its 45° leg lies on, so it belongs
|
||||
// in the name: "curve" alone does not tell you what it can be joined to.
|
||||
return `${k.hand === 'none' ? '' : `${k.hand}-hand `}${geometryLabel(k.geometry)}`;
|
||||
case 'secondSection':
|
||||
return SECOND_SECTION.name;
|
||||
case 'spaceUse':
|
||||
case 'enhancement':
|
||||
case 'mainlineModifier':
|
||||
@@ -2045,6 +2048,14 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
: '';
|
||||
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}${warn}`;
|
||||
}
|
||||
case 'secondSection':
|
||||
// Q9. Worth spelling out: it is the one card that creates the following-train situation §8.1
|
||||
// makes the Superintendent rule on, so playing it is choosing to put that question to them.
|
||||
return (
|
||||
'order a second, identical train right behind one due out this Stage · it needs its own ' +
|
||||
'Crew Tray, and it deliberately creates the following-train situation the Superintendent ' +
|
||||
'must rule on (§8.1)'
|
||||
);
|
||||
case 'track': {
|
||||
// Track is the largest category in the deck, so a player holds it constantly — and what it
|
||||
// can be joined to is decided by the hand, which is not something the name alone conveys.
|
||||
@@ -2088,7 +2099,24 @@ export function cardDescription(s: GameState, id: string): string {
|
||||
: rule?.effect === 'dormantSolo'
|
||||
? ' · never fires in solitaire — it answers an opponent card the solo deck omits'
|
||||
: '';
|
||||
return `${card.effect} · played on ${card.placement}${note}`;
|
||||
/**
|
||||
* WHICH MAINLINE CARDS A REALIGNMENT CAN ACTUALLY CONVERT (Jesse, 2026-09-23: "how does the
|
||||
* user know what the realignment card can be used on?").
|
||||
*
|
||||
* The card printed "Convert one Mainline type to another. Not while a train is on it", which
|
||||
* is true and useless: only four of the nine types convert at all, and a table whose Division
|
||||
* dealt none of them has a card that can never be played. That is exactly what happened —
|
||||
* Uncontrolled Siding, Heavy Grade and Tunnel, with the Siding already converted, so the
|
||||
* second Realignment had no target and nothing said why.
|
||||
*
|
||||
* Named from `REALIGNMENTS` rather than written out, so the list cannot drift from the rule.
|
||||
*/
|
||||
const converts =
|
||||
card.key === 'realignment'
|
||||
? ` · only converts ${REALIGNMENTS.map((r) => mainlineProfile(r.from).name).join(', ')}` +
|
||||
' — no other Mainline card can be realigned'
|
||||
: '';
|
||||
return `${card.effect} · played on ${card.placement}${converts}${note}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+72
-13
@@ -44,6 +44,7 @@ import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
LEGACY_HOUSE_RULES,
|
||||
withSavedOpening,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
industryProfile,
|
||||
@@ -229,8 +230,19 @@ export type Game = {
|
||||
seed: number;
|
||||
/** Every intent submitted, in order — the save file. */
|
||||
history: Intent[];
|
||||
/** Narrated lines, newest last. */
|
||||
log: { text: string; tone: string }[];
|
||||
/**
|
||||
* Narrated lines, newest last.
|
||||
*
|
||||
* `seq` IS A RUNNING COUNT, NOT A POSITION. The log is trimmed to `LOG_LIMIT` below, and a seat's
|
||||
* "what have I sent you" bookmark used to be an index into this array — so once the array stopped
|
||||
* growing, the bookmark ran past its end and `slice` returned nothing FOREVER. Reported from a
|
||||
* two-player game that did not reach Day 5: the history froze for one player at Day 2 Stage 8 and
|
||||
* the other at Day 2 Stage 4, at different moments because each seat had its own bookmark.
|
||||
*
|
||||
* A sequence number survives trimming, so the bookmark stays meaningful however much is dropped —
|
||||
* and a reader can tell a gap from a quiet spell, which an index could never do.
|
||||
*/
|
||||
log: { text: string; tone: string; seq: number }[];
|
||||
/**
|
||||
* True when the hand is OVER the limit, so the turn cannot end until it is played down.
|
||||
*
|
||||
@@ -313,13 +325,30 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
|
||||
*/
|
||||
export const SOLO_PLAYER = 'Solitaire';
|
||||
|
||||
/**
|
||||
* The next sequence number for a game's log, and the only way a line should ever be added.
|
||||
*
|
||||
* Kept off `Game` so the type stays serialisable: the counter is derived from the last line, which
|
||||
* survives a save/restore and a trim alike.
|
||||
*/
|
||||
/**
|
||||
* How many narrated lines a game keeps in memory. Everything older is dropped; `history` still holds
|
||||
* every intent, so a trimmed game replays in full.
|
||||
*/
|
||||
export const LOG_LIMIT = 4000;
|
||||
|
||||
export function pushLine(game: Game, text: string, tone: string): void {
|
||||
const last = game.log[game.log.length - 1];
|
||||
game.log.push({ text, tone, seq: (last?.seq ?? -1) + 1 });
|
||||
}
|
||||
|
||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||
// first, then let the clock take over.
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
game.log.push({ text: `Solitaire · one player · seed ${seed}`, tone: 'quiet' });
|
||||
pushLine(game, 'Game Begins', 'start');
|
||||
pushLine(game, `Solitaire · one player · seed ${seed}`, 'quiet');
|
||||
drain(game);
|
||||
return game;
|
||||
}
|
||||
@@ -335,7 +364,7 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
||||
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
||||
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
pushLine(game, 'Game Begins', 'start');
|
||||
/**
|
||||
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
|
||||
*
|
||||
@@ -350,7 +379,7 @@ export function newMultiplayerGame(seed: number, config: GameConfig, playerNames
|
||||
* less down". The seed remains in `game.seed`, in every save (`session.ts` persistence) and in the
|
||||
* lobby record, so nothing administrative or replayable loses it.
|
||||
*/
|
||||
game.log.push({ text: `${config.mode} · ${playerNames.length} players`, tone: 'quiet' });
|
||||
pushLine(game, `${config.mode} · ${playerNames.length} players`, 'quiet');
|
||||
drain(game);
|
||||
return game;
|
||||
}
|
||||
@@ -1011,7 +1040,21 @@ function trainCardTitle(number: number, isExtra: boolean): string | null {
|
||||
const calls = parts.length > 0 ? parts.join(' + ') : 'no cars at all';
|
||||
const note = p.rules.note ? ` — ${p.rules.note}` : '';
|
||||
const name = `${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}”`;
|
||||
return `Making up ${name}: its card calls for ${calls}${note}`;
|
||||
/**
|
||||
* WHAT THE CARD PAYS FOR, ON ITS OWN LINE (Jesse, 2026-09-23: "is it clear from the text
|
||||
* displayed that it earns points only if fully loaded?").
|
||||
*
|
||||
* The card's printed note has always been here, but as a clause trailing the consist — so the one
|
||||
* condition that decides whether the Circus earns anything at all read as flavour. A train whose
|
||||
* card pays for standing still is played completely differently from one that pays for running,
|
||||
* and that is worth its own sentence.
|
||||
*/
|
||||
const pays = p.rules.stopEarnsPoint
|
||||
? '\nTHIS TRAIN PAYS FOR STOPPING, not for running: a full Stage standing still in a district ' +
|
||||
'earns 1 Revenue, once per district — but ONLY if every car except the caboose is loaded. ' +
|
||||
'Made up short or carrying empties, it earns nothing however long it stands.'
|
||||
: '';
|
||||
return `Making up ${name}: its card calls for ${calls}${note}${pays}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1148,7 +1191,7 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
|
||||
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
if (!result.ok) {
|
||||
game.log.push({ text: `That is not allowed: ${result.code}`, tone: 'bad' });
|
||||
pushLine(game, `That is not allowed: ${result.code}`, 'bad');
|
||||
return false;
|
||||
}
|
||||
game.history.push(intent);
|
||||
@@ -1395,7 +1438,16 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
*/
|
||||
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
|
||||
const ruling = RULINGS.includes(e.type) && who !== null;
|
||||
const mine = who !== null && 'player' in e;
|
||||
/**
|
||||
* EVENTS THAT NAME THEIR OWN PLAYER, and so must not be prefixed with the actor's name as well
|
||||
* — Gitea#31's rule that no line names a player twice.
|
||||
*
|
||||
* `revenueChanged` carries the seat that EARNED it, which is not always the seat that acted: a
|
||||
* train completing its run pays everybody, with no actor at all. Prefixing it with the actor
|
||||
* would be wrong on those lines and redundant on the rest, so `narrate` resolves the name.
|
||||
*/
|
||||
const SELF_NAMED = ['revenueChanged'];
|
||||
const mine = who !== null && 'player' in e && !SELF_NAMED.includes(e.type);
|
||||
const text = ruling
|
||||
? `Superintendent Player ${who} ${uncapitalise(said)}`
|
||||
: mine
|
||||
@@ -1403,7 +1455,7 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
: said;
|
||||
// `trace` is a tone the history panel does not draw — see `inHistory`. The line exists so the
|
||||
// step that caused it has narration to caption the board with, and a dwell to be watched for.
|
||||
game.log.push({ text, tone: inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace' });
|
||||
pushLine(game, text, inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace');
|
||||
|
||||
}
|
||||
game.cues.push(...cuesFor(events));
|
||||
@@ -1433,8 +1485,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
game.announced = `${name} is now the Superintendent — the Fedora passed at the end of Stage ${e.stage}.`;
|
||||
}
|
||||
}
|
||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||
/**
|
||||
* Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||
*
|
||||
* SAFE TO TRIM ONLY BECAUSE LINES CARRY `seq`. While a seat's bookmark was an index into this
|
||||
* array, trimming silently froze that seat's history for the rest of the game — see `Game.log`.
|
||||
* The limit is generous because a four-player game over eight or ten Days is several times the
|
||||
* two-player, five-Day game that first hit it.
|
||||
*/
|
||||
if (game.log.length > LOG_LIMIT) game.log.splice(0, game.log.length - LOG_LIMIT);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1465,7 +1524,7 @@ export function toSave(game: Game): Save {
|
||||
* in force then — never the current defaults.
|
||||
*/
|
||||
function configFor(save: Save, config: GameConfig): GameConfig {
|
||||
return { ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES };
|
||||
return withSavedOpening({ ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
||||
</div>
|
||||
|
||||
<p class="newhere">New to Station Master?
|
||||
<a href="./quickstart.md">Read the Quickstart guide</a> — what the game is, how you win,
|
||||
<a href="./quickstart.html">Read the Quickstart guide</a> — what the game is, how you win,
|
||||
how a Stage runs, what is on the screen, and a first twenty minutes. About twenty minutes to
|
||||
read, and it will save you an hour of guessing.</p>
|
||||
|
||||
|
||||
+13
-6
@@ -582,10 +582,18 @@ function renderTurnChart(f: Frame): void {
|
||||
// chip that can never change is a chip to read past.
|
||||
const superName =
|
||||
f.players.length > 1 ? (f.players.find((p) => p.index === table.superintendent)?.name ?? null) : null;
|
||||
/**
|
||||
* IS IT YOU BEING WAITED ON? The line flashes if so — the commonest way a table stalls is a
|
||||
* player not noticing their turn has come round (playtest). Read from the move ON SCREEN, not the
|
||||
* live one, so it does not start flashing while your board is still catching up on somebody
|
||||
* else's turn and you cannot act yet.
|
||||
*/
|
||||
const yours = !replaying && actor !== null && actor === f.viewer;
|
||||
$('turnchart').innerHTML = turnChartHtml(
|
||||
replaying ? { ...f, ...table, awaiting: null } : f,
|
||||
actorName,
|
||||
superName,
|
||||
yours,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -678,12 +686,11 @@ function renderGameCard(f: Frame): void {
|
||||
* 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.' },
|
||||
{ href: './quickstart.html', label: 'Quickstart', what: 'What the game is and a first twenty minutes — for anyone who has not played.' },
|
||||
{ href: './rules.html', label: 'Rules', what: 'The rules in full, with the FAQ.' },
|
||||
{ href: './home-deck.html', label: 'Home deck', what: 'How the Home Office deck is dealt and played.' },
|
||||
{ href: './mainline-deck.html', label: 'Mainline deck', what: 'The Mainline cards and what each does to a train.' },
|
||||
{ href: './components.html', label: 'Components', what: 'Rolling stock, yards, trays, the Fedora.' },
|
||||
];
|
||||
|
||||
const GUIDE_HTML =
|
||||
|
||||
+21
-1
@@ -441,6 +441,7 @@ ul.blocked li{padding:2px 0}
|
||||
<h2>Join a game</h2>
|
||||
<p class="ng-note">Ask whoever created the game for its code. You will see the whole rule set
|
||||
before you take a seat.</p>
|
||||
<p class="ng-note"><b>Your seat lives in this browser.</b> Rejoining uses a token kept here, so a different browser or device — or clearing this site's data — cannot take your seat back. If that happens, whoever runs the server can issue you a single-use recovery link.</p>
|
||||
<label class="ng-num"><span>Game code</span><input id="lb-code" type="text" autocomplete="off" placeholder="RAIL-1234"></label>
|
||||
<button id="lb-look" type="button">Look up game</button>
|
||||
<p class="lb-error" id="lb-join-err" role="alert"></p>
|
||||
@@ -462,6 +463,7 @@ ul.blocked li{padding:2px 0}
|
||||
<section id="lb-create-panel" hidden>
|
||||
<h2>Create a new game</h2>
|
||||
<p class="ng-note">You become the host — you choose the game type and, once everyone is seated, start the game. 2 to 4 players.</p>
|
||||
<p class="ng-note"><b>Your seat lives in this browser.</b> Rejoining uses a token kept here, so a different browser or device — or clearing this site's data — cannot take your seat back. If that happens, whoever runs the server can issue you a single-use recovery link.</p>
|
||||
|
||||
<!-- TWO COLUMNS WHERE THERE IS ROOM. One 640px-wide column made this form a very long scroll
|
||||
for what is really two short lists: what game this is, and what its rules are. -->
|
||||
@@ -628,6 +630,15 @@ ul.blocked li{padding:2px 0}
|
||||
<input id="lb-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-whistlestart-row">
|
||||
<label class="ng-num"><span>Players start with Whistle Posts, not Depots — the harder game.
|
||||
A Whistle Post has one A/D track and is not a Passenger Facility, so no passenger earns
|
||||
anything until somebody draws and plays a Depot upgrade, and a second train arriving is a
|
||||
collision. Leave this off and every district opens as a Depot, with two A/D tracks and
|
||||
passengers working from Stage 1; the Depot upgrade cards are then left out of the deck</span>
|
||||
<input id="lb-whistlestart" type="checkbox"></label>
|
||||
<span class="set-hint" id="lb-whistlestart-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
|
||||
@@ -853,6 +864,15 @@ ul.blocked li{padding:2px 0}
|
||||
<input id="ss-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-whistlestart-row">
|
||||
<label class="ng-num"><span>Players start with Whistle Posts, not Depots — the harder game.
|
||||
A Whistle Post has one A/D track and is not a Passenger Facility, so no passenger earns
|
||||
anything until somebody draws and plays a Depot upgrade, and a second train arriving is a
|
||||
collision. Leave this off and every district opens as a Depot, with two A/D tracks and
|
||||
passengers working from Stage 1; the Depot upgrade cards are then left out of the deck</span>
|
||||
<input id="ss-whistlestart" type="checkbox"></label>
|
||||
<span class="set-hint" id="ss-whistlestart-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot. Turn this off and a train card can only ever be played onto the timetable.
|
||||
@@ -930,7 +950,7 @@ ul.blocked li{padding:2px 0}
|
||||
every load and nothing let go of it. This keeps your seat — the table waits for you — and
|
||||
your token, and puts you back at the lobby, which lists every game this browser is in. -->
|
||||
<button id="leavegame" hidden title="Go back to the lobby. Your seat is kept and the game waits for you — the lobby lists it under Games you are in, so you can come back or hand the browser to a different game.">Leave game</button>
|
||||
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
|
||||
<a class="home" href="./replays.html" style="font-size:12px" data-tip="Watch a recorded game, or open a save file. A save replays under the rules of the build that opens it, so one made on an earlier build may stop part-way — your file is never altered.">replays</a>
|
||||
<span class="dim build" title="what is actually deployed">__BUILD__</span>
|
||||
</header>
|
||||
|
||||
|
||||
+12
-2
@@ -25,7 +25,7 @@ import {
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
} from '../engine/content.ts';
|
||||
import type { ExtraStartRule, RevenueRules, StartingHand } from '../engine/content.ts';
|
||||
import type { ExtraStartRule, RevenueRules, StartingHand, StartingOffice } from '../engine/content.ts';
|
||||
import type { GameConfig, GameMode } from '../engine/state.ts';
|
||||
|
||||
export type PresetName = 'solitaire' | 'coop' | 'competitive' | 'cutthroat';
|
||||
@@ -54,6 +54,8 @@ export type Settings = {
|
||||
emergencyToolbox: boolean;
|
||||
/** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */
|
||||
discardTimetabled: boolean;
|
||||
/** Ticked = everyone opens on a Whistle Post, the harder game. Unticked = a Depot. */
|
||||
startWhistlePosts: boolean;
|
||||
};
|
||||
|
||||
export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
||||
@@ -69,6 +71,7 @@ export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
||||
'employeeRotation',
|
||||
'emergencyToolbox',
|
||||
'discardTimetabled',
|
||||
'startWhistlePosts',
|
||||
];
|
||||
|
||||
export type Preset = {
|
||||
@@ -103,6 +106,8 @@ const NO_OPTIONAL_RULES = {
|
||||
* about how long a game runs, which is a dial the table already sets for itself.
|
||||
*/
|
||||
discardTimetabled: true,
|
||||
// The default opening is a Depot, so the harder setting is off.
|
||||
startWhistlePosts: false,
|
||||
} as const;
|
||||
|
||||
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
|
||||
@@ -227,6 +232,7 @@ export function settingsOf(config: GameConfig): Settings {
|
||||
employeeRotation: config.optionalRules.employeeRotation,
|
||||
emergencyToolbox: config.optionalRules.emergencyToolbox,
|
||||
discardTimetabled: rules.discardTimetabled,
|
||||
startWhistlePosts: rules.startingOffice === 'whistlePost',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,7 +280,7 @@ export function configFromFrame(f: {
|
||||
maxCollisionsPerDay: number;
|
||||
maxCollisionsTotal: number;
|
||||
optionalRules: GameConfig['optionalRules'];
|
||||
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean };
|
||||
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean; startingOffice?: StartingOffice };
|
||||
}): GameConfig {
|
||||
return {
|
||||
mode: f.mode,
|
||||
@@ -291,6 +297,9 @@ export function configFromFrame(f: {
|
||||
// Carried like the rest: this path describes SOMEONE ELSE'S game to a joiner, so a setting
|
||||
// dropped here shows them a rule the table is not playing (§6.2, Gitea#9).
|
||||
discardTimetabled: f.houseRules.discardTimetabled,
|
||||
// Spread rather than assigned: `exactOptionalPropertyTypes` refuses an explicit `undefined`,
|
||||
// and a Frame from a server that predates this setting simply does not carry it.
|
||||
...(f.houseRules.startingOffice ? { startingOffice: f.houseRules.startingOffice } : {}),
|
||||
revenue: f.houseRules.revenue,
|
||||
},
|
||||
};
|
||||
@@ -355,6 +364,7 @@ export function configFromSettings(
|
||||
startingHand: settings.startingHand,
|
||||
extraStart: settings.extraStart,
|
||||
discardTimetabled: settings.discardTimetabled,
|
||||
startingOffice: settings.startWhistlePosts ? 'whistlePost' : 'depot',
|
||||
revenue: {
|
||||
passengerPerCoach: settings.passengerPerCoach,
|
||||
freightPerLoad: settings.freightPerLoad,
|
||||
|
||||
@@ -69,6 +69,10 @@ input[type=range]{flex:1;min-width:180px}
|
||||
<p class="dim">A Station Master save is a small <code>.json</code> file — a seed and the list of
|
||||
moves made. That is enough to rebuild the whole game, so a replay can be emailed like a text file.
|
||||
Save one from inside a game with <b>Save replay</b>.</p>
|
||||
<!-- #40 — the one thing about saves a player has to be told, wherever saves are handled. -->
|
||||
<p class="dim">A save replays under the rules of the build that opens it, so one made on an
|
||||
earlier build may stop part-way. The viewer says which move it stopped on, and your file is
|
||||
never altered.</p>
|
||||
<input type="file" id="file" accept=".json,application/json">
|
||||
<div id="perr"></div>
|
||||
</section>
|
||||
|
||||
@@ -48,6 +48,7 @@ export const FIELDS: readonly Field[] = [
|
||||
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
|
||||
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
|
||||
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' },
|
||||
{ key: 'startWhistlePosts', kind: 'checkbox', id: 'whistlestart' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -73,6 +74,7 @@ const FIELD_LABELS: Record<keyof Settings, string> = {
|
||||
startingHand: 'Starting hand',
|
||||
extraStart: 'An Extra may start at',
|
||||
discardTimetabled: 'A Timetabled train may be discarded',
|
||||
startWhistlePosts: 'Players start with Whistle Posts, not Depots',
|
||||
passengerPerCoach: 'Passenger per coach',
|
||||
freightPerLoad: 'Freight per load',
|
||||
trainPerTransit: 'Train per transit',
|
||||
@@ -116,7 +118,7 @@ export function rulesListHtml(config: GameConfig, players: number, days: number)
|
||||
|
||||
return (
|
||||
head +
|
||||
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart'])}</dl>` +
|
||||
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart', 'startWhistlePosts'])}</dl>` +
|
||||
`<h4>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
|
||||
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
|
||||
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
|
||||
@@ -209,6 +211,7 @@ export function settingsForm(prefix: string): SettingsForm {
|
||||
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
|
||||
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
|
||||
discardTimetabled: el<HTMLInputElement>('tossloco')?.checked === true,
|
||||
startWhistlePosts: el<HTMLInputElement>('whistlestart')?.checked === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -225,6 +228,7 @@ export function settingsForm(prefix: string): SettingsForm {
|
||||
setChecked('rotation', values.employeeRotation);
|
||||
setChecked('toolbox', values.emergencyToolbox);
|
||||
setChecked('tossloco', values.discardTimetabled);
|
||||
setChecked('whistlestart', values.startWhistlePosts);
|
||||
}
|
||||
|
||||
function setNumber(id: string, value: number): void {
|
||||
|
||||
@@ -23,6 +23,10 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
|
||||
+86
-71
@@ -23,6 +23,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
@@ -1215,6 +1219,7 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
|
||||
assert.ok(waiting, 'no Waiting Area card in the deck');
|
||||
const [cardId] = waiting!;
|
||||
s.decks.hands.set(0, [cardId]);
|
||||
openOffice(s); // a Waiting Area needs a Passenger Facility, which a Whistle Post is not
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
const spots = legalActions(s, 0).filter(
|
||||
@@ -1247,6 +1252,7 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
|
||||
assert.ok(copies.length >= 2, 'the deck should hold more than one Waiting Area');
|
||||
|
||||
s.decks.hands.set(0, [copies[0]!]);
|
||||
openOffice(s);
|
||||
const first = legalActions(s, 0).find(
|
||||
(i) => i.type === 'card.play' && i.cardId === copies[0] && i.placement !== undefined,
|
||||
) as { type: 'card.play'; cardId: string; placement: { row: number; col: number } };
|
||||
@@ -1271,6 +1277,25 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Raise a player's Office to a tier that IS a Passenger Facility.
|
||||
*
|
||||
* A Waiting Area, Restaurant or Hotel may not be played at a Whistle Post (2026-09-23), and these
|
||||
* fixtures name the Whistle Post opening, so a test about those cards has to open the Office first.
|
||||
* Mirrors the `officeUpgraded` reducer: the tier, and the passenger flow that comes with it.
|
||||
*/
|
||||
function openOffice(s: GameState, player = 0 as never, tier: 'depot' | 'station' | 'terminal' = 'depot'): void {
|
||||
const area = areaOf(s, player);
|
||||
const to = officeProfile(tier);
|
||||
area.tier = tier;
|
||||
const card = area.grid.get(coordKey(area.officeCoord));
|
||||
if (card?.facility) {
|
||||
card.facility.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
|
||||
card.facility.capacity = { outbound: to.passengerOut, inbound: to.passengerIn };
|
||||
card.facility.porters = to.porters;
|
||||
}
|
||||
}
|
||||
|
||||
describe('the Limits bound the district, and the nine spots reach round a Facility', () => {
|
||||
/**
|
||||
* A district whose Running Track has been extended one square east, so the sign stands at col 2
|
||||
@@ -1388,12 +1413,15 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a Modifier the DIAGONAL spots around its host, not just the four orthogonal ones', () => {
|
||||
it('refuses a Modifier on a diagonal, and offers only the four orthogonal spots', () => {
|
||||
/**
|
||||
* REPORTED by Jesse: a Modifier could not be placed to the south-east of his industry. §9 places
|
||||
* one "adjacent to a Facility, on any of the nine nearby spots" and `check` has always accepted
|
||||
* all eight neighbours — it was `placementCandidates` that walked north, south, east and west
|
||||
* only, so a diagonal square with no orthogonal neighbour was legal and never offered.
|
||||
* JESSE'S RULING, 2026-09-23, REVERSING HIS OWN EARLIER REPORT. This asserted the opposite: he
|
||||
* had reported that a Modifier could not be placed to the south-east of his industry, and §9's
|
||||
* "any of the nine nearby spots" was read as all eight neighbours. A Modifier must sit SQUARE
|
||||
* against what it serves now — a card on a corner touches it at a point, not along an edge.
|
||||
*
|
||||
* The fixture is unchanged so the reversal is asserted on the very square that prompted the
|
||||
* original change.
|
||||
*/
|
||||
const s = game();
|
||||
const under = district(s);
|
||||
@@ -1408,13 +1436,25 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
|
||||
.map((i) => coordKey((i as { placement: GridCoord }).placement));
|
||||
|
||||
// South-east of the host, and orthogonally adjacent to nothing at all.
|
||||
// South-east of the host: touching it at a corner only.
|
||||
const southEast = at(under.row - 1, under.col + 1);
|
||||
assert.ok(
|
||||
offered.includes(coordKey(southEast)),
|
||||
`the south-east spot (${southEast.row}, ${southEast.col}) is legal but was never offered — offered: ${offered.join(' ')}`,
|
||||
!offered.includes(coordKey(southEast)),
|
||||
`the diagonal spot (${southEast.row}, ${southEast.col}) is still offered — offered: ${offered.join(' ')}`,
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: southEast }),
|
||||
'NOT_CONNECTED',
|
||||
'a Modifier was accepted on a diagonal',
|
||||
);
|
||||
|
||||
// The orthogonal neighbours are still there, or the card would have nowhere to go at all.
|
||||
const east = at(under.row, under.col + 1);
|
||||
assert.ok(
|
||||
offered.includes(coordKey(east)),
|
||||
`the square east of the host is not offered — offered: ${offered.join(' ')}`,
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: east }), null);
|
||||
});
|
||||
|
||||
it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
|
||||
@@ -1555,9 +1595,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
|
||||
// Forced open so this tests the freight/passenger distinction rather than the `allows` gating —
|
||||
// the Office starts as a Whistle Post, which is not a Passenger Facility and takes nothing.
|
||||
openOffice(s);
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
|
||||
const office = officeCard.facility!;
|
||||
office.allows = { outbound: true, inbound: true };
|
||||
const slotsBefore = office.capacity.outbound;
|
||||
const portersBefore = office.porters;
|
||||
|
||||
const waiting = [...s.cards.entries()].find(
|
||||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
|
||||
@@ -1569,29 +1611,27 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
assert.ok(spot, 'a Waiting Area has nowhere legal beside the Office');
|
||||
assert.ok(applyIntent(s, 0, spot!).ok);
|
||||
|
||||
assert.equal(office.capacity.outbound, 1, 'the passenger slot should still be granted');
|
||||
assert.equal(office.porters, 1, 'the porter should still be granted');
|
||||
// Relative, so the assertion says what the Modifier is worth rather than what a Depot prints.
|
||||
assert.equal(office.capacity.outbound, slotsBefore + 1, 'the passenger slot should still be granted');
|
||||
assert.equal(office.porters, portersBefore + 1, 'the porter should still be granted');
|
||||
assert.equal(
|
||||
carsOn(officeCard), officeCard.standing,
|
||||
'a Modifier gave a Passenger Facility an industry track to spot cars on',
|
||||
);
|
||||
});
|
||||
|
||||
it('suppresses a grant the host cannot use — and gives it back when it can', () => {
|
||||
it('refuses a passenger Modifier at a Whistle Post, which cannot use it at all', () => {
|
||||
/**
|
||||
* REPORTED from play: "Restaurant attached to a whistle stop, then upgrade to depot — depot only
|
||||
* shows one green / one red box. I expected two, because Restaurant increases outbound by one."
|
||||
* JESSE'S RULING, 2026-09-23, REVERSING the 2026-09-17 call that let these stand dormant.
|
||||
*
|
||||
* `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility, so the +1
|
||||
* outbound is genuinely unusable while the Office is a Whistle Post — suppressing it is right,
|
||||
* and saying so is what the panel is for. Losing it FOREVER was the bug: the upgrade applied
|
||||
* only the difference between two tiers and knew nothing about what had been discarded.
|
||||
* `hosts: ['office']` includes a Whistle Post, which is NOT a Passenger Facility — it allows
|
||||
* neither direction — so the card's +1 outbound was discarded on the spot and only its porter
|
||||
* landed. Dormant was defensible while the panel explained itself, but a card that can be played
|
||||
* to no effect is a trap however well it is labelled.
|
||||
*
|
||||
* This used to be written against an Ice House on a Grocer's Warehouse, which suppresses again
|
||||
* now that the Grocer's is inbound-only (v0.4.9e). The Office was chosen instead because the
|
||||
* suppression there is TEMPORARY — an upgrade can lift it — and losing the grant forever across
|
||||
* that upgrade was the bug. A Grocer's never ships, so its Ice House is suppressed permanently
|
||||
* and tests nothing about the upgrade path.
|
||||
* THE RECOVERY PATH IN `officeUpgraded` IS LEFT IN PLACE and is now unreachable by play: it
|
||||
* restores a grant suppressed at a Whistle Post, and no such grant can be created any more. It
|
||||
* is kept because it is correct, and relaxing this rule would need it back.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
@@ -1603,57 +1643,32 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'restaurant',
|
||||
)!;
|
||||
s.decks.hands.set(0, [restaurant[0]]);
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'card.play',
|
||||
cardId: restaurant[0],
|
||||
placement: { row: area.officeCoord.row - 1, col: area.officeCoord.col },
|
||||
}).ok, 'the Restaurant could not be played beside the Office');
|
||||
|
||||
const beside = { row: area.officeCoord.row - 1, col: area.officeCoord.col };
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }),
|
||||
'OFFICE_NOT_PASSENGER',
|
||||
'a Restaurant was accepted at a Whistle Post',
|
||||
);
|
||||
const offered = legalActions(s, 0).filter(
|
||||
(i) => i.type === 'card.play' && i.cardId === restaurant[0] && i.placement !== undefined,
|
||||
);
|
||||
assert.equal(offered.length, 0, 'a Restaurant was offered a square at a Whistle Post');
|
||||
|
||||
// Upgrade the Office and the very same square becomes legal.
|
||||
reduce(s, { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' });
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }),
|
||||
null,
|
||||
'a Restaurant is still refused at a Depot, which IS a Passenger Facility',
|
||||
);
|
||||
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: restaurant[0], placement: beside }).ok);
|
||||
|
||||
const f = office.facility!;
|
||||
assert.equal(f.capacity.outbound, 0, 'a Whistle Post gained a passenger slot it cannot have');
|
||||
assert.equal(f.porters, 1, 'the porter has no direction gate and should have landed');
|
||||
|
||||
const fv = snapshot(s, [], null).facilities.find((v) => v.name.includes('Whistle'));
|
||||
assert.ok(fv, 'the Office is missing from the panel');
|
||||
assert.equal(fv!.suppressed.length, 1, 'the dropped bonus is not reported');
|
||||
assert.match(fv!.suppressed[0]!, /Restaurant/);
|
||||
assert.deepEqual(fv!.modifiers, ['Restaurant'], 'the modifier is not listed against its host');
|
||||
|
||||
// Upgrading makes it a Passenger Facility, and the slot the Restaurant always printed arrives.
|
||||
reduce(s, { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' });
|
||||
assert.equal(f.allows.outbound, true);
|
||||
assert.equal(
|
||||
f.capacity.outbound,
|
||||
officeProfile('depot').passengerOut + 1,
|
||||
"the Restaurant's slot did not come back when the Office could finally use it",
|
||||
);
|
||||
|
||||
// And it is paid ONCE: a second upgrade must not grant it again.
|
||||
const afterDepot = f.capacity.outbound;
|
||||
reduce(s, { type: 'officeUpgraded', player: 0, from: 'depot', to: 'station' });
|
||||
assert.equal(
|
||||
f.capacity.outbound,
|
||||
afterDepot + (officeProfile('station').passengerOut - officeProfile('depot').passengerOut),
|
||||
'the Restaurant was paid a second time on the next upgrade',
|
||||
);
|
||||
assert.equal(f.capacity.outbound, officeProfile('depot').passengerOut + 1, 'the slot did not land');
|
||||
assert.equal(f.porters, officeProfile('depot').porters + 1, 'the porter did not land');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Industry cards go on a stub, and lock each other out', () => {
|
||||
/**
|
||||
* A district with a siding hanging off the Running Track, which is the only place an industry may
|
||||
* go. Returns the siding square east of the curve.
|
||||
*
|
||||
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track
|
||||
* row -1: [curve ne] [siding square]
|
||||
*
|
||||
* The turnout is laid ON the east Limits sign, which is how the Running Track grows — so the sign
|
||||
* MOVES OUT with it (§2.1, Gap 4a), exactly as `extendLimitsIfNeeded` does when the card is played
|
||||
* rather than written straight into the grid. Without that the siding square would be outside the
|
||||
* district's own Limits, which is no longer a place track may go.
|
||||
*/
|
||||
function withSiding(s: GameState): GridCoord {
|
||||
const area = areaOf(s, 0);
|
||||
const plain = (geometry: object): TrackCard => ({
|
||||
|
||||
+39
-15
@@ -7,7 +7,8 @@
|
||||
* that now carries what the cards say", so the code sent readers to a table its own banner told them
|
||||
* not to trust. Nothing failed, because nothing checked.
|
||||
*
|
||||
* `docs/rules/as-built.md` is emitted from the same exported catalogues the engine instantiates
|
||||
* The card tables in `docs/home-deck.md` and `docs/mainline-deck.md` are emitted from the same
|
||||
* exported catalogues the engine instantiates
|
||||
* from, and this re-runs the generator and compares. Change a card face without regenerating and
|
||||
* this goes red — which is the whole point: a document nothing verifies is a document that will be
|
||||
* wrong, and this project's own history is the evidence.
|
||||
@@ -22,33 +23,56 @@ import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const doc = join(root, 'docs/rules/as-built.md');
|
||||
const DOCS = ['docs/home-deck.md', 'docs/mainline-deck.md'].map((rel) => join(root, rel));
|
||||
|
||||
describe('docs/rules/as-built.md is generated, and current', () => {
|
||||
describe('the deck references carry generated card tables, and they are current', () => {
|
||||
it('matches what the generator emits from content.ts today', () => {
|
||||
const before = readFileSync(doc, 'utf8');
|
||||
const before = DOCS.map((d) => readFileSync(d, 'utf8'));
|
||||
execFileSync(process.execPath, [join(root, 'scripts/build-card-reference.ts')], { cwd: root });
|
||||
const after = readFileSync(doc, 'utf8');
|
||||
assert.equal(
|
||||
after,
|
||||
before,
|
||||
'the checked-in card reference is stale — run `npm run build:cards` and commit the result',
|
||||
);
|
||||
DOCS.forEach((d, i) => {
|
||||
assert.equal(
|
||||
readFileSync(d, 'utf8'),
|
||||
before[i],
|
||||
`${d} is stale — run \`npm run build:cards\` and commit the result`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('puts every generated block inside a marker pair that exists', () => {
|
||||
/**
|
||||
* The generator throws on a block with nowhere to go, so this guards the other direction: a
|
||||
* marker pair left in a document with no block to fill it would sit there empty and silent.
|
||||
*/
|
||||
for (const d of DOCS) {
|
||||
const text = readFileSync(d, 'utf8');
|
||||
const begins = [...text.matchAll(/<!-- BEGIN CARDS: ([a-z]+) -->/g)].map((m) => m[1]!);
|
||||
const ends = [...text.matchAll(/<!-- END CARDS: ([a-z]+) -->/g)].map((m) => m[1]!);
|
||||
assert.deepEqual(begins, ends, `${d}: card markers are unbalanced`);
|
||||
for (const key of begins) {
|
||||
const body = text.slice(
|
||||
text.indexOf(`<!-- BEGIN CARDS: ${key} -->`) + `<!-- BEGIN CARDS: ${key} -->`.length,
|
||||
text.indexOf(`<!-- END CARDS: ${key} -->`),
|
||||
);
|
||||
assert.match(body, /\|/, `${d}: the "${key}" block has no table in it`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the current train catalogue, not the v0.4.5 deck', () => {
|
||||
// The specific drift that went unnoticed for several releases, asserted by name so a future
|
||||
// regeneration against an old content.ts cannot quietly reintroduce it.
|
||||
const md = readFileSync(doc, 'utf8');
|
||||
const md = readFileSync(join(root, 'docs/home-deck.md'), 'utf8');
|
||||
assert.match(md, /Crack Limited/);
|
||||
assert.match(md, /\| 3 \| Express \|/);
|
||||
assert.ok(!/Mail-Express/.test(md), 'the superseded v0.4.5 train names are back');
|
||||
assert.ok(!/Manifest Freight/.test(md), 'the superseded v0.4.5 train names are back');
|
||||
});
|
||||
|
||||
it('says it is generated, so nobody edits it by hand', () => {
|
||||
const md = readFileSync(doc, 'utf8');
|
||||
assert.match(md, /Generated from `src\/engine\/content\.ts`/);
|
||||
assert.match(md, /Do not edit by/);
|
||||
it('says the tables are generated, so nobody edits them by hand', () => {
|
||||
for (const d of DOCS) {
|
||||
const md = readFileSync(d, 'utf8');
|
||||
assert.match(md, /GENERATED from/, `${d} does not say its tables are generated`);
|
||||
assert.match(md, /`npm run build:cards`/, `${d} does not say what regenerates them`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaOf, check, movesFor } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { describeIntent } from '../src/sim/view.ts';
|
||||
import { badlyMadeUp } from '../src/engine/advance.ts';
|
||||
@@ -26,6 +26,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
@@ -622,3 +626,68 @@ describe('the Small Yard says what each re-order would build', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* WHY A SQUARE IS REFUSED, AND THE ONE PICK-UP EVERY TRAIN MAY MAKE.
|
||||
*
|
||||
* Asked from a table, 2026-09-23: "where does it show that you can't make a particular move because
|
||||
* of a rule that's violated… how does a user know what rule is violated and why you can't go
|
||||
* there?" `exploreMoves` decides where the rails go; the train's own card is enforced afterwards in
|
||||
* `check` — so a square the rails reach and the card forbids was reachable, un-offered, and absent
|
||||
* from the block list with no reason given.
|
||||
*/
|
||||
describe("a train's own card explains the squares it may not enter", () => {
|
||||
/** A drop-only Extra (X13 "may drop MTs but not pick up anything") on row 1, with track beside it. */
|
||||
const dropOnlyAt = (s: GameState, cars: RollingStock[]): { trayId: string; there: GridCoord } => {
|
||||
row(s, 3);
|
||||
switching(s);
|
||||
const trayId = placeTray(s, at(1, 0), [], 'e');
|
||||
const tray = s.trays.get(trayId)!;
|
||||
tray.trainNumber = 13;
|
||||
tray.trainIsExtra = true;
|
||||
const there = at(1, 1);
|
||||
areaOf(s, 0).grid.get(coordKey(there))!.standing = cars;
|
||||
return { trayId, there };
|
||||
};
|
||||
|
||||
it('reports a drop-only train as blocked, in words, rather than silently', () => {
|
||||
const s = game();
|
||||
const { trayId, there } = dropOnlyAt(s, [{ type: 'boxcar', loaded: false }]);
|
||||
|
||||
const { to, blocked } = movesFor(s, 0, trayId);
|
||||
const k = (c: GridCoord): string => `${c.row},${c.col}`;
|
||||
assert.ok(!to.some((c) => k(c) === k(there)), 'a square the card forbids is still offered');
|
||||
|
||||
const b = blocked.find((x) => k(x.coord) === k(there));
|
||||
assert.ok(b, 'the forbidden square is missing from the block list entirely — no reason is shown');
|
||||
assert.equal(b!.kind, 'cardRule', "the block is not attributed to the train's card");
|
||||
assert.match(b!.why, /forbids picking cars up/, `the reason does not name the rule: ${b!.why}`);
|
||||
assert.match(b!.why, /coupling is mandatory/, `the reason does not say why it bites: ${b!.why}`);
|
||||
});
|
||||
|
||||
it('lets a drop-only train recover its OWN caboose', () => {
|
||||
/**
|
||||
* JESSE'S RULING, 2026-09-23. X13 prints "may drop MTs but not pick up anything", and a train
|
||||
* needs its caboose at the far end to be made up (§8.2) — so a train that parted with its
|
||||
* caboose could never legally leave again. It stranded itself, permanently and silently.
|
||||
*
|
||||
* The caboose only, not "your own cars" generally: it is the one car whose absence stops the
|
||||
* train departing, so recovering it repairs a consist rather than doing fresh work.
|
||||
*/
|
||||
const s = game();
|
||||
const { trayId, there } = dropOnlyAt(s, [{ type: 'caboose', loaded: false }]);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId, to: there, reverse: false }),
|
||||
null,
|
||||
'a drop-only train may not recover its own caboose, so it can never be made up again',
|
||||
);
|
||||
|
||||
// A boxcar in the same place is still a pick-up and still refused.
|
||||
areaOf(s, 0).grid.get(coordKey(there))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId, to: there, reverse: false }),
|
||||
'PICKUP_NOT_ALLOWED',
|
||||
'the caboose exemption leaked into ordinary cars',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import assert from 'node:assert/strict';
|
||||
import { advance } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts';
|
||||
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
|
||||
import { officeProfile } from '../src/engine/content.ts';
|
||||
import { narrate } from '../src/sim/narrate.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey, decisionActor, subdivisions, turnOf } from '../src/engine/state.ts';
|
||||
@@ -23,6 +25,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||
@@ -279,6 +285,86 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
||||
assert.ok(areaOf(s, 0).heldAtLimits.includes(id), 'train is held at the Limits');
|
||||
});
|
||||
|
||||
it('does not let a released train and a newcomer share one A/D track', () => {
|
||||
/**
|
||||
* REPORTED FROM A TABLE, 2026-09-23, and measured on the save: a Whistle Post with ONE A/D
|
||||
* track held Trains 8 and 19 at once. The capacity test passed (nothing standing), the train
|
||||
* the Interlocking had been holding at the Limits was then moved into the free slot, and the
|
||||
* arriving train was pushed in after it without anyone asking again whether there was room.
|
||||
*
|
||||
* The held train has priority — it has been waiting — so the NEWCOMER takes the consequence,
|
||||
* and it is the same consequence it would have met had the held train arrived first: held at
|
||||
* the Limits where there is an Interlocking, a collision where there is not.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
|
||||
// One train already waiting at the Limits, and the Office just cleared.
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = [];
|
||||
|
||||
const card = straight();
|
||||
card.enhancements.push('interlocking');
|
||||
addCard(s, at(0, 2), card);
|
||||
const arriving = inbound(s, []);
|
||||
|
||||
advance(s);
|
||||
|
||||
assert.ok(
|
||||
area.adOccupancy.length <= capacity,
|
||||
`the Office holds ${area.adOccupancy.length} trains on ${capacity} A/D track(s)`,
|
||||
);
|
||||
assert.ok(area.adOccupancy.includes('waiting'), 'the train that had been waiting did not get the track');
|
||||
assert.ok(!area.adOccupancy.includes(arriving), 'the newcomer squeezed onto an occupied track');
|
||||
// With an Interlocking it waits its turn rather than wrecking.
|
||||
assert.ok(area.heldAtLimits.includes(arriving), 'the newcomer was neither held nor collided');
|
||||
});
|
||||
|
||||
it('collides the newcomer when a released train takes the last track and there is no Interlocking', () => {
|
||||
// Same situation, no Interlocking: §8.3's collision is what should happen, and did not.
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = [];
|
||||
inbound(s, []);
|
||||
|
||||
advance(s);
|
||||
assert.equal(s.players[0]!.revenue, -5, 'no collision was scored for the train with nowhere to go');
|
||||
assert.ok(area.adOccupancy.length <= officeProfile(area.tier).adTracks, 'the Office is over capacity');
|
||||
});
|
||||
|
||||
it('says so in the history when a held train takes the track that just freed', () => {
|
||||
// The release used to be a silent side effect of somebody else's arrival — reported as "wasn't
|
||||
// clear what changed and why train 8 was suddenly released".
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
s.trays.set('waiting', {
|
||||
id: 'waiting', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['waiting'];
|
||||
area.adOccupancy = [];
|
||||
const card = straight();
|
||||
card.enhancements.push('interlocking');
|
||||
addCard(s, at(0, 2), card);
|
||||
inbound(s, []);
|
||||
|
||||
const released = advance(s).events.find((e) => e.type === 'trainReleasedFromLimits');
|
||||
assert.ok(released, 'the release is still silent');
|
||||
const line = narrate(released as never, { playerName: () => 'A' });
|
||||
assert.match(line.text, /RELEASED from the Limits/, `the line does not say what happened: ${line.text}`);
|
||||
assert.match(line.text, /freed the A\/D track/, `the line does not say why now: ${line.text}`);
|
||||
});
|
||||
|
||||
it('still collides without an Interlocking', () => {
|
||||
const s = game();
|
||||
areaOf(s, 0).adOccupancy = ['blocker'];
|
||||
|
||||
+104
-1
@@ -15,7 +15,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { fromSave, newGame, submit, toSave, view } from '../src/web/game.ts';
|
||||
import { LOG_LIMIT, fromSave, newGame, newMultiplayerGame, pushLine, submit, toSave, view } from '../src/web/game.ts';
|
||||
import { actionGroups, currentActor } from '../src/web/game.ts';
|
||||
import { narrate } from '../src/sim/narrate.ts';
|
||||
|
||||
@@ -92,6 +92,12 @@ const KNOWN_UNREDUCED = [
|
||||
'trainHeld',
|
||||
'trainHighballed',
|
||||
'trainMadeUp',
|
||||
/**
|
||||
* A train the Interlocking held at the Limits taking the A/D track that just freed. Emitted by
|
||||
* `arriveAtOffice` in the phase driver, which moves the tray itself — so this describes rather
|
||||
* than reduces, like every entry on this list.
|
||||
*/
|
||||
'trainReleasedFromLimits',
|
||||
'trainStoodStill',
|
||||
'trainsDestroyed',
|
||||
];
|
||||
@@ -283,3 +289,100 @@ describe('the log names the Mainline card an Enhancement was built on', () => {
|
||||
assert.match(line.text, /\(2,-1\)/, `the square is gone or in the wrong order: ${line.text}`);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* WHOSE REVENUE IT IS.
|
||||
*
|
||||
* Reported from a table: "in the history on +1 revenue and gives the current score, it doesn't list
|
||||
* the player name." The history prefixes lines with the ACTOR, and revenue is not always the
|
||||
* actor's — a train completing its run pays every player with no actor at all, so those lines
|
||||
* carried no name whatsoever.
|
||||
*/
|
||||
describe('a Revenue line names the player who earned it', () => {
|
||||
const ctx = { playerName: (p: number) => ['Alice', 'Bob'][p] ?? `Seat ${p}` };
|
||||
|
||||
it('names the earner on a gain', () => {
|
||||
const line = narrate({ type: 'revenueChanged', player: 1, delta: 1, total: 12, reason: 'boarding' } as never, ctx);
|
||||
assert.match(line.text, /Bob/, `no player named: ${line.text}`);
|
||||
assert.match(line.text, /\+1 Revenue/, `the change is gone: ${line.text}`);
|
||||
assert.match(line.text, /now 12/, `the running total is gone: ${line.text}`);
|
||||
});
|
||||
|
||||
it('names the earner on a loss', () => {
|
||||
const line = narrate({ type: 'revenueChanged', player: 0, delta: -5, total: 7, reason: 'collision' } as never, ctx);
|
||||
assert.match(line.text, /Alice/, `no player named: ${line.text}`);
|
||||
assert.match(line.text, /now 7/, `the running total is gone: ${line.text}`);
|
||||
});
|
||||
|
||||
it('names the player it belongs to, not the one who acted', () => {
|
||||
// The distinction that matters: a train completing its run pays everybody.
|
||||
const a = narrate({ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'a train completed its run' } as never, ctx);
|
||||
const b = narrate({ type: 'revenueChanged', player: 1, delta: 1, total: 9, reason: 'a train completed its run' } as never, ctx);
|
||||
assert.match(a.text, /Alice/, `the first payee is unnamed: ${a.text}`);
|
||||
assert.match(b.text, /Bob/, `the second payee is unnamed: ${b.text}`);
|
||||
assert.notEqual(a.text, b.text, 'both payees produced the same line');
|
||||
});
|
||||
|
||||
it('is excluded from the history prefix, so no line names a player twice', () => {
|
||||
// Gitea#31. `record` prefixes `Player <actor>` onto events carrying a `player`; this one
|
||||
// resolves its own name, so it must be on the exclusion list or it reads "Player Bob Bob +1".
|
||||
const src = readFileSync(join(import.meta.dirname, '..', 'src', 'web', 'game.ts'), 'utf8');
|
||||
const guard = src.slice(src.indexOf('const SELF_NAMED'), src.indexOf('const SELF_NAMED') + 400);
|
||||
assert.match(guard, /'revenueChanged'/, 'revenueChanged is not excluded from the actor prefix');
|
||||
assert.match(guard, /!SELF_NAMED\.includes\(e\.type\)/, 'the exclusion is not applied to `mine`');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* THE HISTORY MUST NOT FREEZE WHEN THE LOG IS TRIMMED.
|
||||
*
|
||||
* Reported from a two-player game that did not reach Day 5: one player's history stopped gaining
|
||||
* lines at Day 2 Stage 8 and the other's at Day 2 Stage 4. The log is trimmed to a limit, and each
|
||||
* seat's "what have I sent you" bookmark was an INDEX into that array — so once a seat's bookmark
|
||||
* reached the limit, the array never grew past it again and the slice returned nothing for the rest
|
||||
* of the game. Different moments per seat because each holds its own bookmark.
|
||||
*/
|
||||
describe('a trimmed log still delivers every line', () => {
|
||||
const config = {
|
||||
mode: 'competitive' as const, days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
|
||||
it('numbers lines by sequence, which survives trimming', () => {
|
||||
const g = newMultiplayerGame(7, config as never, ['A', 'B']);
|
||||
const first = g.log[g.log.length - 1]!.seq;
|
||||
pushLine(g, 'one', 'plain');
|
||||
pushLine(g, 'two', 'plain');
|
||||
assert.equal(g.log[g.log.length - 1]!.seq, first + 2, 'sequence numbers do not advance');
|
||||
|
||||
// Trim the front; the survivors keep the numbers they were given.
|
||||
const keep = g.log[g.log.length - 1]!.seq;
|
||||
g.log.splice(0, g.log.length - 1);
|
||||
assert.equal(g.log[0]!.seq, keep, 'trimming renumbered the lines');
|
||||
});
|
||||
|
||||
it('delivers every line to a seat across many trims', () => {
|
||||
const g = newMultiplayerGame(7, config as never, ['A', 'B']);
|
||||
// The same bookmark the server keeps per seat (`linesSince` in server/session.ts).
|
||||
let bookmark = -1;
|
||||
const since = (): number => {
|
||||
const fresh = g.log.filter((l) => l.seq > bookmark);
|
||||
const last = g.log[g.log.length - 1];
|
||||
if (last) bookmark = last.seq;
|
||||
return fresh.length;
|
||||
};
|
||||
since();
|
||||
|
||||
const pushes = LOG_LIMIT * 2;
|
||||
let delivered = 0;
|
||||
for (let i = 0; i < pushes; i++) {
|
||||
pushLine(g, `line ${i}`, 'plain');
|
||||
if (g.log.length > LOG_LIMIT) g.log.splice(0, g.log.length - LOG_LIMIT);
|
||||
delivered += since();
|
||||
}
|
||||
// Every line reaches the seat, though the log holds only the last LOG_LIMIT of them.
|
||||
assert.equal(delivered, pushes, `only ${delivered} of ${pushes} lines were delivered`);
|
||||
assert.equal(g.log.length, LOG_LIMIT, 'the log is not being trimmed at all');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,10 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
|
||||
@@ -34,6 +34,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
|
||||
@@ -38,6 +38,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||
@@ -727,6 +731,18 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
|
||||
|
||||
describe('a turnout may be laid on top of a card already down', () => {
|
||||
/** Lays a straight inside the Limits and returns where it went. */
|
||||
/** Force an industry card into hand and return its id, the way `trackInHand` does for track. */
|
||||
const facilityInHand = (s: GameState): string => {
|
||||
for (const [id, card] of s.cards) {
|
||||
if ((card.kind as { kind: string }).kind !== 'freightFacility') continue;
|
||||
const hand = s.decks.hands.get(0) ?? [];
|
||||
if (!hand.includes(id)) hand.push(id);
|
||||
s.decks.hands.set(0, hand);
|
||||
return id;
|
||||
}
|
||||
throw new Error('no freight facility card in the deck');
|
||||
};
|
||||
|
||||
const layStraight = (s: GameState): GridCoord => {
|
||||
const area = areaOf(s, 0);
|
||||
const target = { row: area.runningRow, col: area.limitsEast.col };
|
||||
@@ -799,6 +815,82 @@ describe('a turnout may be laid on top of a card already down', () => {
|
||||
assert.deepEqual(matching, ['right/1'], 'exactly the turnout diverging onto `ne` should be accepted');
|
||||
});
|
||||
|
||||
it('builds an industry over a straight already laid, off the Running Track', () => {
|
||||
/**
|
||||
* REPORTED FROM A TABLE: "just like you could play a turnout over a straight or a curve, the
|
||||
* game should allow placing an industry over a straight on a non-running track."
|
||||
*
|
||||
* A player who lays the rail first and draws the industry afterwards otherwise has no move,
|
||||
* which punishes building a district in the sensible order. The swap is safe for the same
|
||||
* reason the turnout upgrade is: `protoCard` builds every Facility as plain east-west track, so
|
||||
* replacing a straight is port-for-port and no neighbour loses a join.
|
||||
*/
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
turnOf(s, 0).option = 'draw';
|
||||
|
||||
// A stub below the main: a turnout on the Running Track, a straight hanging under it.
|
||||
const turnoutAt = { row: area.runningRow, col: area.limitsEast.col };
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: turnoutAt, variant: 0,
|
||||
}).ok, 'the turnout should lay on the Limits sign');
|
||||
const curveAt = { row: area.runningRow - 1, col: turnoutAt.col };
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: curveAt, variant: 1,
|
||||
}).ok, 'the matching curve should hang under the turnout');
|
||||
// Laying on the Limits sign moved it outward, so the square east of the curve is now inside.
|
||||
const stub = { row: curveAt.row, col: curveAt.col + 1 };
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: stub, variant: 0,
|
||||
}).ok, 'the straight should run east off the curve');
|
||||
|
||||
const industry = facilityInHand(s);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: industry, placement: stub, variant: 0 }),
|
||||
null,
|
||||
'an industry could not be built over a straight on a stub',
|
||||
);
|
||||
|
||||
// And the menu offers it, or the rule exists and is never presented.
|
||||
const offered = legalActions(s, 0).some(
|
||||
(i) => i.type === 'card.play' && i.cardId === industry &&
|
||||
i.placement?.row === stub.row && i.placement.col === stub.col,
|
||||
);
|
||||
assert.ok(offered, 'the square is legal but never enumerated, so it cannot be chosen');
|
||||
|
||||
// It really replaces the track, rather than being refused after the fact.
|
||||
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: industry, placement: stub, variant: 0 }).ok);
|
||||
assert.equal(area.grid.get(`${stub.row},${stub.col}`)?.geometry.kind, 'facility');
|
||||
});
|
||||
|
||||
it('will not build an industry over the Running Track, a curve or a turnout', () => {
|
||||
// The Running Track is §11.2's own rule. Curves and turnouts carry ports a Facility does not,
|
||||
// so building over one could sever a neighbour's join — which is why only a straight is allowed.
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
turnOf(s, 0).option = 'draw';
|
||||
|
||||
const turnoutAt = { row: area.runningRow, col: area.limitsEast.col };
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: turnoutAt, variant: 0,
|
||||
}).ok, 'the turnout should lay on the Limits sign');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: facilityInHand(s), placement: turnoutAt, variant: 0 }),
|
||||
'ON_RUNNING_TRACK',
|
||||
'an industry was allowed onto the Running Track',
|
||||
);
|
||||
|
||||
const curveAt = { row: area.runningRow - 1, col: turnoutAt.col };
|
||||
assert.ok(applyIntent(s, 0, {
|
||||
type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: curveAt, variant: 1,
|
||||
}).ok, 'the curve should hang under the main');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId: facilityInHand(s), placement: curveAt, variant: 0 }),
|
||||
'NOT_UPGRADEABLE_TRACK',
|
||||
'an industry was allowed over a curve, whose ports it does not carry',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to swap the track out from under a car, or out from under an Interlocking', () => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* The documentation renderer.
|
||||
*
|
||||
* The five player-facing documents are written in Markdown — that is the one copy, and the whole
|
||||
* reason TODO #15a exists — and rendered to pages at build time. This covers the subset those
|
||||
* documents actually use, and the two properties that matter most: a table comes out as a TABLE
|
||||
* (the entire point of rendering rather than serving text), and nothing in the prose can become
|
||||
* markup by accident.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { renderMarkdown } from '../scripts/markdown.ts';
|
||||
|
||||
const html = (src: string): string => renderMarkdown(src).html;
|
||||
|
||||
describe('the documentation renderer', () => {
|
||||
it('turns a pipe table into a real table, with its alignment', () => {
|
||||
// This is what rendering is FOR. A card reference is mostly tables, and as plain text a table
|
||||
// is rows of pipes — which is exactly how the guide read when it was served as text/plain.
|
||||
const out = html(
|
||||
['| Card | Regions | Passes |', '| --- | ---: | :---: |', '| Plains | 1 | no |', '| Tunnel | 2 | yes |'].join('\n'),
|
||||
);
|
||||
assert.match(out, /<table>/, 'the table is not a table');
|
||||
assert.match(out, /<thead><tr><th>Card<\/th>/, 'the header row is not a header');
|
||||
assert.match(out, /<th class="ta-right">Regions<\/th>/, 'a right-aligned column lost its alignment');
|
||||
assert.match(out, /<th class="ta-center">Passes<\/th>/, 'a centred column lost its alignment');
|
||||
assert.match(out, /<td>Plains<\/td><td class="ta-right">1<\/td>/, 'a body row lost its cells');
|
||||
assert.equal((out.match(/<tr>/g) ?? []).length, 3, 'wrong number of rows');
|
||||
// Wrapped, so a wide table scrolls inside the page instead of widening it on a phone.
|
||||
assert.match(out, /<div class="tablewrap">/, 'the table can widen the page on a narrow screen');
|
||||
});
|
||||
|
||||
it('gives every heading an id and an anchor, numbered prefixes stripped', () => {
|
||||
const { html: out, headings } = renderMarkdown('## 4.2 Local Operations\n\ntext\n');
|
||||
assert.deepEqual(headings, [{ level: 2, text: '4.2 Local Operations', id: 'local-operations' }]);
|
||||
assert.match(out, /<h2 id="local-operations">/, 'the heading has no id to link to');
|
||||
assert.match(out, /<a class="anchor" href="#local-operations"/, 'the heading has no anchor');
|
||||
});
|
||||
|
||||
it('numbers a repeated heading rather than pointing two links at one place', () => {
|
||||
const { headings } = renderMarkdown('## Trains\n\na\n\n## Trains\n\nb\n');
|
||||
assert.deepEqual(headings.map((h) => h.id), ['trains', 'trains-2']);
|
||||
});
|
||||
|
||||
it('renders lists, quotes, rules and fenced code', () => {
|
||||
assert.match(html('- one\n- two\n'), /<ul><li>one<\/li><li>two<\/li><\/ul>/);
|
||||
assert.match(html('1. first\n2. second\n'), /<ol><li>first<\/li><li>second<\/li><\/ol>/);
|
||||
assert.match(html('> a note\n> continued\n'), /<blockquote><p>a note continued<\/p><\/blockquote>/);
|
||||
assert.match(html('---\n'), /<hr>/);
|
||||
assert.match(html('```\nconst x = 1;\n```\n'), /<pre><code>const x = 1;<\/code><\/pre>/);
|
||||
});
|
||||
|
||||
it('renders a table inside a block quote', () => {
|
||||
// The rules reference puts one there, so this is not hypothetical.
|
||||
const out = html('> | A | B |\n> | --- | --- |\n> | 1 | 2 |\n');
|
||||
assert.match(out, /<blockquote><div class="tablewrap"><table>/, 'a quoted table did not render');
|
||||
});
|
||||
|
||||
it('handles bold, italic and code spans, and leaves markup inside code alone', () => {
|
||||
assert.match(html('**loud** and *quiet*\n'), /<strong>loud<\/strong> and <em>quiet<\/em>/);
|
||||
// `**` inside backticks is a literal, which matters: the docs quote field names that way.
|
||||
assert.match(html('`**not bold**`\n'), /<code>\*\*not bold\*\*<\/code>/);
|
||||
assert.ok(!/<strong>/.test(html('`**not bold**`\n')), 'markup inside a code span was rendered');
|
||||
});
|
||||
|
||||
it('escapes everything, so prose can never become markup', () => {
|
||||
const out = html('A < B & C > D, and "quoted".\n');
|
||||
assert.match(out, /A < B & C > D/, 'angle brackets or ampersands reached the page raw');
|
||||
assert.ok(!/<script/i.test(html('<script>alert(1)</script>\n')), 'raw HTML passed through');
|
||||
assert.match(html('<script>alert(1)</script>\n'), /<script>/, 'raw HTML was not escaped');
|
||||
});
|
||||
|
||||
it('rewrites links between documents, and opens external ones in a new tab', () => {
|
||||
const out = renderMarkdown(
|
||||
'[Rules](rules.md) and [anchor](rules.md#draw) and [site](https://example.com)\n',
|
||||
(href) => (/^https?:/.test(href) ? href : href.replace(/\.md(#|$)/, '.html$1')),
|
||||
).html;
|
||||
assert.match(out, /<a href="rules\.html">Rules<\/a>/, 'a link between documents still points at the Markdown');
|
||||
assert.match(out, /<a href="rules\.html#draw">/, 'an anchored link lost its fragment');
|
||||
assert.match(out, /<a href="https:\/\/example\.com" target="_blank" rel="noopener">/, 'an external link is not safe');
|
||||
});
|
||||
|
||||
it('drops HTML comments, so the generated-card markers never show', () => {
|
||||
// `build-card-reference.ts` writes its tables between `<!-- BEGIN CARDS: … -->` markers.
|
||||
const out = html('before\n\n<!-- BEGIN CARDS: track -->\n| A |\n| --- |\n| 1 |\n<!-- END CARDS: track -->\n\nafter\n');
|
||||
assert.ok(!/BEGIN CARDS/.test(out), 'a build marker is visible on the page');
|
||||
assert.match(out, /<table>/, 'the generated table inside the markers was lost with them');
|
||||
assert.match(out, /before/, 'content before the markers was lost');
|
||||
assert.match(out, /after/, 'content after the markers was lost');
|
||||
});
|
||||
|
||||
it('renders each real document without losing its tables or headings', () => {
|
||||
// The documents themselves, not a fixture: what has to render is what is actually written.
|
||||
for (const name of ['quickstart', 'rules', 'home-deck', 'mainline-deck', 'components']) {
|
||||
const src = readFileSync(join(import.meta.dirname, '..', 'docs', `${name}.md`), 'utf8');
|
||||
const { html: out, headings } = renderMarkdown(src);
|
||||
assert.ok(headings.length > 2, `${name}.md rendered only ${headings.length} headings`);
|
||||
assert.ok(out.length > 1000, `${name}.md rendered almost nothing`);
|
||||
// No pipe table survives as text — that would mean a table failed to parse.
|
||||
const stripped = out.replace(/<[^>]+>/g, '');
|
||||
assert.ok(
|
||||
!/^\s*\|\s*---/m.test(stripped),
|
||||
`${name}.md has a table the renderer did not recognise`,
|
||||
);
|
||||
assert.ok(!/BEGIN CARDS/.test(out), `${name}.md leaked a build marker onto the page`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,10 @@ const competitive: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
|
||||
@@ -29,6 +29,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
|
||||
+21
-8
@@ -22,6 +22,7 @@ import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
||||
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
||||
import { summarize } from '../src/sim/stats.ts';
|
||||
import { officeProfile } from '../src/engine/content.ts';
|
||||
|
||||
// Mirrors `record()`'s own default exactly (`replay.ts`) — "does not drift from the engine" below
|
||||
// plays the same seed through both paths and compares outcomes, so they must share one floor.
|
||||
@@ -32,6 +33,8 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
// NO house rules, deliberately: `record()` names none either, so both take today's defaults and
|
||||
// "does not drift from the engine" below compares two games that were dealt the same way.
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
@@ -63,6 +66,7 @@ const SAMPLES: GameEvent[] = [
|
||||
// line whose whole job is to be read when nothing happened, so an empty or fallback sentence
|
||||
// would reproduce the silence it exists to fix.
|
||||
{ type: 'freightAgentIdled', player: 0 },
|
||||
{ type: 'trainReleasedFromLimits', trainNumber: 8, office: 'Whistle Post', owner: 0, freedBy: 14 },
|
||||
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
|
||||
@@ -127,7 +131,7 @@ describe('narration', () => {
|
||||
/**
|
||||
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
|
||||
*
|
||||
* `SAMPLES` exercises the TEXT of 31 of the 56 declared events; the other 25 have a narration
|
||||
* `SAMPLES` exercises the TEXT of 32 of the 57 declared events; the other 25 have a narration
|
||||
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
|
||||
* 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
|
||||
@@ -217,9 +221,12 @@ describe('impediments', () => {
|
||||
});
|
||||
|
||||
it('warns when every A/D track is occupied', () => {
|
||||
// Gap 2d — the next arrival is an automatic collision.
|
||||
// Gap 2d — the next arrival is an automatic collision. Filled to CAPACITY rather than to one
|
||||
// train, so the fixture says what it means whatever the Office opens as: a Depot has two A/D
|
||||
// tracks and one occupied is not a warning.
|
||||
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||
s.officeAreas.get(0)!.adOccupancy = ['t0'];
|
||||
const area = s.officeAreas.get(0)!;
|
||||
area.adOccupancy = Array.from({ length: officeProfile(area.tier).adTracks }, (_, i) => `t${i}`);
|
||||
const found = impediments(s, 0);
|
||||
assert.ok(found.some((b) => /A\/D/.test(b.why) && b.severity === 'risk'));
|
||||
});
|
||||
@@ -720,8 +727,14 @@ describe('the replay behaves like the game it is replaying', () => {
|
||||
* TWENTY-FOUR, not twelve, and the difference is instructive: on this stride the first game that
|
||||
* couples anything is index 13, so a twelve-seed pool still contained none. The rate is what
|
||||
* matters, not the count — measured 39 in 200, with couplers at indices 13, 19, 22, 24, 28 …
|
||||
*
|
||||
* SIXTY, not twenty-four, since every player opens on a Depot. Two A/D tracks instead of one is
|
||||
* the single biggest reason a game used to end in a wreck, so collisions went from rare to much
|
||||
* rarer: measured on this stride, no crash cue appears in the first 24 seeds or the first 40,
|
||||
* and the pool needs 60 to contain one. Widened rather than dropped, per the note below — §10
|
||||
* is the one event a player most needs to hear.
|
||||
*/
|
||||
const recs = Array.from({ length: 24 }, (_, i) => record(1000 + i * 7919, 'standard', 4000));
|
||||
const recs = Array.from({ length: 60 }, (_, i) => record(1000 + i * 7919, 'standard', 4000));
|
||||
const withCues = recs.flatMap((rec) => rec.frames.filter((f) => (f.cues?.length ?? 0) > 0));
|
||||
assert.ok(withCues.length > 20, `only ${withCues.length} frames carry a cue`);
|
||||
|
||||
@@ -736,10 +749,10 @@ describe('the replay behaves like the game it is replaying', () => {
|
||||
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
|
||||
assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound');
|
||||
assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound');
|
||||
// Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not
|
||||
// guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current
|
||||
// stride and seed count. If this starts failing after either changes, widen the pool rather than
|
||||
// deleting the assertion — §10 is the one event a player most needs to hear.
|
||||
// Collisions are rarer still now that every Office opens as a Depot — see the note above. This
|
||||
// is the one cue the pool is not guaranteed to contain on every stride. If it starts failing
|
||||
// after the stride, the seed count or the opening changes, widen the pool rather than deleting
|
||||
// the assertion — §10 is the one event a player most needs to hear.
|
||||
assert.ok(kinds.has('crash'), 'a collision never made a sound');
|
||||
|
||||
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
|
||||
|
||||
@@ -37,6 +37,10 @@ const config = (): GameConfig => {
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,6 +24,10 @@ const competitive: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
|
||||
@@ -15,6 +15,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
|
||||
@@ -16,6 +16,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
|
||||
+85
-8
@@ -7,7 +7,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { CarType } from '../src/engine/content.ts';
|
||||
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK } from '../src/engine/content.ts';
|
||||
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK, withSavedOpening } from '../src/engine/content.ts';
|
||||
import {
|
||||
DECK_SIZE,
|
||||
EXTRA_TRAINS,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
nextOfficeTier,
|
||||
officeProfile,
|
||||
} from '../src/engine/content.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import { createRng } from '../src/engine/rng.ts';
|
||||
import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts';
|
||||
import type { StartingHand } from '../src/engine/content.ts';
|
||||
@@ -40,6 +41,10 @@ const solitaireConfig: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
@@ -55,7 +60,8 @@ const gameDealtWith = (startingHand: StartingHand, seed = 1234) =>
|
||||
createGame({
|
||||
id: 'g1',
|
||||
seed,
|
||||
config: { ...solitaireConfig, houseRules: { startingHand } },
|
||||
// Spread, not replaced: `solitaireConfig` names the Whistle Post opening these counts assume.
|
||||
config: { ...solitaireConfig, houseRules: { ...solitaireConfig.houseRules, startingHand } },
|
||||
playerNames: ['Jesse'],
|
||||
});
|
||||
|
||||
@@ -73,7 +79,7 @@ describe('card catalogue (component 1)', () => {
|
||||
// industry tripling (27 → 9) — because both were measured against a deck holding 96 track
|
||||
// cards, and sheet 5 halves that. content.ts carries the measurements that decided it.
|
||||
//
|
||||
// We are at 143 rather than the sheet's 155 for ONE reason: the ten Safety, Event, Inspection
|
||||
// We are at 144 rather than the sheet's 155 for ONE reason: the ten Safety, Event, Inspection
|
||||
// and Space-use cards sheet 5 adds are not built, and stay out until they are (Jesse,
|
||||
// 2026-08-26) — Cargo Theft, Civic Improvement, Civilian angel, Delayed Clearance, Flares 2,
|
||||
// Robbery, Service Delays, Shipper complaints, Strike, Union Hall 2. Twelve copies in all.
|
||||
@@ -85,9 +91,9 @@ describe('card catalogue (component 1)', () => {
|
||||
// "TBD"; and the sharp curves, whose only difference from an ordinary curve was a Move cost
|
||||
// nothing ever charged — sheet 5 deals those zero too, so the catalogue and the design agree.
|
||||
//
|
||||
// DECK_SIZE is the CATALOGUE, 143. The deck actually dealt is smaller: the 20 opponent-directed
|
||||
// DECK_SIZE is the CATALOGUE, 144. The deck actually dealt is smaller: the 20 opponent-directed
|
||||
// cards are held back in every mode until they are implemented, so `buildDeck` returns 123.
|
||||
assert.equal(DECK_SIZE, 143);
|
||||
assert.equal(DECK_SIZE, 144);
|
||||
assert.equal(buildDeck().length, SOLITAIRE_DECK_SIZE);
|
||||
});
|
||||
|
||||
@@ -104,6 +110,8 @@ describe('card catalogue (component 1)', () => {
|
||||
industry: 9,
|
||||
modifier: 23,
|
||||
train: 22,
|
||||
// Q9 — dealt since 2026-09-23, which is what makes `newTrain.secondSection` cost something.
|
||||
secondSection: 1,
|
||||
spaceUse: 11,
|
||||
// 6 — the dispatching ladder and Facing Point Locks are dealt 0 copies (see
|
||||
// ENHANCEMENT_CARDS), and Interlocking, Water column and ABS Signals came down to the sheet's
|
||||
@@ -123,12 +131,12 @@ describe('card catalogue (component 1)', () => {
|
||||
// Q6 took Space-use and Action cards out of solitaire, where they have no legal target. They are
|
||||
// now out of the COMPETITIVE deck too, until they are implemented: `checkPlay` answers both
|
||||
// categories NOT_IMPLEMENTED, so dealing them would be a dead draw.
|
||||
// 121, not 123: the 20 opponent-directed cards come out, and so do the TWO that exist only to
|
||||
// 122, not 124: the 20 opponent-directed cards come out, and so do the TWO that exist only to
|
||||
// answer them — one Water Column and one Overpass. A defence with nothing to defend against is
|
||||
// the same dead draw as the attack would be. `SimpleCard.answers` names the pairing, so they
|
||||
// return together. It was seven until Gitea#14 dealt Facing Point Locks zero copies: a card at
|
||||
// zero is already out, so it no longer needs holding back.
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 121);
|
||||
assert.equal(SOLITAIRE_DECK_SIZE, 122);
|
||||
assert.equal(DEFENCE_ONLY_COPIES, 2);
|
||||
for (const c of DEFENCE_ONLY_CARDS) {
|
||||
assert.ok(c.answers, `${c.name} is held back without saying what it answers`);
|
||||
@@ -164,7 +172,7 @@ describe('card catalogue (component 1)', () => {
|
||||
});
|
||||
|
||||
it('makes track the largest category in the deck', () => {
|
||||
// 48 of 121. Building a district is paid for in the industry or train you did not draw, which
|
||||
// 48 of 122. Building a district is paid for in the industry or train you did not draw, which
|
||||
// is the whole reason it matters that track is a card rather than a private supply.
|
||||
//
|
||||
// This asked for a THIRD of the deck until Gitea#14, which was only ever a rule of thumb. It
|
||||
@@ -616,3 +624,72 @@ describe('game setup (component 2)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* WHICH OFFICE EVERY PLAYER OPENS ON.
|
||||
*
|
||||
* Jesse's call, 2026-09-23: a Whistle Post has one A/D track and is not a Passenger Facility, so the
|
||||
* opening of every game was spent unable to work a passenger and one arrival away from a collision.
|
||||
* The default is a Depot now; the Whistle Post opening stays as the harder setting.
|
||||
*/
|
||||
describe('the starting Office, and the deck that goes with it', () => {
|
||||
const withRules = (houseRules: Record<string, unknown>) =>
|
||||
createGame({
|
||||
id: 'so', seed: 7,
|
||||
config: { ...solitaireConfig, mode: 'competitive', houseRules } as never,
|
||||
playerNames: ['A', 'B'],
|
||||
});
|
||||
|
||||
const officeCards = (g: ReturnType<typeof withRules>, tier: string): number =>
|
||||
[...g.cards.values()].filter((c) => c.kind.kind === 'office' && (c.kind as { tier: string }).tier === tier).length;
|
||||
|
||||
it('deals Depots by default, and leaves the Depot upgrades out of the deck', () => {
|
||||
// A Depot card at a table that already has Depots is a dead draw: `check` refuses it, because an
|
||||
// upgrade must be to the NEXT tier. Station and Terminal are still upgrades and stay in.
|
||||
const g = createGame({
|
||||
id: 'd', seed: 7,
|
||||
config: { ...solitaireConfig, mode: 'competitive', houseRules: {} } as never,
|
||||
playerNames: ['A', 'B'],
|
||||
});
|
||||
assert.deepEqual([...g.officeAreas.values()].map((a) => a.tier), ['depot', 'depot']);
|
||||
assert.equal(officeCards(g, 'depot'), 0, 'Depot upgrades are still in the deck');
|
||||
assert.ok(officeCards(g, 'station') > 0, 'Station upgrades were dropped too');
|
||||
assert.ok(officeCards(g, 'terminal') > 0, 'Terminal upgrades were dropped too');
|
||||
});
|
||||
|
||||
it('deals Whistle Posts when the table asks for the harder game, Depot cards and all', () => {
|
||||
const g = withRules({ startingOffice: 'whistlePost' });
|
||||
assert.deepEqual([...g.officeAreas.values()].map((a) => a.tier), ['whistlePost', 'whistlePost']);
|
||||
assert.ok(officeCards(g, 'depot') > 0, 'the Depot upgrade is missing from a Whistle Post game');
|
||||
});
|
||||
|
||||
it('gives a Depot two A/D tracks and a working passenger facility from Stage 1', () => {
|
||||
// This is the whole of why the default moved: one A/D track is what made an arrival a collision,
|
||||
// and a Whistle Post earns nothing from a passenger however well the district is built.
|
||||
const g = createGame({
|
||||
id: 'p', seed: 7, config: { ...solitaireConfig, houseRules: {} } as never, playerNames: ['A'],
|
||||
});
|
||||
const area = g.officeAreas.get(0 as never)!;
|
||||
assert.equal(officeProfile(area.tier).adTracks, 2, 'a Depot should have two A/D tracks');
|
||||
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
|
||||
assert.equal(f.allows.outbound, true, 'a Depot should board passengers from the start');
|
||||
assert.equal(f.allows.inbound, true, 'a Depot should detrain passengers from the start');
|
||||
assert.ok(f.porters > 0, 'a Depot should have a Porter');
|
||||
});
|
||||
|
||||
it('replays a save written before the setting as the Whistle Post game it was', () => {
|
||||
/**
|
||||
* The one house rule that changes how a game is DEALT rather than how it plays, so replaying it
|
||||
* under the wrong opening is a different railroad from intent one — silently. `withSavedOpening`
|
||||
* fills it for a save that names other rules and cannot name this one.
|
||||
*/
|
||||
const saved: { houseRules: { startingHand: 'sixRandom'; startingOffice?: 'depot' | 'whistlePost' } } =
|
||||
{ houseRules: { startingHand: 'sixRandom' } };
|
||||
assert.equal(withSavedOpening(saved).houseRules.startingOffice, 'whistlePost');
|
||||
|
||||
// A config that names it is left exactly as it is, in both directions.
|
||||
assert.equal(withSavedOpening({ houseRules: { startingOffice: 'depot' as const } }).houseRules.startingOffice, 'depot');
|
||||
// And a config with no house rules at all is a fresh game, not an old save.
|
||||
assert.deepEqual(withSavedOpening({}), {});
|
||||
});
|
||||
});
|
||||
|
||||
+11
-1
@@ -27,6 +27,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
@@ -1024,7 +1028,13 @@ describe('the bot does not lay track that cannot work (regression)', () => {
|
||||
//
|
||||
// Now that track is drawn rather than taken from a private supply, this is a real and frequent
|
||||
// situation rather than a constructed one: the curve you need may simply not be in hand.
|
||||
const lays = laysIn(4242);
|
||||
/**
|
||||
* SEED CHANGED, NOT THE FLOOR. 4242 laid 25 pieces when every district opened on a Whistle
|
||||
* Post; opening on a Depot gives the bot passenger work from Stage 1, so it spends fewer turns
|
||||
* laying track and that seed fell to 3 — below the sample this needs to mean anything. The
|
||||
* floor is what makes the assertion below worth making, so the seed moved instead.
|
||||
*/
|
||||
const lays = laysIn(2024);
|
||||
assert.ok(lays.length > 3, `the bot laid only ${lays.length} pieces`);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
|
||||
@@ -41,6 +41,10 @@ const config = (): GameConfig => {
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
};
|
||||
@@ -68,7 +72,13 @@ describe('switching planner', () => {
|
||||
it('never changes the game it plans for, and every plan replays to the position it promised', () => {
|
||||
let checked = 0;
|
||||
let withSteps = 0;
|
||||
for (const seed of [1000, 8919, 16838]) {
|
||||
/**
|
||||
* EIGHT SEEDS, NOT THREE. Adding the Second Section card to the deck (Q9) reshuffles every
|
||||
* seeded deal, and none of the first three produced a non-empty plan any more — so `withSteps`
|
||||
* below, which is what proves the replay path is exercised at all, fell to zero. Widened on the
|
||||
* same stride rather than weakening the assertion; TODO #84 is about exactly this fixture shape.
|
||||
*/
|
||||
for (const seed of [1000, 8919, 16838, 24757, 32676, 40595, 48514, 56433]) {
|
||||
const s = createGame({ id: `plan-${seed}`, seed, config: config(), playerNames: ['bot'] });
|
||||
const r = playGame(s, developerBot, pump, 50_000, undefined, (st) => {
|
||||
const p = actingPlayer(st);
|
||||
|
||||
+14
-9
@@ -32,6 +32,10 @@ const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
...over,
|
||||
});
|
||||
@@ -151,14 +155,15 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
|
||||
it('splits Revenue into what was earned and what was given back', () => {
|
||||
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
|
||||
// lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
|
||||
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
|
||||
// lost IS the score the engine kept. Seed 9 is named because it is one where Revenue actually
|
||||
// moves in both directions — it earns 2 and gives back 5 to a collision — so the two halves are
|
||||
// being told apart rather than both sitting at zero.
|
||||
//
|
||||
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
|
||||
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
|
||||
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
|
||||
for (const seed of [1, 7, 44]) {
|
||||
// It was seed 42 until v0.8.0.10, and seed 44 until 0.8.2. Each time the SEED moved, not the
|
||||
// assertion: 42's collision went away with the Gitea#26 ruling, and 44's deal changed when the
|
||||
// Second Section card joined the deck (Q9) and reshuffled everything. This is the fixture shape
|
||||
// TODO #84 is about — the seed means "a game like this", so it is expected to move.
|
||||
for (const seed of [1, 7, 9]) {
|
||||
const { state } = playKeepingEvents(seed);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.equal(
|
||||
@@ -167,10 +172,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
|
||||
);
|
||||
}
|
||||
const { state } = playKeepingEvents(44);
|
||||
const { state } = playKeepingEvents(9);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
|
||||
assert.ok(me.revenueGained > 0, 'seed 9 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 9 lost nothing — the lost half is not being counted');
|
||||
});
|
||||
|
||||
it('records a Circus set-up as the one-off it is, not as a streak', () => {
|
||||
|
||||
@@ -24,6 +24,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||
|
||||
@@ -32,6 +32,10 @@ const config: GameConfig = {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
employeeRotation: false,
|
||||
@@ -403,6 +407,14 @@ describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', (
|
||||
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
|
||||
} as never);
|
||||
if (node?.kind === 'mainline') {
|
||||
/**
|
||||
* PIN THE TERRAIN, as `enhancements.test.ts` does for the same reason. Mainline types come
|
||||
* from the SHUFFLED deck, so deck composition decides them — and Double Track and Uncontrolled
|
||||
* Siding print "trains may pass", which legitimately removes the §8.1 bar this test is about.
|
||||
* Adding the Second Section card (Q9) reshuffled seed 7 into one of those and the ruling
|
||||
* stopped being called for.
|
||||
*/
|
||||
node.card = 'plains';
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
|
||||
|
||||
+116
-13
@@ -20,11 +20,11 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
|
||||
|
||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { BOARD_CSS, divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { TURNCHART_CSS, turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
import type { Frame } from '../src/sim/view.ts';
|
||||
@@ -56,6 +56,15 @@ const root = join(import.meta.dirname, '..');
|
||||
* other test in this file built it. `npm run test` directly (skipping `npm test`'s `pretest` hook)
|
||||
* will not have run it.
|
||||
*/
|
||||
/**
|
||||
* A solitaire game that opens on a WHISTLE POST rather than the default Depot.
|
||||
*
|
||||
* Two tests below are about the Whistle Post itself — its single A/D track, and the fact that a
|
||||
* Station is not the next tier up from it — so they name the opening rather than inheriting it.
|
||||
*/
|
||||
const whistlePostGame = (seed: number): ReturnType<typeof newGame> =>
|
||||
newGame(seed, { ...SOLO_CONFIG, houseRules: { ...SOLO_CONFIG.houseRules, startingOffice: 'whistlePost' } });
|
||||
|
||||
const dist = join(root, 'dist');
|
||||
/**
|
||||
* A directory the actual "run the build command" test below builds into, kept separate from the
|
||||
@@ -1145,7 +1154,7 @@ describe('the page explains itself', () => {
|
||||
// A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential
|
||||
// (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
|
||||
// action that did nothing.
|
||||
const game = newGame(111);
|
||||
const game = whistlePostGame(111);
|
||||
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
||||
// A Station upgrade is PUT in hand rather than drawn for. This used to take whatever seed 111
|
||||
// happened to deal, which made it luck: the moment deck composition changed it dealt no upgrade
|
||||
@@ -2570,7 +2579,7 @@ describe('the static build', () => {
|
||||
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
|
||||
// decides whether the next arrival is an automatic collision (§8.3). The Roster Pass replaced
|
||||
// the pips with one roster chip per A/D track (docs/plans/switching-paths.md), free or occupied.
|
||||
const game = newGame(555);
|
||||
const game = whistlePostGame(555);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
const cell = view(game).cells.find((c) => c.kind === 'office')!;
|
||||
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
|
||||
@@ -3116,6 +3125,10 @@ describe('the Division map shows the whole route', () => {
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
|
||||
@@ -3123,6 +3136,72 @@ describe('the Division map shows the whole route', () => {
|
||||
return divisionSvg(snapshot(s, [], null).division);
|
||||
};
|
||||
|
||||
it('flashes who is being waited on, but only when it is you', () => {
|
||||
/**
|
||||
* REPORTED FROM A TABLE: "when my turn and waiting on me — make the waiting on flash brightly on
|
||||
* and off." The commonest way a table stalls is a player not noticing their turn came round, and
|
||||
* the chip was the same violet whoever it named.
|
||||
*
|
||||
* ONLY WHEN IT IS ACTUALLY YOUR MOVE. `renderTurnChart` reads the actor ON SCREEN rather than
|
||||
* the live one, so it does not start flashing while your board is still replaying somebody
|
||||
* else's turn and you cannot act yet.
|
||||
*/
|
||||
const frame = { day: 1, stage: 1, clock: '00:00', phase: 'Local Operations', phaseKey: 'localOps', actor: 0 };
|
||||
|
||||
const theirs = turnChartHtml(frame, 'Bob', null, false);
|
||||
assert.match(theirs, /waiting on/, 'the chart stopped saying who is waited on');
|
||||
assert.ok(!/tc-yours/.test(theirs), 'someone else\'s turn is flashing at you');
|
||||
|
||||
const yours = turnChartHtml(frame, 'Alice', null, true);
|
||||
assert.match(yours, /tc-yours/, 'your own turn does not flash');
|
||||
assert.match(yours, /waiting on/, 'the flashing line stopped saying what it is about');
|
||||
|
||||
// An automatic phase waits on nobody, so there is nothing to flash even for the viewer.
|
||||
const auto = turnChartHtml({ ...frame, actor: null, phaseKey: 'mainline', phase: 'Mainline' }, null, null, true);
|
||||
assert.ok(!/tc-yours/.test(auto), 'an automatic phase flashed as though it were your move');
|
||||
|
||||
// And the style is actually shipped, or the class is decoration with no effect.
|
||||
assert.match(TURNCHART_CSS, /\.tc-who\.tc-yours/, 'the flash has no styling');
|
||||
assert.match(TURNCHART_CSS, /@keyframes tc-flash/, 'the flash does not animate');
|
||||
assert.match(TURNCHART_CSS, /prefers-reduced-motion/, 'the flash has no reduced-motion fallback');
|
||||
});
|
||||
|
||||
it('draws a train the Interlocking is holding at the Limits', () => {
|
||||
/**
|
||||
* REPORTED 2026-09-23: "should there be a tooltip on a train holding at limits due to
|
||||
* interlocking that clearly states it is holding at limits because of interlocking?" The
|
||||
* tooltip was already there — the view has carried `heldAtLimits` since #99 — and NO renderer
|
||||
* read the flag, so the train drew like any other chip and nothing told a player to hover.
|
||||
*/
|
||||
const game = newGame(555);
|
||||
const s = game.state;
|
||||
const area = s.officeAreas.get(0)!;
|
||||
// A held train has no grid position at all — that is the whole of #99 — so it is built here and
|
||||
// named only on `heldAtLimits`, exactly as `arriveAtOffice` leaves it.
|
||||
s.trays.set('held1', {
|
||||
id: 'held1', trainNumber: 8, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: false }], direction: 'east',
|
||||
position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
} as never);
|
||||
area.heldAtLimits = ['held1'];
|
||||
|
||||
// An eastbound train entered from the west, so it is held at the WESTERN Limits (view.ts).
|
||||
const at = area.limitsWest;
|
||||
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
|
||||
assert.ok(cell.trains?.some((x) => x.heldAtLimits), 'the view lost the held flag');
|
||||
|
||||
const svg = officeSvg([cell], area.runningRow);
|
||||
assert.match(svg, /bs-held/, 'a held train draws like any other');
|
||||
assert.match(svg, /HELD AT THE LIMITS/, 'the held train says nothing about why it stopped');
|
||||
assert.match(svg, /Interlocking/, 'the tooltip does not name what is holding it');
|
||||
assert.match(BOARD_CSS, /\.bs-crew\.bs-held rect/, 'the held mark has no styling');
|
||||
|
||||
// An ordinary train is unmarked, or the cue means nothing.
|
||||
area.heldAtLimits = [];
|
||||
const officeCell = view(game).cells.find((c) => c.kind === 'office')!;
|
||||
assert.ok(!/bs-held/.test(officeSvg([officeCell], area.runningRow)), 'an ordinary square draws as held');
|
||||
});
|
||||
|
||||
it('draws a signal on a Mainline card carrying ABS Signals, not only a tooltip', () => {
|
||||
/**
|
||||
* REPORTED FROM A TABLE, Day 1 Stage 1 of v0.8.0.16: "when played on the trestle, there was no
|
||||
@@ -3136,6 +3215,10 @@ describe('the Division map shows the whole route', () => {
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['A', 'B'],
|
||||
@@ -3173,6 +3256,10 @@ describe('the Division map shows the whole route', () => {
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['A', 'B', 'C', 'D'],
|
||||
@@ -3678,7 +3765,12 @@ describe('the sounds fire on the events they name', () => {
|
||||
// The model names WHAT happened and the page decides what it sounds like. Getting this wrong is
|
||||
// not a silent failure — it is a whistle every few seconds, or a bell that never rings — so the
|
||||
// count is checked against the clock rather than trusted.
|
||||
const game = newGame(555);
|
||||
/**
|
||||
* SEED CHANGED, NOT THE ASSERTION. Adding the Second Section card to the deck (Q9) reshuffles
|
||||
* every seeded deal, and 555 stopped scheduling a train inside the window. This is the fixture
|
||||
* shape TODO #84 is about: the seed means "a game like this", not this exact game.
|
||||
*/
|
||||
const game = newGame(9999);
|
||||
const cues: Record<string, number> = {};
|
||||
let stageBoundaries = 0;
|
||||
let dayBoundaries = 0;
|
||||
@@ -3941,6 +4033,10 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
maxCollisionsPerDay: 3,
|
||||
maxCollisionsTotal: 10,
|
||||
pvpCardsAllowed: false,
|
||||
// These fixtures were written against a Whistle Post opening — one A/D track and no
|
||||
// Control Point — and several of them test exactly that. Named explicitly since the
|
||||
// default became a Depot.
|
||||
houseRules: { startingOffice: 'whistlePost' },
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Joe', 'Bot 1'],
|
||||
@@ -5713,17 +5809,19 @@ describe('the Quickstart guide reaches the site', () => {
|
||||
it('is published into dist and linked from the splash page', () => {
|
||||
const guide = join(dist, 'quickstart.md');
|
||||
assert.ok(existsSync(guide), 'the build did not publish quickstart.md');
|
||||
assert.ok(existsSync(join(dist, 'quickstart.html')), 'the build did not RENDER the guide');
|
||||
|
||||
const text = readFileSync(guide, 'utf8');
|
||||
assert.match(text, /^# Station Master — Quickstart/, 'quickstart.md is not the guide');
|
||||
// The version is the first thing on the page, before anything else — see TODO's process rules.
|
||||
assert.match(
|
||||
text,
|
||||
/Describes the game as built at v/,
|
||||
'the guide does not say which build it describes',
|
||||
text.split('\n').slice(0, 4).join('\n'),
|
||||
/\*\*Version \d+\.\d+/,
|
||||
'the guide does not carry its version at the top',
|
||||
);
|
||||
|
||||
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
||||
assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide');
|
||||
assert.match(splash, /href="\.\/quickstart\.html"/, 'the splash page does not link the rendered guide');
|
||||
});
|
||||
|
||||
it('brings the lobby doors back every time the lobby is shown', () => {
|
||||
@@ -5769,17 +5867,22 @@ describe('the Quickstart guide reaches the site', () => {
|
||||
* 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');
|
||||
const section = guide.slice(guide.indexOf('## 8. Documentation / References'));
|
||||
assert.ok(section.length > 0, 'the guide no longer has a "Documentation / References" section');
|
||||
|
||||
// Markdown links, minus anchors and absolute URLs — what a reader can actually click.
|
||||
const targets = [...section.matchAll(/\]\(([^)#][^)]*)\)/g)]
|
||||
.map((m) => m[1]!.replace(/^`|`$/g, ''))
|
||||
.filter((t) => !/^https?:/.test(t));
|
||||
assert.ok(targets.length >= 4, `only ${targets.length} references parsed out of the guide`);
|
||||
assert.ok(targets.length >= 3, `only ${targets.length} references parsed out of the guide`);
|
||||
|
||||
for (const t of targets) {
|
||||
assert.ok(existsSync(join(dist, t)), `the guide links ${t}, which the build does not publish`);
|
||||
// And the rendered page it becomes, since that is what a reader actually follows.
|
||||
assert.ok(
|
||||
existsSync(join(dist, t.replace(/\.md$/, '.html'))),
|
||||
`the guide links ${t}, whose rendered page the build does not publish`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5800,7 +5903,7 @@ describe('the Quickstart guide reaches the site', () => {
|
||||
assert.ok(bundle.includes('GUIDE_DOCS') || bundle.includes('quickstart.md'), 'the bundle has no guide links');
|
||||
|
||||
// Every document offered in-game must be a file the build published.
|
||||
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.md)["'`]/g)].map((m) => m[1]!);
|
||||
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.html)["'`]/g)].map((m) => m[1]!);
|
||||
assert.ok(hrefs.length >= 5, `only ${hrefs.length} in-game guide links found`);
|
||||
for (const h of hrefs) {
|
||||
assert.ok(existsSync(join(dist, h.replace(/^\.\//, ''))), `the game links ${h}, which is not published`);
|
||||
|
||||
Reference in New Issue
Block a user